AUTONOMY DIRECTORATE

⟨ QUANTUM ERROR PORTAL ⟩

Navigate the Error Dimensions

PQ Crypta

HTTP Request Smuggling Across Five Protocols

What the attack surface looks like when your proxy speaks HTTP/1.1, HTTP/2, HTTP/3, QUIC, and WebTransport simultaneously

HTTP/2 & HTTP/3 Request Smuggling WebTransport Allan Riddel · PQ Crypta · 23 May 2026

HTTP request smuggling is a parser disagreement attack. The front-end proxy and the back-end server read the same bytes and reach different conclusions about where a request ends. The defender loses when those conclusions diverge. This article documents what that attack surface looks like across all five transport layers a modern proxy may speak simultaneously — HTTP/1.1, HTTP/2, HTTP/3, QUIC, and WebTransport — using the pqcrypta-proxy pentest suite, not theory.

Key Findings
  • Each protocol version has a different normative prohibition on Transfer-Encoding, but a different enforcement mechanism
  • HTTP/2 (RFC 9113 §8.2.2) and HTTP/3 (RFC 9114 §4.2) forbid TE structurally; five obfuscated TE variants are in active pentest rotation regardless
  • WebTransport runs on a separate UDP port (4433) — TCP probes silently fail, making it invisible to scanners that test only TCP surfaces
  • CVE-2023-44487 (Rapid Reset) is an H2 connection-level attack, distinct from the stream-level TE desync class — different test, different mitigation
  • curl in HTTP/2 mode strips Transfer-Encoding before sending — you must force --http1.1 over TLS to actually deliver a CL.TE payload to a backend

The Same Pattern at Every Layer

HTTP request smuggling was well-documented against HTTP/1.1 proxies in 2019. The canonical forms — CL.TE (front-end trusts Content-Length, back-end trusts Transfer-Encoding) and TE.CL (reversed) — have since been patched out of most major reverse proxies. The HTTP/2 literature is thinner. The HTTP/3 and WebTransport literature is almost entirely absent.

The reason the literature is thin is not that the attack class does not apply. It is that few production proxies speak HTTP/3 and WebTransport in the same service, and fewer still test all five surfaces simultaneously. When a proxy supports HTTP/2 on port 443 and WebTransport on port 4433 UDP and HTTP/1.1 upgrade paths for legacy clients, all three attack surfaces coexist. The test suite either covers all of them or it covers none of them in any meaningful sense.

The same failure mode appears in the PDF domain: six parsers reading the same file and three reporting JavaScript present while two report absent. HTTP smuggling is that failure mode at the transport layer. One parser at the boundary, another at the origin — and the attacker controls which one sees the malicious request.

Five Surfaces, One Test Script

Script 07 of the pqcrypta-proxy pentest suite covers HTTP request smuggling across seven distinct sub-tests in a single run:

# 07 - HTTP Request Smuggling
# Full coverage: HTTP/1.1 (nc/port 80), HTTP/2 (TLS), HTTP/3 (QUIC), WebTransport
# CL.TE / TE.CL / TE.TE obfuscation — proxy-specific immunity documented per RFC

“Proxy-specific immunity documented per RFC” is load-bearing. RFC compliance does not mean immunity. It means the RFC defines the expected behavior; the test verifies it is actually enforced. These are different claims.

Surface 1 — HTTP/1.1 Plain (Port 80, TCP)

The test delivers CL.TE via netcat directly to port 80:

printf 'POST / HTTP/1.1\r\nHost: %s\r\nContent-Length: 13\r\n
Transfer-Encoding: chunked\r\n\r\n0\r\n\r\nSMUGGLED' "$HOST" | nc -w 5 "$HOST" 80

Expected result: TCP refused or 301/302/308 redirect. The proxy is HTTPS-only. Port 80 either drops the connection or redirects without parsing the body. No plain HTTP surface — no CL.TE surface at the transport layer.

Deploying an HTTP listener on port 80 to “support legacy clients” while routing real traffic through HTTPS creates a CL.TE surface that does not exist on the HTTPS path. The test documents that port 80 is dead or redirect-only, which eliminates this surface by construction.

Surface 2 — HTTP/1.1 over TLS (Port 443, Forced)

