In short

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-Encoding override Content-Length and 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.

Verify it

  1. 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.
  2. Check the origin access log for the probe window: no rejected probe may appear there.
  3. For every proxy hop, record the product, version and upstream protocol, and confirm proxy_http_version 2 or keepalive 0 where nginx fronts an HTTP/1.1 origin.
  4. Scan the chain with PortSwigger's HTTP Request Smuggler in an authorized test environment.
  5. Run the local project with EXERCISE_MODULE=reference node --test exercise.test.mjs and 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.

Learning goals and prerequisitesAbout 33 minutes: 9 to read and 24 for the exercise, plus 45 to 60 for the local project

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 practice

One request, two different ends

InternetYour request chainClientFront-end proxyOrigin serverContent-Length and chunked inone message1Finds the end by oneframing rule2Forward on a reused upstreamconnection2Finds a different end;bytes stay unread3Another client's request, sameconnection3The leftover bytes prefixthat request3Repair: 400, then close theconnection4Trust boundaryUntrusted input1Step, described below
  1. Ambiguous requestOne message carries both Content-Length and Transfer-Encoding: chunked, so it can be framed two ways.
  2. Front-end proxyIt frames the body by one rule and forwards it on an upstream connection that it reuses for other clients.
  3. Origin serverIt frames the same bytes by the other rule; the unread bytes are prepended to the next request on that connection.
  4. Strict framingAt every HTTP/1.1 hop, reject both framing headers, conflicting lengths or an unknown Transfer-Encoding with 400 and close the connection; HTTP/2 to the origin removes the ambiguity.
The defect is a disagreement between two parsers, not one unusual header. Test the deployed chain, and check that a rejected request causes no origin work.

The boundary itself is a security decision

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.

Count the supplied bytes before discussing a fix

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.

Plain text
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.

Recognize the desync variants

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.

Recognize the desync variants
VariantWhat the two hops disagree aboutDefense
CL.TE and TE.CLOne hop uses Content-Length, the other chunked encodingReject 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 onlyReject any request Transfer-Encoding other than exactly chunked, and reject whitespace before a colon
H2.CL and H2.TEAn HTTP/2 front end downgrades to HTTP/1.1 and forwards a length the HTTP/2 layer should have rejectedTreat 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 desyncThe back end ignores Content-Length on some endpoints, so the body becomes the next requestRead or discard every declared body; avoid reusing connections after an error
0.CLThe front end ignores Content-Length and the back end waits for the bodyHTTP/2 to the origin, or no upstream connection reuse
Expect-basedExpect: 100-continue handling differs between hopsNormalize or reject unusual Expect values at the edge; HTTP/2 upstream

Reject framing at the parser; reject ambiguous authority before use

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.

Remove HTTP/1.1 from the proxy-to-origin hop

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.

Configuration/etc/nginx/conf.d/edge.confnginx
# 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;
    }
}

Run the real proxy and 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.

Verify the deployed chain and keep its configuration explainable

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.

Testprobe-framing.mjsJavaScript
// 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)'}`);
}

Evidence that distinguishes a repair from blanket rejection

These results apply to the supplied strict lab contract. Capture a permitted case beside each denial and record which component rejected the invalid input.

Evidence that distinguishes a repair from blanket rejection
CaseExpected lab resultEvidence
A: one valid lengthAccept four body bytesOrigin receives exactly PING
B: transfer coding plus length400 and connection closeNo origin hit
C: conflicting lengths400 and connection closeNo origin hit
D: early end of bodyIncomplete request rejectedNo completed business action
E: duplicate identity fields400 before forwardingNo chosen Alice/Bob identity

Use the project as a mechanism test

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
Plain text
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. 1. Locate the disagreement

    Calculate how many wire bytes each parser consumes in B and how many remain downstream.

    Expected result and reasoning

    Content-Length consumes 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. 2. Choose the rejecting component

    Explain why adding a body-string check in /echo is an insufficient repair.

    Expected result and reasoning

    The request has already been framed before /echo runs. 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. 3. Reject duplicate authority

    Explain why checking only normalized headers.authorization misses E.

    Expected result and reasoning

    The normalized object has already selected or collapsed values. Counting case-insensitive names in rawHeaders reveals two Authorization fields and enables a pre-forwarding rejection. Choosing either Alice or Bob would invent authority from ambiguous input.

  4. 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_error and 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. 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.

Your deliverable: A byte-boundary calculation, a raw-versus-normalized header decision, and a test record showing rejection location plus a valid request.

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.

Node.js 22+ · loopback servers · no dependencies · 45–60 min

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:

Shell
node observe.mjs
node --test exercise.test.mjs

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

  1. RFC 9112: HTTP/1.1 framing and request smuggling. June 2022.
  2. Node.js HTTP parser and raw headers. Node.js v26.11.1.
  3. Envoy HTTP protocol options
  4. RFC 9110 §8.6: Content-Length. RFC 9110 (June 2022).
  5. RFC 9113: HTTP/2. June 2022.
  6. PortSwigger Research: HTTP/1.1 must die (2025)
  7. PortSwigger Web Security Academy: HTTP/2 downgrading
  8. nginx: ngx_http_proxy_module
  9. nginx: changes in the 1.30 stable branch
  10. 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

Download this guide as Markdown