diff --git a/lib/adapters/http.js b/lib/adapters/http.js index 943bc429..63947ff4 100755 --- a/lib/adapters/http.js +++ b/lib/adapters/http.js @@ -520,7 +520,8 @@ export default isHttpAdapterSupported && } ); // support for https://www.npmjs.com/package/form-data api - } else if (utils.isFormData(data) && utils.isFunction(data.getHeaders)) { + } else if (utils.isFormData(data) && utils.isFunction(data.getHeaders) && + data.getHeaders !== Object.prototype.getHeaders) { headers.set(data.getHeaders()); if (!headers.hasContentLength()) { diff --git a/lib/utils.js b/lib/utils.js index 73c4f0d2..6bcd6726 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -257,16 +257,16 @@ const G = getGlobal(); const FormDataCtor = typeof G.FormData !== 'undefined' ? G.FormData : undefined; const isFormData = (thing) => { - let kind; - return thing && ( - (FormDataCtor && thing instanceof FormDataCtor) || ( - isFunction(thing.append) && ( - (kind = kindOf(thing)) === 'formdata' || - // detect form-data instance - (kind === 'object' && isFunction(thing.toString) && thing.toString() === '[object FormData]') - ) - ) - ); + if (!thing) return false; + if (FormDataCtor && thing instanceof FormDataCtor) return true; + // Reject plain objects inheriting directly from Object.prototype so prototype-pollution gadgets can't spoof FormData (GHSA-6chq-wfr3-2hj9). + const proto = getPrototypeOf(thing); + if (!proto || proto === Object.prototype) return false; + if (!isFunction(thing.append)) return false; + const kind = kindOf(thing); + return kind === 'formdata' || + // detect form-data instance + (kind === 'object' && isFunction(thing.toString) && thing.toString() === '[object FormData]'); }; /** diff --git a/tests/unit/adapters/http.test.js b/tests/unit/adapters/http.test.js index edf4d96f..3b492f29 100644 --- a/tests/unit/adapters/http.test.js +++ b/tests/unit/adapters/http.test.js @@ -2512,6 +2512,55 @@ describe('supports http with nodejs', () => { } }); }); + + describe('prototype pollution (GHSA-6chq-wfr3-2hj9)', () => { + const pollutedKeys = ['getHeaders', 'append', 'pipe', 'on', 'once']; + const toStringTagSym = Symbol.toStringTag; + + function pollute() { + Object.prototype[toStringTagSym] = 'FormData'; + Object.prototype.append = () => {}; + Object.prototype.getHeaders = () => ({ + 'x-injected': 'attacker', + 'authorization': 'Bearer ATTACKER_TOKEN', + }); + Object.prototype.pipe = function (d) { if (d && d.end) d.end(); return d; }; + Object.prototype.on = function () { return this; }; + Object.prototype.once = function () { return this; }; + } + + function cleanup() { + for (const k of pollutedKeys) delete Object.prototype[k]; + delete Object.prototype[toStringTagSym]; + } + + it('should not merge prototype-polluted getHeaders into outgoing request', async () => { + let receivedHeaders; + const server = await startHTTPServer( + (req, res) => { + receivedHeaders = req.headers; + res.end('{}'); + }, + { port: SERVER_PORT } + ); + + try { + pollute(); + await axios.post( + `http://localhost:${server.address().port}/`, + { userId: 42 }, + { headers: { 'Authorization': 'Bearer VALID_USER_TOKEN' } } + ); + } finally { + cleanup(); + await stopHTTPServer(server); + } + + assert.ok(receivedHeaders, 'request did not reach server'); + assert.strictEqual(receivedHeaders['x-injected'], undefined); + assert.notStrictEqual(receivedHeaders['authorization'], 'Bearer ATTACKER_TOKEN'); + }); + }); }); describe('toFormData helper', () => {