<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Roshan</title>
    <description>The latest articles on DEV Community by Roshan (@roshan_f0594b48669bd7d630).</description>
    <link>https://dev.to/roshan_f0594b48669bd7d630</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4185708%2Fcdff37db-08aa-4efd-b1fb-338fc8005416.png</url>
      <title>DEV Community: Roshan</title>
      <link>https://dev.to/roshan_f0594b48669bd7d630</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/roshan_f0594b48669bd7d630"/>
    <language>en</language>
    <item>
      <title>When POST becomes GET: a redirect bug in a Node.js fetch wrapper</title>
      <dc:creator>Roshan</dc:creator>
      <pubDate>Thu, 15 Oct 2026 02:07:53 +0000</pubDate>
      <link>https://dev.to/roshan_f0594b48669bd7d630/when-post-becomes-get-a-redirect-bug-in-a-nodejs-fetch-wrapper-373e</link>
      <guid>https://dev.to/roshan_f0594b48669bd7d630/when-post-becomes-get-a-redirect-bug-in-a-nodejs-fetch-wrapper-373e</guid>
      <description>&lt;p&gt;A POST followed a 302 redirect and became a GET. Its body disappeared, but its &lt;code&gt;Content-Type&lt;/code&gt; header stayed behind.&lt;/p&gt;

&lt;p&gt;That showed up while extending the client comparisons in &lt;a href="https://github.com/roshan-amble/ssrf-lab" rel="noopener noreferrer"&gt;ssrf-lab&lt;/a&gt;, a testing harness I maintain. The affected package was &lt;code&gt;@tak-ps/node-safeurl@1.8.0&lt;/code&gt;, a Node.js fetch wrapper that checks URLs and redirect destinations.&lt;/p&gt;

&lt;p&gt;This is a functional redirect-conformance bug. The proposed fix is &lt;a href="https://github.com/dfpc-coe/node-safeurl/pull/4" rel="noopener noreferrer"&gt;upstream PR #4&lt;/a&gt;, submitted and awaiting review as of October 14, 2026. These tests do not demonstrate an SSRF bypass.&lt;/p&gt;

&lt;h2&gt;
  
  
  The response looked fine. The request told the story.
&lt;/h2&gt;

&lt;p&gt;The comparison uses a small HTTP server on &lt;code&gt;127.0.0.1&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;Both native fetch and the wrapper successfully completed this request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hello&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/plain&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After a 302 redirect, the destination observed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Client&lt;/th&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Body&lt;/th&gt;
&lt;th&gt;Content-Type&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Native fetch&lt;/td&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;empty&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;node-safeurl 1.8.0&lt;/td&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;empty&lt;/td&gt;
&lt;td&gt;text/plain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;The &lt;a href="https://fetch.spec.whatwg.org/#http-redirect-fetch" rel="noopener noreferrer"&gt;Fetch redirect algorithm&lt;/a&gt; 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: &lt;code&gt;Content-Encoding&lt;/code&gt;, &lt;code&gt;Content-Language&lt;/code&gt;, &lt;code&gt;Content-Location&lt;/code&gt;, and &lt;code&gt;Content-Type&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The wrapper already handled the method and body changes. It was missing the corresponding header cleanup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix belongs in the method-rewrite branch
&lt;/h2&gt;

&lt;p&gt;The patch adds &lt;code&gt;Headers&lt;/code&gt; to the existing Undici import, then extends the existing &lt;code&gt;switchToGet&lt;/code&gt; branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;switchToGet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;fetchInit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;fetchInit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fetchInit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-encoding&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-language&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-location&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-length&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;fetchInit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Content-Length&lt;/code&gt; 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.&lt;/p&gt;

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

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

&lt;h2&gt;
  
  
  Prove both the fix and the unchanged cases
&lt;/h2&gt;

&lt;p&gt;The upstream regression test records actual incoming requests and checks seven cases:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Redirect cases&lt;/th&gt;
&lt;th&gt;Before patch&lt;/th&gt;
&lt;th&gt;After patch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;301 POST, 302 POST, 303 PUT&lt;/td&gt;
&lt;td&gt;All three fail&lt;/td&gt;
&lt;td&gt;All three pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;307 POST, 308 POST, 301 PUT, 302 PUT&lt;/td&gt;
&lt;td&gt;All four pass&lt;/td&gt;
&lt;td&gt;All four pass&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;The &lt;a href="https://github.com/roshan-amble/ssrf-lab/blob/41659cda6825b2f7e324294d7242ef1b076b6cce/docs/evidence/2026-10-14-619eaae/README.md" rel="noopener noreferrer"&gt;recorded evidence&lt;/a&gt; includes the original failures, fixed tests, full-suite logs, and published-package comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the comparison
&lt;/h2&gt;

&lt;p&gt;With Node 22.19 or newer and npm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/roshan-amble/ssrf-lab.git
&lt;span class="nb"&gt;cd &lt;/span&gt;ssrf-lab
git checkout 41659cda6825b2f7e324294d7242ef1b076b6cce
npm ci &lt;span class="nt"&gt;--ignore-scripts&lt;/span&gt; &lt;span class="nt"&gt;--prefix&lt;/span&gt; integrations/community
node integrations/community/check-redirects.mjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The harness, adapters, and reproduction are in &lt;a href="https://github.com/roshan-amble/ssrf-lab" rel="noopener noreferrer"&gt;ssrf-lab&lt;/a&gt;. Which redirect or URL-handling edge case would you add to this coverage?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AI disclosure: Codex assisted with the investigation, implementation, and test execution, and drafted this article from the linked code and recorded results.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>testing</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
