Skip to content

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 if prefix itself 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/... when BASE_DIR is /var/data - always compare against BASE_DIR + path.sep (or use path.relative() and reject results starting with ..).
  • Passing untrusted input as the base argument to new URL(): new URL(userInput, 'https://example.com') treats a userInput of //evil.com/path as protocol-relative and resolves to https://evil.com/path - validate the fully resolved url.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.

Additional Resources