Every Input Surface
Body, query, path, headers, cookies, files, webhooks, external responses — and the second-order case where your own database hands back something a request wrote.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
Which surfaces carry untrusted input, and what is the specific check each one needs?
The service is being reviewed. The question is not "do we validate" but "which of the ways data enters this process have a check, and what does each check do?".
We validate the request body with a schema on every endpoint. That is where user input comes from.
A sort parameter from the query string is interpolated into ORDER BY, because parameterised queries cover values and not identifiers (SQL Injection).
- A sort parameter from the query string is interpolated into
ORDER BY, because parameterised queries cover values and not identifiers (SQL Injection). - A path parameter is a well-formed id belonging to another tenant, and the handler loads it because it validated the format (Object-Level Authorization).
X-Forwarded-Foris read as the client IP and used for rate limiting. It is client-supplied unless the proxy overwrites it, so the limit is bypassed by sending a header (Rate Limiting).- An uploaded filename containing
../is used as a storage key, or aContent-Typeofimage/pngis trusted for a file whose bytes are a script (File Uploads Through the Backend, Path Traversal). - A webhook body is parsed and acted on before the signature is verified — or the signature is verified against the re-serialized body rather than the raw bytes, so verification silently never matches (Webhook Signature Verification).
- An external API adds a field, or returns
nullwhere a string was documented, and the value flows into the database and later into someone's browser. - A job payload written by yesterday's producer is consumed by today's worker during a rolling deploy, with a field that no longer exists (Rolling Deployments).
What is actually happening
- The Trust Boundary establishes the principle: the process edge is the boundary and nothing crossing it is trusted. This lesson is the inventory, because the principle is easy to agree with and the surfaces are easy to miss.
- The surfaces differ in who controls them and in what the specific check is. A body needs a schema; a sort parameter needs an allowlist; a webhook needs a signature over raw bytes; a file needs its content sniffed rather than its declared type believed.
- Some surfaces are untrusted in a way that surprises people: headers set by your own proxy are trustworthy only if the proxy overwrites rather than appends, and cookies are attacker-controlled unless signed or encrypted.
- The second-order case is the one most often missed: a value that entered as a request, was stored, and is read back later. It is untrusted data with a trusted-looking source, and it is how stored XSS and delayed injection work (XSS Defense by Output Context).
- Egress is an input surface in disguise: a URL supplied by a caller and fetched by your server turns your process into the attacker's HTTP client, with access to the internal network and the cloud metadata endpoint (SSRF — When the Backend Fetches a URL).
- For agent-enabled backends the same rule extends: model output and tool results are untrusted input. A tool call is a client calling an endpoint, and the endpoint validates it exactly as it would any other (A Tool Call Is a Backend Call).
The inventory
This table is the lesson. It is meant to be read against a real service with a specific question per row: does anything check this, and is the check the one in the third column?
Note how few of the checks are "run a schema over it". Each surface has a characteristic weakness, and applying the body's remedy to all of them leaves most of them open.
| Surface | Who controls it | The check it specifically needs | What it becomes unchecked |
|---|---|---|---|
| Request body | The caller, entirely | Schema, size limit, unknown-field policy | Mass assignment, oversized-payload DoS (Mass Assignment and Over-Posting) |
| Query parameters | The caller, entirely | Schema and an allowlist for anything becoming an identifier | Injection through ORDER BY, unbounded page sizes (SQL Injection) |
| Path parameters | The caller, entirely | Format check and an object-level authorization check | IDOR — a valid id belonging to someone else (Object-Level Authorization) |
| Headers | The caller, except those a proxy overwrites | Trusted-proxy config; count, size and encoding limits | Rate-limit bypass via X-Forwarded-For, cache poisoning |
| Cookies | The caller, unless signed or encrypted | Integrity (signature), plus the same parsing as any input | Session forgery, privilege escalation (Cookies and Their Attributes) |
| Uploaded files | The caller: bytes, name and declared type | Size cap before read, content sniffing, generated storage key | Path traversal, stored malware, storage exhaustion (File Upload Security) |
| Webhook payloads | Anyone who can reach the URL | HMAC over the raw bytes, timestamp window, replay check | Forged state changes from an unauthenticated POST (Webhook Signature Verification) |
| External API responses | The provider, plus anyone who compromised them | Parse against your expected shape; fail loudly on mismatch | Bad data stored, then rendered — second-order injection |
| Queue / job payloads | A producer, possibly an older deployed version | Schema with version tolerance; treat as an external contract | Worker crash loops and poison messages (Dead-Letter Queues) |
| Rows read back | Whoever wrote them, originally a request | Encode for the destination context at output | Stored XSS, delayed injection (XSS Defense by Output Context) |
| Environment / config | Whoever deploys — and it is all strings | Parse and validate once at boot; exit on failure | A typo silently disabling a control (Validate at Startup, Fail Loudly) |
| Caller-supplied URLs | The caller | Allowlist, scheme check, re-resolve and re-check after DNS | SSRF into the internal network or metadata service (SSRF Defense in Depth) |
| Model / tool output | Partly the model, partly whatever it read | Validate as a request; authorize server-side, never in the prompt | Prompt injection driving privileged actions (Agent Authorization) |
The parameters that are not values
quote_ident and format(%I) for dynamic SQL, MySQL has backtick quoting with its own rules, and neither is a substitute for an allowlist because both still let the caller name *any* column, including ones you did not intend to expose.Parameterised queries protect *values*. They cannot protect identifiers — a column name, a table name, a sort direction — because those are part of the statement's structure, and no placeholder exists for them. This is the single most common way a codebase that "uses an ORM everywhere" still has an injection.
The fix is not escaping. It is an allowlist: a fixed map from a caller-supplied token to a value you wrote yourself, with anything unrecognised rejected.
1// WRONG: parameterised value, interpolated identifier2const { sort = 'created_at', dir = 'asc' } = req.query as Record<string, string>3await db.query(4 `SELECT * FROM orders WHERE org_id = $1 ORDER BY ${sort} ${dir} LIMIT 50`,5 [orgId], // org_id is safe. `sort` is not a value and cannot be bound.6)7// ?sort=(SELECT CASE WHEN (SELECT substr(password_hash,1,1) FROM users8// WHERE id=1)='$' THEN id ELSE name END) -> a working blind oracle9 10// RIGHT: the caller picks a key; you own every character that reaches the SQL11const SORTABLE = {12 created: 'o.created_at',13 total: 'o.total_cents',14 customer: 'c.name',15} as const16const DIRECTIONS = { asc: 'ASC', desc: 'DESC' } as const17 18const column = SORTABLE[req.query.sort as keyof typeof SORTABLE]19const dir = DIRECTIONS[req.query.dir as keyof typeof DIRECTIONS]20if (!column || !dir) return badRequest('sort') // reject, do not default silently21 22await db.query(23 `SELECT o.* FROM orders o JOIN customers c ON c.id = o.customer_id24 WHERE o.org_id = $1 ORDER BY ${column} ${dir}, o.id LIMIT $2`,25 [orgId, Math.min(Number(req.query.limit) || 50, 200)], // bound the page too26)Three things beyond the injection: rejecting an unknown sort key rather than defaulting means a client typo is visible instead of silently ignored; o.id as a tiebreaker stops rows repeating across pages; and the limit is capped because an unbounded page size is a memory incident with a valid-looking request (Pagination That Survives a Large Table).
Second-order input: your own database
The hardest surface to internalise is the one with a trusted-looking source. A value that arrived in a request, passed validation, and was stored is still attacker-supplied data — validation established that it was well-formed, not that it is safe to interpolate into a different context later.
The reason it is missed is that the write and the read are far apart, often in different services, often written by different people, often months apart. The defence is contextual encoding at output rather than a belief about where the value came from.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
A display name containing <img onerror=...> | Script runs in an admin dashboard, with admin session | Stored as valid text, rendered later without HTML encoding | Encode at render for the HTML context; never rely on input filtering (XSS Defense by Output Context) |
| A filename stored from an upload, reused as a path | Reads or writes outside the intended directory | The name is caller-controlled and was only checked for length | Generate the storage key; keep the original as metadata only (Path Traversal) |
| A stored URL fetched by a nightly job | The job reaches an internal admin endpoint | It was validated as a URL, never as an *allowed* destination | Allowlist at fetch time, re-check after DNS resolution (SSRF Defense in Depth) |
| A CSV export of user-supplied text | A formula executes when opened in a spreadsheet | A leading = is a formula in the CSV context and was harmless in JSON | Escape for the CSV context at export; the destination decides the encoding |
| A queue payload enqueued before a deploy | Workers crash-loop on a field that no longer exists | The payload is a contract between two versions of your own code | Version the payload; validate on consume (Job Queues, Dead-Letter Queues) |
| A cached object read back after a type change | A field is undefined where the type says string | Cache contents outlive the deploy that changed the shape | Include a schema version in the cache key (Cache Invalidation) |
How to build it
Most important first.
- Write the inventory down for your service and check each surface has an owner. This is a fifteen-minute exercise that finds real gaps.
- Validate query and path parameters with the same schema machinery as the body — they are strings from a URL and reach the same code (Query Parameters, Path Parameters).
- Allowlist anything that becomes an identifier: sort columns, filter fields, table or index names, redirect targets. Parameterisation does not cover these (SQL Injection).
- Read forwarding headers only through a trusted-proxy configuration that knows how many hops to skip; otherwise they are caller-controlled (The Request Lifecycle).
- Verify webhook signatures on the raw body bytes, before parsing, and reject on mismatch without a timing-variable comparison (Webhook Signature Verification).
- For files: cap the size before reading, sniff the content type from the bytes, never use the client filename as a storage key, and store outside the web root (File Uploads Through the Backend).
- Parse external API responses against your expected shape and fail loudly on mismatch, rather than letting
undefinedpropagate (Calling Something You Do Not Control). - Re-validate on read where a stored value crosses into a new context — rendering, a shell, a query, a URL — because the safe encoding depends on the destination (Defence in Depth).
What can go wrong
- A schema on the body only, with query and path parameters read directly off the request object.
- A global "sanitize all input" middleware that strips characters, breaking legitimate data (an O'Brien surname, a password with
<) while stopping nothing specific (Secure Defaults). - Signature verification after body parsing, on a re-serialized object whose key order and whitespace differ from the bytes that were signed.
- Trusting
Content-Typeor the file extension to decide how to process an upload. - A schema for external responses that is stricter than the provider's contract, so a harmless additive change breaks your integration at 3am.
- Environment variables read and coerced at point of use rather than parsed at boot, so
MAX_RETRIES=''becomes0and retries are silently disabled (Validate at Startup, Fail Loudly). - Validation applied to the request but not to the job payload it enqueues, so the queue becomes an unvalidated path into the same logic (Job Queues).
- Time-of-check/time-of-use on a fetched URL: a hostname validated as public can resolve to a private address on the request that follows, which is the DNS-rebinding form of SSRF (SSRF Defense in Depth).
- An uploaded file validated and then processed by a worker can be replaced between the two if the storage key is caller-influenced (File Uploads Through the Backend).
- Duplicate webhook deliveries are the normal case, not an anomaly: the same signed payload arrives twice and must be recognised, not merely accepted (Webhook Idempotency).
- Nearly every serious backend vulnerability class is a surface that was not on someone's list: injection, SSRF, IDOR, path traversal, mass assignment and stored XSS all begin with input from a place nobody thought of as input (Attack Surface).
- Identity and tenancy come from the authenticated principal, never from a field, header or cookie the caller can set (Tenant Isolation).
- Cookies are attacker-controlled unless signed or encrypted, and a session cookie without integrity is a session you do not control (Cookies and Their Attributes).
- A caller-supplied URL that your server fetches must be validated against an allowlist and re-checked after DNS resolution, because a name can resolve to a private address between check and use (SSRF Defense in Depth).
- For agent backends, prompt injection is untrusted input arriving through a new door: content the model read becomes instructions it acts on, and the backend's authorization must not depend on the model's judgement (Agent Authorization).
- "We validate user input" — user input is one surface. Headers, cookies, webhooks, external responses and your own queue are the ones that get missed.
- "It came from our database, so it is clean." It is as clean as whatever wrote it, and a request wrote most of it (XSS Defense by Output Context).
- "It is an internal service, so it is trusted." Internal is a network property, not a data property.
- "The provider is reputable." Reputable providers ship regressions and get compromised, and your parser meets the payload either way.
- "Sanitising input is the defence." Encoding at *output*, for the specific destination, is the defence. Input sanitisation destroys data and misses contexts (Defence in Depth).
- "The webhook came from the right IP." IP allowlists are a weak signal and no substitute for a signature over the raw body (Secure Webhooks).
Operating it
- Count rejections per surface, not just per endpoint. A validator that has never rejected anything is either unreachable or not doing what you think.
- Log the surface and rule on rejection —
{ surface: 'query', rule: 'sort_allowlist' }— so a probing pattern is visible (Structured Logging). - Alert on webhook signature failures. A nonzero steady rate is a misconfigured sender or a key rotation; a spike is someone trying (Inbound Webhooks).
- Track external-response parse failures per provider. They are the earliest warning that a partner changed something without telling you (Calling Something You Do Not Control).
- The inventory does not grow with traffic. It grows with integrations: every new webhook sender, provider and queue topic is a new row.
- At 10x request rate the cheap checks stay cheap; the expensive ones (content sniffing, image decoding, virus scanning) belong in a background job rather than the request path (What Happens After the Bytes Land).
- At 100x the surfaces multiply through services: an internal caller is still an input surface, and "internal" describes the network path rather than the data (The Trust Boundary).
- Validating every surface is real work with real maintenance, and some of it will never catch anything. That is what a control looks like when it is working.
- Strict parsing of external responses turns a partner's additive change into your outage. Being strict about fields you use and lenient about fields you ignore is the usual compromise.
- Validating internal calls duplicates effort and adds latency, in exchange for containing the blast radius of one buggy service.
- Content sniffing and scanning uploads costs CPU and time, which is why it belongs off the request path — at the cost of a window where the file exists and is not yet cleared (File Uploads Through the Backend).
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALThe inventory applies to any backend on any stack. Which surfaces exist is a function of what your service accepts, not of your framework.
- FRAMEWORK-SPECIFICWhere a surface is exposed and how easy it is to miss differs sharply. Express gives
req.queryas untyped strings (and, with the defaultextendedparser, nested objects and arrays from a query string, which surprises people); FastAPI validates query and path parameters through the same Pydantic machinery as the body by default, so they are the *hardest* to miss there; Go requires explicitr.URL.Query().Getper parameter, so nothing is validated unless you write it. The raw-body problem for webhook signatures is universal and framework-specific in its fix: Express needsexpress.raw()on that route before the JSON parser, FastAPI needsawait request.body()before touching the model. - PROTOCOL-SPECIFICHTTP/2 and HTTP/3 carry pseudo-headers and enforce lowercase header names, and header-size accounting differs from HTTP/1.1 because of HPACK/QPACK compression — so a limit expressed in raw bytes means something different per version. Duplicate headers are also handled differently by proxy and framework, which is the mechanism behind request smuggling.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.