---
key: "http-request-desynchronization"
title: "Keep HTTP request boundaries consistent across intermediaries"
url: "https://softwaresecurity.com/domains/runtime/edge-defense/http-request-desynchronization/"
api: "https://softwaresecurity.com/api/v1/articles/http-request-desynchronization.json"
domain: "runtime"
topic: "Runtime, cloud \u0026 APIs"
type: "Guide"
identifiers: ["CWE-444","A06:2025","API8:2023","V4.2.1","V4.2.2","V4.2.3","CAPEC-33"]
tags: ["Proxies, caches and request smuggling"]
stages: ["operate"]
roles: ["developer","platform-sre"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Keep HTTP request boundaries consistent across intermediaries

Trace framing disagreements, reject ambiguous requests and verify what reaches the origin with a real loopback proxy project.

Canonical URL: https://softwaresecurity.com/domains/runtime/edge-defense/http-request-desynchronization/

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

## Recommended defaults

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

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

## 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

Audience: Application and platform engineers reviewing a proxy-to-origin request path

Before you start:

- 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

Time: About 33 minutes: 9 to read and 24 for the exercise, plus 45 to 60 for the local project

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

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

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

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

```nginx
# 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.

```javascript
// 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.

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

## 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 material

```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. Locate the disagreement

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

Worked answer: `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. Choose the rejecting component

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

Worked answer: 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. Reject duplicate authority

Explain why checking only normalized `headers.authorization` misses E.

Worked answer: 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. Evaluate the evidence

Decide what the repaired B trace and positive control establish, and what they do not.

Worked answer: 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. 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.

Worked answer: 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.

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

Scope: The paper trace demonstrates the disagreement; the downloadable project runs the named local parser checks.

## Executable practice

- [Repair a real proxy and shared cache: project ZIP](https://softwaresecurity.com/downloads/projects/proxy-cache-boundaries-66af6733aa29700d.zip) — Node.js 22+ · loopback servers · no dependencies

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [RFC 9112: HTTP/1.1 framing and request smuggling](https://www.rfc-editor.org/rfc/rfc9112.html). June 2022.
- [Node.js HTTP parser and raw headers](https://nodejs.org/api/http.html). Node.js v26.11.1.
- [Envoy HTTP protocol options](https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/core/v3/protocol.proto)
- [RFC 9110 §8.6: Content-Length](https://www.rfc-editor.org/rfc/rfc9110.html#section-8.6). RFC 9110 (June 2022).
- [RFC 9113: HTTP/2](https://www.rfc-editor.org/rfc/rfc9113.html). June 2022.
- [PortSwigger Research: HTTP/1.1 must die (2025)](https://portswigger.net/research/http1-must-die)
- [PortSwigger Web Security Academy: HTTP/2 downgrading](https://portswigger.net/web-security/request-smuggling/advanced/http2-downgrading)
- [nginx: ngx_http_proxy_module](https://nginx.org/en/docs/http/ngx_http_proxy_module.html)
- [nginx: changes in the 1.30 stable branch](https://nginx.org/en/CHANGES-1.30)
- [PortSwigger: HTTP Request Smuggler](https://github.com/PortSwigger/http-request-smuggler)