From ff19af90920b0637d6c628e15aa0f295bb6f2e64 Mon Sep 17 00:00:00 2001 From: blaine-arcjet <146491715+blaine-arcjet@users.noreply.github.com> Date: Fri, 29 Nov 2024 08:48:39 -0700 Subject: [PATCH] fix(nosecone)!: Change return value to Headers (#2362) While writing the docs for nosecone, I ran into a problem where I could not use the `res.setHeaders()` API that Node.js added in v18.15.0 because nosecone wasn't returning a `Header` or `Map` object. The original choice for a plain object was for the SvelteKit adapter, but the implementation of it changed and that plain object is no longer needed. Changing it to an actual `Headers` object will still work in the places we expected, with the added benefits of working with the Node.js API. The only downside is that they aren't spreadable. I've also added the nosecone usage to one of our Node.js examples. --- examples/nodejs-rate-limit/index.js | 5 +- examples/nodejs-rate-limit/package-lock.json | 72 +++++++++----- examples/nodejs-rate-limit/package.json | 3 +- nosecone-next/index.ts | 18 ++-- nosecone-sveltekit/index.ts | 2 +- nosecone/index.ts | 28 +++--- nosecone/test/nosecone.test.ts | 98 +++++++++++--------- 7 files changed, 130 insertions(+), 96 deletions(-) diff --git a/examples/nodejs-rate-limit/index.js b/examples/nodejs-rate-limit/index.js index dbac2568e..e7776b51d 100644 --- a/examples/nodejs-rate-limit/index.js +++ b/examples/nodejs-rate-limit/index.js @@ -1,5 +1,6 @@ import arcjet, { fixedWindow, shield } from "@arcjet/node"; import * as http from "node:http"; +import nosecone from "nosecone"; const aj = arcjet({ // Get your site key from https://app.arcjet.com and set it as an environment @@ -25,6 +26,8 @@ const aj = arcjet({ const server = http.createServer(async function (req, res) { const decision = await aj.protect(req); + res.setHeaders(nosecone()); + if (decision.isDenied()) { res.writeHead(429, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Too Many Requests" })); @@ -34,4 +37,4 @@ const server = http.createServer(async function (req, res) { } }); -server.listen(3000); \ No newline at end of file +server.listen(3000); diff --git a/examples/nodejs-rate-limit/package-lock.json b/examples/nodejs-rate-limit/package-lock.json index 5b51ddd27..22735c0f9 100644 --- a/examples/nodejs-rate-limit/package-lock.json +++ b/examples/nodejs-rate-limit/package-lock.json @@ -1,11 +1,12 @@ { - "name": "nodejs-simple", + "name": "nodejs-rate-limit", "lockfileVersion": 3, "requires": true, "packages": { "": { "dependencies": { - "@arcjet/node": "../../arcjet-node" + "@arcjet/node": "../../arcjet-node", + "nosecone": "../../nosecone" }, "devDependencies": { "@types/node": "^20", @@ -39,22 +40,42 @@ }, "../../arcjet-node": { "name": "@arcjet/node", - "version": "1.0.0-alpha.8", + "version": "1.0.0-alpha.32", "license": "Apache-2.0", "dependencies": { - "@arcjet/ip": "1.0.0-alpha.8", - "@connectrpc/connect-node": "1.3.0", - "arcjet": "1.0.0-alpha.8" + "@arcjet/body": "1.0.0-alpha.32", + "@arcjet/env": "1.0.0-alpha.32", + "@arcjet/headers": "1.0.0-alpha.32", + "@arcjet/ip": "1.0.0-alpha.32", + "@arcjet/logger": "1.0.0-alpha.32", + "@arcjet/protocol": "1.0.0-alpha.32", + "@arcjet/transport": "1.0.0-alpha.32", + "arcjet": "1.0.0-alpha.32" }, "devDependencies": { - "@arcjet/eslint-config": "1.0.0-alpha.8", - "@arcjet/rollup-config": "1.0.0-alpha.8", - "@arcjet/tsconfig": "1.0.0-alpha.8", + "@arcjet/eslint-config": "1.0.0-alpha.32", + "@arcjet/rollup-config": "1.0.0-alpha.32", + "@arcjet/tsconfig": "1.0.0-alpha.32", "@jest/globals": "29.7.0", - "@rollup/wasm-node": "4.12.0", + "@rollup/wasm-node": "4.27.4", "@types/node": "18.18.0", "jest": "29.7.0", - "typescript": "5.3.3" + "typescript": "5.7.2" + }, + "engines": { + "node": ">=18" + } + }, + "../../nosecone": { + "version": "1.0.0-alpha.32", + "license": "Apache-2.0", + "devDependencies": { + "@arcjet/eslint-config": "1.0.0-alpha.32", + "@arcjet/rollup-config": "1.0.0-alpha.32", + "@arcjet/tsconfig": "1.0.0-alpha.32", + "@rollup/wasm-node": "4.27.4", + "@types/node": "18.18.0", + "typescript": "5.7.2" }, "engines": { "node": ">=18" @@ -65,19 +86,25 @@ "link": true }, "node_modules/@types/node": { - "version": "20.11.20", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.20.tgz", - "integrity": "sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==", + "version": "20.17.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.8.tgz", + "integrity": "sha512-ahz2g6/oqbKalW9sPv6L2iRbhLnojxjYWspAqhjvqSWBgGebEJT5GvRmk0QXPj3sbC6rU0GTQjPLQkmR8CObvA==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, + "node_modules/nosecone": { + "resolved": "../../nosecone", + "link": true + }, "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -87,10 +114,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" } } } diff --git a/examples/nodejs-rate-limit/package.json b/examples/nodejs-rate-limit/package.json index fe60e9bf9..25d25f724 100644 --- a/examples/nodejs-rate-limit/package.json +++ b/examples/nodejs-rate-limit/package.json @@ -5,7 +5,8 @@ "start": "node --env-file .env.local ./index.js" }, "dependencies": { - "@arcjet/node": "../../arcjet-node" + "@arcjet/node": "../../arcjet-node", + "nosecone": "../../nosecone" }, "devDependencies": { "@types/node": "^20", diff --git a/nosecone-next/index.ts b/nosecone-next/index.ts index bb135b703..e8ba8dfb6 100644 --- a/nosecone-next/index.ts +++ b/nosecone-next/index.ts @@ -36,17 +36,13 @@ function nonce() { export function createMiddleware(options: NoseconeOptions = defaults) { return async () => { const headers = nosecone(options); + // Setting this specific header is the way that Next.js implements + // middleware. See: + // https://github.com/vercel/next.js/blob/5c45d58cd058a9683e435fd3a1a9b8fede8376c3/packages/next/src/server/web/spec-extension/response.ts#L148 + // Note: we don't create the `x-middleware-override-headers` header so + // the original headers pass through + headers.set("x-middleware-next", "1"); - return new Response(null, { - headers: { - ...headers, - // Setting this specific header is the way that Next.js implements - // middleware. See: - // https://github.com/vercel/next.js/blob/5c45d58cd058a9683e435fd3a1a9b8fede8376c3/packages/next/src/server/web/spec-extension/response.ts#L148 - // Note: we don't create the `x-middleware-override-headers` header so - // the original headers pass through - "x-middleware-next": "1", - }, - }); + return new Response(null, { headers }); }; } diff --git a/nosecone-sveltekit/index.ts b/nosecone-sveltekit/index.ts index 8d38f7fa9..2853d210e 100644 --- a/nosecone-sveltekit/index.ts +++ b/nosecone-sveltekit/index.ts @@ -29,7 +29,7 @@ export function createHook(options: NoseconeOptions = defaults): Handle { const response = await resolve(event); const headers = nosecone(options); - for (const [headerName, headerValue] of Object.entries(headers)) { + for (const [headerName, headerValue] of headers.entries()) { // Only add headers that aren't already set. For example, SvelteKit will // likely have added `Content-Security-Policy` if configured with `csp` if (!response.headers.has(headerName)) { diff --git a/nosecone/index.ts b/nosecone/index.ts index 3ef84f339..f243158e3 100644 --- a/nosecone/index.ts +++ b/nosecone/index.ts @@ -616,84 +616,84 @@ export default function nosecone({ xPermittedCrossDomainPolicies = defaults.xPermittedCrossDomainPolicies; } - const headers: Record = {}; + const headers = new Headers(); if (contentSecurityPolicy) { const [headerName, headerValue] = createContentSecurityPolicy( contentSecurityPolicy, ); - headers[headerName] = headerValue; + headers.set(headerName, headerValue); } if (crossOriginEmbedderPolicy) { const [headerName, headerValue] = createCrossOriginEmbedderPolicy( crossOriginEmbedderPolicy, ); - headers[headerName] = headerValue; + headers.set(headerName, headerValue); } if (crossOriginOpenerPolicy) { const [headerName, headerValue] = createCrossOriginOpenerPolicy( crossOriginOpenerPolicy, ); - headers[headerName] = headerValue; + headers.set(headerName, headerValue); } if (crossOriginResourcePolicy) { const [headerName, headerValue] = createCrossOriginResourcePolicy( crossOriginResourcePolicy, ); - headers[headerName] = headerValue; + headers.set(headerName, headerValue); } if (originAgentCluster) { const [headerName, headerValue] = createOriginAgentCluster(); - headers[headerName] = headerValue; + headers.set(headerName, headerValue); } if (referrerPolicy) { const [headerName, headerValue] = createReferrerPolicy(referrerPolicy); - headers[headerName] = headerValue; + headers.set(headerName, headerValue); } if (strictTransportSecurity) { const [headerName, headerValue] = createStrictTransportSecurity( strictTransportSecurity, ); - headers[headerName] = headerValue; + headers.set(headerName, headerValue); } if (xContentTypeOptions) { const [headerName, headerValue] = createContentTypeOptions(); - headers[headerName] = headerValue; + headers.set(headerName, headerValue); } if (xDnsPrefetchControl) { const [headerName, headerValue] = createDnsPrefetchControl(xDnsPrefetchControl); - headers[headerName] = headerValue; + headers.set(headerName, headerValue); } if (xDownloadOptions) { const [headerName, headerValue] = createDownloadOptions(); - headers[headerName] = headerValue; + headers.set(headerName, headerValue); } if (xFrameOptions) { const [headerName, headerValue] = createFrameOptions(xFrameOptions); - headers[headerName] = headerValue; + headers.set(headerName, headerValue); } if (xPermittedCrossDomainPolicies) { const [headerName, headerValue] = createPermittedCrossDomainPolicies( xPermittedCrossDomainPolicies, ); - headers[headerName] = headerValue; + headers.set(headerName, headerValue); } if (xXssProtection) { const [headerName, headerValue] = createXssProtection(); - headers[headerName] = headerValue; + headers.set(headerName, headerValue); } return headers; diff --git a/nosecone/test/nosecone.test.ts b/nosecone/test/nosecone.test.ts index 65587b5df..239bcaa3d 100644 --- a/nosecone/test/nosecone.test.ts +++ b/nosecone/test/nosecone.test.ts @@ -547,42 +547,46 @@ describe("nosecone", () => { describe("nosecone", () => { it("uses default configuration if no options provided", () => { const headers = nosecone(); - assert.deepStrictEqual(headers, { - "content-security-policy": + assert.deepStrictEqual(Array.from(headers.entries()), [ + [ + "content-security-policy", "base-uri 'none'; child-src 'none'; connect-src 'self'; default-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; frame-src 'none'; img-src 'self' blob: data:; manifest-src 'self'; media-src 'self'; object-src 'none'; script-src 'self'; style-src 'self'; worker-src 'self'; upgrade-insecure-requests;", - "cross-origin-embedder-policy": "require-corp", - "cross-origin-opener-policy": "same-origin", - "cross-origin-resource-policy": "same-origin", - "origin-agent-cluster": "?1", - "referrer-policy": "no-referrer", - "strict-transport-security": "max-age=31536000; includeSubDomains", - "x-content-type-options": "nosniff", - "x-dns-prefetch-control": "off", - "x-download-options": "noopen", - "x-frame-options": "SAMEORIGIN", - "x-permitted-cross-domain-policies": "none", - "x-xss-protection": "0", - }); + ], + ["cross-origin-embedder-policy", "require-corp"], + ["cross-origin-opener-policy", "same-origin"], + ["cross-origin-resource-policy", "same-origin"], + ["origin-agent-cluster", "?1"], + ["referrer-policy", "no-referrer"], + ["strict-transport-security", "max-age=31536000; includeSubDomains"], + ["x-content-type-options", "nosniff"], + ["x-dns-prefetch-control", "off"], + ["x-download-options", "noopen"], + ["x-frame-options", "SAMEORIGIN"], + ["x-permitted-cross-domain-policies", "none"], + ["x-xss-protection", "0"], + ]); }); it("uses default configuration if field not provided", () => { const headers = nosecone({}); - assert.deepStrictEqual(headers, { - "content-security-policy": + assert.deepStrictEqual(Array.from(headers.entries()), [ + [ + "content-security-policy", "base-uri 'none'; child-src 'none'; connect-src 'self'; default-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; frame-src 'none'; img-src 'self' blob: data:; manifest-src 'self'; media-src 'self'; object-src 'none'; script-src 'self'; style-src 'self'; worker-src 'self'; upgrade-insecure-requests;", - "cross-origin-embedder-policy": "require-corp", - "cross-origin-opener-policy": "same-origin", - "cross-origin-resource-policy": "same-origin", - "origin-agent-cluster": "?1", - "referrer-policy": "no-referrer", - "strict-transport-security": "max-age=31536000; includeSubDomains", - "x-content-type-options": "nosniff", - "x-dns-prefetch-control": "off", - "x-download-options": "noopen", - "x-frame-options": "SAMEORIGIN", - "x-permitted-cross-domain-policies": "none", - "x-xss-protection": "0", - }); + ], + ["cross-origin-embedder-policy", "require-corp"], + ["cross-origin-opener-policy", "same-origin"], + ["cross-origin-resource-policy", "same-origin"], + ["origin-agent-cluster", "?1"], + ["referrer-policy", "no-referrer"], + ["strict-transport-security", "max-age=31536000; includeSubDomains"], + ["x-content-type-options", "nosniff"], + ["x-dns-prefetch-control", "off"], + ["x-download-options", "noopen"], + ["x-frame-options", "SAMEORIGIN"], + ["x-permitted-cross-domain-policies", "none"], + ["x-xss-protection", "0"], + ]); }); it("disables header with explicit false", () => { @@ -601,7 +605,7 @@ describe("nosecone", () => { xPermittedCrossDomainPolicies: false, xXssProtection: false, }); - assert.deepStrictEqual(headers, {}); + assert.deepStrictEqual(Array.from(headers.entries()), []); }); it("enabled default header with explicit true", () => { @@ -620,22 +624,24 @@ describe("nosecone", () => { xPermittedCrossDomainPolicies: true, xXssProtection: true, }); - assert.deepStrictEqual(headers, { - "content-security-policy": + assert.deepStrictEqual(Array.from(headers.entries()), [ + [ + "content-security-policy", "base-uri 'none'; child-src 'none'; connect-src 'self'; default-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; frame-src 'none'; img-src 'self' blob: data:; manifest-src 'self'; media-src 'self'; object-src 'none'; script-src 'self'; style-src 'self'; worker-src 'self'; upgrade-insecure-requests;", - "cross-origin-embedder-policy": "require-corp", - "cross-origin-opener-policy": "same-origin", - "cross-origin-resource-policy": "same-origin", - "origin-agent-cluster": "?1", - "referrer-policy": "no-referrer", - "strict-transport-security": "max-age=31536000; includeSubDomains", - "x-content-type-options": "nosniff", - "x-dns-prefetch-control": "off", - "x-download-options": "noopen", - "x-frame-options": "SAMEORIGIN", - "x-permitted-cross-domain-policies": "none", - "x-xss-protection": "0", - }); + ], + ["cross-origin-embedder-policy", "require-corp"], + ["cross-origin-opener-policy", "same-origin"], + ["cross-origin-resource-policy", "same-origin"], + ["origin-agent-cluster", "?1"], + ["referrer-policy", "no-referrer"], + ["strict-transport-security", "max-age=31536000; includeSubDomains"], + ["x-content-type-options", "nosniff"], + ["x-dns-prefetch-control", "off"], + ["x-download-options", "noopen"], + ["x-frame-options", "SAMEORIGIN"], + ["x-permitted-cross-domain-policies", "none"], + ["x-xss-protection", "0"], + ]); }); }); });