diff --git a/package-lock.json b/package-lock.json index 0118c0f..ce36493 100644 --- a/package-lock.json +++ b/package-lock.json @@ -758,9 +758,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", "funding": [ { "type": "individual", diff --git a/package.json b/package.json index d54ba6b..59ac0f6 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "overrides": { "undici": "^6.28.0", "ws": "^8.21.0", - "shell-quote": "1.12.0" + "shell-quote": "1.12.0", + "follow-redirects": "1.16.1" }, "devDependencies": { "concurrently": "^9.2.4" diff --git a/test/redirect-security.test.js b/test/redirect-security.test.js new file mode 100644 index 0000000..c9bc64d --- /dev/null +++ b/test/redirect-security.test.js @@ -0,0 +1,64 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { once } from "node:events"; +import { createRequire } from "node:module"; + +// Exercise Axios's actual dependency, even when it is nested rather than hoisted. +const axiosRequire = createRequire(createRequire(import.meta.url).resolve("axios")); +const followRedirects = axiosRequire("follow-redirects"); + +async function redirectedHeaders(t, headers, sensitiveHeaders = []) { + let received; + const target = http.createServer((req, res) => { + received = req.headers; + res.end("ok"); + }); + target.listen(0, "127.0.0.1"); + await once(target, "listening"); + const origin = http.createServer((req, res) => { + req.resume(); + res.writeHead(302, { Location: `http://127.0.0.1:${target.address().port}/` }); + res.end(); + }); + origin.listen(0, "127.0.0.1"); + await once(origin, "listening"); + t.after(async () => { + for (const server of [origin, target]) { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + } + }); + if (Array.isArray(headers)) { + headers = ["Host", `127.0.0.1:${origin.address().port}`, ...headers]; + } + await new Promise((resolve, reject) => { + const request = followRedirects.http.get({ + protocol: "http:", hostname: "127.0.0.1", port: origin.address().port, + path: "/", headers, sensitiveHeaders + }, response => { + response.resume(); + response.on("end", resolve); + response.on("error", reject); + }); + request.on("error", reject); + }); + assert.ok(received, "the redirected target must receive a request"); + return received; +} + +test("redirects strip array-form credentials across origins", { timeout: 5000 }, async t => { + const headers = await redirectedHeaders(t, ["Authorization", "test-marker", "Cookie", "test-cookie", "Proxy-Authorization", "test-proxy"]); + for (const name of ["authorization", "cookie", "proxy-authorization"]) { + assert.equal(headers[name], undefined, `${name} must not cross origins`); + } +}); + +test("redirects strip sensitive header names containing regex metacharacters", { timeout: 5000 }, async t => { + const headers = await redirectedHeaders(t, { + "X-Api^Key": "test-marker", "X-Token|Secret": "test-marker", "X-Control": "preserved" + }, ["X-Api^Key", "X-Token|Secret"]); + assert.equal(headers["x-api^key"], undefined); + assert.equal(headers["x-token|secret"], undefined); + assert.equal(headers["x-control"], "preserved"); +});