An input validation review should follow a value in the form each component actually receives it: request bytes, parsed object, validated value, queued message, reconstructed object and final operation. At each boundary, ask who can supply this version of the value, what exactly was checked, and whether later code uses the checked value or an older copy. A successful JSON parse proves that the bytes formed JSON; it does not prove the field has an allowed type, meaning, owner or use. The answer changes when a worker accepts messages from another producer, a serializer applies defaults or coercion, or a downstream operation interprets a previously harmless string as syntax.
OWASP's Input Validation Cheat Sheet distinguishes syntax from meaning and treats authorization and output encoding as separate controls. CWE-20 likewise includes type, range, consistency and domain rules, while warning that “input validation” is too broad a label for many specific failures. Review the real boundary rather than stopping at a decorator named validate.
Illustrative case: a report export with a queued worker
All code, systems and values here are illustrative. In Northstar Reports, a signed-in member requests a CSV export with a sort field. The API parses JSON, enqueues an export request, and a worker builds a query. The review question is whether an attacker-influenced sort choice can change query syntax. A separate question is whether the member may export the requested account's data; an enum check cannot answer that authorization question.
The intended safe flow is:
request bytes
-> bounded JSON parse
-> exact request shape and allowed sort key
-> new server-owned message containing tenant and allowed key
-> worker rechecks message origin and allowed key
-> server-owned column mapping; data values stay bound parameters
For this small interface, a safe illustrative boundary function could be:
import json
SORT_COLUMNS = {"created": "created_at", "total": "total_cents"}
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate JSON key")
result[key] = value
return result
def requested_sort(body: bytes) -> str:
if len(body) > 4096:
raise ValueError("request too large")
raw = json.loads(body, object_pairs_hook=unique_object)
if type(raw) is not dict or set(raw) != {"sort"}:
raise ValueError("unexpected request shape")
sort = raw["sort"]
if type(sort) is not str or sort not in SORT_COLUMNS:
raise ValueError("unknown sort")
return sort
The caller should derive the tenant from authenticated server context, then create a new message with that tenant and the returned key. It should not copy a client-supplied tenant_id or pass the original raw object onward. The worker should accept only its authorized producer and verify the message's account/job binding before using it; where another producer can write to the queue, it must validate the field again. The column name comes from SORT_COLUMNS, never directly from the message. SQL data values require bound parameters; an allowed sort identifier normally needs a fixed mapping because ordinary value placeholders do not select SQL identifiers. OWASP's SQL injection guidance explains this distinction.
Enforce the request-size limit while receiving the body, before buffering arbitrary bytes; the len check is a second check after receipt. Convert parser, duplicate-key and nesting failures into the documented rejection without retrying a more permissive parser. The small accepted schema needs no nested user objects.
This snippet is a narrow example, not a complete export service. It does not implement authentication, authorization, queue signatures, query construction or resource limits for every nested document. Python's json documentation describes decoding into Python objects; the type and domain checks are application work. OWASP's deserialization guidance also warns that richer native object deserializers can have different, sometimes dangerous, behavior. Do not generalize a JSON example to pickle, YAML object loading or a framework's automatic model binding.
Make the trace an evidence table
Walk one value and its authority across the whole system:
| Hop | Representation and possible writer | Required check | Evidence to inspect |
|---|---|---|---|
| HTTP entry | Raw bytes from signed-in client | Size, content type, identity and route permission | Route, gateway and request limits |
| JSON parse | Language objects | Parser behavior, duplicate keys, malformed input | Actual parser/options and error handler |
| API validation | sort field |
Exact type and allowed key; reject unknown fields | Validation call and normalized return value |
| Message creation | Queue payload | Tenant/job derived from server authority; only checked fields copied | Producer code and message schema |
| Queue delivery | Bytes from permitted producers | Producer identity, job binding, schema/version and replay policy | Queue policy, consumer handler and message contract |
| Worker query | Server-owned column name and bound data | Fixed identifier mapping and proper driver API | Mapping, query builder and driver call |
The table reveals a common review mistake: the API validates sort, then serializes the original request dictionary instead of the validated key. Another mistake is trusting a message because “the API sends it” while a migration tool or internal job also has write access to the queue. Neither observation proves exploitation by itself; they identify a path and a missing control to check.
Compare the checked value with the serialized value
A subtle mistake appears when a developer expands the interface with an alias. Suppose a migration accepts newest and normalizes it to created. The following pseudocode validates a local result but queues the original field:
checked_sort = validate_and_normalize(request.sort) # "created"
queue.send({sort: request.sort}) # "newest"
This is not the behavior of the strict requested_sort function above; it is a proposed compatibility change to review. The validator's success only speaks about checked_sort. A worker expecting the old contract may reject newest, choose an undocumented fallback, or pass it onward. Follow that branch before declaring injection: a clean rejection is a reliability problem, while raw SQL assembly could become a security path.
The narrower producer constructs a new record from the return value and server authority:
{"schema":2,"job_id":"E-7","tenant_id":"Cedar","sort":"created"}
E-7 resolves to a server-owned job that binds Cedar, the initiating actor and the allowed action. This message is illustrative; it is not a signed credential. The worker must verify the permitted producer and job binding, validate the schema/key, and map created to the fixed identifier created_at. Merely copying a trustworthy-looking tenant string is insufficient.
Resolve compatibility instead of silently broadening it
The review discovers a scheduled producer still emits schema 1 with order_by. There are three choices: upgrade and retire that producer; implement a narrow explicit version-1 adapter; or reject version 1 and communicate the interruption. A generic fallback that accepts either field from any message makes the contract harder to establish.
In this synthetic decision, the owner chooses an adapter while upgrading the producer:
| Input | Expected worker behavior |
|---|---|
Schema 2, sort=created, valid E-7/Cedar binding |
Select constant created_at |
Schema 2, duplicate sort fields in encoded JSON |
Reject during strict decoding |
Schema 2, extra order_by field |
Reject unexpected shape |
| Schema 1, approved legacy producer, exact supported key | Normalize through the documented adapter, then use the same mapper |
| Schema 1 from a different producer or unknown key | Reject before query construction |
| Any schema, job belongs to another account | Deny regardless of valid sort |
These are planned outcomes, not test results. Python's JSON decoder supports an ordered-pairs hook, which the earlier function uses to detect duplicate fields instead of letting later parsing choose a winner. The worker needs an equivalent contract at its own decoding boundary; the API's parse does not govern separately produced queue bytes.
The final evidence packet contains the raw example message, normalized record, mapper output and driver call. It records the compatibility adapter's removal owner. The reader can now point to exactly which representation was validated and used, and distinguish a rejected unsupported request from an alternate path that reaches interpreter syntax.
Counterevidence and uncertainty
If the API constructs a fresh immutable message from the checked key, queue writes are restricted to that producer, and the worker maps the key to one of two literal columns, the proposed sort-to-SQL-syntax path may be defeated at the reviewed revision. Record those concrete observations. If the worker also receives older schema versions or a batch import path, the conclusion must cover those paths separately. If deployment routing is unknown, state “source-supported control, production applicability unverified” rather than a universal pass.
Conversely, finding an unrecognized queue producer or an original-body copy is a reason to investigate, not automatic proof that a user can alter query syntax. Trace that producer's actor, checks and reachable deployment, then ask what the database driver actually receives. An error in input validation may create a bug without creating injection; the harmful effect still needs a supported path. OWASP's secure code review guidance frames review as following input through processing to sinks and across trust zones.
Review the rejection path as carefully as the accepted shape
Northstar's strict request function can reject malformed JSON, duplicate fields, an unexpected object shape, or an unsupported sort key. The caller must preserve that rejection. Inspect whether its error handler returns the documented failure or retries with a permissive parser, a default raw query, or the original request body. A validator is useful evidence only if later control flow respects its result.
For the synthetic schema, rejecting an extra tenant_id does not establish tenant authorization; it prevents this interface from accepting an alternate tenant selector. The caller still derives account authority from the authenticated context and persisted job. Keep those claims separate in the review packet so a developer does not remove the membership lookup after adding an exact-shape validator.
Similarly, successful normalization creates a value with a defined meaning. It does not make the original bytes safe for every destination. The queue should contain the returned sort key, and the worker should map that key to a fixed identifier. If the original body is retained for troubleshooting, it remains untrusted and may contain sensitive data; it should not become an alternate source for execution.
Inspect schema transitions at both ends
During the version1-to-version2 migration, the producer and worker may deploy at different times. Write down which combinations are intentionally supported. A narrow legacy adapter can preserve availability, but its accepted fields, producer identity, and output schema must be explicit. Unknown combinations should not fall through to a broad compatibility path.
The worker's normalized representation is a useful review artifact. Show the incoming version1 message, the adapter's version2-equivalent result, and the fixed mapper output. This demonstrates which fields survive and which are discarded. If the adapter copies order_by into SQL directly, the newer API's strict validation does not protect that branch.
Record an owner and retirement condition for the adapter. Otherwise an emergency compatibility path can become a permanent second input language that later reviewers overlook. Retiring it means checking actual producers and queued messages, not merely deleting a reference from the current web client.
The final finding or rejection should name the representation that reaches the interpreter. “Validated at the API” is incomplete; “worker receives the normalized allowed key from an authenticated, job-bound message and selects a fixed column” is a claim another reviewer can inspect. Any missing producer or deployment relationship remains a separate evidence gap.
For your next boundary review, take one disputed field and draw its representation at every hop before rating severity. If the disagreement is whether the final API call is reachable with attacker-controlled syntax, continue with the dangerous-call decision guide. If the path is supported, choose a remediation at the interpreter boundary.