Request smuggling occurs when components disagree about HTTP message boundaries and an attacker exploits the disagreement. In a proxy chain, bytes treated as one request’s body by the front end may become another request at the back end. The important evidence is inconsistent parsing and forwarding, not simply finding two HTTP status lines in a response capture.

One stream, incompatible boundaries

HTTP/1.1 framing follows explicit rules. A valid Content-Length can define the body length; chunked transfer coding ends with its terminating chunk and trailer section. A request with neither framing mechanism has no body. Transfer-Encoding takes precedence when both fields appear, but that combination is suspicious and requires special handling rather than casual acceptance.

RFC 9112 permits a server to reject a request containing both fields or process it according to Transfer-Encoding alone. Either way, it must close the connection after responding. An intermediary choosing to forward such a message must first remove the received Content-Length and process Transfer-Encoding before forwarding. Rejecting dual framing at an entry boundary is a useful strict policy, but it is not the only behavior described by the standard.

CL.TE names a front end using Content-Length and a back end using Transfer-Encoding; TE.CL reverses those choices. Other discrepancies involve conflicting lengths or inconsistent handling of transfer-coding syntax. These labels describe parser behavior, not two equally valid interpretations. Invalid input must not become a reason for different hops to guess independently.

Desynchronization has more than one impact

A complete hidden request may be processed immediately; it need not wait for a victim’s next request. Other attacks leave an incomplete prefix that combines with later traffic. Response-queue poisoning is one possible consequence, but route-filter bypass, cache poisoning, and unauthorized backend requests can matter without that exact sequence. Impact depends on connection reuse, routing, authentication, and the application’s behavior.

An extra backend request should therefore be traced to the actual bytes and connection. Counts alone are insufficient: retries, health checks, caching, and asynchronous logging can legitimately make layers disagree. A backend log entry lacking its expected edge counterpart is an investigation lead, not automatic proof. Use correlation data and a controlled fixture with identifiable synthetic requests.

When it breaks

A superficial header filter can miss ambiguity created during protocol conversion. HTTP/2 uses frames rather than HTTP/1.1 chunked delimiting, but downgrading to HTTP/1.1 creates a new serialization boundary that must be correct. For HTTP/2 requests, any Content-Length must agree with the content size; no-content responses have separate rules. It is incorrect to treat HTTP/2 as having no relevant length checks.

End-to-end HTTP/2 avoids some downgrade-specific problems, but is not a universal immunity claim. Validate protocol transitions, reject malformed messages consistently, and avoid reusing a connection after unrecoverable framing errors. A strict edge can block a specific attack even if another component is tolerant; assess each reachable path rather than asserting that hardening one hop can never help.

Client-side curl output showing HTTP/2 only establishes the protocol on that client connection. It cannot reveal the protocol used behind a CDN or reverse proxy. Similarly, two responses may simply answer two ordinary pipelined requests. A safe assessment must demonstrate which bytes each component classified and whether the result crossed an intended request boundary.

A 57-byte schematic body splits into a five-byte terminator and a 52-byte follow-up, with explicit CL and TE remaining-byte comparisons; an agreed ordinary pipeline is separate from dual-framing rules and protocol-conversion limits.
The same synthetic bytes expose incompatible boundary arithmetic. A two-request pipeline alone is not smuggling; live parser and connection behavior remain untested.

Check it quickly

Save as framing_demo.py and run python3 framing_demo.py. This offline model constructs a byte string in memory, computes the two deliberately incompatible boundaries, and checks a strict rejection policy. Nothing is sent to a server, no socket is opened, and the synthetic path identifies no real administrative endpoint. The declared length is calculated from the actual body rather than copied as a magic constant.

# An in-memory boundary model, not an HTTP parser or network probe.
followup = b'GET /synthetic-next HTTP/1.1\r\nHost: example.test\r\n\r\n'
body = b'0\r\n\r\n' + followup
head = (b'POST /upload HTTP/1.1\r\nHost: example.test\r\nContent-Length: '
        + str(len(body)).encode() + b'\r\nTransfer-Encoding: chunked\r\n\r\n')
wire = head + body
header_end = wire.index(b'\r\n\r\n') + 4
cl_boundary = header_end + len(body)
# This fixture has precisely one zero chunk and an empty trailer section.
te_boundary = header_end + len(b'0\r\n\r\n')
if wire[cl_boundary:] != b'' or wire[te_boundary:] != followup:
    raise RuntimeError('boundary expectations failed')
if cl_boundary == te_boundary:
    raise RuntimeError('the two interpretations must differ')


def reject_dual_framing(header):
    names = {line.split(b':', 1)[0].lower() for line in header.split(b'\r\n')[1:] if b':' in line}
    if {b'content-length', b'transfer-encoding'} <= names:
        raise ValueError('dual framing rejected')


try:
    reject_dual_framing(head)
except ValueError as error:
    print(error)
else:
    raise RuntimeError('ambiguous message accepted')

ordinary_first = b'GET /first HTTP/1.1\r\nHost: example.test\r\n\r\n'
ordinary_pipeline = ordinary_first + followup
first_boundary = ordinary_pipeline.index(b'\r\n\r\n') + 4
if ordinary_pipeline[first_boundary:] != followup:
    raise RuntimeError('ordinary pipeline example failed')
print('CL remaining bytes:', len(wire[cl_boundary:]))
print('TE remaining bytes:', len(wire[te_boundary:]))
print('ordinary pipeline: two intentionally separate requests')
print('PASS: different framing boundaries; dual framing rejected; pipelining distinguished')

The Content-Length interpretation leaves zero remaining bytes; the chunked interpretation leaves the complete fifty-two-byte synthetic request. The strict policy rejects the combination. A separate ordinary pipeline intentionally contains two requests and is not labeled smuggling merely because both are present. PASS confirms these limited relationships when run successfully. This source review checked the unchanged bytes and retained records without executing the model or contacting a proxy.

This is not a general HTTP parser. The chunked case contains only a zero chunk and an empty trailer section; the header check does not implement all field grammar, duplicate-header rules, or protocol conversion. It demonstrates why a boundary disagreement matters without claiming a live proxy is vulnerable. Integration testing needs an isolated, authorized chain, its actual parser versions, connection lifecycle observations, and negative cases proving ambiguous input cannot reach an unintended backend operation.

Sources