From df26a9266edff821e39380d5f74364e46bba04b0 Mon Sep 17 00:00:00 2001 From: Blaine Bublitz Date: Wed, 27 Nov 2024 12:21:53 -0700 Subject: [PATCH 1/2] feat(nosecone): Add withVercelToolbar utility function --- nosecone-next/index.ts | 2 + nosecone-sveltekit/index.ts | 2 + nosecone/index.ts | 145 ++++++++++++++ nosecone/test/nosecone.test.ts | 344 +++++++++++++++++++++++++++++++++ 4 files changed, 493 insertions(+) diff --git a/nosecone-next/index.ts b/nosecone-next/index.ts index bb135b703..65ea84ec9 100644 --- a/nosecone-next/index.ts +++ b/nosecone-next/index.ts @@ -1,6 +1,8 @@ import nosecone, { defaults as baseDefaults } from "nosecone"; import type { NoseconeOptions } from "nosecone"; +export { withVercelToolbar, type NoseconeOptions } from "nosecone"; + export const defaults = { ...baseDefaults, contentSecurityPolicy: { diff --git a/nosecone-sveltekit/index.ts b/nosecone-sveltekit/index.ts index 8d38f7fa9..25019d8a1 100644 --- a/nosecone-sveltekit/index.ts +++ b/nosecone-sveltekit/index.ts @@ -7,6 +7,8 @@ import nosecone, { import type { CspDirectives, NoseconeOptions } from "nosecone"; import type { Handle, KitConfig } from "@sveltejs/kit"; +export { withVercelToolbar, type NoseconeOptions } from "nosecone"; + export const defaults = { ...baseDefaults, directives: { diff --git a/nosecone/index.ts b/nosecone/index.ts index 3ef84f339..93df99515 100644 --- a/nosecone/index.ts +++ b/nosecone/index.ts @@ -698,3 +698,148 @@ export default function nosecone({ return headers; } + +/** + * Augment some Nosecone configuration with the values necessary for using the + * Vercel Toolbar. + * + * Follows the guidance at + * https://vercel.com/docs/workflow-collaboration/vercel-toolbar/managing-toolbar#using-a-content-security-policy + * + * @param config Base configuration for you application + * @returns Augmented configuration to allow Vercel Toolbar + */ +export function withVercelToolbar(config: NoseconeOptions) { + let contentSecurityPolicy = config.contentSecurityPolicy; + if (contentSecurityPolicy === true) { + contentSecurityPolicy = defaults.contentSecurityPolicy; + } + + let augmentedContentSecurityPolicy = contentSecurityPolicy; + if (contentSecurityPolicy) { + let scriptSrc = contentSecurityPolicy.directives?.scriptSrc; + if (scriptSrc === true) { + scriptSrc = defaults.contentSecurityPolicy.directives.scriptSrc; + } + + let connectSrc = contentSecurityPolicy.directives?.connectSrc; + if (connectSrc === true) { + connectSrc = defaults.contentSecurityPolicy.directives.connectSrc; + } + + let imgSrc = contentSecurityPolicy.directives?.imgSrc; + if (imgSrc === true) { + imgSrc = defaults.contentSecurityPolicy.directives.imgSrc; + } + + let frameSrc = contentSecurityPolicy.directives?.frameSrc; + if (frameSrc === true) { + frameSrc = defaults.contentSecurityPolicy.directives.frameSrc; + } + + let styleSrc = contentSecurityPolicy.directives?.styleSrc; + if (styleSrc === true) { + styleSrc = defaults.contentSecurityPolicy.directives.styleSrc; + } + + let fontSrc = contentSecurityPolicy.directives?.fontSrc; + if (fontSrc === true) { + fontSrc = defaults.contentSecurityPolicy.directives.fontSrc; + } + + augmentedContentSecurityPolicy = { + ...contentSecurityPolicy, + directives: { + ...contentSecurityPolicy.directives, + scriptSrc: scriptSrc + ? [ + ...scriptSrc.filter( + (v) => v !== "'none'" && v !== "https://vercel.live", + ), + "https://vercel.live", + ] + : scriptSrc, + connectSrc: connectSrc + ? [ + ...connectSrc.filter( + (v) => + v !== "'none'" && + v !== "https://vercel.live" && + v !== "wss://ws-us3.pusher.com", + ), + "https://vercel.live", + "wss://ws-us3.pusher.com", + ] + : connectSrc, + imgSrc: imgSrc + ? [ + ...imgSrc.filter( + (v) => + v !== "'none'" && + v !== "https://vercel.live" && + v !== "https://vercel.com" && + v !== "data:" && + v !== "blob:", + ), + "https://vercel.live", + "https://vercel.com", + "data:", + "blob:", + ] + : imgSrc, + frameSrc: frameSrc + ? [ + ...frameSrc.filter( + (v) => v !== "'none'" && v !== "https://vercel.live", + ), + "https://vercel.live", + ] + : frameSrc, + styleSrc: styleSrc + ? [ + ...styleSrc.filter( + (v) => + v !== "'none'" && + v !== "https://vercel.live" && + v !== "'unsafe-inline'", + ), + "https://vercel.live", + "'unsafe-inline'", + ] + : styleSrc, + fontSrc: fontSrc + ? [ + ...fontSrc.filter( + (v) => + v !== "'none'" && + v !== "https://vercel.live" && + v !== "https://assets.vercel.com", + ), + "https://vercel.live", + "https://assets.vercel.com", + ] + : fontSrc, + }, + }; + } + + let crossOriginEmbedderPolicy = config.crossOriginEmbedderPolicy; + if (crossOriginEmbedderPolicy === true) { + crossOriginEmbedderPolicy = defaults.crossOriginEmbedderPolicy; + } + + let augmentedCrossOriginEmbedderPolicy = crossOriginEmbedderPolicy; + if (crossOriginEmbedderPolicy) { + augmentedCrossOriginEmbedderPolicy = { + policy: crossOriginEmbedderPolicy.policy + ? "unsafe-none" + : crossOriginEmbedderPolicy.policy, + }; + } + + return { + ...config, + contentSecurityPolicy: augmentedContentSecurityPolicy, + crossOriginEmbedderPolicy: augmentedCrossOriginEmbedderPolicy, + } as const; +} diff --git a/nosecone/test/nosecone.test.ts b/nosecone/test/nosecone.test.ts index 65587b5df..92850d6e5 100644 --- a/nosecone/test/nosecone.test.ts +++ b/nosecone/test/nosecone.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import nosecone, { + defaults, createContentSecurityPolicy, createContentTypeOptions, createCrossOriginEmbedderPolicy, @@ -16,6 +17,7 @@ import nosecone, { createStrictTransportSecurity, createXssProtection, NoseconeValidationError, + withVercelToolbar, } from "../index"; describe("nosecone", () => { @@ -638,4 +640,346 @@ describe("nosecone", () => { }); }); }); + + describe("withVercelToolbar", () => { + it("adds nothing if `contentSecurityPolicy` and `crossOriginEmbedderPolicy` undefined", () => { + const policy = withVercelToolbar({}); + assert.deepStrictEqual(policy, { + contentSecurityPolicy: undefined, + crossOriginEmbedderPolicy: undefined, + }); + }); + + it("adds nothing if policies not in use", () => { + const policy = withVercelToolbar({ + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: false, + }); + assert.deepStrictEqual(policy, { + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: false, + }); + }); + + it("augments policies using defaults if true", () => { + const policy = withVercelToolbar({ + contentSecurityPolicy: true, + crossOriginEmbedderPolicy: true, + }); + assert.deepStrictEqual(policy, { + contentSecurityPolicy: { + directives: { + baseUri: ["'none'"], + childSrc: ["'none'"], + connectSrc: [ + "'self'", + "https://vercel.live", + "wss://ws-us3.pusher.com", + ], + defaultSrc: ["'self'"], + fontSrc: [ + "'self'", + "https://vercel.live", + "https://assets.vercel.com", + ], + formAction: ["'self'"], + frameAncestors: ["'none'"], + frameSrc: ["https://vercel.live"], + imgSrc: [ + "'self'", + "https://vercel.live", + "https://vercel.com", + "data:", + "blob:", + ], + manifestSrc: ["'self'"], + mediaSrc: ["'self'"], + objectSrc: ["'none'"], + scriptSrc: ["'self'", "https://vercel.live"], + styleSrc: ["'self'", "https://vercel.live", "'unsafe-inline'"], + upgradeInsecureRequests: true, + workerSrc: ["'self'"], + }, + }, + crossOriginEmbedderPolicy: { + policy: "unsafe-none", + }, + }); + }); + + it("adds nothing if directives not in use", () => { + const policy = withVercelToolbar({ + contentSecurityPolicy: { + directives: { + scriptSrc: false, + connectSrc: false, + imgSrc: false, + frameSrc: false, + styleSrc: false, + fontSrc: false, + }, + }, + crossOriginEmbedderPolicy: false, + }); + assert.deepStrictEqual(policy, { + contentSecurityPolicy: { + directives: { + scriptSrc: false, + connectSrc: false, + imgSrc: false, + frameSrc: false, + styleSrc: false, + fontSrc: false, + }, + }, + crossOriginEmbedderPolicy: false, + }); + }); + + it("adds nothing if directives are undefined", () => { + const policy = withVercelToolbar({ + contentSecurityPolicy: { + directives: {}, + }, + crossOriginEmbedderPolicy: false, + }); + assert.deepStrictEqual(policy, { + contentSecurityPolicy: { + directives: { + scriptSrc: undefined, + connectSrc: undefined, + imgSrc: undefined, + frameSrc: undefined, + styleSrc: undefined, + fontSrc: undefined, + }, + }, + crossOriginEmbedderPolicy: false, + }); + }); + + it("augments directives using defaults if true", () => { + const policy = withVercelToolbar({ + contentSecurityPolicy: { + directives: { + scriptSrc: true, + connectSrc: true, + imgSrc: true, + frameSrc: true, + styleSrc: true, + fontSrc: true, + }, + }, + crossOriginEmbedderPolicy: false, + }); + assert.deepStrictEqual(policy, { + contentSecurityPolicy: { + directives: { + connectSrc: [ + "'self'", + "https://vercel.live", + "wss://ws-us3.pusher.com", + ], + fontSrc: [ + "'self'", + "https://vercel.live", + "https://assets.vercel.com", + ], + frameSrc: ["https://vercel.live"], + imgSrc: [ + "'self'", + "https://vercel.live", + "https://vercel.com", + "data:", + "blob:", + ], + scriptSrc: ["'self'", "https://vercel.live"], + styleSrc: ["'self'", "https://vercel.live", "'unsafe-inline'"], + }, + }, + crossOriginEmbedderPolicy: false, + }); + }); + + it("removes `'none'` values", () => { + const policy = withVercelToolbar({ + contentSecurityPolicy: { + directives: { + scriptSrc: ["'none'"], + connectSrc: ["'none'"], + imgSrc: ["'none'"], + frameSrc: ["'none'"], + styleSrc: ["'none'"], + fontSrc: ["'none'"], + }, + }, + crossOriginEmbedderPolicy: false, + }); + assert.deepStrictEqual(policy, { + contentSecurityPolicy: { + directives: { + connectSrc: ["https://vercel.live", "wss://ws-us3.pusher.com"], + fontSrc: ["https://vercel.live", "https://assets.vercel.com"], + frameSrc: ["https://vercel.live"], + imgSrc: [ + "https://vercel.live", + "https://vercel.com", + "data:", + "blob:", + ], + scriptSrc: ["https://vercel.live"], + styleSrc: ["https://vercel.live", "'unsafe-inline'"], + }, + }, + crossOriginEmbedderPolicy: false, + }); + }); + + it("removes `'self'` values", () => { + const policy = withVercelToolbar({ + contentSecurityPolicy: { + directives: { + scriptSrc: ["'self'"], + connectSrc: ["'self'"], + imgSrc: ["'self'"], + frameSrc: ["'self'"], + styleSrc: ["'self'"], + fontSrc: ["'self'"], + }, + }, + crossOriginEmbedderPolicy: false, + }); + assert.deepStrictEqual(policy, { + contentSecurityPolicy: { + directives: { + connectSrc: [ + "'self'", + "https://vercel.live", + "wss://ws-us3.pusher.com", + ], + fontSrc: [ + "'self'", + "https://vercel.live", + "https://assets.vercel.com", + ], + frameSrc: ["'self'", "https://vercel.live"], + imgSrc: [ + "'self'", + "https://vercel.live", + "https://vercel.com", + "data:", + "blob:", + ], + scriptSrc: ["'self'", "https://vercel.live"], + styleSrc: ["'self'", "https://vercel.live", "'unsafe-inline'"], + }, + }, + crossOriginEmbedderPolicy: false, + }); + }); + + it("can be called twice without duplicating values", () => { + const policy = withVercelToolbar( + withVercelToolbar({ + contentSecurityPolicy: true, + crossOriginEmbedderPolicy: true, + }), + ); + assert.deepStrictEqual(policy, { + contentSecurityPolicy: { + directives: { + baseUri: ["'none'"], + childSrc: ["'none'"], + connectSrc: [ + "'self'", + "https://vercel.live", + "wss://ws-us3.pusher.com", + ], + defaultSrc: ["'self'"], + fontSrc: [ + "'self'", + "https://vercel.live", + "https://assets.vercel.com", + ], + formAction: ["'self'"], + frameAncestors: ["'none'"], + frameSrc: ["https://vercel.live"], + imgSrc: [ + "'self'", + "https://vercel.live", + "https://vercel.com", + "data:", + "blob:", + ], + manifestSrc: ["'self'"], + mediaSrc: ["'self'"], + objectSrc: ["'none'"], + scriptSrc: ["'self'", "https://vercel.live"], + styleSrc: ["'self'", "https://vercel.live", "'unsafe-inline'"], + upgradeInsecureRequests: true, + workerSrc: ["'self'"], + }, + }, + crossOriginEmbedderPolicy: { + policy: "unsafe-none", + }, + }); + }); + + it("can be applied to defaults", () => { + const policy = withVercelToolbar(defaults); + assert.deepStrictEqual(policy, { + contentSecurityPolicy: { + directives: { + baseUri: ["'none'"], + childSrc: ["'none'"], + connectSrc: [ + "'self'", + "https://vercel.live", + "wss://ws-us3.pusher.com", + ], + defaultSrc: ["'self'"], + fontSrc: [ + "'self'", + "https://vercel.live", + "https://assets.vercel.com", + ], + formAction: ["'self'"], + frameAncestors: ["'none'"], + frameSrc: ["https://vercel.live"], + imgSrc: [ + "'self'", + "https://vercel.live", + "https://vercel.com", + "data:", + "blob:", + ], + manifestSrc: ["'self'"], + mediaSrc: ["'self'"], + objectSrc: ["'none'"], + scriptSrc: ["'self'", "https://vercel.live"], + styleSrc: ["'self'", "https://vercel.live", "'unsafe-inline'"], + upgradeInsecureRequests: true, + workerSrc: ["'self'"], + }, + }, + crossOriginEmbedderPolicy: { policy: "unsafe-none" }, + crossOriginOpenerPolicy: { policy: "same-origin" }, + crossOriginResourcePolicy: { policy: "same-origin" }, + originAgentCluster: true, + referrerPolicy: { policy: ["no-referrer"] }, + strictTransportSecurity: { + maxAge: 31536000, + includeSubDomains: true, + preload: false, + }, + xContentTypeOptions: true, + xDnsPrefetchControl: { allow: false }, + xDownloadOptions: true, + xFrameOptions: { action: "sameorigin" }, + xPermittedCrossDomainPolicies: { permittedPolicies: "none" }, + xXssProtection: true, + }); + }); + }); }); From b26dcb849268ebb22ad99041aa3ee2e2b78a9a74 Mon Sep 17 00:00:00 2001 From: Blaine Bublitz Date: Wed, 27 Nov 2024 12:24:10 -0700 Subject: [PATCH 2/2] update test name --- nosecone/test/nosecone.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nosecone/test/nosecone.test.ts b/nosecone/test/nosecone.test.ts index 92850d6e5..97c8014a3 100644 --- a/nosecone/test/nosecone.test.ts +++ b/nosecone/test/nosecone.test.ts @@ -835,7 +835,7 @@ describe("nosecone", () => { }); }); - it("removes `'self'` values", () => { + it("keeps `'self'` values", () => { const policy = withVercelToolbar({ contentSecurityPolicy: { directives: {