From: Ardis Lu Date: Sat, 23 May 2026 06:19:48 +0000 (-0700) Subject: Verify fetch metadata request headers on POST ("resource isolation policy") X-Git-Tag: v0.1.2~8 X-Git-Url: https://git.ardis.lu/?a=commitdiff_plain;h=41036302c4548f3ffb2ca48c4ae92d9d333129c0;p=envpass Verify fetch metadata request headers on POST ("resource isolation policy") This is more hardening against cross-origin attacks. --- diff --git a/package-lock.json b/package-lock.json index efbace5..480a31e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ "devDependencies": { "@types/node": "^25.8.0", "playwright-core": "^1.60.0", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "undici": "^8.3.0" }, "engines": { "node": ">=20" @@ -229,6 +230,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", + "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", diff --git a/package.json b/package.json index bf9304b..74cf416 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "devDependencies": { "@types/node": "^25.8.0", "playwright-core": "^1.60.0", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "undici": "^8.3.0" } } diff --git a/src/getPrf.js b/src/getPrf.js index 97e30f1..8dc3c6f 100644 --- a/src/getPrf.js +++ b/src/getPrf.js @@ -114,7 +114,10 @@ export async function getPrf(options = {}) { } // Verify then use the prf else if (request.method === 'POST') { - if (request.headers.origin !== `http://localhost:${port}`) { + if (request.headers.origin !== `http://localhost:${port}` + || request.headers['sec-fetch-site'] !== 'same-origin' + || request.headers['sec-fetch-mode'] !== 'cors' + || request.headers['sec-fetch-dest'] !== 'empty') { response.writeHead(403).end(); return; } diff --git a/test/getPrf.test.js b/test/getPrf.test.js index 229a74b..8260ccc 100644 --- a/test/getPrf.test.js +++ b/test/getPrf.test.js @@ -1,6 +1,8 @@ import { suite, test } from 'node:test'; import { deepStrictEqual, match, ok, rejects } from 'node:assert/strict'; +import { request } from 'undici'; + import { WINDOW_EXPIRATION_DURATION } from '#src/getPrf.js'; import { setupPlaywright, setupServer } from '#test/setup.js'; @@ -27,6 +29,9 @@ function makePostRequest(url, options = {}) { const mergedHeaders = { 'Content-Type': 'application/json', 'Origin': origin, + 'Sec-Fetch-Site': 'same-origin', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Dest': 'empty', ...headers } const filteredHeaders = Object.fromEntries(Object.entries(mergedHeaders).filter(([, v]) => v !== null)); @@ -59,6 +64,51 @@ suite('getPrf.js (server)', () => { deepStrictEqual((await fetch(makePostRequest(url, { headers: { 'Origin': 'null' } }))).status, 403); deepStrictEqual((await fetch(makePostRequest(url, { headers: { 'Origin': 'https://example.com' } }))).status, 403); }); + test('validates Sec-Fetch-Site header on POST', async (t) => { + const { url } = await setupServer(t); + deepStrictEqual((await fetch(makePostRequest(url, { headers: { 'Sec-Fetch-Site': null } }))).status, 403); + deepStrictEqual((await fetch(makePostRequest(url, { headers: { 'Sec-Fetch-Site': 'cross-site' } }))).status, 403); + deepStrictEqual((await fetch(makePostRequest(url, { headers: { 'Sec-Fetch-Site': 'same-site' } }))).status, 403); + deepStrictEqual((await fetch(makePostRequest(url, { headers: { 'Sec-Fetch-Site': 'same-origin' } }))).status, 200); + }); + test('validates Sec-Fetch-Mode header on POST', async (t) => { + // undici `fetch` does not allow changing the Sec-Fetch-Mode header, so use the lower-level `request` instead. + // See https://github.com/nodejs/undici/issues/1305 + const { url } = await setupServer(t); + const { origin, searchParams } = new URL(url); + const challenge = searchParams.get('challenge'); + const prf = 'deadbeef000000000000000000000000000000000000000000000000cafebabe'; + const options = { + method: 'POST', + headers: /** @type {{[key:string]:string}} */({ + 'Content-Type': 'application/json', + 'Origin': origin, + 'Sec-Fetch-Site': 'same-origin', + 'Sec-Fetch-Dest': 'empty' + }), + body: JSON.stringify({ challenge, prf }) + } + deepStrictEqual((await request(origin, options)).statusCode, 403); // Tests the unset case + options.headers['Sec-Fetch-Mode'] = 'navigate'; + deepStrictEqual((await request(origin, options)).statusCode, 403); + options.headers['Sec-Fetch-Mode'] = 'no-cors'; + deepStrictEqual((await request(origin, options)).statusCode, 403); + options.headers['Sec-Fetch-Mode'] = 'same-origin'; + deepStrictEqual((await request(origin, options)).statusCode, 403); + options.headers['Sec-Fetch-Mode'] = 'websocket'; + deepStrictEqual((await request(origin, options)).statusCode, 403); + options.headers['Sec-Fetch-Mode'] = 'cors'; + deepStrictEqual((await request(origin, options)).statusCode, 200); + }); + test('validates Sec-Fetch-Dest header on POST', async (t) => { + const { url } = await setupServer(t); + deepStrictEqual((await fetch(makePostRequest(url, { headers: { 'Sec-Fetch-Dest': null } }))).status, 403); + deepStrictEqual((await fetch(makePostRequest(url, { headers: { 'Sec-Fetch-Dest': 'frame' } }))).status, 403); + deepStrictEqual((await fetch(makePostRequest(url, { headers: { 'Sec-Fetch-Dest': 'iframe' } }))).status, 403); + deepStrictEqual((await fetch(makePostRequest(url, { headers: { 'Sec-Fetch-Dest': 'document' } }))).status, 403); + deepStrictEqual((await fetch(makePostRequest(url, { headers: { 'Sec-Fetch-Dest': 'embed' } }))).status, 403); + deepStrictEqual((await fetch(makePostRequest(url, { headers: { 'Sec-Fetch-Dest': 'empty' } }))).status, 200); + }); test('validates Content-Type header on POST', async (t) => { const { url } = await setupServer(t); deepStrictEqual((await fetch(makePostRequest(url, { headers: { 'Content-Type': null } }))).status, 400);