Validating Untrusted Input

Treat every request value as hostile: check type, length, range and format with allowlists, block path traversal, cap sizes and reply with clear 400s.

Syntaxif (raw == null || raw.isBlank()) errors.add("name is required"); else if (raw.length() > 50) errors.add("name too long"); Path p = base.resolve(fileName).normalize(); if (!p.startsWith(base)) throw new IllegalArgumentException("bad path");

Everything a client sends is untrusted: the body, query parameters, path segments, headers, cookies, file names and uploaded content. A browser form's maxlength or a mobile app's checks mean nothing, because anyone can send any bytes straight to your API with curl. The server must decide what it accepts.

Allowlists, not denylists

Describe what is valid and reject everything else. A username rule of ^[a-z0-9_]{3,20}$ is easy to reason about. A rule that tries to strip <script>, quotes or ../ always misses an encoding or a variant someone thought of first.

For each field, check in this order:

  1. Presence. Missing and blank are not the same as valid.
  2. Size. Check length before anything expensive, including regular expressions. Some patterns backtrack exponentially on long hostile input (ReDoS).
  3. Type. Parse into int, UUID, LocalDate or an enum and catch the parse exception. A parsed value cannot be malformed later.
  4. Range and format. Page size between 1 and 100, quantity above zero, an email with one @.

Collect every error and return them together in one 400 response, ideally as Problem Details, so a client can fix all fields in one go. Once a value passes, wrap it in a record whose compact constructor enforces the same rules, so the rest of the code only ever sees valid data.

Limits that protect the server

  • Body size. Read at most a fixed number of bytes and reply 413 above it, instead of calling readAllBytes() on whatever arrives.
  • Collection sizes. A JSON array of a million ids is a valid list and a denial of service.
  • File paths. Never build a path from a client value without resolving it against a base directory, normalising it and checking it still starts with that base. Otherwise ../../etc/passwd walks out.

Validation is not escaping

Validation decides whether to accept a value. Safe use is a separate step at the point of output: a PreparedStatement parameter for SQL, HTML escaping for pages, argument arrays rather than shell strings for processes. A name like O'Brien is valid and must still be stored safely. Do both; neither replaces the other.

Mistakes people make

  • Trusting hidden form fields, client-sent prices or user ids taken from the body instead of the authenticated session.
  • Echoing the raw invalid value in an error message that ends up in HTML or logs.
  • Validating, then normalising (trimming, decoding, case-folding), and using a value that was never checked.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Validate a sign-up form and return every field error in one 400 response instead of failing on the first.
  • Serve user-uploaded files by name only after resolving, normalising and confining the path to the uploads directory.
  • Clamp and validate page and size query parameters before they reach a database query.

More examples

Blocking path traversal

normalize() removes the .. segments so the check sees the real destination, and resolve() with an absolute path returns that path unchanged, which the startsWith check also catches. Path.startsWith compares whole segments, so /srv/app/uploads2 would not pass either.

Example · java
static final Path UPLOADS = Path.of("/srv/app/uploads");

String resolve(String fileName) {
    Path target = UPLOADS.resolve(fileName).normalize();
    return target.startsWith(UPLOADS) ? "serve " + target : "403 refused " + fileName;
}

void main() {
    IO.println(resolve("invoice-981.pdf"));
    IO.println(resolve("2026/../invoice-981.pdf"));
    IO.println(resolve("../../../etc/passwd"));
    IO.println(resolve("/etc/passwd"));
}
// Output:
// serve /srv/app/uploads/invoice-981.pdf
// serve /srv/app/uploads/invoice-981.pdf
// 403 refused ../../../etc/passwd
// 403 refused /etc/passwd

Parsing a numeric query parameter

A missing parameter gets a default, while a present but invalid one is an error rather than being silently replaced. The length check runs first, so huge inputs never reach the parser.

Example · java
record Param(int value, String error) {}

Param pageSize(String raw) {
    if (raw == null) return new Param(20, null);                 // default
    if (raw.length() > 4) return new Param(0, "size is too long");
    try {
        int n = Integer.parseInt(raw);
        if (n < 1 || n > 100) return new Param(0, "size must be between 1 and 100");
        return new Param(n, null);
    } catch (NumberFormatException e) {
        return new Param(0, "size must be a number");
    }
}

void main() {
    List<String> inputs = Arrays.asList(null, "50", "0", "abc", "1e3", "99999999999");
    for (String raw : inputs) {
        Param p = pageSize(raw);
        IO.println("?size=" + raw + " -> " + (p.error() == null ? "ok " + p.value() : "400 " + p.error()));
    }
}
// Output:
// ?size=null -> ok 20
// ?size=50 -> ok 50
// ?size=0 -> 400 size must be between 1 and 100
// ?size=abc -> 400 size must be a number
// ?size=1e3 -> 400 size must be a number
// ?size=99999999999 -> 400 size is too long

Capping the request body size

readNBytes stops at the limit, so a client sending gigabytes costs you at most the cap plus one byte of memory. Reading one byte past the cap is how you tell 'exactly at the limit' from 'too big'.

Example · java
static final int MAX_BODY = 1024;

String readBody(InputStream in) throws IOException {
    byte[] bytes = in.readNBytes(MAX_BODY + 1);       // never reads more than the cap plus one
    if (bytes.length > MAX_BODY) {
        return "413 Content Too Large (limit " + MAX_BODY + " bytes)";
    }
    return "200 read " + bytes.length + " bytes";
}

void main() throws IOException {
    byte[] small = "{\"name\":\"Desk lamp\"}".getBytes(StandardCharsets.UTF_8);
    byte[] huge = new byte[5_000_000];

    IO.println(readBody(new ByteArrayInputStream(small)));
    IO.println(readBody(new ByteArrayInputStream(huge)));
}
// Output:
// 200 read 20 bytes
// 413 Content Too Large (limit 1024 bytes)

Discussion

  • Be the first to comment on this lesson.