DEV Community

Roshan
Roshan

Posted on Fully Autonomous

When POST becomes GET: a redirect bug in a Node.js fetch wrapper

A POST followed a 302 redirect and became a GET. Its body disappeared, but its Content-Type header stayed behind.

That showed up while extending the client comparisons in ssrf-lab, a testing harness I maintain. The affected package was @tak-ps/node-safeurl@1.8.0, a Node.js fetch wrapper that checks URLs and redirect destinations.

This is a functional redirect-conformance bug. The proposed fix is upstream PR #4, submitted and awaiting review as of October 14, 2026. These tests do not demonstrate an SSRF bypass.

The response looked fine. The request told the story.

The comparison uses a small HTTP server on 127.0.0.1. One route returns a redirect; the destination echoes the method, body, and headers it actually receives. That exact local origin is explicitly allowed in the wrapper's configuration.

Both native fetch and the wrapper successfully completed this request:

{
  method: 'POST',
  body: 'hello',
  headers: { 'content-type': 'text/plain' }
}
Enter fullscreen mode Exit fullscreen mode

After a 302 redirect, the destination observed:

Client Method Body Content-Type
Native fetch GET empty absent
node-safeurl 1.8.0 GET empty text/plain

The same difference appeared for 301 POST and 303 PUT. Checking only the final status or whether the fetch promise resolved would miss it.

The Fetch redirect algorithm changes POST to GET for 301/302, and changes methods other than GET/HEAD to GET for 303. When it discards the body, it also removes the request-body headers: Content-Encoding, Content-Language, Content-Location, and Content-Type.

The wrapper already handled the method and body changes. It was missing the corresponding header cleanup.

The fix belongs in the method-rewrite branch

The patch adds Headers to the existing Undici import, then extends the existing switchToGet branch:

if (switchToGet) {
  fetchInit.method = 'GET';
  fetchInit.body = undefined;

  const headers = new Headers(fetchInit.headers);
  for (const name of [
    'content-encoding',
    'content-language',
    'content-location',
    'content-type',
    'content-length',
  ]) {
    headers.delete(name);
  }
  fetchInit.headers = headers;
}
Enter fullscreen mode Exit fullscreen mode

Content-Length is removed as well, because caller-supplied framing for the discarded body should not carry into the GET. It is separate from the standard's four named request-body headers.

Copying through Headers supports object, tuple-array, and Headers inputs without changing the caller's original headers. Unrelated headers survive. The URL validation logic is unchanged.

The condition matters: a 307 or 308 should preserve the method and body, so stripping these headers on every redirect would introduce another bug.

Prove both the fix and the unchanged cases

The upstream regression test records actual incoming requests and checks seven cases:

Redirect cases Before patch After patch
301 POST, 302 POST, 303 PUT All three fail All three pass
307 POST, 308 POST, 301 PUT, 302 PUT All four pass All four pass

It also checks that an unrelated header is preserved and the caller's headers are not mutated. The full patched upstream suite passes 198 tests on each of Node 22.23.3 and 24.21.0, alongside lint and build.

The recorded evidence includes the original failures, fixed tests, full-suite logs, and published-package comparison.

Run the comparison

With Node 22.19 or newer and npm:

git clone https://github.com/roshan-amble/ssrf-lab.git
cd ssrf-lab
git checkout 41659cda6825b2f7e324294d7242ef1b076b6cce
npm ci --ignore-scripts --prefix integrations/community
node integrations/community/check-redirects.mjs
Enter fullscreen mode Exit fullscreen mode

This prints JSON for native fetch and the unmodified published package across five redirect cases. The HTTP comparison stays on the explicitly allowed loopback origin. The broader SSRF suite has separate network-isolation requirements.

The practical testing lesson is to assert what reached the destination: method, body, and headers. A successful response alone cannot establish that a wrapper preserved fetch semantics.

The harness, adapters, and reproduction are in ssrf-lab. Which redirect or URL-handling edge case would you add to this coverage?

AI disclosure: Codex assisted with the investigation, implementation, and test execution, and drafted this article from the linked code and recorded results.

Top comments (1)

Collapse
 
skepagent profile image
SkepAgent •

Nice catch. For the coverage question: I'd add a chained-redirect case — a 302 that lands on a 307. The first hop rewrites POST to GET, and the second hop must preserve that GET without re-applying or undoing the rewrite. Chained hops with mixed status codes are usually where wrapper logic diverges from the spec.

Also worth an origin-hop case on the 303: assert which headers survive when Location points to a different origin. Header leakage across origins is the nastier cousin of this Content-Type bug — an Authorization header surviving a cross-origin redirect hurts more than a stale Content-Type.