Eliminating port 80 does not eliminate HTTP/1.1 as a protocol. Many proxies accept HTTP/1.1 negotiated over TLS on port 443 for backward compatibility. If the front-end accepts HTTP/1.1-over-TLS and forwards to an HTTP/1.1 back-end, the CL.TE surface is intact — it just runs over TLS now.

The test uses --http1.1 to force HTTP/1.1 negotiation over the TLS connection, bypassing ALPN:

curl -sk --http1.1 -X POST "$TARGET/" \
  -H 'Content-Length: 13' \
  -H 'Transfer-Encoding: chunked' \
  --data-raw $'0\r\n\r\nSMUGGLED'

Critical detail: curl in HTTP/2 mode strips the Transfer-Encoding header before sending. If you run this test without --http1.1, curl silently removes TE before the request reaches the wire. You are not testing what you think you are testing. The flag is not optional.

Surface 3 — HTTP/2 over TLS — RFC 9113 §8.2.2

RFC 9113 §8.2.2 is unambiguous:

Transfer-Encoding MUST NOT be used in HTTP/2.
A server MUST treat the receipt of a request containing Transfer-Encoding
as a stream error of type PROTOCOL_ERROR.

The structural prohibition exists because HTTP/2 frames each request as a length-prefixed binary unit. The concept of chunked transfer — a streaming encoding for HTTP/1.1 connections where the server does not know body length in advance — has no role in a framed protocol. Its presence is either a mistake or an attack.

The test sends CL.TE over HTTP/2. Expected responses: 400, 403, or 422. A 200 means the server silently ignored the malformed TE header — which means a more cleverly constructed header may reach the back-end in an unexpected form.

Surface 4 — TE.TE Obfuscation (Five Variants)

TE.TE obfuscation describes attacks where both the front-end and back-end parse Transfer-Encoding, but one accepts a non-canonical form that the other rejects. All five variants should produce identical rejections on a correctly implemented HTTP/2 server. Any response code difference between variants is a finding.

Variant Header sent What it exploits
xchunked Transfer-Encoding: xchunked Some parsers treat any TE value beginning with “chunked” as chunked
List form Transfer-Encoding: chunked, identity Comma-separated TE; front-end may parse only the first token
JSON form Transfer-Encoding: ["chunked"] Parsers expecting plain strings may skip validation of JSON-encoded values
Double header Transfer-Encoding: chunked twice RFC requires duplicate TE to be treated as PROTOCOL_ERROR; some implementations do not
Tab-separated Transfer-Encoding:<tab>chunked RFC permits LWS after the colon; not all validators normalize tabs to spaces

Surface 5 — HTTP/3 / QUIC (Port 443, UDP)

RFC 9114 §4.2 repeats the HTTP/2 prohibition for HTTP/3:

HTTP/3 does not use the Transfer-Encoding header field.
Endpoints MUST NOT generate HTTP/3 frames that contain Transfer-Encoding;
clients MUST NOT send it.

The test uses two detection paths. If curl has HTTP/3 support (--http3), it delivers CL.TE directly over QUIC and expects a rejection per RFC 9114. If curl lacks HTTP/3 support, the test falls back to Alt-Svc discovery:

ALT_SVC=$(curl -sk --http2 -I -D - --max-time 10 "$TARGET/" | \
  grep -i 'alt-svc:' | head -1)
echo "$ALT_SVC" | grep -qi 'h3'

Alt-Svc discovery matters because a server advertising HTTP/3 support that is not being tested over QUIC is advertising an untested attack surface.

Surface 6 — WebTransport (Port 4433, UDP)

WebTransport runs over QUIC on a dedicated port. The test confirms isolation:

# TCP probe — QUIC is UDP; expect "closed"
timeout 4 bash -c "echo >/dev/tcp/$HOST/4433" 2>&1 && echo "open" || echo "closed"

# HTTPS over TCP to the QUIC port — expect 000
curl -sk --max-time 5 -o /dev/null -w '%{http_code}' "https://$HOST:4433/speedtest"

The expected curl return code is 000: no TCP connection established. A non-zero code here means something is accepting TCP on the QUIC port — a misconfiguration worth investigating.

A scanner that probes https://target:4433 over TCP, gets a connection refused, and marks the port as “no HTTP service” has not tested the WebTransport surface. It has only confirmed there is no TCP listener. Absent from the output and not present are not the same condition.

Surface 7 — CVE-2023-44487 / HTTP/2 Rapid Reset

