Résumé

Axios: maxRedirects: 0 is not enforced by the fetch adapter, allowing redirect-based SSRF

Détails de l’avis

Summary

Axios exposes maxRedirects to limit redirect following, and maxRedirects: 0 is used by applications as a redirect-based SSRF guard. The Node HTTP adapter enforces this option. The fetch adapter does not read it and does not set a Fetch API redirect mode, so the runtime default of redirect: 'follow' applies.

Applications are affected when they rely on maxRedirects: 0 and use the fetch adapter, either explicitly or because the runtime selects it.

Impact

An attacker who controls the initial URL or a redirecting server can cause a fetch-adapter request to follow a redirect even though the caller configured maxRedirects: 0. If the redirect target is reachable only from the application environment, this can expose internal responses or trigger state-changing internal endpoints.

This should not be described as unconditional SSRF. The bypass requires a redirect source, such as an attacker-controlled server or open redirect, and an application that trusted maxRedirects: 0 as the redirect guard.

Affected Functionality

Affected:

  • adapter: 'fetch'.
  • Runtime environments where fetch is selected by adapter resolution.
  • Requests configured with maxRedirects: 0 but without fetchOptions.redirect: 'manual' or equivalent runtime-specific redirect control.

Not affected:

  • Node HTTP adapter, which enforces maxRedirects: 0.
  • Requests that do not follow redirects in the underlying fetch implementation because the caller explicitly configured fetch redirect behavior.

Technical Details

lib/adapters/fetch.js destructures many fields from resolveConfig(config), but not maxRedirects. It then builds fetch options without a redirect key:

const resolvedOptions = {
  ...fetchOptions,
  signal: composedSignal,
  method: method.toUpperCase(),
  headers: toByteStringHeaderObject(headers.normalize()),
  body: data,
  duplex: 'half',
  credentials: isCredentialsSupported ? withCredentials : undefined,
};

Because redirect is absent, the Fetch API default is to follow redirects.

Local verification on axios 1.18.1 showed the HTTP adapter throwing with maxRedirects: 0, while the fetch adapter followed the same loopback 302 and returned the internal response.

Proof of Concept of Attack

Constrained local demonstration:

  1. Start server A that returns 302 Location: http://127.0.0.1:<server-b>/internal.
  2. Start server B that returns INTERNAL.
  3. Compare:
await axios.get(serverA, { adapter: 'http', maxRedirects: 0 });  // throws
await axios.get(serverA, { adapter: 'fetch', maxRedirects: 0 }); // returns INTERNAL

Workarounds

For fetch-adapter requests, set fetchOptions: { redirect: 'manual' } where the runtime supports it, or use the Node HTTP adapter for requests that rely on axios redirect limits.

Original report

Summary

Axios 1.17.0 exposes maxRedirects as a configuration option to limit redirect following, and setting it to 0 is a documented pattern for preventing redirect-based SSRF. The HTTP adapter enforces this via follow-redirects. The fetch adapter does not read maxRedirects at all - it passes requests to the underlying fetch() call with no redirect option, which defaults to 'follow', so redirects are followed by the runtime rather than being constrained by axios maxRedirects.

In the attached PoC, a request issued with maxRedirects: 0 and adapter: 'fetch' follows a 302 redirect to an internal service and returns its response, while the same request with adapter: 'http' correctly throws. A second bypass case demonstrates that the redirect can reach a state-changing internal endpoint, not just read-only ones, proving both confidentiality and integrity impact.

This affects any application that sets maxRedirects: 0 as a redirect guard and runs in an environment where the fetch adapter is active: Deno, Bun, Cloudflare Workers, or Node.js with adapter: 'fetch' set explicitly.

Details

The fetch adapter destructures config fields from resolveConfig at lib/adapters/fetch.js:

let {
  url, method, data, signal, cancelToken, timeout,
  onDownloadProgress, onUploadProgress, responseType,
  headers, withCredentials, fetchOptions,
  maxContentLength, maxBodyLength,
} = resolveConfig(config);
// maxRedirects is not extracted

The options object passed to fetch() has no redirect key:

const resolvedOptions = {
  ...fetchOptions,       // redirect only set here if caller explicitly passes fetchOptions.redirect
  signal: composedSignal,
  method: method.toUpperCase(),
  headers: toByteStringHeaderObject(headers.normalize()),
  body: data,
  duplex: 'half',
  credentials: isCredentialsSupported ? withCredentials : undefined,
};

Because no redirect key is present, the Fetch API default of redirect: 'follow' applies, so redirects are handled by the runtime rather than constrained by axios maxRedirects. The HTTP adapter, by contrast, delegates to follow-redirects, which reads maxRedirects, enforces the cap, and strips Authorization, Cookie, and Proxy-Authorization on cross-origin redirects.

The discrepancy between adapters is not documented. The threat model covers credential stripping on redirects (T-R2) and cites follow-redirects as the mitigation, but makes no mention that the fetch adapter does not participate in this mitigation. See: https://github.com/axios/axios/blob/master/THREATMODEL.md#t-r2-credential-leakage-on-cross-origin-redirect

The behaviour difference between adapters is summarised below:

Behaviour HTTP adapter Fetch adapter
Reads config.maxRedirects Yes No
Enforces maxRedirects: 0 Yes -- throws on any redirect No -- follows silently
Strips Authorization cross-origin Yes (follow-redirects >=1.15.8) Runtime-dependent
Strips Cookie cross-origin Yes Runtime-dependent

When is the fetch adapter selected?

  • Deno, Bun, Cloudflare Workers: no Node.js http module available; the adapter list falls through to 'fetch'
  • Explicit config: axios.get(url, { adapter: 'fetch' })
  • Custom adapter list: axios.create({ adapter: ['fetch'] })

PoC

import http from 'http';
import axios from './index.js';

// Server A: the "trusted" external target, issues open redirects to Server B
const serverA = http.createServer((req, res) => {
  if (req.url === '/api/data') {
    res.writeHead(302, { Location: 'http://127.0.0.1:13802/internal/secrets' });
    return res.end();
  }
  if (req.url === '/api/change-config') {
    res.writeHead(302, { Location: 'http://127.0.0.1:13802/internal/admin/config' });
    return res.end();
  }
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ ok: true }));
});

let internalHits = 0;
let internalConfig = {};

// Server B: the "internal" service, should be unreachable from the application
const serverB = http.createServer(async (req, res) => {
  internalHits++;

  if (req.url === '/internal/secrets') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({
      secret: 'FAKE_AWS_SECRET_ACCESS_KEY_FOR_POC_ONLY',
      role:   'arn:aws:iam::000000000000:role/FakeProductionRole',
    }));
  }

  if (req.url === '/internal/admin/config') {
    internalConfig = { compromised: true, source: 'redirect-followed-by-fetch-adapter' };
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({
      reached: 'state-changing internal admin endpoint',
      changed: true,
      internalConfig,
    }));
  }

  res.writeHead(404);
  res.end();
});

await Promise.all

Références