Make every hop agree on where each request ends. Use HTTP/2 between the proxy and the origin where both support it, because HTTP/2 frames messages itself and leaves no choice between Content-Length and Transfer-Encoding. On any remaining HTTP/1.1 hop, reject requests with both framing headers, conflicting lengths or an unrecognized Transfer-Encoding with 400 and close the connection. Then test the whole chain with raw requests and the origin log.
- Request smuggling needs two parsers that disagree about one request's length on a reused connection.
- RFC 9112 makes
Transfer-EncodingoverrideContent-Lengthand says the pair ought to be handled as an error; strict servers answer 400 and close. - PortSwigger's 2025 research added 0.CL and
Expect-based desyncs and concluded that HTTP/1.1 between proxy and origin is the root problem. - nginx 1.30 can proxy over HTTP/2 with
proxy_http_version 2, and since 1.29.7 it reuses HTTP/1.1 upstream connections by default. - Duplicate identity headers are a separate, application-level ambiguity: count raw header fields before trusting one.
| Setting | Value | Note | Source |
|---|---|---|---|
| Proxy-to-origin protocol | HTTP/2 (proxy_http_version 2 in nginx) | Keep TLS verification on the origin hop. | nginx: ngx_http_proxy_module |
| HTTP/1.1-only origin (stopgap) | keepalive 0 in the nginx upstream block | nginx 1.29.7 and later keep 32 idle upstream connections per worker by default. | nginx: ngx_http_proxy_module |
Request with both Content-Length and Transfer-Encoding | Reject with 400 and close the connection | — | RFC 9112: HTTP/1.1 framing and request smuggling |
Transfer-Encoding other than chunked in a request | Reject (nginx answers 501, Node.js 400) | — | RFC 9112: HTTP/1.1 framing and request smuggling |
Node.js insecureHTTPParser | false (the default) | — | Node.js HTTP parser and raw headers |
- Run
node probe-framing.mjs <host> <port>against a disposable copy of the chain; expect 200 for the valid chunked control and 400 or 501 for every other case. - Check the origin access log for the probe window: no rejected probe may appear there.
- For every proxy hop, record the product, version and upstream protocol, and confirm
proxy_http_version 2orkeepalive 0where nginx fronts an HTTP/1.1 origin. - Scan the chain with PortSwigger's HTTP Request Smuggler in an authorized test environment.
- Run the local project with
EXERCISE_MODULE=reference node --test exercise.test.mjsand expect all 12 tests to pass.
- Applies to
- nginx 1.29.4 or later for proxy_http_version 2; 1.30.1 or later fixes CVE-2026-42926; tested on 1.30.5
- Node.js 22 or later for the project; probes tested on 26.3.0
HTTP/1.1 ingress and intermediary chains; HTTP/2 conversion requires separate end-to-end testing.
- Part of
- Workflows: Fix a finding, Test an application
Learning goals and prerequisites
What you will learn
- Calculate where two framing interpretations end a request
- Separate parser rejection from application header validation
- Verify ambiguous input causes no origin processing while valid traffic works
Before you start
Application and platform engineers reviewing a proxy-to-origin request path
- Read an HTTP request line and headers
- Distinguish bytes from characters and an edge proxy from an origin
- For the project: Node.js 22+ and permission to bind loopback ports
Refresh first: Apply security limits to GraphQL, WebSocket and gRPC operations
Go to the guided practiceOne request, two different ends
- Ambiguous requestOne message carries both
Content-LengthandTransfer-Encoding: chunked, so it can be framed two ways. - Front-end proxyIt frames the body by one rule and forwards it on an upstream connection that it reuses for other clients.
- Origin serverIt frames the same bytes by the other rule; the unread bytes are prepended to the next request on that connection.
- Strict framingAt every HTTP/1.1 hop, reject both framing headers, conflicting lengths or an unknown
Transfer-Encodingwith 400 and close the connection; HTTP/2 to the origin removes the ambiguity.
A reverse proxy and an application server can each parse the same connection successfully while disagreeing about where one request ends. The downstream component may then interpret leftover bytes as part of another request. This can undermine an edge authorization decision or associate responses with the wrong request. The defect is a disagreement across a chain, not simply the presence of an unusual header.
Draw the actual chain: browser or API client, CDN, load balancer, service proxy and origin. Record the protocol on each link and whether upstream connections are reused. A front end accepting HTTP/2 does not prove its HTTP/1.1 origin hop is safe. Upgrade tunnels, protocol translation and runtime versions belong in that inventory. A check against one process cannot certify an untested intermediary.
The fixture uses PING as four harmless ASCII bytes. In B, the visible sequence 0\r\n\r\nPING contains nine bytes. A defective parser that chooses Content-Length: 9 consumes all nine as the body. A chunked parser reads the zero-sized terminating chunk and the final empty line after five bytes, leaving four bytes. PING is not a complete HTTP request; this exercise demonstrates disagreement without constructing a second request.
RFC 9112 §6.3 defines the framing precedence: Transfer-Encoding overrides Content-Length, a message with both "ought to be handled as an error", and an intermediary that forwards one must first remove the Content-Length. An invalid Content-Length is an unrecoverable error that a server answers with 400 before closing the connection. RFC 9110 §8.6 adds one exception: a recipient of the same value repeated, such as Content-Length: 42, 42, may either reject the message or replace the field with the single value, which RFC 9112 then uses. This site's local project deliberately adopts a narrower acceptance policy: reject requests containing both Transfer-Encoding and Content-Length, reject duplicate length fields, and close on a parser error. That stricter rule is permitted, but it is not the only standards-conforming behavior.
A: Content-Length: 4; body bytes = PING
B: Content-Length: 9 AND Transfer-Encoding: chunked; body = 0\r\n\r\nPING
C: Content-Length: 4 AND Content-Length: 5; body = PING
D: Content-Length: 6; only PING arrives, then EOF
E: Authorization: Bearer alice AND Authorization: Bearer bob
Lab policy: one unambiguous framing interpretation; no duplicate identity fields.The classic cases disagree about which length header wins; later research found disagreements in protocol translation, in servers that ignore a body, and in Expect handling. In 2025 PortSwigger reported 0.CL and Expect-based desyncs affecting major CDNs, including CVE-2025-32094 in Akamai, and concluded that HTTP/1.1 between proxy and origin is the root problem.
| Variant | What the two hops disagree about | Defense |
|---|---|---|
| CL.TE and TE.CL | One hop uses Content-Length, the other chunked encoding | Reject requests carrying both fields |
| TE.TE (obfuscation) | A malformed Transfer-Encoding, such as xchunked or a space before the colon, is honored by one hop only | Reject any request Transfer-Encoding other than exactly chunked, and reject whitespace before a colon |
| H2.CL and H2.TE | An HTTP/2 front end downgrades to HTTP/1.1 and forwards a length the HTTP/2 layer should have rejected | Treat Transfer-Encoding and mismatched content-length in HTTP/2 as malformed (RFC 9113), or keep HTTP/2 to the origin |
| CL.0 and client-side desync | The back end ignores Content-Length on some endpoints, so the body becomes the next request | Read or discard every declared body; avoid reusing connections after an error |
| 0.CL | The front end ignores Content-Length and the back end waits for the body | HTTP/2 to the origin, or no upstream connection reuse |
Expect-based | Expect: 100-continue handling differs between hops | Normalize or reject unusual Expect values at the edge; HTTP/2 upstream |
Application code normally receives an already parsed request. If the HTTP parser accepts contradictory framing, a handler cannot reliably repair the original wire interpretation after the fact. Keep maintained parsers in their strict mode, reject rather than guessing at malformed traffic, and ensure an error does not leave the connection reusable with unread bytes.
A separate problem appears when normalized header objects discard duplicate values. For E, the parsed headers object may expose only Alice even though two authorization fields arrived. Counting raw header names before deriving identity allows the application boundary to reject this ambiguity. This is not a substitute for framing enforcement; identity fields and message-length fields fail at different layers.
In the Node project, both origin and proxy use the default strict HTTP parser. A clientError handler sends 400 and closes the socket. The proxy also rebuilds the forwarded header set from an allowlist, so caller-supplied verified-identity headers never become trusted origin metadata. Response hop-by-hop framing is removed before Node generates the outgoing response framing.
HTTP/2 carries each request in its own frames with an explicit end, so a proxy and an origin cannot disagree about length the way two HTTP/1.1 parsers can; RFC 9113 also makes Transfer-Encoding illegal in HTTP/2. nginx added HTTP/2 to upstreams in 1.29.4 and ships it in the 1.30 stable branch; HAProxy, Envoy and several CDNs can also speak HTTP/2 to the origin. Clients can keep using HTTP/1.1 to reach the edge. Keep every HTTP/2 hop patched against the stream attacks in the layer 7 DDoS guide at /domains/runtime/availability/l7-ddos-resilience/.
Check the defaults of the version you run. nginx 1.29.7 changed proxy_http_version to 1.1 and enabled upstream keepalive (keepalive 32 local), so a default nginx 1.30 now reuses HTTP/1.1 connections to the origin; in a test, three requests to a Node.js origin shared one connection, while keepalive 0 opened one connection per request. Disabling reuse costs latency and origin capacity, so treat it as a stopgap for an origin that cannot speak HTTP/2. The configuration below was tested with nginx 1.30.5: requests reached the origin as HTTP/2 over verified TLS, and the edge answered 400 or 501 to every ambiguous probe.
# nginx 1.30: speak HTTP/2 to the origin, so no HTTP/1.1 framing crosses that hop
upstream origin {
server origin.internal:8443;
}
# Stopgap for an origin that only speaks HTTP/1.1: since 1.29.7 nginx reuses
# upstream connections by default (keepalive 32), so turn reuse off
upstream legacy_origin {
server legacy.internal:8081;
keepalive 0;
}
server {
listen 443 ssl;
server_name api.example.test;
ssl_certificate /etc/nginx/certs/edge.crt;
ssl_certificate_key /etc/nginx/certs/edge.key;
location / {
proxy_pass https://origin;
proxy_http_version 2; # 1.29.4+; run 1.30.1+ for CVE-2026-42926
proxy_ssl_server_name on;
proxy_ssl_name origin.internal;
proxy_ssl_verify on;
proxy_ssl_trusted_certificate /etc/nginx/certs/origin-ca.crt;
}
location /legacy/ {
proxy_pass http://legacy_origin;
}
}Download the proxy/cache project from the local projects section linked with this guide. It starts two real servers on 127.0.0.1 with automatically assigned ports. No production domain, account or external target is involved. Raw socket tests deliver the conflicting framing to the proxy; an origin-hit counter establishes whether any application work occurred.
Run node --test exercise.test.mjs. The deliberately incomplete starter has four passing and eight failing cases across cache and header policy. Conflicting transfer/length and duplicate length tests already pass because the strict parser is supplied. Repair validHeaders in exercise.mjs to reject duplicate singleton identity headers, then complete the cache contract covered by the companion caching guide at /domains/runtime/edge-defense/cache-poisoning-deception/. EXERCISE_MODULE=reference node --test exercise.test.mjs should pass all twelve tests.
Inspect both the wire response and the origin counter. A 400 status without an origin observation is weaker evidence than 400 plus zero origin hits. Conversely, a successful ordinary POST with the correct body demonstrates that the repair has not simply disabled requests. The test’s one-request connections are sufficient for these rejection checks but do not reproduce a production persistent connection pool.
For each hop, retain exact product and runtime versions, strictness settings, accepted protocols and a diagram of translation boundaries. Test malformed cases in an isolated instance of that chain, with synthetic identities and observable origin counters. Do not treat the Node fixture as evidence for a different CDN or vendor proxy.
When translating protocols, test what the next hop receives after the translation. An HTTP/2 message with a Transfer-Encoding field or a content-length that does not match its DATA frames is malformed under RFC 9113 and must not be forwarded as HTTP/1.1. Examine duplicate pseudo-header and header treatment, prohibited hop-specific fields, length consistency and connection or stream error behavior using the relevant product's supported options. Configuration names and defaults vary; copy neither an obsolete hardening list nor another product's setting without verifying the installed version.
The probe below sends six raw requests and prints each status line. Against nginx 1.30.5 and against a default Node.js 26.3.0 server, the valid chunked control returned 200 and every ambiguous case was rejected: 400 for both length fields, conflicting lengths, whitespace before the colon and a bare line feed in a chunked body, and 501 from nginx (400 from Node.js) for Transfer-Encoding: xchunked. Run it only against a disposable copy of your own chain.
Contain a suspected disagreement by taking the affected path or version out of service under the incident process and invalidating any affected cache entries. Redacted request identifiers, routing decisions, parser errors and origin request counts help determine scope. Avoid retaining raw credentials or sensitive request bodies merely to make logs more detailed.
// Send raw HTTP/1.1 requests with ambiguous framing to a disposable test chain and print each
// response's status line. Then check the origin's access log: no rejected probe may appear there.
// Usage: node probe-framing.mjs <host> <port>
import net from 'node:net';
const [host, port] = [process.argv[2], Number(process.argv[3])];
const head = 'POST /probe HTTP/1.1\r\nHost: example.test\r\nConnection: close\r\n';
const cases = {
'valid chunked (control)': head + 'Transfer-Encoding: chunked\r\n\r\n4\r\nPING\r\n0\r\n\r\n',
'CL.TE: both length fields': head + 'Content-Length: 9\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\nPING',
'conflicting Content-Length': head + 'Content-Length: 4\r\nContent-Length: 5\r\n\r\nPING',
'TE obfuscation: xchunked': head + 'Transfer-Encoding: xchunked\r\nContent-Length: 4\r\n\r\nPING',
'whitespace before colon': head + 'Transfer-Encoding : chunked\r\nContent-Length: 4\r\n\r\nPING',
'bare LF in chunked body': head + 'Transfer-Encoding: chunked\r\n\r\n4\nPING\n0\n\n',
};
for (const [name, raw] of Object.entries(cases)) {
const reply = await new Promise(resolve => {
let data = '';
const socket = net.connect(port, host, () => socket.write(raw));
socket.setTimeout(3000, () => socket.destroy());
socket.on('data', chunk => (data += chunk));
socket.on('close', () => resolve(data));
socket.on('error', () => resolve(data));
});
console.log(`${name.padEnd(28)} ${reply.split('\r\n')[0] || '(closed without a response)'}`);
}These results apply to the supplied strict lab contract. Capture a permitted case beside each denial and record which component rejected the invalid input.
| Case | Expected lab result | Evidence |
|---|---|---|
| A: one valid length | Accept four body bytes | Origin receives exactly PING |
| B: transfer coding plus length | 400 and connection close | No origin hit |
| C: conflicting lengths | 400 and connection close | No origin hit |
| D: early end of body | Incomplete request rejected | No completed business action |
| E: duplicate identity fields | 400 before forwarding | No chosen Alice/Bob identity |
The local project exercises HTTP/1.1 parsing and a single Node implementation on both sides. It does not model HTTP/2 multiplexing, a browser, TLS termination, multiple proxy vendors, request trailers or a production cache. The capstone is to replace one hop with your maintained deployment stack in a disposable environment and preserve the same observations. A scanner label alone is not the evidence; the boundary disagreement and its business effect are what the review must explain.
Guided practice
Review a synthetic framing trace, identify where authority becomes ambiguous, then reproduce the rejection checks in the downloadable proxy/cache project. The reasoning portion is self-contained.
Supplied scenario material
Observed B trace: edge parser reports body_bytes=9; origin parser reports chunked_body_bytes=0; origin parser reports four remaining connection bytes: PING.
Observed E trace: raw_headers contains two Authorization fields; normalized headers.authorization contains only Bearer alice.
After candidate repair: B gives HTTP 400; proxy logs parser_error; origin_hits delta=0.
Positive control: ordinary POST /echo body=hello gives 200 and origin returns hello.
No valid second request or cross-user response is present in this trace.1. Locate the disagreement
Calculate how many wire bytes each parser consumes in B and how many remain downstream.
Expected result and reasoning
Content-Lengthconsumes 9. The chunk terminator and final empty line consume 5 wire bytes; the decoded body is zero bytes and 4 bytes remain. The trace demonstrates inconsistent framing. It does not establish that PING is an executable second request or that a victim response was compromised.2. Choose the rejecting component
Explain why adding a body-string check in
/echois an insufficient repair.Expected result and reasoning
The request has already been framed before
/echoruns. The parser/intermediary must reject or consistently process framing at the boundary. For this lab, both framing fields are rejected and the socket is closed; origin business code should not see the request.3. Reject duplicate authority
Explain why checking only normalized
headers.authorizationmisses E.Expected result and reasoning
The normalized object has already selected or collapsed values. Counting case-insensitive names in
rawHeadersreveals twoAuthorizationfields and enables a pre-forwarding rejection. Choosing either Alice or Bob would invent authority from ambiguous input.4. Evaluate the evidence
Decide what the repaired B trace and positive control establish, and what they do not.
Expected result and reasoning
400,
parser_errorand zero origin hits establish rejection before application work for this fixture. The ordinary echo establishes useful valid traffic remains. They do not certify HTTP/2 translation, a different vendor chain or persistent connection behavior.5. Run and retain a regression
Run the downloadable twelve-case project. Keep the result for the duplicate-header and two framing tests with the full suite result.
Expected result and reasoning
The reference passes 12/12. The starter already rejects ambiguous framing but fails eight policy checks, including duplicate authorization. Preserve both allowed and denied requests. When testing a real stack, record its actual versions and origin observations rather than reusing the local result.
The paper trace demonstrates the disagreement; the downloadable project runs the named local parser checks.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Run a local project
Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.
Repair a real proxy and shared cache
Reproduce a private-response leak, repair cache policy and verify HTTP framing with real origin/proxy servers and raw socket requests.
In the extracted folder, run:
node observe.mjs
node --test exercise.test.mjs12 regression tests · The starter intentionally fails some cases.
Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.
Part of the learning path Browser and API boundaries
Actual HTTP/1.1 loopback traffic and a narrow cache contract; it is not a full HTTP cache or a test of your production intermediary chain.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- RFC 9112: HTTP/1.1 framing and request smuggling. June 2022.
- Node.js HTTP parser and raw headers. Node.js v26.11.1.
- Envoy HTTP protocol options
- RFC 9110 §8.6: Content-Length. RFC 9110 (June 2022).
- RFC 9113: HTTP/2. June 2022.
- PortSwigger Research: HTTP/1.1 must die (2025)
- PortSwigger Web Security Academy: HTTP/2 downgrading
- nginx: ngx_http_proxy_module
- nginx: changes in the 1.30 stable branch
- PortSwigger: HTTP Request Smuggler
Reference information
RFC 9110, 9112 and 9113; nginx 1.30.5 and Node.js 26.3.0 tested on 2026-10-10; Node.js 22+ loopback project.
- SDLC phase
- Maintenance
- ASVS 5.0.0 requirements
- v5.0.0-4.2.1 · v5.0.0-4.2.2 · v5.0.0-4.2.3
- CAPEC attack patterns