diff --git a/apps/docs/pages/api-docs.mdx b/apps/docs/pages/api-docs.mdx index 78f0cde..04192c7 100644 --- a/apps/docs/pages/api-docs.mdx +++ b/apps/docs/pages/api-docs.mdx @@ -126,4 +126,9 @@ You can read our guide for creating a policy [here](/guides/policy) The reason this is set to false by default is because you might not need to hash your applications JS code for production. Inline scripts inside your `index.html` are hashed regardless of this setting. - \ No newline at end of file + + +### override +- Type: `boolean` +- Default: `false` +- Description: This is a flag to override the default policy. When set to false, the plugin will merge the default policy (provided by the plugin) with your policy. When set to true, the plugin will **only** use your policy only. \ No newline at end of file diff --git a/apps/react/tests/main.spec.ts b/apps/react/tests/main.spec.ts index 6e27e34..16cc25d 100644 --- a/apps/react/tests/main.spec.ts +++ b/apps/react/tests/main.spec.ts @@ -46,6 +46,23 @@ test("JQuery is blocked by CSP", async ({ page }) => { expect(cspViolationDetected).toBe(true); }); +test("Override flag is working in plugin", async ({ page }) => { + await page.goto("/"); + await expect(page.locator(`text=${TITLE}`)).toBeVisible(); + + //Get the meta tag and read the "font-src" attribute, which should contain the value "https://fonts.gstatic.com" + //And it shouldn't contain "img-src" attribute at all. + + const metaElement = page.locator( + 'meta[http-equiv="Content-Security-Policy"]' + ); + + const content = await metaElement.getAttribute("content"); + + expect(content).toContain("font-src https://fonts.gstatic.com"); + expect(content).not.toContain("img-src"); +}); + test("Inline script is blocked by CSP", async ({ page }) => { let cspViolationDetected = false; let inlineScriptExecuted = false; diff --git a/apps/react/vite.config.ts b/apps/react/vite.config.ts index e82cbd8..0de8df0 100644 --- a/apps/react/vite.config.ts +++ b/apps/react/vite.config.ts @@ -11,6 +11,11 @@ export default defineConfig({ dev: { run: true, }, + policy: { + "font-src": ["https://fonts.gstatic.com"], + "script-src-elem": ["'self'"], + }, + override: true, }), ], preview: { diff --git a/packages/vite-plugin-csp-guard/src/index.ts b/packages/vite-plugin-csp-guard/src/index.ts index aafa764..d9adf2d 100644 --- a/packages/vite-plugin-csp-guard/src/index.ts +++ b/packages/vite-plugin-csp-guard/src/index.ts @@ -1,13 +1,17 @@ import { Plugin, ViteDevServer } from "vite"; import { PluginContext } from "rollup"; import { MyPluginOptions, TransformationStatus } from "./types"; -import { DEFAULT_POLICY } from "./policy/constants"; -import { calculateSkip, createNewCollection } from "./policy/core"; +import { DEFAULT_DEV_POLICY, DEFAULT_POLICY } from "./policy/constants"; +import { + calculateSkip, + createNewCollection, + mergePolicies, + overrideChecker, +} from "./policy/core"; import { transformHandler, transformIndexHtmlHandler } from "./transform"; import { cssFilter, jsFilter, - mergePolicies, parseOutliers, preCssFilter, tsFilter, @@ -20,25 +24,32 @@ export default function vitePluginCSP( ): Plugin { const { algorithm = "sha256", - policy = DEFAULT_POLICY, + policy, dev = {}, features = FEATURE_FLAGS, build = {}, + override = false, } = options; + let pluginContext: PluginContext | undefined = undefined; //Needed for logging + let isDevMode = false; // This is a flag to check if we are in dev mode + let server: ViteDevServer | undefined = undefined; const { outlierSupport = [], run = false } = dev; const { hash = false } = build; const CORE_COLLECTION = createNewCollection(); - const effectivePolicy = mergePolicies(DEFAULT_POLICY, policy); - - let isDevMode = false; // This is a flag to check if we are in dev mode + const overrideIsFine = overrideChecker({ + userPolicy: policy, + override, + }); + if (!overrideIsFine) { + throw new Error( + "Override cannot be true when a csp policy is not provided" + ); + } const isUserDevOpt = run; // This is a flag to check if the user wants to run in dev mode - const canRunInDevMode = () => isDevMode && isUserDevOpt; // This is a function to check if we can run in dev mode - let pluginContext: PluginContext | undefined = undefined; //Needed for logging - - let server: ViteDevServer | undefined = undefined; + const isDevAndAllowed = () => isDevMode && isUserDevOpt; // This is a function to check if we can run in dev mode const transformationStatus: TransformationStatus = new Map(); const isTransformationStatusEmpty = () => transformationStatus.size === 0; @@ -86,7 +97,7 @@ export default function vitePluginCSP( } }, load(id) { - if (!canRunInDevMode()) return null; // Exit early if we are not in dev mode or if we are in dev mode but the user does not want to run in dev mode + if (!isDevAndAllowed()) return null; // Exit early if we are not in dev mode or if we are in dev mode but the user does not want to run in dev mode // Entry points to files that need to be transformed const isCss = cssFilter(id); @@ -105,7 +116,7 @@ export default function vitePluginCSP( console.log(id); } - if (!canRunInDevMode()) return null; // Exit early if we are not in dev mode or if we are in dev mode but the user does not want to run in dev mode + if (!isDevAndAllowed()) return null; // Exit early if we are not in dev mode or if we are in dev mode but the user does not want to run in dev mode await transformHandler({ code, @@ -125,6 +136,13 @@ export default function vitePluginCSP( if (features.mpa) { console.log("transformIndexHtml"); } + + const defaultPolicy = isDevAndAllowed() + ? DEFAULT_DEV_POLICY + : DEFAULT_POLICY; + + const effectivePolicy = mergePolicies(defaultPolicy, policy, override); + return transformIndexHtmlHandler({ html, context, @@ -132,7 +150,6 @@ export default function vitePluginCSP( policy: effectivePolicy, collection: CORE_COLLECTION, pluginContext, - canRunInDevMode: canRunInDevMode(), isTransformationStatusEmpty: isTransformationStatusEmpty(), isHashing: hash, shouldSkip, @@ -140,10 +157,7 @@ export default function vitePluginCSP( }, }, onLog(_level, log) { - if ( - log.plugin === "vite-plugin-csp-guard" && - log.pluginCode === "WARNING_CODE" - ) { + if (log.plugin === "vite-plugin-csp-guard") { this.warn(log); } }, diff --git a/packages/vite-plugin-csp-guard/src/policy/core.ts b/packages/vite-plugin-csp-guard/src/policy/core.ts index 05b50b3..6524574 100644 --- a/packages/vite-plugin-csp-guard/src/policy/core.ts +++ b/packages/vite-plugin-csp-guard/src/policy/core.ts @@ -4,6 +4,7 @@ import { HashCollection, HashCollectionKey, HashDataCollection, + OverrideCheckerProps, ShouldSkip, WarnMissingPolicyProps, } from "../types"; @@ -59,6 +60,50 @@ export const addHash = ({ hash, key, data, collection }: AddHashProps) => { } }; +export const overrideChecker = ({ + userPolicy, + override, +}: OverrideCheckerProps) => { + const userPolicyExists = userPolicy && Object.keys(userPolicy).length > 0; + if (override && !userPolicyExists) { + return false; + } + return true; +}; + +export const mergePolicies = ( + basePolicy: CSPPolicy, + newPolicy: CSPPolicy | undefined, + shouldOverride: boolean +): CSPPolicy => { + const newPolicyExists = newPolicy && Object.keys(newPolicy).length > 0; + + if (shouldOverride) { + return newPolicy as CSPPolicy; + } + if (!newPolicyExists) return basePolicy; + + const mergedPolicy: CSPPolicy = { ...basePolicy }; + + for (const key in newPolicy as CSPPolicy) { + const _key = key as keyof CSPPolicy; + if (newPolicy.hasOwnProperty(key)) { + const defaultValues = basePolicy[_key] || []; + const userValues = newPolicy[_key] || []; + + if (Array.isArray(userValues)) { + mergedPolicy[_key] = Array.from( + new Set([...defaultValues, ...userValues]) + ); + } else { + mergedPolicy[_key] = userValues; + } + } + } + + return mergedPolicy; +}; + export const warnMissingPolicy = ({ currentPolicy, source, @@ -78,7 +123,7 @@ export const warnMissingPolicy = ({ } }; -export const calculateSkip = (policy: CSPPolicy): ShouldSkip => { +export const calculateSkip = (policy: CSPPolicy | undefined): ShouldSkip => { const defaultShouldSkip = { "script-src": false, "script-src-attr": false, @@ -87,6 +132,7 @@ export const calculateSkip = (policy: CSPPolicy): ShouldSkip => { "style-src-attr": false, "style-src-elem": false, }; + if (!policy) return defaultShouldSkip; const keysToCheck = Object.keys(defaultShouldSkip) as (keyof ShouldSkip)[]; diff --git a/packages/vite-plugin-csp-guard/src/transform/handleIndexHtml.ts b/packages/vite-plugin-csp-guard/src/transform/handleIndexHtml.ts index d565df4..0c8e45b 100644 --- a/packages/vite-plugin-csp-guard/src/transform/handleIndexHtml.ts +++ b/packages/vite-plugin-csp-guard/src/transform/handleIndexHtml.ts @@ -137,6 +137,5 @@ export function handleIndexHtml({ // }); // }); // } - console.log($.html()); return { HASH_COLLECTION, html: $.html() }; } diff --git a/packages/vite-plugin-csp-guard/src/transform/index.ts b/packages/vite-plugin-csp-guard/src/transform/index.ts index 5560001..4550dde 100644 --- a/packages/vite-plugin-csp-guard/src/transform/index.ts +++ b/packages/vite-plugin-csp-guard/src/transform/index.ts @@ -10,7 +10,6 @@ import { } from "../types"; import { handleIndexHtml } from "./handleIndexHtml"; import { PluginContext } from "rollup"; -import { DEFAULT_DEV_POLICY } from "../policy/constants"; import { generatePolicyString, policyToTag } from "../policy/createPolicy"; import { cssFilter, jsFilter, preCssFilter, tsFilter } from "../utils"; import { getCSS } from "../css/extraction"; @@ -111,9 +110,8 @@ export interface TransformIndexHtmlHandlerProps { collection: HashCollection; policy: CSPPolicy; pluginContext: PluginContext | undefined; - canRunInDevMode: Boolean; - isTransformationStatusEmpty: Boolean; - isHashing: Boolean; + isTransformationStatusEmpty: boolean; + isHashing: boolean; shouldSkip: ShouldSkip; } @@ -124,7 +122,6 @@ export const transformIndexHtmlHandler = async ({ policy, collection, pluginContext, - canRunInDevMode, isTransformationStatusEmpty, isHashing, shouldSkip, @@ -204,22 +201,9 @@ export const transformIndexHtmlHandler = async ({ } ); - const finalPolicy = { ...policy }; - - if (canRunInDevMode) { - const defaultDevPolicy = DEFAULT_DEV_POLICY; - - for (const [key, defaultValues] of Object.entries(defaultDevPolicy)) { - const currentPolicy = finalPolicy[key as keyof CSPPolicy] ?? []; - finalPolicy[key as keyof CSPPolicy] = Array.from( - new Set([...currentPolicy, ...defaultValues]) - ); - } - } - const policyString = generatePolicyString({ collection: updatedCollection, - policy: finalPolicy, + policy: policy, }); const InjectedHtmlTags = policyToTag(policyString); diff --git a/packages/vite-plugin-csp-guard/src/types.ts b/packages/vite-plugin-csp-guard/src/types.ts index 5a1f48e..8ff6be0 100644 --- a/packages/vite-plugin-csp-guard/src/types.ts +++ b/packages/vite-plugin-csp-guard/src/types.ts @@ -98,6 +98,11 @@ export type MyPluginOptions = { */ cssInJs?: boolean; }; + /** + * This is a flag to override the default policy. When set to false, the plugin will merge the default policy (provided by the plugin) with your policy. When set to true, the plugin will **only** use your policy only. + * @default false + */ + override?: boolean; /** * Options that apply only when running `vite dev`. */ @@ -141,6 +146,11 @@ export type WarnMissingPolicyProps = { context?: PluginContext; }; +export type OverrideCheckerProps = { + userPolicy: CSPPolicy | undefined; + override: boolean; +}; + export type TransformationStatus = Map; export type ShouldSkip = { diff --git a/packages/vite-plugin-csp-guard/src/utils.ts b/packages/vite-plugin-csp-guard/src/utils.ts index 0faf9f6..945c8c8 100644 --- a/packages/vite-plugin-csp-guard/src/utils.ts +++ b/packages/vite-plugin-csp-guard/src/utils.ts @@ -1,5 +1,5 @@ import { createFilter } from "vite"; -import { CSPPolicy, Outlier, WarnMissingPolicyProps } from "./types"; +import { Outlier, WarnMissingPolicyProps } from "./types"; import { REQUIRE_POST_TRANSFORM } from "./transform/constants"; export const extractBaseURL = (url: string): string | false => { @@ -36,33 +36,6 @@ export const jsFilter = createFilter(["**/*.js?(*)", "**/*.jsx?(*)"]); export const tsFilter = createFilter(["**/*.ts", "**/*.tsx"]); export const htmlFilter = createFilter("**.html"); -export const mergePolicies = ( - defaultPolicy: CSPPolicy, - userPolicy: CSPPolicy | undefined -): CSPPolicy => { - if (!userPolicy) return defaultPolicy; - - const mergedPolicy: CSPPolicy = { ...defaultPolicy }; - - for (const key in userPolicy as CSPPolicy) { - const _key = key as keyof CSPPolicy; - if (userPolicy.hasOwnProperty(key)) { - const defaultValues = defaultPolicy[_key] || []; - const userValues = userPolicy[_key] || []; - - if (Array.isArray(userValues)) { - mergedPolicy[_key] = Array.from( - new Set([...defaultValues, ...userValues]) - ); - } else { - mergedPolicy[_key] = userValues; - } - } - } - - return mergedPolicy; -}; - export const parseOutliers = (outliers: Array) => { return { postTransform: outliers.some((outlier) => diff --git a/packages/vite-plugin-csp-guard/tests/policies.test.ts b/packages/vite-plugin-csp-guard/tests/policies.test.ts index 9d633ca..159891d 100644 --- a/packages/vite-plugin-csp-guard/tests/policies.test.ts +++ b/packages/vite-plugin-csp-guard/tests/policies.test.ts @@ -1,14 +1,14 @@ import { describe, expect, test } from "vitest"; import { CSPPolicy } from "../src/types"; -import { mergePolicies } from "../src/utils"; import { DEFAULT_POLICY } from "../src/policy/constants"; +import { mergePolicies } from "../src/policy/core"; describe("Policy Tests", () => { test("Simple Policy Merge", () => { const policy: CSPPolicy = { "frame-src": ["example.com"], }; - const mergedPolicy = mergePolicies(DEFAULT_POLICY, policy); + const mergedPolicy = mergePolicies(DEFAULT_POLICY, policy, false); expect(mergedPolicy).toEqual({ "default-src": ["'self'"], @@ -24,7 +24,7 @@ describe("Policy Tests", () => { "img-src": ["example.com"], }; - const mergedPolicy = mergePolicies(DEFAULT_POLICY, policy); + const mergedPolicy = mergePolicies(DEFAULT_POLICY, policy, false); expect(mergedPolicy).toEqual({ "default-src": ["'self'"], @@ -33,4 +33,16 @@ describe("Policy Tests", () => { "script-src-elem": ["'self'"], }); }); + + test("Override Policy Merge", () => { + const policy: CSPPolicy = { + "img-src": ["example.com"], + }; + + const mergedPolicy = mergePolicies(DEFAULT_POLICY, policy, true); + + expect(mergedPolicy).toEqual({ + "img-src": ["example.com"], + }); + }); });