HTTP status alone is a poor measure of a WAF test. A 200 response may be a custom block page, while a 403 may come from the application. Compare what the same request bytes become at the inspection point and in business logic.
A useful investigation follows evidence rather than accumulating encodings. Deployment path, inspection coverage, parsing, and application semantics are four separate questions.
Map interpretation before guessing rules
flowchart TD A["Original request bytes"] --> B["Ingress routing and message framing"] B --> C["Content visible to inspection"] C --> D["Forwarding and backend parsing"] D --> E["Value read by business logic"] E --> F["Observable application effect"]
This is an analysis model, not a universal product pipeline. Deployment determines the order of reputation checks, rate limits, challenges, scoring, and content rules. Preserve original bytes, edge logs, forwarded content, and application values to locate the disagreement.
A matching certificate, favicon, or historical DNS address is only a deployment clue. Templates reuse icons, certificates cover multiple entry points, and SPF describes email sending. None proves that two paths reach the same origin. Compare origin configuration, Host/SNI routing, and application logs, including routes that only log or skip inspection.
Forwarded headers need a trust decision
X-Forwarded-For and X-Real-IP start as request data. Their trusted meaning depends on the preceding hop, header cleanup, and backend proxy configuration, not the field name.
| Location | Record |
|---|---|
| Connection | TCP peer and actual ingress |
| Edge input | Original client-supplied forwarding fields |
| Edge output | Fields after removal, replacement, or append |
| Framework | Final client-address API result |
| Decision | Trusted range, exemption, and rule ID |
Express trust proxy must match the real topology. When trusting forwarded values broadly, the last trusted proxy must remove or overwrite the relevant client-supplied fields. Hop-count trust also needs to account for paths of different lengths. Membership in a large shared network or ASN is not application authentication.
Size limits and oversize actions differ
Total request size, inspected prefix, decompressed size, field length, and upload size may have different limits. Character counts are not UTF-8 byte counts, and the WAF inspection limit is not necessarily the backend acceptance limit.
AWS WAF offers Continue, Match, and No match for oversize components. Continue inspects available content within the limit; Match and No match determine the current statement's match result. Match does not inherently mean block: the rule action and other rules still matter.
- 1
Fix the baseline
Keep route, media type, compression, and total byte length constant. Use a unique marker with no execution semantics.
- 2
Move one field
Change the marker position without changing total length. Record whether inspection recognizes it and whether the backend reads it completely.
- 3
Check oversize behavior
Separate full rejection, prefix inspection, logging, and backend truncation. If the backend also rejects the input, an application-impact path has not been established.
Prove semantic equivalence before detection differences
Case, whitespace, and comments have grammar-specific roles. UNION/**/SELECT can separate two tokens in an appropriate SQL dialect; UN/**/ION does not universally become one keyword. HTML tag names are not reconstructed by stripping arbitrary comments either.
JavaScript identifiers are case-sensitive, and string concatenation only produces a string. Unicode escapes, octal notation, and non-breaking spaces depend on grammar position, strict mode, and encoding. A lone %a0 is not a universal UTF-8 whitespace representation.
For browser effects, inspect insertion context, template escaping, DOM APIs, CSP, Trusted Types, and event reachability. The appearance of a JSFuck expression proves neither execution nor harmlessness. Reflection and reaching a code- or markup-interpreting sink are different observations.
Name the API for duplicate values and decoding
This is an ordinary query example, with no attached server response or test result:
GET /review?q=first&q=second HTTP/1.1
Host: example.com
X-Review-Case: duplicate-query| Parsed object | Single-value access | List access |
|---|---|---|
| Django 5.2 QueryDict | Last value | getlist() preserves the list |
| Werkzeug MultiDict | First value | getlist() preserves the list |
| Application-defined merging | Depends on the code | Do not assume automatic concatenation |
The implementation language is not a precise parser label. Query parameters, form fields, duplicate JSON keys, and same-named multipart parts need separate tests. If business logic does not join a list, no implicit join should be invented.
This model checks URL decoding depth, duplicate-value selection, and a cp037 round trip. It does not execute Django, Werkzeug, or a commercial WAF:
from urllib.parse import (
unquote, parse_qsl, quote_from_bytes, unquote_to_bytes
)
raw = "%252f"
once, twice = unquote(raw), unquote(unquote(raw))
assert (once, twice) == ("%2f", "/")
pairs = parse_qsl("q=first&q=second", keep_blank_values=True)
values = [v for k, v in pairs if k == "q"]
assert (values[0], values[-1], ",".join(values)) == (
"first", "second", "first,second"
)
marker = "review_marker"
encoded = quote_from_bytes(marker.encode("cp037"), safe="")
assert unquote_to_bytes(encoded).decode("cp037") == marker
print("decode=%2f -> /; first=first; last=second; cp037=roundtrip")A charset=ibm037 declaration may be rejected or ignored by the backend. %252f becomes a slash only if another relevant layer decodes it again. Even different representations may have no impact when the value remains ordinary text.
Cookie quotes, backslashes, and the historical $Version field also depend on the parser. Record each layer's key-value output and subsequent decoding. Acceptance of a field is not evidence of browser execution.
multipart and chunked operate at different layers
Recover the HTTP message content before interpreting multipart delimiters. The application decides whether to parse nested parts; multiple parts do not inherently concatenate into one business value.
Quoting a boundary does not make every character valid. RFC 2046 permits a colon, so boundary="Review:Boundary" is a suitable quoted-parameter test. A semicolon is outside that boundary character set: treat it as malformed-input and error-handling coverage, not a valid equivalent spelling.
This example builds an ordinary form, encodes it as HTTP/1.1 chunks of at most 17 bytes, then independently decodes it for comparison:
boundary = b"Review:Boundary"
body = (b"--" + boundary + b"\r\n"
b'Content-Disposition: form-data; name="q"\r\n\r\n'
b"review_marker\r\n--" + boundary + b"--\r\n")
parts = [body[i:i + 17] for i in range(0, len(body), 17)]
wire = b"".join(
f"{len(p):X}\r\n".encode("ascii") + p + b"\r\n" for p in parts
) + b"0\r\n\r\n"
def decode_fixture(data):
pos, result = 0, bytearray()
while True:
end = data.index(b"\r\n", pos)
size = int(data[pos:end], 16)
pos = end + 2
if size == 0:
assert data[pos:] == b"\r\n"
return bytes(result)
result += data[pos:pos + size]
pos += size
assert data[pos:pos + 2] == b"\r\n"
pos += 2
assert decode_fixture(wire) == body
assert body.endswith(b"--" + boundary + b"--\r\n")
print(f"body={len(body)}; chunks={len(parts)}; roundtrip=OK")The decoder serves only this controlled fixture without extensions or trailers; it is not a general HTTP parser. Chunk lengths are hexadecimal byte counts. The multipart closing boundary remains inside the body, before the zero-length chunk and terminating empty line.
HTTP/2 carries content in DATA frames rather than HTTP/1.1 chunked coding. Inspect both representations when a proxy converts protocols. Transfer-Encoding does not carry over into HTTP/2; TE: trailers is a different field with a specific exception.
Close the chain with a minimal counterexample
- 1
Preserve the baseline
Record versions, ingress, route, original-byte digest, rule ID, and the value read by the application.
- 2
Change one variable
Validate syntax and lengths before comparing edge and backend objects. Avoid combining several encodings at the outset.
- 3
Minimize the difference
Remove unrelated fields and transformations until a minimal input still produces the disagreement.
- 4
Verify application impact
Distinguish reflection, data changes, event reachability, and execution. Do not rely on status alone.
- 5
Add a counterexample and regression tests
Removing the key change should restore the baseline. After a fix, check both the original case and normal requests.
The offline models validate representation changes and message lengths only. They send no requests and make no claim that these candidate conditions work universally against current products. Fix the inconsistent layer: origin exposure, proxy trust, oversize handling, parsing policy, or context-sensitive handling at the application sink. Adding another keyword addresses only the visible string.