~/posts/web/waf-backend-parser-differentials.md

WAF and backend: evidence for parser differentials

Trace proxy trust, inspection limits, duplicate values, decoding, and multipart framing. Distinguish changed responses from parser disagreement and observable application impact.

date[31:24]
read[23:16]
6 min
cat[15:8]
Web security
tags[7:0]
Contents
  1. 0x00Map interpretation before guessing rules
  2. 0x01Forwarded headers need a trust decision
  3. 0x02Size limits and oversize actions differ
  4. 0x03Prove semantic equivalence before detection differences
  5. 0x04Name the API for duplicate values and decoding
  6. 0x05multipart and chunked operate at different layers
  7. 0x06Close the chain with a minimal counterexample
  8. 0x07References

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.

Minimum proxy-trust evidence5 rows
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.

Test coverage with an ordinary marker3 steps
  1. 1

    Fix the baseline

    Keep route, media type, compression, and total byte length constant. Use a unique marker with no execution semantics.

  2. 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. 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:

Request
GET /review?q=first&q=second HTTP/1.1
Host: example.com
X-Review-Case: duplicate-query
Single-value access versus complete lists3 rows
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:

representation_model.pypython
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:

multipart_chunk_fixture.pypython
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

A reproducible test loop5 steps
  1. 1

    Preserve the baseline

    Record versions, ingress, route, original-byte digest, rule ID, and the value read by the application.

  2. 2

    Change one variable

    Validate syntax and lengths before comparing edge and backend objects. Avoid combining several encodings at the outset.

  3. 3

    Minimize the difference

    Remove unrelated fields and transformations until a minimal input still produces the disagreement.

  4. 4

    Verify application impact

    Distinguish reflection, data changes, event reachability, and execution. Do not rely on status alone.

  5. 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.

References

NORMAL~/posts/web/waf-backend-parser-differentials.md§--
0%en