PixelVault: three incidents in one afternoon and a regex-shaped answer
PixelVault is a photo-sharing backend. A client sends JSON containing an image_url and a filename. The service downloads the URL, writes the bytes to /var/uploads/, then shells out to ImageMagick to generate three thumbnails.
Three things happened in one afternoon. A 6 GB PNG exhausted container memory and the pod was OOM-killed four times. A request with filename ../../etc/passwd overwrote a configuration file. And a scanner showed the service had successfully fetched http://169.254.169.254/latest/meta-data/iam/security-credentials/ and written the resulting IAM credentials into an application log.
The service runs in Kubernetes with an egress NetworkPolicy set to allow 0.0.0.0/0, and the pod carries an IAM role. The team's proposed fix is a regex that rejects filenames containing two consecutive dots and URLs containing 169.254 or localhost. A release is due in ten days and the product owner will not drop the feature.
1. Evaluate the proposed regex. For each of the three incidents, state whether it prevents the attack and what a determined attacker tries next.
Read the worked answer
Path traversal - partial, easily bypassed. The regex inspects the raw string. %2e%2e%2f, ....//, ..%5c on any Windows-derived component, an absolute path like /etc/cron.d/x, a trailing dot or space, and Unicode lookalikes all survive it. Strip-based filtering is worse than rejecting: removing a substring from ....// yields ../, which is exactly the payload. The real fix is architectural - the client filename never becomes a path component. Generate a UUID server-side, derive the extension from decoded content, and store the original name as a database field that is escaped at render time. If you must accept a name, allowlist [a-z0-9-]{1,64} plus a known extension, then resolve the final path and verify it still sits under the upload root.
SSRF - not prevented at all. Blocklisting two strings is a whitelist of two strings for the attacker to route around: decimal 2852039166, IPv6 ::ffff:169.254.169.254, a DNS name whose A record points at the metadata address with TTL 0 (rebinding), a 302 redirect from an allowed host to the metadata endpoint, or other private ranges (10/8, 172.16/12, 192.168/16, 0.0.0.0, 127.0.0.1 variants). This is not a string problem; it is an egress problem.
Memory exhaustion - nothing. A 6 GB file has a perfectly legal filename and URL. You need a Content-Length cap, streaming with a hard byte ceiling, image dimension limits before decode, ImageMagick policy.xml resource limits, and a timeout.
2. Where should validation live and in what order? Explain why validating before canonicalization is a bug that produces bypasses.
Read the worked answer
Order matters more than the regex. Size and rate limits at the edge, then authentication/authorization, then decode and canonicalize exactly once, then validate against an allowlist schema, then pass to a safe consumer, then encode on output.
Why pre-canonicalization validation fails. If the gateway validates the encoded string and the service later calls a decode function, the validator and the consumer disagree about what the string means - a parser differential. ../ passes because the validator saw %2e%2e%2f and the path library saw dots. Double-encoding breaks it again. Unicode normalization is a second source: a ligature, a fullwidth dot, or an NFC/NFD mismatch can normalize into a character the validator never considered. Trailing dots vanish on some filesystems. So: canonicalize to the exact representation the sink will use, then validate that representation, then hand that same representation to the sink. Never re-decode after validation.
Two more principles. Reject, do not sanitize - repair functions are the classic bypass source. And validate against an allowlist, not a denylist, because the set of bad inputs is unbounded. Also cap length before any regex runs to avoid catastrophic backtracking, and remember client-side validation is a UX affordance with zero security value. Defense in depth: gateway schema validation for shape, service validation for semantics, and a safe API at the sink (parameterized paths, no shell invocation, argument arrays instead of concatenated command strings).
3. You have ten days and the feature must ship. Sequence the changes, distinguish code from configuration, and describe a test that fails before each fix.
Read the worked answer
Days 1-2, configuration only, no feature change: ImageMagick policy.xml with memory, disk, time and pixel-area limits and unused coders disabled; request body and download byte ceilings; a worker timeout. Remove the IAM role from the thumbnail pod - the SSRF only mattered because the metadata endpoint had credentials behind it. Add an egress NetworkPolicy allowlisting only the object store. This converts all three incidents from critical to contained without touching product behavior.
Days 3-6, code: replace the server-side fetch with presigned direct upload - client uploads to storage, the service consumes from a bucket key. The SSRF class disappears rather than being filtered. The feature is unchanged from the user's perspective.
Days 6-9, code: filename becomes a generated UUID plus an extension derived from decoded content; original name stored as metadata and escaped at render.
Day 9-10, regression tests: (a) a fetch attempt to 169.254.169.254 must fail at the network layer, not in application code - assert the connection is refused; (b) traversal matrix with %2e%2e%2f, ....//, ..%5c, Unicode dots and an absolute path, each asserting a 400 and no file written outside the root; (c) a 6 GB upload returns 413 within a second or two, not an OOM; (d) a 100,000 x 100,000 decompression bomb returns a 4xx instead of killing the pod. Each test must fail on the pre-fix build - if it passes before the change, it is not testing the fix.