The Rapid Reset attack is an HTTP/2 connection-level attack, not a stream-level TE desync. An attacker opens many streams and immediately sends RST_STREAM on each, flooding the server’s connection handling without completing any request. This is categorically different from the Transfer-Encoding attacks above — different primitive, different mitigation, different test.

The test sends ten rapid HTTP/2 requests and confirms all return expected response codes (200, 403, or 429). Ten requests is not enough to trigger a real Rapid Reset condition; it is enough to detect a server that is already not handling HTTP/2 lifecycle correctly.

A Representative Smuggling Chain

The following is a constructed scenario built from the attack mechanics in Script 07. It is not a named incident. It is the simplest chain that combines the H2-to-H1.1 translation gap with the TE.TE double-header variant to produce a smuggled request that poisons a subsequent legitimate client.

Setup. A CDN terminates HTTP/2 from the public internet and forwards to an origin over HTTP/1.1, reusing a persistent keep-alive connection. The CDN does not strictly enforce the Transfer-Encoding prohibition — it passes malformed TE headers through to the origin rather than rejecting the stream. The origin interprets a chunked TE header as authoritative over Content-Length.

Step 1 — Attacker sends HTTP/2 POST with double Transfer-Encoding:
  :method POST
  :path /api/data
  content-length: 6
  transfer-encoding: chunked        ← first occurrence
  transfer-encoding: identity       ← second (TE.TE double-header variant)

  0\r\n\r\n                         ← zero-length chunk; CL says 6 bytes, chunked sees end

Step 2 — CDN translates H2→H1.1, keeps one TE header (implementation-defined which):
  POST /api/data HTTP/1.1
  Content-Length: 6
  Transfer-Encoding: chunked        ← forwarded; origin trusts TE over CL

  0\r\n\r\n                         ← origin sees end of chunked body after 5 bytes
                                     1 byte buffered on the backend keep-alive connection

Step 3 — Next client request arrives on the same backend connection:
  GET /user/profile HTTP/1.1
  Authorization: Bearer eyJ...

  The 1 buffered byte prepends to this request, corrupting the method line.
  The Authorization header from the legitimate client is now readable by the
  attacker if the origin reflects it in an error response.

The critical enabler is keep-alive connection reuse. If the CDN opened a new TCP connection per client request, the smuggled suffix would have nowhere to go. The efficiency optimization is the attack primitive.

Script 09 (cache poisoning) tests a related amplifier: X-Custom-IP-Authorization: 127.0.0.1 as an unkeyed header. If the origin trusts this header for access control and the CDN forwards it without stripping, a smuggled request can inject it into the poisoned prefix — and the next client’s request arrives at the origin appearing to come from localhost. Smuggling combined with an unkeyed trusted header collapses boundary smuggling and authentication bypass into a single payload.

Why CDNs Fail Here

CDNs terminate TLS, parse HTTP/2 or HTTP/3 frames, and forward to origins over a connection pool they maintain independently of any individual client. Three structural gaps enable the smuggling attack class.

Protocol translation at the edge. A CDN that accepts HTTP/2 from clients and forwards to HTTP/1.1 origins performs protocol translation in both directions. The framing model changes entirely: H2 uses binary length-prefixed frames; H1.1 uses header-terminated chunks or content-length to delimit the body. Any ambiguity in the H2 request — a prohibited TE header forwarded instead of rejected, a malformed content-length, a trailer injection — must be resolved at translation time. How a given CDN resolves it is implementation-specific and not always documented.

Connection pooling to the origin. The CDN maintains persistent HTTP/1.1 connections to the origin. Multiple client streams are multiplexed over the H2 front-end and serialized onto these pooled backend connections. A request that leaves residue on a backend connection contaminates the next request from whatever client happens to be assigned to that connection. The attacker does not need to know which client is next — only that one will be.

Unkeyed header forwarding. Script 09 tests headers that CDNs commonly forward and origins commonly trust: X-Forwarded-Host, X-Forwarded-Proto, X-Original-URL, X-Custom-IP-Authorization. If any of these are forwarded without stripping client-supplied values, a smuggled prefix can inject them into the next client’s request. The test checks whether the injected value is reflected in the response body — reflection is the minimum signal; actual access-control trust of the header is the worst-case outcome.

