Request Smuggling and Proxy Desync

When a front-end proxy and your server disagree about where one request ends, an attacker can insert another.

HTTP request smuggling exploits a disagreement between two servers about where a request ends. A front-end proxy forwards what it believes is one request; your back-end reads it as two. The second — the smuggled one — is prepended to the next user's request.

The mechanism

HTTP/1.1 offers two ways to state a body's length: Content-Length and Transfer-Encoding: chunked. Send both, and the two servers may prioritise them differently:

  • CL.TE — the front end uses Content-Length, the back end uses Transfer-Encoding.
  • TE.CL — the reverse.
  • TE.TE — both support chunked, but one can be induced to ignore it with an obfuscated header.

Why it is severe

The smuggled prefix attaches to another user's request, so an attacker can capture their credentials, redirect them to a controlled page, or execute an authenticated action as them. It also bypasses front-end controls entirely — path-based access rules on the proxy never see the smuggled request.

Defences

  1. Use HTTP/2 end to end. Its framing is unambiguous, which removes the class. Note that HTTP/2 downgraded to HTTP/1.1 at the back end reintroduces it.
  2. Reject ambiguous requests. Both Content-Length and Transfer-Encoding, duplicate headers, or a malformed chunked body should produce a 400 at the edge.
  3. Normalise at the front end so the back end receives one canonical form.
  4. Keep both ends on the same, current software. Most known variants are parser bugs that have been fixed.
  5. Avoid connection reuse to the back end where you can afford it — smuggling depends on a shared connection.

Related: header injection

The same family. A newline in a value you copy into a response header lets an attacker inject headers or a body. Never build a header from unvalidated input.

Example

Example · bash
# CL.TE: the front end believes Content-Length, the back end believes chunked
POST /api/anything HTTP/1.1
Host: dfg.com
Content-Length: 6
Transfer-Encoding: chunked

0

X

# Front end: "a 6-byte body" → forwards the whole thing.
# Back end:  chunked, terminated by "0" → the request ENDS there.
#            The trailing "X" is left in the buffer.
#
# The next user's request arrives and becomes:
#   XPOST /api/orders HTTP/1.1
#   ...
# Their request is now corrupted or, worse, prefixed with attacker content.

When to use it

  • A CL.TE desync between a CDN and an origin lets an attacker capture other users' session cookies from a shared connection.
  • A smuggled request bypasses a proxy's path-based access rules and reaches an admin endpoint the front end was supposed to block.
  • A newline in a filename injects a header into a download response until the value is sanitised.

More examples

Rejecting ambiguous requests at the edge

The HTTP/2 downgrade caveat is the one that catches teams out — terminating HTTP/2 at the CDN and speaking HTTP/1.1 to the origin is the common and vulnerable arrangement.

Example · bash
# The core defence: never forward a request whose length is ambiguous.

# ── nginx ────────────────────────────────────────────────────────────
# Refuse both length headers together
map "$http_content_length:$http_transfer_encoding" $ambiguous {
    default 0;
    "~^.+:.+$" 1;              # both present
}

server {
    if ($ambiguous) { return 400; }

    # Use HTTP/1.1 upstream, and disable connection reuse where you can —
    # smuggling requires a shared connection to the back end.
    proxy_http_version 1.1;
    proxy_set_header Connection "";

    # Do not pass through a Transfer-Encoding the client chose
    proxy_set_header Transfer-Encoding "";

    proxy_pass http://app;
}

# ── HAProxy ──────────────────────────────────────────────────────────
# HAProxy 2.x rejects most ambiguity by default; be explicit anyway.
global
    tune.h2.max-concurrent-streams 100

defaults
    option http-buffer-request        # buffer the whole request before forwarding
    http-request deny if { req.hdr_cnt(content-length) gt 1 }
    http-request deny if { req.hdr_cnt(transfer-encoding) gt 1 }
    http-request deny if { req.hdr(content-length) -m found } \
                          { req.hdr(transfer-encoding) -m found }

# ── Envoy ────────────────────────────────────────────────────────────
# http_protocol_options:
#   allow_chunked_length: false
#   accept_http_10: false
#   headers_with_underscores_action: REJECT_REQUEST

# ── Application layer, as a backstop ─────────────────────────────────
# Node's HTTP parser is strict by default; do not relax it.
# ❌ node --insecure-http-parser        ← re-enables the ambiguity

# ── The strongest answer: HTTP/2 end to end ──────────────────────────
# HTTP/2 framing is length-prefixed and unambiguous, which removes the class.
#   client ──HTTP/2──▶ CDN ──HTTP/2──▶ load balancer ──HTTP/2──▶ app
#
# ⚠️ HTTP/2 at the edge downgraded to HTTP/1.1 at the back end REINTRODUCES
#    it ("H2.CL" and "H2.TE" desync). The whole chain has to agree.

Header injection: the same family, in your code

