CWE-183: Permissive List of Allowed Inputs - JavaScript/TypeScript
Overview
JavaScript and TypeScript-specific guidance for implementing strict input validation using regular expressions, URL APIs, and Set-based allowlists.
Primary Defence: Use fully anchored regex patterns with ^ and $, validate with the native APIs - the URL constructor for URLs, path.resolve() for file paths - prefer Sets over regex wherever the legal values are a fixed list, and enforce length limits before the pattern runs.
Common Vulnerable Patterns
Unanchored Regular Expressions
// VULNERABLE - no anchors, matches substring
function validateUsername(username) {
// Attacker: "admin'; DROP TABLE users--"
const pattern = /[a-zA-Z0-9]+/;
return pattern.test(username); // Matches substring!
}
// VULNERABLE - permissive URL validation
function validateURL(url) {
// Attacker: "javascript:alert(1)"
return /.*:\/\/.*/.test(url); // Allows any protocol!
}
Why this is vulnerable: JavaScript anchors nothing implicitly. RegExp.test() and String.match() both succeed on a match anywhere in the string, so a pattern without ^ and $ describes a substring the input must contain rather than the shape the input must have.
Two extras change the meaning of the anchors once they are added. The m flag makes ^ and $ match at every line break, so a multiline payload satisfies an anchored pattern one line at a time; and a regex with the g flag keeps lastIndex between calls, so the same validator returns different answers for the same input on alternate invocations.
Permissive File Extension Check
function validateFilename(filename) {
// VULNERABLE - checks if extension appears anywhere
// Attacker: "malware.exe.jpg"
return /\.(jpg|png|gif)/.test(filename);
}
Why this is vulnerable: Finding .jpg anywhere in the name is not the same as the name ending in .jpg, so avatar.jpg.js passes a check intended to admit images only.
The extension is also not what decides how the file is treated. express.static sets Content-Type from the final extension, and a stored file whose real extension is .html becomes a same-origin XSS vector regardless of what this function concluded. Compare the last extension against an allowlist, sniff the content rather than trusting the client's declared type, and serve uploads with Content-Disposition: attachment from a path that is not the application origin.
Secure Patterns
Strict Username Validation
const MAX_USERNAME_LENGTH = 20;
const USERNAME_PATTERN = /^[a-z0-9_]{3,20}$/i;
const RESERVED_NAMES = new Set(['admin', 'root', 'system', 'administrator']);
function validateUsername(username: string): boolean {
if (!username || username.length > MAX_USERNAME_LENGTH) {
return false;
}
// Strict: anchored pattern
if (!USERNAME_PATTERN.test(username)) {
return false;
}
// Reject reserved names
if (RESERVED_NAMES.has(username.toLowerCase())) {
return false;
}
return true;
}
Why this works: The anchored pattern /^[a-z0-9_]{3,20}$/i uses ^ (start) and $ (end) to match the entire string, so a substring match no longer lets "admin'; DROP TABLE users--" through. The case-insensitive flag (i) accepts either casing without widening the character set. The length check rejects oversized input before the pattern runs. The Set of reserved names gives O(1) lookup and blocks registrations that would impersonate a built-in administrative account. Defining the pattern as a constant keeps validation consistent across the application and compiles the regex once.
Strict URL Validation
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
function validateURL(urlString: string): boolean {
try {
const url = new URL(urlString);
// Strict: only allow specific protocols
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
return false;
}
// Validate hostname exists
if (!url.hostname) {
return false;
}
// Optional: reject localhost and private IP literals
const hostname = url.hostname.toLowerCase();
if (hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '0.0.0.0' ||
hostname === '[::1]' ||
hostname.startsWith('192.168.') ||
hostname.startsWith('169.254.') ||
hostname.startsWith('10.') ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
/^\[(f[cd][0-9a-f]{2}:|fe80:)/i.test(hostname)) {
return false;
}
return true;
} catch (e) {
return false;
}
}
Why this works: The native URL constructor parses the string into components so the code can validate the protocol and host directly instead of searching URL text with a regex. By validating url.protocol against a Set of allowed protocols, the code prevents dangerous protocols like javascript:, data:, file:, or vbscript: that could enable XSS or local file access attacks. Checking for a non-empty hostname prevents URLs like http:// that have valid schemes but no destination. The private-address checks reject common IP-literal cases (127.0.0.1, 0.0.0.0, 169.254.x.x, 192.168.x.x, 10.x.x.x, 172.16-31.x.x, ::1, and IPv6 private/link-local ranges). Treat this as a URL-shape check, not a complete SSRF control: production SSRF defenses also need DNS resolution, redirect handling, and connection-time IP enforcement.
Strict Email Validation
This is strictly based on xxxxx@yyyyy.zzzzzz. Full RFC5322 compliance can be much more complex.
const MAX_EMAIL_LENGTH = 254;
const MAX_LOCAL_LENGTH = 64;
const EMAIL_PATTERN = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
function validateEmail(email: string): boolean {
if (!email || email.length > MAX_EMAIL_LENGTH) {
return false;
}
// Anchored regex validates entire string
if (!EMAIL_PATTERN.test(email)) {
return false;
}
// Additional semantic checks
const [local] = email.split('@');
if (local.length > MAX_LOCAL_LENGTH) {
return false;
}
return true;
}
Why this works: The anchored pattern /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ enforces strict email structure with clear separation between local part, @ symbol, domain, and TLD. The anchors prevent accepting emails embedded in larger strings (like "user@example.com<script>alert(1)</script>"). Length validation at 254 characters matches the RFC 5321 limit and caps the work the pattern does on long input. The local part length check (64 characters) enforces RFC 5321 mailbox limits. The pattern requires at least a 2-character TLD (.co, .uk), which rejects a bare domain with no dot in it.
Strict Filename Validation
const MAX_FILENAME_LENGTH = 255;
const FILENAME_PATTERN = /^[a-zA-Z0-9_-]+\.(jpg|png|gif)$/i;
function validateFilename(filename: string): boolean {
if (!filename || filename.length > MAX_FILENAME_LENGTH) {
return false;
}
// Anchored pattern - must END with allowed extension
if (!FILENAME_PATTERN.test(filename)) {
return false;
}
// Additional security checks
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
return false;
}
return true;
}
Why this works: The pattern /^[a-zA-Z0-9_-]+\.(jpg|png|gif)$/i uses the $ anchor to ensure the filename ends with an allowed extension, preventing double-extension attacks like "malware.exe.jpg" where the real extension is .exe but .jpg appears in the filename. The character allowlist [a-zA-Z0-9_-] blocks special characters that could be used for path traversal or command injection. Length validation caps the name at 255 characters. The explicit checks for .., /, and \ provide defense-in-depth against path traversal, even though the regex should already block these. Case-insensitive matching (i flag) prevents bypasses like "file.JPG" vs "file.jpg".
Path Validation (Node.js)
import path from 'path';
import fs from 'fs';
const BASE_DIR = path.resolve('/var/data');
const ALLOWED_FILES = new Set(['report.pdf', 'data.csv', 'summary.txt']);
function getFilePath(filename: string): string {
// Strict allowlist
if (!ALLOWED_FILES.has(filename)) {
throw new Error('File not allowed');
}
// Resolve to absolute path
const filePath = path.resolve(BASE_DIR, filename);
// Verify within allowed directory
if (!filePath.startsWith(BASE_DIR + path.sep)) {
throw new Error('Path traversal detected');
}
// Verify file exists
if (!fs.existsSync(filePath)) {
throw new Error('File not found');
}
return filePath;
}
Why this works: ALLOWED_FILES is the primary control: a name that is not on the list never reaches the filesystem at all. The path.resolve() method converts relative paths to absolute ones and normalizes them (removing ., .., redundant separators), so textual traversal such as "../../etc/passwd" collapses before it is used. It does not follow symbolic links, so a symlink planted inside the base directory still resolves inside it - use fs.realpath() when that matters. The startsWith() check against BASE_DIR + path.sep confirms the resolved path is still under the base directory, and using path.sep keeps that check correct on both Windows \ and Unix /. The fs.existsSync() check improves error handling; it is not a TOCTOU defence, because the file can still be replaced between the check and the read. This defense-in-depth approach combines allowlisting, canonicalization, and boundary checking.
Enum-Based Validation (TypeScript)
enum Role {
USER = 'user',
MODERATOR = 'moderator',
ADMIN = 'admin'
}
function validateRole(role: string): boolean {
// Type-safe validation
return Object.values(Role).includes(role as Role);
}
// Alternative: using Set
const ALLOWED_ROLES = new Set(['user', 'moderator', 'admin']);
function validateRoleSet(role: string): boolean {
return ALLOWED_ROLES.has(role.toLowerCase());
}
Why this works: A TypeScript enum fixes the set of allowed values at compile time, and it cannot be extended at runtime. The Object.values(Role).includes() check accepts only values that exist in the enum, which is exact allowlist matching with no pattern to get wrong. The Set alternative is an O(1) lookup where an array .includes() is O(n), and lowercasing the input makes the comparison case-insensitive without widening what it accepts. Type checking also catches a misspelled role name in the calling code, before it becomes a value that silently fails validation.
Numeric ID Validation
const ID_PATTERN = /^[0-9]{8}$/;
const MIN_ID = 10000000;
const MAX_ID = 99999999;
function validateID(idStr: string): boolean {
// Format validation
if (!ID_PATTERN.test(idStr)) {
return false;
}
// Semantic validation: check range
const id = parseInt(idStr, 10);
return id >= MIN_ID && id <= MAX_ID;
}
Why this works: The pattern /^[0-9]{8}$/ enforces exactly 8 digits with anchors, preventing inputs like "12345678abc" or "abc12345678" that contain valid substrings. This format validation happens before parsing, catching malformed input early and preventing issues with parseInt() which would silently ignore trailing non-numeric characters (e.g., parseInt("123abc", 10) returns 123). The range check with MIN_ID and MAX_ID enforces semantic validity - "00000001" is eight digits and matches the format, but falls below MIN_ID and is rejected. Validating in the order format → parsing → range means each stage only sees input the previous one accepted.
JavaScript/TypeScript-Specific Best Practices
Use Anchored Regular Expressions
// WRONG: matches substring
const pattern1 = /[a-z0-9]+/;
// CORRECT: anchored to match entire string
const pattern2 = /^[a-z0-9]+$/;
// Test the difference
console.log(pattern1.test("admin'; DROP TABLE")); // true (matches "admin")
console.log(pattern2.test("admin'; DROP TABLE")); // false (no full match)
Define Patterns as Constants
// Define once, reuse many times
const USERNAME_PATTERN = /^[a-z0-9_]{3,20}$/i;
const EMAIL_PATTERN = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
function validate(username: string): boolean {
return USERNAME_PATTERN.test(username);
}
Use Sets for Allowlists
// Faster lookup than arrays
const ALLOWED_EXTENSIONS = new Set(['jpg', 'png', 'gif', 'pdf']);
function hasAllowedExtension(filename: string): boolean {
const ext = filename.split('.').pop()?.toLowerCase();
return ext ? ALLOWED_EXTENSIONS.has(ext) : false;
}
Use URL API for URL Validation
// Built-in URL parsing is safer than regex
function isValidHttpUrl(urlString: string): boolean {
try {
const url = new URL(urlString);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
TypeScript Type Guards
type ValidatedString = string & { __validated: true };
function validateAndTag(input: string): ValidatedString | null {
const pattern = /^[a-z0-9_]{3,20}$/i;
if (pattern.test(input)) {
return input as ValidatedString;
}
return null;
}
// Usage ensures validated strings are used safely
function processValidatedInput(input: ValidatedString) {
// Input is guaranteed to be validated
console.log(input);
}
const userInput = "test_user";
const validated = validateAndTag(userInput);
if (validated) {
processValidatedInput(validated);
}
Frontend-Specific Considerations
Client-Side Validation is Not Security
// Client-side validation for UX only
function validateClientSide(email: string): boolean {
const pattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
return pattern.test(email);
}
// ALWAYS validate on server side too
// Never trust client-side validation for security
Sanitize Before DOM Insertion
// Even with validation, sanitize for XSS prevention
function sanitizeForHTML(input: string): string {
const div = document.createElement('div');
div.textContent = input;
return div.innerHTML;
}
// Or use a library like DOMPurify
import DOMPurify from 'dompurify';
function displayUserInput(input: string) {
const clean = DOMPurify.sanitize(input);
document.getElementById('output')!.innerHTML = clean;
}
Use DOMPurify 3.4.13 or later; earlier 3.4.x releases carry fixed sanitization-bypass advisories.
Common Pitfalls
- Building a pattern from concatenated or interpolated fragments:
new RegExp('^' + prefix + '$')silently drops the anchors' protection ifprefixitself is allowed to contain regex metacharacters (.,*,|) - anchors only help if the whole assembled pattern, not just the literal template, is anchored and escaped. - Checking
startsWith(BASE_DIR)without a trailing separator boundary:filePath.startsWith(BASE_DIR)also matches/var/data-secret/...whenBASE_DIRis/var/data- always compare againstBASE_DIR + path.sep(or usepath.relative()and reject results starting with..). - Passing untrusted input as the
baseargument tonew URL():new URL(userInput, 'https://example.com')treats auserInputof//evil.com/pathas protocol-relative and resolves tohttps://evil.com/path- validate the fully resolvedurl.hostname, not just that construction succeeded. - Case-sensitive
Set/array lookups without normalization:ALLOWED_ROLES.has(role)only blocks the exact casing stored in the set - normalize with.toLowerCase()(or a fixed Unicode normalization form) on both the input and the stored values before comparing.