CDN vendors claim RFC 9113 §8.2.2 compliance universally. RFC compliance for the CDN’s own H2 parser is not the same as enforcement at the H2→H1.1 translation boundary, which is where the attack lives. The test that matters is Script 07’s TE.TE obfuscation suite against the translated origin request, not against the CDN’s H2 parser directly.

Why QUIC Middleboxes Are Blind

Traditional network security appliances — IDS, IPS, DPI, inline WAF — operate by inspecting TCP streams. They reassemble byte sequences, apply signatures to content, and pass or block. This model works against HTTP/1.1 smuggling because CL.TE and TE.CL attacks are visible in the TCP byte stream. An IDS that sees Transfer-Encoding: chunked alongside Content-Length can flag it. The signature is trivially writable.

QUIC eliminates that inspection surface entirely. QUIC encrypts not only the payload but nearly the entire transport header. After the first byte of the packet, everything is encrypted under the QUIC packet protection key. A network device without the session key sees opaque UDP datagrams. It knows source and destination ports and that the traffic is UDP. It knows nothing about the HTTP/3 frames inside.

IDS signatures for HTTP request smuggling — including signatures for the five TE obfuscation variants in Script 07 — are byte-pattern rules applied to reassembled TCP segments. None of them function on QUIC traffic because there are no reassembled TCP segments to inspect. A WAF advertised as blocking HTTP request smuggling does not block it on QUIC. The WAF is simply not on the path.

The fallback behavior compounds this. Middleboxes that cannot inspect QUIC often block UDP/443 entirely. When a client’s QUIC handshake is blocked, the client falls back to HTTP/2 over TLS on TCP — correct per Alt-Svc specification, and exactly what Script 07’s Alt-Svc detection path documents. The attacker can force this downgrade deliberately by disrupting UDP/443, then attack the TCP path where the middlebox’s own signatures apply — but the QUIC-level prohibitions no longer do. The middlebox that blocked QUIC has moved the attack surface, not reduced it.

The correct posture is to test both paths: the QUIC path with a QUIC-capable client, and the TCP fallback with --http2 over TLS. Script 07 tests both because both exist on any server advertising HTTP/3 via Alt-Svc.

Why WebTransport Is the New Frontier

WebSocket (RFC 6455) has been the subject of serious security research for over a decade. Cross-site WebSocket hijacking, WebSocket smuggling via HTTP upgrade confusion, and token leakage via query-string negotiation are all documented and tested in Script 13 — cross-origin upgrade from evil.com, unauthenticated upgrade, invalid token, token in query string, upgrade against /admin/, and the RFC 8441 HTTP/2 WebSocket variant. The research exists because WebSocket is widely deployed and has been attackable long enough to accumulate a documented body of cases.

WebTransport (RFC 9297, W3C Level 1) is in a different position. It is HTTP/3-native, runs over QUIC streams and datagrams, and is deployed on a separate port (4433 UDP in pqcrypta-proxy). The published security research on WebTransport-specific attack techniques is sparse. The attack surface is not sparse — WebTransport supports multiple bidirectional streams per session and unreliable datagrams, giving an attacker more degrees of freedom than a WebSocket connection — but the research has not caught up to the deployment. Most WAF vendors have no WebTransport-specific signatures. Most pentest tooling has no WebTransport-aware clients. Most security teams have not added WebTransport to their test matrix because it is not in their vulnerability scanner’s default profile.

Script 13 confirms the port isolation: https://host:4433 over TCP returns 000. That result, which registers as a failure in a naive port scan, is the correct behavior. But it means every scanner that probes TCP ports and tests HTTP over TCP has silently skipped the WebTransport surface and produced a result indistinguishable from “nothing there.” Absent from the output and not present are not the same condition, and the difference is not visible in the scan report.

The Adjacent Surface: Header Injection and IP Spoofing

Request smuggling is one class of parser disagreement attack at the HTTP layer. A related class is header injection — where a forwarded header is trusted by an upstream system in a way the sender did not intend.

Script 03 sends nine commonly-trusted forwarding headers with loopback or RFC 1918 addresses:

X-Forwarded-For: 127.0.0.1
X-Real-IP: 127.0.0.1
X-Originating-IP: 127.0.0.1
True-Client-IP: 127.0.0.1
CF-Connecting-IP: 127.0.0.1
X-Forwarded-For: ::1