RFC 5987's filename* parameter is the correct way to handle non-ASCII names — the common alternative of percent-encoding into the plain filename breaks in several browsers.

Example · javascript
// A newline in a value you place into a response header lets an attacker add
// headers, or terminate them and inject a body.

// ❌ The filename comes from the client
res.set('Content-Disposition', `attachment; filename="${req.query.name}"`);
// name = x"\r\nSet-Cookie: admin=true\r\n\r\n<script>alert(1)</script>
// → an injected cookie and an injected body

// ❌ A redirect built from input
res.redirect(`/files/${req.params.id}?token=${req.query.t}`);

// ✅ Sanitise anything that becomes a header value
export function headerSafe(value, maxLength = 200) {
  return String(value ?? '')
    .replace(/[\r\n\0]/g, '')            // CR, LF and NUL — the injection chars
    .replace(/[^\x20-\x7e]/g, '_')       // non-printable ASCII
    .slice(0, maxLength);
}

res.set('Content-Disposition',
  `attachment; filename="${headerSafe(file.originalName).replace(/"/g, '')}"`);

// ✅ Better: RFC 5987 encoding handles non-ASCII filenames correctly
function contentDisposition(filename) {
  const ascii = headerSafe(filename).replace(/["\\]/g, '_');
  const encoded = encodeURIComponent(filename).replace(/['()]/g, escape);
  return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
}

// ✅ Modern runtimes reject CRLF in header values, but do not rely on it —
//    older versions, proxies and other languages differ.
try {
  res.set('X-Custom', 'a\r\nX-Injected: yes');
} catch (err) {
  // Node throws ERR_INVALID_CHAR here. PHP's header() historically did not.
}

// ✅ And the same rule for anything reflected into a Location header
function safeRedirect(res, next, fallback = '/') {
  const target = safeRelative(next, fallback);      // see the open-redirect lesson
  res.redirect(headerSafe(target, 2000));
}

// Test it:
it('rejects CRLF in a filename', async () => {
  const res = await request(app)
    .get('/api/files/1/download?name=' +
         encodeURIComponent('x"\r\nSet-Cookie: admin=true'))
    .set('Authorization', `Bearer ${token}`);

  expect(res.headers['set-cookie']).toBeUndefined();
  expect(res.headers['content-disposition']).not.toContain('\n');
});

Detection, since prevention is mostly infrastructural

The burst of 400s from unrelated clients is the most reliable operational signature of an active desync — it is the victims' corrupted requests, not the attacker's.

Example · javascript
// Smuggling attempts have a distinctive shape, and legitimate traffic does not
// produce it. That makes the signal unusually clean.

app.use((req, res, next) => {
  const suspicious = [];

  // 1. Both length headers present — no legitimate client does this
  if (req.headers['content-length'] && req.headers['transfer-encoding']) {
    suspicious.push('cl_and_te');
  }

  // 2. Duplicate headers where duplication is meaningless
  const raw = req.rawHeaders;
  const counts = {};
  for (let i = 0; i < raw.length; i += 2) {
    const name = raw[i].toLowerCase();
    counts[name] = (counts[name] ?? 0) + 1;
  }
  for (const name of ['content-length', 'transfer-encoding', 'host']) {
    if (counts[name] > 1) suspicious.push(`duplicate_${name}`);
  }

  // 3. Obfuscated Transfer-Encoding — the TE.TE variant
  const te = req.headers['transfer-encoding'];
  if (te && !/^chunked$/i.test(te.trim())) suspicious.push('obfuscated_te');

  // 4. A header name with whitespace before the colon, or underscores where
  //    the proxy and app disagree about normalisation
  for (let i = 0; i < raw.length; i += 2) {
    if (/\s/.test(raw[i])) suspicious.push('whitespace_in_header_name');
  }

  if (suspicious.length) {
    logger.error({
      signals: suspicious,
      ip: req.ip,
      path: req.path,
      rawHeaders: raw.slice(0, 40),
    }, 'possible request smuggling attempt');

    metrics.increment('security.smuggling_attempt');
    alertSecurityChannel(`Smuggling signals from ${req.ip}: ${suspicious.join(', ')}`);

    return res.status(400).json({ error: 'bad_request' });
  }
  next();
});

// ── And a symptom worth alerting on ──────────────────────────────────
// Successful smuggling corrupts the NEXT user's request, which usually
// produces a burst of malformed-request errors from unrelated clients.
//
//   alert: 400 rate from distinct IPs > 10x baseline over 5 minutes
//
// That pattern — many different users suddenly sending "malformed" requests —
// is close to a signature for an active desync.

// ── Test your own chain (only against systems you own) ───────────────
// docker run --rm -it portswigger/http-request-smuggler ... , or Burp's
// HTTP Request Smuggler extension. Run it against staging with a chain that
// mirrors production, including the CDN.

Discussion

  • Be the first to comment on this lesson.