CWE-183: Permissive List of Allowed Inputs - Java
Overview
Java-specific guidance for implementing strict input validation using Pattern, URI, and Path classes.
Primary Defence: Anchor patterns with ^ and $, call matches() rather than find() so the whole input has to match, and apply a length limit before the regex runs. For structured values, parse with URI, Path, or InetAddress instead of matching the text.
Common Vulnerable Patterns
Unanchored Pattern Matching
import java.util.regex.*;
public class VulnerableValidator {
// VULNERABLE - no anchors, matches substring
public boolean validateUsername(String username) {
// Attacker: "admin'; DROP TABLE users--"
Pattern pattern = Pattern.compile("[a-zA-Z0-9]+");
return pattern.matcher(username).find(); // Matches substring!
}
// VULNERABLE - permissive URL validation
public boolean validateURL(String url) {
// Attacker: "javascript:alert(1)"
return url.matches(".*://.*"); // Allows any protocol!
}
}
Why this is vulnerable: Java draws the distinction at the method rather than in the pattern. Matcher.matches() requires the expression to consume the entire input; Matcher.find() and Pattern.matches differ in exactly that respect, and find() succeeds on any substring - so a validator built on it accepts whatever surrounds the part that matched.
Where anchors are written by hand, $ matches before a final line terminator by default, so \z is the strict end. Pattern.MULTILINE widens ^ and $ to every line and is worth checking for in a validator, because it turns one anchored pattern into a per-line one.
Permissive File Extension Check
public boolean validateFilename(String filename) {
// VULNERABLE - checks if extension appears anywhere
// Attacker: "malware.exe.jpg"
return filename.matches(".*\\.(jpg|png|gif).*");
}
Why this is vulnerable: Testing whether an extension appears somewhere in the name is a different question from testing what the name ends with, and report.jpg.jsp answers the first one yes.
Extensions are also the wrong basis for the decision. What matters is how the file will be handled once stored, and a servlet container mapping *.jsp does not consult this method. Check the final extension against an allowlist, confirm the content by reading the leading bytes rather than trusting the client's Content-Type, and write uploads to a directory the container does not serve.
Secure Patterns
Strict Username Validation
import java.util.regex.*;
import java.util.Set;
public class SecureValidator {
private static final int MAX_USERNAME_LENGTH = 20;
private static final Pattern USERNAME_PATTERN =
Pattern.compile("^[a-z0-9_]{3,20}$", Pattern.CASE_INSENSITIVE);
private static final Set<String> RESERVED_NAMES =
Set.of("admin", "root", "system", "administrator");
public boolean validateUsername(String username) {
if (username == null || username.length() > MAX_USERNAME_LENGTH) {
return false;
}
// Strict: anchored pattern, use matches() not find()
if (!USERNAME_PATTERN.matcher(username).matches()) {
return false;
}
// Reject reserved names
if (RESERVED_NAMES.contains(username.toLowerCase())) {
return false;
}
return true;
}
}
Why this works: The anchored pattern ^[a-z0-9_]{3,20}$ has to consume the whole string, so "admin'; DROP TABLE users--" no longer passes on the strength of its admin prefix. matches() enforces that whole-string match, where find() would have accepted the same input. The MAX_USERNAME_LENGTH check rejects oversized input before the regex runs. The reserved names check blocks admin, root, system and administrator, which all satisfy the pattern on their own. Pre-compiling the pattern as a static final constant compiles the regex once at class load rather than on every call.
Strict URL Validation
import java.net.*;
import java.util.Set;
public class SecureValidator {
private static final Set<String> ALLOWED_SCHEMES = Set.of("http", "https");
public boolean validateURL(String urlString) {
try {
URI uri = new URI(urlString);
String scheme = uri.getScheme();
// Strict: only allow specific protocols
if (scheme == null || !ALLOWED_SCHEMES.contains(scheme.toLowerCase())) {
return false;
}
// Validate host exists
String host = uri.getHost();
if (host == null || host.isEmpty()) {
return false;
}
// Optional: reject private/loopback addresses
InetAddress addr = InetAddress.getByName(host);
if (addr.isLoopbackAddress() || addr.isSiteLocalAddress() ||
addr.isAnyLocalAddress() || addr.isLinkLocalAddress()) {
return false;
}
return true;
} catch (URISyntaxException | UnknownHostException e) {
return false;
}
}
}
Why this works: The URI class parses the string into components so the code can validate the scheme and host directly instead of searching URL text with a regex. By validating uri.getScheme() against an allowlist (ALLOWED_SCHEMES), the code prevents dangerous protocols like javascript:, data:, file:, or vbscript: that could enable XSS or local file access attacks. Checking for a non-null, non-empty host prevents URLs like http:// or http:evil that have a scheme but no network destination. InetAddress.getByName() verifies that the host resolves, and the address checks reject loopback, private, wildcard, and link-local addresses, including metadata-service ranges such as 169.254.169.254. Treat this as a URL-shape check, not a complete SSRF control: production SSRF defenses also need redirect handling, DNS rebinding protection, and connection-time IP enforcement.
Strict Email Validation
This is strictly based on xxxxx@yyyyy.zzzzzz. Full RFC5322 compliance can be much more complex.
import java.util.regex.Pattern;
public class EmailValidator {
private static final int MAX_EMAIL_LENGTH = 254;
private static final int MAX_LOCAL_LENGTH = 64;
private static final Pattern EMAIL_PATTERN = Pattern.compile(
"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"
);
public boolean validateEmail(String email) {
if (email == null || email.length() > MAX_EMAIL_LENGTH) {
return false;
}
// Pattern matching with anchored regex
if (!EMAIL_PATTERN.matcher(email).matches()) {
return false;
}
// Additional semantic checks
String[] parts = email.split("@");
if (parts[0].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>"). The 254-character cap on the whole address and the 64-character cap on the local part both come from RFC 5321. Requiring a TLD of at least two characters (.co, .uk) rejects a domain that ends in a bare dot or a single letter.
Strict Filename Validation
import java.util.regex.Pattern;
public class FilenameValidator {
private static final int MAX_FILENAME_LENGTH = 255;
private static final Pattern FILENAME_PATTERN =
Pattern.compile("^[a-zA-Z0-9_-]+\\.(jpg|png|gif)$", Pattern.CASE_INSENSITIVE);
public boolean validateFilename(String filename) {
if (filename == null || filename.length() > MAX_FILENAME_LENGTH) {
return false;
}
// Anchored pattern - must END with allowed extension
if (!FILENAME_PATTERN.matcher(filename).matches()) {
return false;
}
// Additional security checks
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
return false;
}
return true;
}
}
Why this works: The pattern ^[a-zA-Z0-9_-]+\.(jpg|png|gif)$ 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_-] leaves no room for the dots and separators a traversal sequence needs. The length check rejects an oversized name before the regex runs. The explicit checks for .., /, and \ are defense-in-depth against path traversal, even though the regex should already block these. Case-insensitive matching accepts "file.JPG" as readily as "file.jpg", so a legitimate upload is not rejected over the case of its extension.
Path Validation with Canonicalization
import java.io.*;
import java.nio.file.*;
import java.util.Set;
public class FileAccessValidator {
private static final Path BASE_DIR = Paths.get("/var/data").toAbsolutePath();
private static final Set<String> ALLOWED_FILES =
Set.of("report.pdf", "data.csv", "summary.txt");
public File getFile(String filename) throws IOException {
// Strict allowlist
if (!ALLOWED_FILES.contains(filename)) {
throw new IllegalArgumentException("File not allowed");
}
// Resolve to canonical path
Path filePath = BASE_DIR.resolve(filename).toRealPath();
// Verify within allowed directory
if (!filePath.startsWith(BASE_DIR)) {
throw new IllegalArgumentException("Path traversal detected");
}
return filePath.toFile();
}
}
Why this works: The ALLOWED_FILES set settles which files can be read before any path handling happens, so a name that is not on the list never reaches the filesystem. The toRealPath() method resolves symbolic links and normalizes the path (removing ., .., redundant separators), so "../../etc/passwd", a symlink out of the tree, or a Windows trailing-dot name like "file...." is resolved to what it actually points at. The startsWith() check then confirms the canonical path is still within BASE_DIR. Using Paths.get().toAbsolutePath() for the base directory keeps that comparison consistent regardless of the current working directory.
Enum-Based Validation
public enum Role {
USER, MODERATOR, ADMIN;
public static boolean isValid(String role) {
if (role == null) {
return false;
}
try {
Role.valueOf(role.toUpperCase());
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
}
// Usage
public boolean validateRole(String role) {
return Role.isValid(role);
}
Why this works: A Java enum fixes the set of allowed values at compile time, and nothing can extend it at runtime. valueOf() throws IllegalArgumentException for any string that does not exactly name a constant, which is allowlist validation without a pattern to under-constrain: the value either names a constant or it does not. Converting input to uppercase (role.toUpperCase()) makes the comparison case-insensitive without loosening which values are accepted. Enum constants are also easier to maintain than string constants, because a typo in one is a compile error and refactoring tools can rename it.
Numeric ID Validation
import java.util.regex.Pattern;
public class IDValidator {
private static final Pattern ID_PATTERN = Pattern.compile("^[0-9]{8}$");
private static final int MIN_ID = 10000000;
private static final int MAX_ID = 99999999;
public boolean validateID(String idStr) {
// Format validation
if (!ID_PATTERN.matcher(idStr).matches()) {
return false;
}
// Semantic validation: check range
try {
int id = Integer.parseInt(idStr);
return id >= MIN_ID && id <= MAX_ID;
} catch (NumberFormatException e) {
return false;
}
}
}
Why this works: The pattern ^[0-9]{8}$ requires exactly 8 digits end to end, so "12345678abc" and "abc12345678" are rejected despite containing a valid substring. Running the format check before parsing means Integer.parseInt never sees a non-numeric character. The range check with MIN_ID and MAX_ID then rejects a value that matches the format but falls outside the issued range: "00000001" is 8 digits and still not an ID this system hands out. The catch block is a backstop rather than a live case, since 8 digits cannot overflow an int.
Java-Specific Best Practices
Use matches() Not find()
import java.util.regex.*;
Pattern pattern = Pattern.compile("^[a-z0-9]+$");
Matcher matcher = pattern.matcher(input);
// WRONG: finds substring match
if (matcher.find()) { }
// CORRECT: matches entire string (when pattern is anchored)
if (matcher.matches()) { }
// ALTERNATIVE: use String.matches() for simple cases
if (input.matches("^[a-z0-9]+$")) { }
Pre-compile Patterns as Constants
public class Validator {
// Compile once, reuse many times
private static final Pattern USERNAME_PATTERN =
Pattern.compile("^[a-z0-9_]{3,20}$", Pattern.CASE_INSENSITIVE);
public boolean validate(String username) {
return USERNAME_PATTERN.matcher(username).matches();
}
}
Use Set.of() for Allowlists (Java 9+)
// Immutable set (Java 9+)
private static final Set<String> ALLOWED_EXTENSIONS =
Set.of("jpg", "png", "gif", "pdf");
// Older Java versions
private static final Set<String> ALLOWED_EXTENSIONS =
Collections.unmodifiableSet(new HashSet<>(
Arrays.asList("jpg", "png", "gif", "pdf")
));
Use NIO Path APIs for File Operations
import java.nio.file.*;
public Path validatePath(String filename) throws IOException {
Path basePath = Paths.get("/var/data").toAbsolutePath().normalize();
Path filePath = basePath.resolve(filename).normalize();
// Check if resolved path is within base directory
if (!filePath.startsWith(basePath)) {
throw new SecurityException("Path traversal attempt");
}
return filePath;
}
Common Pitfalls
- Falling back to
find()aftermatches()"doesn't work" in testing: a pattern that fails to match because of an unaccounted-for trailing character (whitespace, a newline) sometimes gets "fixed" by swappingmatches()forfind()instead of fixing the pattern - this silently reintroduces substring matching for every future caller. - Skipping the canonicalization check when
toRealPath()throws:toRealPath()requires the file to already exist, so validating a path for a file about to be created throwsNoSuchFileException. Catching that broadly and falling back to the unresolved path (instead of resolving the parent directory and re-appending the filename) skips the boundary check entirely for new-file writes. - Comparing allowlist entries without normalizing case or Unicode form first:
Set.of("admin", "root")only blocks exact matches -"ADMIN"or a Unicode-confusable lookalike passes unless both the input and the stored values are normalized (e.g.,toLowerCase(Locale.ROOT),Normalizer.normalize(NFC)) before the comparison.