If any of these cause the server to grant elevated access, the response code diverges from the baseline. IPv6 variants extend the same test to formats ASCII-based filters frequently miss: [::1], ::ffff:127.0.0.1, 0000:0000:0000:0000:0000:0000:0000:0001, and [::ffff:127.0.0.1] in both XFF and Host header positions. A filter that blocks 127.0.0.1 in XFF but accepts ::ffff:127.0.0.1 has a documented bypass.

The Unifying Principle

Every attack in this article follows the same structural pattern:

  1. Two parsers read the same bytes. Front-end and back-end, or scanner and renderer, or proxy and origin — the roles vary, but there are always exactly two.
  2. The bytes are ambiguous under at least one parser’s rules. CL.TE: body length defined twice, definitions conflict. TE.TE obfuscation: the header value is non-canonical and parsers normalize it differently. IP spoofing: the header is structurally valid but semantically trusted by one side and distrusted by the other.
  3. The attacker controls which parser sees which interpretation. The attack is not about breaking a parser. It is about making two correct parsers disagree on the same input.

This is why the test suite covers all five protocol surfaces in a single script. A proxy that blocks HTTP/1.1 CL.TE but accepts an HTTP/2 TE.TE obfuscation has not solved the problem. It has moved it. Testing one surface while advertising support for five is not security testing — it is auditing a fraction of the attack surface and calling it done.

What pqcrypta-proxy Does About It

The design decisions that address this attack surface are structural, not configuration:

Design decision Surface eliminated
HTTP/2+TLS only; port 80 redirectsPlain HTTP CL.TE and TE.CL
TLS 1.2 minimum; TLS 1.0/1.1 rejected at handshakeLegacy cipher negotiation attacks
RFC 9113 §8.2.2 enforcement: TE header returns STREAM_ERRORHTTP/2 CL.TE, TE.TE obfuscation variants
RFC 9114 §4.2 enforcement on QUIC endpointHTTP/3 Transfer-Encoding injection
WebTransport on isolated UDP port 4433; no TCP listenerTCP-based smuggling into WebTransport surface
Untrusted forwarding headers; XFF not used for access controlIP spoofing via X-Forwarded-For and related headers
HTTP/2 connection rate limiting; RST_STREAM handlingRapid Reset DoS surface (CVE-2023-44487)

The test suite exists because claiming RFC compliance is not the same as enforcing it. The proxy documents its expected behavior per RFC, then runs 32 automated attack scripts across 12 phases to verify that the documented behavior is what the server actually produces. Script 07 is one of 32. It runs on every deploy.

How to Run the Test on Your Own Proxy

If you operate a proxy that speaks more than one HTTP version, the minimum test coverage for this attack class:

  1. Confirm port 80 accepts no plain HTTP (nc probe, no body required).
  2. Force HTTP/1.1 over TLS with --http1.1 and deliver CL.TE and TE.CL payloads. Confirm the response indicates rejection, not success.
  3. Send all five TE.TE obfuscation variants over HTTP/2. Confirm identical response codes. Divergence between variants is a finding.
  4. Check Alt-Svc header for h3. If present, test HTTP/3 CL.TE with a QUIC-capable client. Expect rejection per RFC 9114.
  5. TCP-probe your WebTransport port. Expect closed. TCP open on a UDP service port is a misconfiguration.
  6. Send XFF with loopback and RFC 1918 values. Confirm identical response to a baseline request. Any difference is a potential access-control bypass.

None of these require specialized tooling beyond curl, nc, and a QUIC-capable client. The complexity is in knowing which protocol surface each test actually reaches — particularly the --http1.1 requirement on the TLS surface, and the TCP/UDP distinction on the WebTransport port.

Related Research

The same parser-disagreement pattern in the PDF document format is documented separately at PQ PDF — where the scanner’s parser and the viewer’s parser report different structure for the same file:

PDF Parser Disagreement: Six Parsers, Eleven Lies →

The canonical example from that study is a 772-byte PDF with two complete document structures. Five parsers load the second document (correct per spec: read from the last %%EOF). pdf.js loads the first (incorrect: scans for the first /Count value in file order). Same file. Same machine. Different page count. The HTTP/2 TE.TE double-header variant is the same attack: two parsers, same bytes, different interpretation, attacker wins the difference.

For the full pqcrypta-proxy architecture including HTTP/3, WebTransport, and hybrid post-quantum TLS:

QUIC vs TCP Real-World Analysis: HTTP/3 & WebTransport →