CVE-2024-29896: Insufficient Information (NVD-CWE-noinfo)
value: Astro-Shield is a library to compute the subresource integrity hashes for your JS scripts and CSS stylesheets. When automated CSP headers generation for SSR content is enabled and the web application serves content that can be partially controlled by external users, then it is possible that the CSP headers generation feature might be "allow-listing" malicious injected resources like inlined JS, or references to external malicious scripts. The fix is available in version 1.3.0.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-03-28T13:15Z
Repository: https://github.com/KindSpells/astro-shield
Language: Other
Files changed: README.md, package.json, src/core.mjs, src/headers.mjs, src/main.mjs, tests/core.test.mts, tests/headers.test.mts, tests/main.test.mts, vitest.config.unit.mts
Commit date: 2024-03-27 17:48:22 +0100
Commit message: feat: cross-origin resources allow-lists

Signed-off-by: Andres Correa Casablanca <andreu@kindspells.dev>

Diff: diff --git a/README.md b/README.md
index 5684eef..dc2ef93 100644
--- a/README.md
+++ b/README.md
@@ -51,22 +51,66 @@ import { shield } from '@kindspells/astro-shield'
 const rootDir = new URL('.', import.meta.url).pathname
 
 export default defineConfig({
   integrations: [
     shield({
-      // Enables SRI hashes generation for statically generated pages
-      enableStatic_SRI: true, // true by default
-
-      // Enables a middleware that generates SRI hashes for dynamically
-      // generated pages
-      enableMiddleware_SRI: false, // false by default
-
-      // This is the path where we'll generate the module containing the SRI
-      // hashes for your scripts and styles. There's no need to pass this
-      // parameter if you don't need this data, but it can be useful to
-      // configure your CSP policies.
-      sriHashesModule: resolve(rootDir, 'src', 'utils', 'sriHashes.mjs'),
+      sri: {
+        // Enables SRI hashes generation for statically generated pages
+        enableStatic: true, // true by default
+
+        // Enables a middleware that generates SRI hashes for dynamically
+        // generated pages
+        enableMiddleware: false, // false by default
+
+        // This is the path where we'll generate the module containing the SRI
+        // hashes for your scripts and styles. There's no need to pass this
+        // parameter if you don't need this data, but it can be useful to
+        // configure your CSP policies.
+        hashesModule: resolve(rootDir, 'src', 'utils', 'sriHashes.mjs'),
+
+        // For SSR content, Cross-Origin scripts must be explicitly allow-listed
+        // by URL in order to be allowed by the Content Security Policy.
+        //
+        // Defaults to []
+        scriptsAllowListUrls: [
+          'https://code.jquery.com/jquery-3.7.1.slim.min.js',
+        ],
+
+        // For SSR content, Cross-Origin styles must be explicitly allow-listed
+        // by URL in order to be allowed by the Content Security Policy.
+        //
+        // Defaults to []
+        stylesAllowListUrls: [
+          'https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css',
+        ],
+
+        /**
+         * Inline styles are usually considered unsafe because they could make it
+         * easier for an attacker to inject CSS rules in dynamic pages. However, they
+         * don't pose a serious security risk for _most_ static pages.
+         *
+         * You can disable this option in case you want to enforce a stricter policy.
+         *
+         * @type {'all' | 'static' | false}
+         *
+         * Defaults to 'all'.
+         */
+        allowInlineStyles: 'all',
+
+        /**
+         * Inline scripts are usually considered unsafe because they could make it
+         * easier for an attacker to inject JS code in dynamic pages. However, they
+         * don't pose a serious security risk for _most_ static pages.
+         *
+         * You can disable this option in case you want to enforce a stricter policy.
+         *
+         * @type {'all' | 'static' | false}
+         *
+         * Defaults to 'all'.
+         */
+        allowInlineScript: 'all',
+      },
 
       // - If set, it controls how the security headers will be generated in the
       //   middleware.
       // - If not set, no security headers will be generated in the middleware.
       securityHeaders: {
diff --git a/package.json b/package.json
index c754440..a18ae90 100644
--- a/package.json
+++ b/package.json
@@ -1,8 +1,8 @@
 {
 	"name": "@kindspells/astro-shield",
-	"version": "1.2.0",
+	"version": "1.3.0",
 	"description": "Astro integration to enhance your website's security with SubResource Integrity hashes, Content-Security-Policy headers, and other techniques.",
 	"private": false,
 	"type": "module",
 	"main": "src/main.mjs",
 	"types": "src/main.d.ts",
diff --git a/src/core.mjs b/src/core.mjs
index 4ad1430..66dbcc2 100644
--- a/src/core.mjs
+++ b/src/core.mjs
@@ -39,32 +39,32 @@ export const generateSRIHash = data => {
 	return `sha256-${hash.digest('base64')}`
 }
 
 /**
  * @typedef {(
- *   hash: string,
+ *   hash: string | null,
  *   attrs: string,
  *   setCrossorigin: boolean,
  *   content?: string | undefined,
  * ) => string} ElemReplacer
  */
 
 /** @type {ElemReplacer} */
 const scriptReplacer = (hash, attrs, setCrossorigin, content) =>
-	`<script${attrs} integrity="${hash}"${
+	`<script${attrs}${hash !== null ? ` integrity="${hash}"` : ''}${
 		setCrossorigin ? ' crossorigin="anonymous"' : ''
 	}>${content ?? ''}</script>`
 
 /** @type {ElemReplacer} */
 const styleReplacer = (hash, attrs, setCrossorigin, content) =>
-	`<style${attrs} integrity="${hash}"${
+	`<style${attrs}${hash !== null ? ` integrity="${hash}"` : ''}${
 		setCrossorigin ? ' crossorigin="anonymous"' : ''
 	}>${content ?? ''}</style>`
 
 /** @type {ElemReplacer} */
 const linkStyleReplacer = (hash, attrs, setCrossorigin) =>
-	`<link${attrs} integrity="${hash}"${
+	`<link${attrs}${hash !== null ? ` integrity="${hash}"` : ''}${
 		setCrossorigin ? ' crossorigin="anonymous"' : ''
 	}/>`
 
 const srcRegex = /\s+(src|href)\s*=\s*("(?<src1>.*?)"|'(?<src2>.*?)')/i
 const integrityRegex =
@@ -240,11 +240,11 @@ export const updateStaticPageSriHashes = async (
  */
 export const updateDynamicPageSriHashes = async (
 	logger,
 	content,
 	globalHashes,
-	sri
+	sri,
 ) => {
 	const processors = getRegexProcessors()
 
 	let updatedContent = content
 	let match
@@ -329,16 +329,27 @@ export const updateDynamicPageSriHashes = async (
 						sriHash = globalHashes[t2].get(src)
 
 						if (sriHash) {
 							pageHashes[t2].add(sriHash)
 						} else {
-							const resourceResponse = await fetch(src, { method: 'GET' })
-							const resourceContent = await resourceResponse.arrayBuffer()
+							logger.warn(
+								`Detected reference to not-allow-listed external resource "${src}"`,
+							)
+							if (setCrossorigin) {
+								updatedContent = updatedContent.replace(
+									match[0],
+									replacer(null, attrs, true, ''),
+								)
+							}
+							continue
 
-							sriHash = generateSRIHash(resourceContent)
-							globalHashes[t2].set(src, sriHash)
-							pageHashes[t2].add(sriHash)
+							// TODO: add scape hatch to allow fetching arbitrary external resources
+							// const resourceResponse = await fetch(src, { method: 'GET' })
+							// const resourceContent = await resourceResponse.arrayBuffer()
+							// sriHash = generateSRIHash(resourceContent)
+							// globalHashes[t2].set(src, sriHash)
+							// pageHashes[t2].add(sriHash)
 						}
 					} else {
 						logger.warn(`Unable to process external resource: "${src}"`)
 						continue
 					}
@@ -515,10 +526,34 @@ export const scanForNestedResources = async (logger, dirPath, h) => {
 		},
 		_filePath => ['.js', '.mjs', '.css'].includes(extname(_filePath)),
 	)
 }
 
+/**
+ * @param {Required<Pick<SRIOptions, 'scriptsAllowListUrls' | 'stylesAllowListUrls'>>} sri
+ * @param {HashesCollection} h
+ */
+export const scanAllowLists = async (sri, h) => {
+	for (const scriptUrl of sri.scriptsAllowListUrls) {
+		const resourceResponse = await fetch(scriptUrl, { method: 'GET' })
+		const resourceContent = await resourceResponse.arrayBuffer()
+		const sriHash = generateSRIHash(resourceContent)
+
+		h.extScriptHashes.add(sriHash)
+		h.perResourceSriHashes.scripts.set(scriptUrl, sriHash)
+	}
+
+	for (const styleUrl of sri.stylesAllowListUrls) {
+		const resourceResponse = await fetch(styleUrl, { method: 'GET' })
+		const resourceContent = await resourceResponse.arrayBuffer()
+		const sriHash = generateSRIHash(resourceContent)
+
+		h.extStyleHashes.add(sriHash)
+		h.perResourceSriHashes.styles.set(styleUrl, sriHash)
+	}
+}
+
 /**
  * @param {Logger} logger
  * @param {HashesCollection} h
  * @param {string} sriHashesModule
  * @param {boolean} enableMiddleware_SRI
@@ -671,23 +706,26 @@ export const processStaticFiles = async (logger, { distDir, sri }) => {
 		sri.enableMiddleware,
 	)
 }
 
 /**
+ * @param {Logger} logger
  * @param {MiddlewareHashes} globalHashes
+ * @param {Required<SRIOptions>} sri
  * @returns {import('astro').MiddlewareHandler}
  */
-export const getMiddlewareHandler = globalHashes => {
+export const getMiddlewareHandler = (logger, globalHashes, sri) => {
 	/** @satisfies {import('astro').MiddlewareHandler} */
 	return async (_ctx, next) => {
 		const response = await next()
 		const content = await response.text()
 
 		const { updatedContent } = await updateDynamicPageSriHashes(
-			console,
+			logger,
 			content,
 			globalHashes,
+			sri,
 		)
 
 		const patchedResponse = new Response(updatedContent, {
 			status: response.status,
 			statusText: response.statusText,
@@ -698,24 +736,32 @@ export const getMiddlewareHandler = globalHashes => {
 }
 
 /**
  * Variant of `getMiddlewareHandler` that also applies security headers.
  *
+ * @param {Logger} logger
  * @param {MiddlewareHashes} globalHashes
  * @param {SecurityHeadersOptions} securityHeadersOpts
+ * @param {Required<SRIOptions>} sri
  * @returns {import('astro').MiddlewareHandler}
  */
-export const getCSPMiddlewareHandler = (globalHashes, securityHeadersOpts) => {
+export const getCSPMiddlewareHandler = (
+	logger,
+	globalHashes,
+	securityHeadersOpts,
+	sri,
+) => {
 	/** @satisfies {import('astro').MiddlewareHandler} */
 	return async (_ctx, next) => {
 		const response = await next()
 		const content = await response.text()
 
 		const { updatedContent, pageHashes } = await updateDynamicPageSriHashes(
-			console,
+			logger,
 			content,
 			globalHashes,
+			sri,
 		)
 
 		const patchedResponse = new Response(updatedContent, {
 			status: response.status,
 			statusText: response.statusText,
@@ -762,10 +808,11 @@ const loadVirtualMiddlewareModule = async (
 		}
 
 		// We generate a provisional hashes module. It won't contain the hashes for
 		// resources created by Astro, but it can be useful nonetheless.
 		await scanForNestedResources(logger, publicDir, h)
+		await scanAllowLists(sri, h)
 		await generateSRIHashesModule(
 			logger,
 			h,
 			sri.hashesModule,
 			false, // So we don't get redundant warnings
@@ -819,14 +866,14 @@ export const onRequest = await (async () => {
 
 	${staticHashesModuleLoader}
 
 	return defineMiddleware(${
 		securityHeadersOptions !== undefined
-			? `getCSPMiddlewareHandler(globalHashes, ${JSON.stringify(
+			? `getCSPMiddlewareHandler(console, globalHashes, ${JSON.stringify(
 					securityHeadersOptions,
-				)})`
-			: 'getMiddlewareHandler(globalHashes)'
+				)}, ${JSON.stringify(sri)})`
+			: `getMiddlewareHandler(console, globalHashes, ${JSON.stringify(sri)})`
 	})
 })()
 `
 }
 
diff --git a/src/headers.mjs b/src/headers.mjs
index bddf658..8af07f6 100644
--- a/src/headers.mjs
+++ b/src/headers.mjs
@@ -94,13 +94,17 @@ export const patchCspHeader = (plainHeaders, pageHashes, cspOpts) => {
 			}
 		: cspOpts.cspDirectives ?? /** @type {CSPDirectives} */ ({})
 
 	if (pageHashes.scripts.size > 0) {
 		setSrcDirective(directives, 'script-src', pageHashes.scripts)
+	} else {
+		directives['script-src'] = "'none'"
 	}
 	if (pageHashes.styles.size > 0) {
 		setSrcDirective(directives, 'style-src', pageHashes.styles)
+	} else {
+		directives['style-src'] = "'none'"
 	}
 	if (Object.keys(directives).length > 0) {
 		plainHeaders['content-security-policy'] = serialiseCspDirectives(directives)
 	}
 }
diff --git a/src/main.mjs b/src/main.mjs
index e97d7e7..3d54453 100644
--- a/src/main.mjs
+++ b/src/main.mjs
@@ -76,16 +76,16 @@ export const shield = ({
 	}
 
 	return /** @satisfies {AstroIntegration} */ {
 		name: '@kindspells/astro-shield',
 		hooks: {
-			...((enableStatic_SRI ?? true) === true
+			...(_sri.enableStatic === true
 				? {
 						'astro:build:done': getAstroBuildDone(_sri),
 					}
 				: undefined),
-			...(enableMiddleware_SRI === true
+			...(_sri.enableMiddleware === true
 				? {
 						'astro:config:setup': getAstroConfigSetup(_sri, securityHeaders),
 					}
 				: undefined),
 		},
diff --git a/tests/core.test.mts b/tests/core.test.mts
index 6bff87f..90eb33c 100644
--- a/tests/core.test.mts
+++ b/tests/core.test.mts
@@ -5,16 +5,19 @@
  */
 
 import { resolve } from 'node:path'
 import { readdir, rm } from 'node:fs/promises'
 
-import { beforeEach, describe, expect, it } from 'vitest'
+import { assert, beforeEach, describe, expect, it } from 'vitest'
 import {
 	arraysEqual,
 	generateSRIHash,
 	generateSRIHashesModule,
+	getCSPMiddlewareHandler,
+	getMiddlewareHandler,
 	pageHashesEqual,
+	scanAllowLists,
 	scanForNestedResources,
 	sriHashesEqual,
 	updateDynamicPageSriHashes,
 	updateStaticPageSriHashes,
 } from '#as/core.mjs'
@@ -360,11 +363,11 @@ describe('updateStaticPageSriHashes', () => {
 		const expected = `<html>
 			<head>
 				<title>My Test Page</title>
 			</head>
 			<body>
-				<script type="module" src="/core.mjs" integrity="sha256-vSvqa4zN5DZN/gOtz1s6Xuw0MUYNKQXvUPL8pXWgHGo="></script>
+				<script type="module" src="/core.mjs" integrity="sha256-Xbdu1jxIAqCjb78wAdgir+Swc5faxBuLHPm0DC/lG80="></script>
 			</body>
 		</html>`
 
 		const h = getEmptyHashes()
 		const updated = await updateStaticPageSriHashes(
@@ -377,11 +380,11 @@ describe('updateStaticPageSriHashes', () => {
 
 		expect(updated).toEqual(expected)
 		expect(h.extScriptHashes.size).toBe(1)
 		expect(
 			h.extScriptHashes.has(
-				'sha256-vSvqa4zN5DZN/gOtz1s6Xuw0MUYNKQXvUPL8pXWgHGo=',
+				'sha256-Xbdu1jxIAqCjb78wAdgir+Swc5faxBuLHPm0DC/lG80=',
 			),
 		).toBe(true)
 		expect(h.inlineScriptHashes.size).toBe(0)
 		expect(h.inlineStyleHashes.size).toBe(0)
 		expect(h.extStyleHashes.size).toBe(0)
@@ -654,11 +657,60 @@ describe('updateDynamicPageSriHashes', () => {
 			),
 		).toBe(true)
 		expect(pageHashes.styles.size).toBe(0)
 	})
 
-	it('adds sri hash to external script (cross origin)', async () => {
+	it('avoids adding sri hash to external script when not allow-listed (cross origin)', async () => {
+		const remoteScript =
+			'https://raw.githubusercontent.com/KindSpells/astro-shield/ae9521048f2129f633c075b7f7ef24e11bbd1884/main.mjs'
+		const content = `<html>
+			<head>
+				<title>My Test Page</title>
+			</head>
+			<body>
+				<script type="module" src="${remoteScript}"></script>
+			</body>
+		</html>`
+
+		const expected = `<html>
+			<head>
+				<title>My Test Page</title>
+			</head>
+			<body>
+				<script type="module" src="${remoteScript}" crossorigin="anonymous"></script>
+			</body>
+		</html>`
+
+		const h = getMiddlewareHashes()
+		let warnCounter = 0
+		const { pageHashes, updatedContent } = await updateDynamicPageSriHashes(
+			{
+				info: () => {},
+				warn: () => {
+					warnCounter += 1
+				},
+				error: () => {},
+			},
+			content,
+			h,
+		)
+
+		expect(warnCounter).toBe(1)
+		expect(updatedContent).toEqual(expected)
+		expect(h.scripts.size).toBe(0)
+		expect(h.styles.size).toBe(0)
+		expect(h.scripts.get(remoteScript)).toBeUndefined()
+		expect(pageHashes.scripts.size).toBe(0)
+		expect(
+			pageHashes.scripts.has(
+				'sha256-i4WR4ifasidZIuS67Rr6Knsy7/hK1xbVTc8ZAmnAv1Q=',
+			),
+		).toBe(false)
+		expect(pageHashes.styles.size).toBe(0)
+	})
+
+	it('adds sri hash to external script when allow-listed (cross origin)', async () => {
 		const remoteScript =
 			'https://raw.githubusercontent.com/KindSpells/astro-shield/ae9521048f2129f633c075b7f7ef24e11bbd1884/main.mjs'
 		const content = `<html>
 			<head>
 				<title>My Test Page</title>
@@ -676,10 +728,14 @@ describe('updateDynamicPageSriHashes', () => {
 				<script type="module" src="${remoteScript}" integrity="sha256-i4WR4ifasidZIuS67Rr6Knsy7/hK1xbVTc8ZAmnAv1Q=" crossorigin="anonymous"></script>
 			</body>
 		</html>`
 
 		const h = getMiddlewareHashes()
+		h.scripts.set(
+			remoteScript,
+			'sha256-i4WR4ifasidZIuS67Rr6Knsy7/hK1xbVTc8ZAmnAv1Q=',
+		)
 		const { pageHashes, updatedContent } = await updateDynamicPageSriHashes(
 			console,
 			content,
 			h,
 		)
@@ -776,20 +832,15 @@ describe('updateDynamicPageSriHashes', () => {
 			</body>
 		</html>`
 
 		let warnCalls = 0
 		const testLogger = {
-			info(msg: string) {
-				return console.info(msg)
-			},
-			warn(msg: string) {
+			info(_msg: string) {},
+			warn(_msg: string) {
 				warnCalls += 1
-				return console.warn(msg)
-			},
-			error(msg: string) {
-				return console.error(msg)
 			},
+			error(_msg: string) {},
 		}
 
 		const h = getMiddlewareHashes()
 		const { updatedContent } = await updateDynamicPageSriHashes(
 			testLogger,
@@ -844,10 +895,37 @@ describe('updateDynamicPageSriHashes', () => {
 			'Unable to obtain SRI hash for local resource: "/problematic/local/script.js"',
 		)
 	})
 })
 
+describe('scanAllowLists', () => {
+	it('populates hashes collection with hashes from allow-listed resources', async () => {
+		const scriptUrl =
+			'https://raw.githubusercontent.com/KindSpells/astro-shield/ae9521048f2129f633c075b7f7ef24e11bbd1884/main.mjs'
+		const styleUrl =
+			'https://raw.githubusercontent.com/KindSpells/astro-shield/26fdf5399d79baa3a8ea70ded526116b0bfc06ed/e2e/fixtures/hybrid2/src/styles/normalize.css'
+
+		const h = getEmptyHashes()
+		await scanAllowLists(
+			{
+				scriptsAllowListUrls: [scriptUrl],
+				stylesAllowListUrls: [styleUrl],
+			},
+			h,
+		)
+
+		expect(h.extScriptHashes.size).toBe(1)
+		expect(h.extStyleHashes.size).toBe(1)
+		expect(h.perResourceSriHashes.scripts.get(scriptUrl)).toBe(
+			'sha256-i4WR4ifasidZIuS67Rr6Knsy7/hK1xbVTc8ZAmnAv1Q=',
+		)
+		expect(h.perResourceSriHashes.styles.get(styleUrl)).toBe(
+			'sha256-7o69ZgSUx++S5DC0Ek7X2CbY4GnxxUkwGZDdybWxSG8=',
+		)
+	})
+})
+
 describe('scanForNestedResources', () => {
 	it('populates our hashes collection with hashes from nested resources', async () => {
 		const h = getEmptyHashes()
 		await scanForNestedResources(console, fixturesDir, h)
 
@@ -899,5 +977,265 @@ describe('generateSRIHashesModule', () => {
 		expect(hashesModule).toHaveProperty('extStyleHashes')
 		expect(hashesModule).toHaveProperty('perPageSriHashes')
 		expect(hashesModule).toHaveProperty('perResourceSriHashes')
 	})
 })
+
+describe('getMiddlewareHandler', () => {
+	it('returns a working middleware handler', async () => {
+		const hashes = {
+			scripts: new Map<string, string>(),
+			styles: new Map<string, string>(),
+		}
+		let warnCounter = 0
+		const middleware = getMiddlewareHandler(
+			{
+				info: () => {},
+				warn: () => {
+					warnCounter += 1
+				},
+				error: () => {},
+			},
+			hashes,
+			{
+				enableStatic: true,
+				enableMiddleware: true,
+				hashesModule: undefined,
+				allowInlineScripts: 'all',
+				allowInlineStyles: 'all',
+				scriptsAllowListUrls: [],
+				stylesAllowListUrls: [],
+			},
+		)
+		type MidParams = Parameters<typeof middleware>
+
+		const patchedResponse = await middleware(
+			undefined as unknown as MidParams[0],
+			(async () => {
+				return {
+					text: async () => `
+<html>
+	<head>
+		<title>My Test Page</title>
+	</head>
+	<body>
+		<script>console.log("Hello World!")</script>
+	</body>
+</html>`,
+					status: 200,
+					statusText: 'OK',
+					headers: new Headers(),
+				}
+			}) as MidParams[1],
+		)
+
+		expect(warnCounter).toBe(0)
+		assert(patchedResponse instanceof Response)
+		const responseText = await patchedResponse.text()
+		expect(responseText).toBe(`
+<html>
+	<head>
+		<title>My Test Page</title>
+	</head>
+	<body>
+		<script integrity="sha256-TWupyvVdPa1DyFqLnQMqRpuUWdS3nKPnz70IcS/1o3Q=">console.log("Hello World!")</script>
+	</body>
+</html>`)
+	})
+
+	it('protects from validating disallowed inline scripts', async () => {
+		const hashes = {
+			scripts: new Map<string, string>(),
+			styles: new Map<string, string>(),
+		}
+
+		let warnCounter = 0
+		const middleware = getMiddlewareHandler(
+			{
+				info: () => {},
+				warn: () => {
+					warnCounter += 1
+				},
+				error: () => {},
+			},
+			hashes,
+			{
+				enableStatic: true,
+				enableMiddleware: true,
+				hashesModule: undefined,
+				allowInlineScripts: 'static',
+				allowInlineStyles: 'static',
+				scriptsAllowListUrls: [],
+				stylesAllowListUrls: [],
+			},
+		)
+		type MidParams = Parameters<typeof middleware>
+
+		const patchedResponse = await middleware(
+			undefined as unknown as MidParams[0],
+			(async () => {
+				return {
+					text: async () => `
+<html>
+	<head>
+		<title>My Test Page</title>
+	</head>
+	<body>
+		<script>console.log("Hello World!")</script>
+	</body>
+</html>`,
+					status: 200,
+					statusText: 'OK',
+					headers: new Headers(),
+				}
+			}) as MidParams[1],
+		)
+
+		expect(warnCounter).toBe(1)
+		assert(patchedResponse instanceof Response)
+		const responseText = await patchedResponse.text()
+		expect(patchedResponse.headers.has('content-security-policy')).toBe(false)
+		expect(responseText).toBe(`
+<html>
+	<head>
+		<title>My Test Page</title>
+	</head>
+	<body>
+		<script>console.log("Hello World!")</script>
+	</body>
+</html>`)
+	})
+})
+
+describe('getCSPMiddlewareHandler', () => {
+	it('returns a working middleware handler', async () => {
+		const hashes = {
+			scripts: new Map<string, string>(),
+			styles: new Map<string, string>(),
+		}
+		let warnCounter = 0
+		const middleware = getCSPMiddlewareHandler(
+			{
+				info: () => {},
+				warn: () => {
+					warnCounter += 1
+				},
+				error: () => {},
+			},
+			hashes,
+			{
+				contentSecurityPolicy: {},
+			},
+			{
+				enableStatic: true,
+				enableMiddleware: true,
+				hashesModule: undefined,
+				allowInlineScripts: 'all',
+				allowInlineStyles: 'all',
+				scriptsAllowListUrls: [],
+				stylesAllowListUrls: [],
+			},
+		)
+		type MidParams = Parameters<typeof middleware>
+
+		const patchedResponse = await middleware(
+			undefined as unknown as MidParams[0],
+			(async () => {
+				return {
+					text: async () => `
+<html>
+	<head>
+		<title>My Test Page</title>
+	</head>
+	<body>
+		<script>console.log("Hello World!")</script>
+	</body>
+</html>`,
+					status: 200,
+					statusText: 'OK',
+					headers: new Headers(),
+				}
+			}) as MidParams[1],
+		)
+
+		expect(warnCounter).toBe(0)
+		assert(patchedResponse instanceof Response)
+		expect(patchedResponse.headers.has('content-security-policy')).toBe(true)
+		expect(patchedResponse.headers.get('content-security-policy')).toBe(
+			`script-src 'self' 'sha256-TWupyvVdPa1DyFqLnQMqRpuUWdS3nKPnz70IcS/1o3Q='; style-src 'none'`,
+		)
+		const responseText = await patchedResponse.text()
+		expect(responseText).toBe(`
+<html>
+	<head>
+		<title>My Test Page</title>
+	</head>
+	<body>
+		<script integrity="sha256-TWupyvVdPa1DyFqLnQMqRpuUWdS3nKPnz70IcS/1o3Q=">console.log("Hello World!")</script>
+	</body>
+</html>`)
+	})
+
+	it('protects from validating disallowed inline scripts', async () => {
+		const hashes = {
+			scripts: new Map<string, string>(),
+			styles: new Map<string, string>(),
+		}
+
+		let warnCounter = 0
+		const middleware = getCSPMiddlewareHandler(
+			{
+				info: () => {},
+				warn: () => {
+					warnCounter += 1
+				},
+				error: () => {},
+			},
+			hashes,
+			{ contentSecurityPolicy: {} },
+			{
+				enableStatic: true,
+				enableMiddleware: true,
+				hashesModule: undefined,
+				allowInlineScripts: 'static',
+				allowInlineStyles: 'static',
+				scriptsAllowListUrls: [],
+				stylesAllowListUrls: [],
+			},
+		)
+		type MidParams = Parameters<typeof middleware>
+
+		const patchedResponse = await middleware(
+			undefined as unknown as MidParams[0],
+			(async () => {
+				return {
+					text: async () => `
+<html>
+	<head>
+		<title>My Test Page</title>
+	</head>
+	<body>
+		<script>console.log("Hello World!")</script>
+	</body>
+</html>`,
+					status: 200,
+					statusText: 'OK',
+					headers: new Headers(),
+				}
+			}) as MidParams[1],
+		)
+
+		expect(warnCounter).toBe(1)
+		assert(patchedResponse instanceof Response)
+		const responseText = await patchedResponse.text()
+		expect(patchedResponse.headers.has('content-security-policy')).toBe(true)
+		expect(responseText).toBe(`
+<html>
+	<head>
+		<title>My Test Page</title>
+	</head>
+	<body>
+		<script>console.log("Hello World!")</script>
+	</body>
+</html>`)
+	})
+})
diff --git a/tests/headers.test.mts b/tests/headers.test.mts
index 6701c8b..3fed2cf 100644
--- a/tests/headers.test.mts
+++ b/tests/headers.test.mts
@@ -152,11 +152,11 @@ describe('patchHeaders', () => {
 			},
 		}
 
 		const patchedHeaders = patchHeaders(headers, pageHashes, settings)
 		expect(patchedHeaders.get('content-security-policy')).toBe(
-			"form-action 'self'; frame-ancestors 'none'",
+			"form-action 'self'; frame-ancestors 'none'; script-src 'none'; style-src 'none'",
 		)
 	})
 
 	it('sets csp header based on hashes', () => {
 		const headers = new Headers()
diff --git a/tests/main.test.mts b/tests/main.test.mts
index 2395a91..00d260a 100644
--- a/tests/main.test.mts
+++ b/tests/main.test.mts
@@ -34,21 +34,21 @@ describe('sriCSP', () => {
 		const integration = shield({})
 		checkIntegration(integration)
 	})
 
 	it('returns a valid AstroIntegration object for almost-default config', () => {
-		const integration = shield({ enableStatic_SRI: true })
+		const integration = shield({ sri: { enableStatic: true } })
 		checkIntegration(integration)
 	})
 
 	it('returns an "empty" integration when we disable all features', () => {
-		const integration = shield({ enableStatic_SRI: false })
+		const integration = shield({ sri: { enableStatic: false } })
 		checkIntegration(integration, [])
 	})
 
 	it('returns hooks for static & dynamic content when we enable middleware', () => {
-		const integration = shield({ enableMiddleware_SRI: true })
+		const integration = shield({ sri: { enableMiddleware: true } })
 		checkIntegration(integration, ['astro:build:done', 'astro:config:setup'])
 	})
 
 	it('returns hooks only for dynamic content when we enable middleware and disable static sri', () => {
 		const integration = shield({
diff --git a/vitest.config.unit.mts b/vitest.config.unit.mts
index 057ed7f..3cdbb6d 100644
--- a/vitest.config.unit.mts
+++ b/vitest.config.unit.mts
@@ -17,14 +17,14 @@ export default defineConfig({
 				'coverage/**/*',
 				'coverage-e2e/**/*',
 				'coverage-unit/**/*',
 			],
 			thresholds: {
-				statements: 72.0,
-				branches: 76.0,
-				functions: 80.0,
-				lines: 72.0,
+				statements: 77.0,
+				branches: 77.0,
+				functions: 87.0,
+				lines: 77.0,
 			},
 			reportsDirectory: 'coverage-unit',
 		},
 		include: ['tests/**/*.test.mts'],
 	},

Vulnerable code:       // Enables SRI hashes generation for statically generated pages
      enableStatic_SRI: true, // true by default

      // Enables a middleware that generates SRI hashes for dynamically
      // generated pages
      enableMiddleware_SRI: false, // false by default

      // This is the path where we'll generate the module containing the SRI
      // hashes for your scripts and styles. There's no need to pass this
      // parameter if you don't need this data, but it can be useful to
      // configure your CSP policies.
      sriHashesModule: resolve(rootDir, 'src', 'utils', 'sriHashes.mjs'),
	"version": "1.2.0",
 *   hash: string,
	`<script${attrs} integrity="${hash}"${
	`<style${attrs} integrity="${hash}"${
	`<link${attrs} integrity="${hash}"${
	sri
							const resourceResponse = await fetch(src, { method: 'GET' })
							const resourceContent = await resourceResponse.arrayBuffer()
							sriHash = generateSRIHash(resourceContent)
							globalHashes[t2].set(src, sriHash)
							pageHashes[t2].add(sriHash)
export const getMiddlewareHandler = globalHashes => {
			console,
export const getCSPMiddlewareHandler = (globalHashes, securityHeadersOpts) => {
			console,
			? `getCSPMiddlewareHandler(globalHashes, ${JSON.stringify(
				)})`
			: 'getMiddlewareHandler(globalHashes)'
			...((enableStatic_SRI ?? true) === true
			...(enableMiddleware_SRI === true
import { beforeEach, describe, expect, it } from 'vitest'
				<script type="module" src="/core.mjs" integrity="sha256-vSvqa4zN5DZN/gOtz1s6Xuw0MUYNKQXvUPL8pXWgHGo="></script>
				'sha256-vSvqa4zN5DZN/gOtz1s6Xuw0MUYNKQXvUPL8pXWgHGo=',
	it('adds sri hash to external script (cross origin)', async () => {
			info(msg: string) {
				return console.info(msg)
			},
			warn(msg: string) {
				return console.warn(msg)
			},
			error(msg: string) {
				return console.error(msg)
			"form-action 'self'; frame-ancestors 'none'",
		const integration = shield({ enableStatic_SRI: true })
		const integration = shield({ enableStatic_SRI: false })
		const integration = shield({ enableMiddleware_SRI: true })
				statements: 72.0,
				branches: 76.0,
				functions: 80.0,
				lines: 72.0,
Fixed code:       sri: {
        // Enables SRI hashes generation for statically generated pages
        enableStatic: true, // true by default

        // Enables a middleware that generates SRI hashes for dynamically
        // generated pages
        enableMiddleware: false, // false by default

        // This is the path where we'll generate the module containing the SRI
        // hashes for your scripts and styles. There's no need to pass this
        // parameter if you don't need this data, but it can be useful to
        // configure your CSP policies.
        hashesModule: resolve(rootDir, 'src', 'utils', 'sriHashes.mjs'),

        // For SSR content, Cross-Origin scripts must be explicitly allow-listed
        // by URL in order to be allowed by the Content Security Policy.
        //
        // Defaults to []
        scriptsAllowListUrls: [
          'https://code.jquery.com/jquery-3.7.1.slim.min.js',
        ],

        // For SSR content, Cross-Origin styles must be explicitly allow-listed
        // by URL in order to be allowed by the Content Security Policy.
        //
        // Defaults to []
        stylesAllowListUrls: [
          'https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css',
        ],

        /**
         * Inline styles are usually considered unsafe because they could make it
         * easier for an attacker to inject CSS rules in dynamic pages. However, they
         * don't pose a serious security risk for _most_ static pages.
         *
         * You can disable this option in case you want to enforce a stricter policy.
         *
         * @type {'all' | 'static' | false}
         *
         * Defaults to 'all'.
         */
        allowInlineStyles: 'all',

        /**
         * Inline scripts are usually considered unsafe because they could make it
         * easier for an attacker to inject JS code in dynamic pages. However, they
         * don't pose a serious security risk for _most_ static pages.
         *
         * You can disable this option in case you want to enforce a stricter policy.
         *
         * @type {'all' | 'static' | false}
         *
         * Defaults to 'all'.
         */
        allowInlineScript: 'all',
      },
	"version": "1.3.0",
 *   hash: string | null,
	`<script${attrs}${hash !== null ? ` integrity="${hash}"` : ''}${
	`<style${attrs}${hash !== null ? ` integrity="${hash}"` : ''}${
	`<link${attrs}${hash !== null ? ` integrity="${hash}"` : ''}${
	sri,
							logger.warn(
								`Detected reference to not-allow-listed external resource "${src}"`,
							)
							if (setCrossorigin) {
								updatedContent = updatedContent.replace(
									match[0],
									replacer(null, attrs, true, ''),
								)
							}
							continue
							// TODO: add scape hatch to allow fetching arbitrary external resources
							// const resourceResponse = await fetch(src, { method: 'GET' })
							// const resourceContent = await resourceResponse.arrayBuffer()
							// sriHash = generateSRIHash(resourceContent)
							// globalHashes[t2].set(src, sriHash)
							// pageHashes[t2].add(sriHash)
/**
 * @param {Required<Pick<SRIOptions, 'scriptsAllowListUrls' | 'stylesAllowListUrls'>>} sri
 * @param {HashesCollection} h
 */
export const scanAllowLists = async (sri, h) => {
	for (const scriptUrl of sri.scriptsAllowListUrls) {
		const resourceResponse = await fetch(scriptUrl, { method: 'GET' })
		const resourceContent = await resourceResponse.arrayBuffer()
		const sriHash = generateSRIHash(resourceContent)

		h.extScriptHashes.add(sriHash)
		h.perResourceSriHashes.scripts.set(scriptUrl, sriHash)
	}

	for (const styleUrl of sri.stylesAllowListUrls) {
		const resourceResponse = await fetch(styleUrl, { method: 'GET' })
		const resourceContent = await resourceResponse.arrayBuffer()
		const sriHash = generateSRIHash(resourceContent)

		h.extStyleHashes.add(sriHash)
		h.perResourceSriHashes.styles.set(styleUrl, sriHash)
	}
}

 * @param {Logger} logger
 * @param {Required<SRIOptions>} sri
export const getMiddlewareHandler = (logger, globalHashes, sri) => {
			logger,
			sri,
 * @param {Logger} logger
 * @param {Required<SRIOptions>} sri
export const getCSPMiddlewareHandler = (
	logger,
	globalHashes,
	securityHeadersOpts,
	sri,
) => {
			logger,
			sri,
		await scanAllowLists(sri, h)
			? `getCSPMiddlewareHandler(console, globalHashes, ${JSON.stringify(
				)}, ${JSON.stringify(sri)})`
			: `getMiddlewareHandler(console, globalHashes, ${JSON.stringify(sri)})`
	} else {
		directives['script-src'] = "'none'"
	} else {
		directives['style-src'] = "'none'"
			...(_sri.enableStatic === true
			...(_sri.enableMiddleware === true
import { assert, beforeEach, describe, expect, it } from 'vitest'
	getCSPMiddlewareHandler,
	getMiddlewareHandler,
	scanAllowLists,
				<script type="module" src="/core.mjs" integrity="sha256-Xbdu1jxIAqCjb78wAdgir+Swc5faxBuLHPm0DC/lG80="></script>
				'sha256-Xbdu1jxIAqCjb78wAdgir+Swc5faxBuLHPm0DC/lG80=',
	it('avoids adding sri hash to external script when not allow-listed (cross origin)', async () => {
		const remoteScript =
			'https://raw.githubusercontent.com/KindSpells/astro-shield/ae9521048f2129f633c075b7f7ef24e11bbd1884/main.mjs'
		const content = `<html>
			<head>
				<title>My Test Page</title>
			</head>
			<body>
				<script type="module" src="${remoteScript}"></script>
			</body>
		</html>`

		const expected = `<html>
			<head>
				<title>My Test Page</title>
			</head>
			<body>
				<script type="module" src="${remoteScript}" crossorigin="anonymous"></script>
			</body>
		</html>`

		const h = getMiddlewareHashes()
		let warnCounter = 0
		const { pageHashes, updatedContent } = await updateDynamicPageSriHashes(
			{
				info: () => {},
				warn: () => {
					warnCounter += 1
				},
				error: () => {},
			},
			content,
			h,
		)

		expect(warnCounter).toBe(1)
		expect(updatedContent).toEqual(expected)
		expect(h.scripts.size).toBe(0)
		expect(h.styles.size).toBe(0)
		expect(h.scripts.get(remoteScript)).toBeUndefined()
		expect(pageHashes.scripts.size).toBe(0)
		expect(
			pageHashes.scripts.has(
				'sha256-i4WR4ifasidZIuS67Rr6Knsy7/hK1xbVTc8ZAmnAv1Q=',
			),
		).toBe(false)
		expect(pageHashes.styles.size).toBe(0)
	})

	it('adds sri hash to external script when allow-listed (cross origin)', async () => {
		h.scripts.set(
			remoteScript,
			'sha256-i4WR4ifasidZIuS67Rr6Knsy7/hK1xbVTc8ZAmnAv1Q=',
		)
			info(_msg: string) {},
			warn(_msg: string) {
			error(_msg: string) {},
describe('scanAllowLists', () => {
	it('populates hashes collection with hashes from allow-listed resources', async () => {
		const scriptUrl =
			'https://raw.githubusercontent.com/KindSpells/astro-shield/ae9521048f2129f633c075b7f7ef24e11bbd1884/main.mjs'
		const styleUrl =
			'https://raw.githubusercontent.com/KindSpells/astro-shield/26fdf5399d79baa3a8ea70ded526116b0bfc06ed/e2e/fixtures/hybrid2/src/styles/normalize.css'

		const h = getEmptyHashes()
		await scanAllowLists(
			{
				scriptsAllowListUrls: [scriptUrl],
				stylesAllowListUrls: [styleUrl],
			},
			h,
		)

		expect(h.extScriptHashes.size).toBe(1)
		expect(h.extStyleHashes.size).toBe(1)
		expect(h.perResourceSriHashes.scripts.get(scriptUrl)).toBe(
			'sha256-i4WR4ifasidZIuS67Rr6Knsy7/hK1xbVTc8ZAmnAv1Q=',
		)
		expect(h.perResourceSriHashes.styles.get(styleUrl)).toBe(
			'sha256-7o69ZgSUx++S5DC0Ek7X2CbY4GnxxUkwGZDdybWxSG8=',
		)
	})
})


describe('getMiddlewareHandler', () => {
	it('returns a working middleware handler', async () => {
		const hashes = {
			scripts: new Map<string, string>(),
			styles: new Map<string, string>(),
		}
		let warnCounter = 0
		const middleware = getMiddlewareHandler(
			{
				info: () => {},
				warn: () => {
					warnCounter += 1
				},
				error: () => {},
			},
			hashes,
			{
				enableStatic: true,
				enableMiddleware: true,
				hashesModule: undefined,
				allowInlineScripts: 'all',
				allowInlineStyles: 'all',
				scriptsAllowListUrls: [],
				stylesAllowListUrls: [],
			},
		)
		type MidParams = Parameters<typeof middleware>

		const patchedResponse = await middleware(
			undefined as unknown as MidParams[0],
			(async () => {
				return {
					text: async () => `
<html>
	<head>
		<title>My Test Page</title>
	</head>
	<body>
		<script>console.log("Hello World!")</script>
	</body>
</html>`,
					status: 200,
					statusText: 'OK',
					headers: new Headers(),
				}
			}) as MidParams[1],
		)

		expect(warnCounter).toBe(0)
		assert(patchedResponse instanceof Response)
		const responseText = await patchedResponse.text()
		expect(responseText).toBe(`
<html>
	<head>
		<title>My Test Page</title>
	</head>
	<body>
		<script integrity="sha256-TWupyvVdPa1DyFqLnQMqRpuUWdS3nKPnz70IcS/1o3Q=">console.log("Hello World!")</script>
	</body>
</html>`)
	})

	it('protects from validating disallowed inline scripts', async () => {
		const hashes = {
			scripts: new Map<string, string>(),
			styles: new Map<string, string>(),
		}

		let warnCounter = 0
		const middleware = getMiddlewareHandler(
			{
				info: () => {},
				warn: () => {
					warnCounter += 1
				},
				error: () => {},
			},
			hashes,
			{
				enableStatic: true,
				enableMiddleware: true,
				hashesModule: undefined,
				allowInlineScripts: 'static',
				allowInlineStyles: 'static',
				scriptsAllowListUrls: [],
				stylesAllowListUrls: [],
			},
		)
		type MidParams = Parameters<typeof middleware>

		const patchedResponse = await middleware(
			undefined as unknown as MidParams[0],
			(async () => {
				return {
					text: async () => `
<html>
	<head>
		<title>My Test Page</title>
	</head>
	<body>
		<script>console.log("Hello World!")</script>
	</body>
</html>`,
					status: 200,
					statusText: 'OK',
					headers: new Headers(),
				}
			}) as MidParams[1],
		)

		expect(warnCounter).toBe(1)
		assert(patchedResponse instanceof Response)
		const responseText = await patchedResponse.text()
		expect(patchedResponse.headers.has('content-security-policy')).toBe(false)
		expect(responseText).toBe(`
<html>
	<head>
		<title>My Test Page</title>
	</head>
	<body>
		<script>console.log("Hello World!")</script>
	</body>
</html>`)
	})
})

describe('getCSPMiddlewareHandler', () => {
	it('returns a working middleware handler', async () => {
		const hashes = {
			scripts: new Map<string, string>(),
			styles: new Map<string, string>(),
		}
		let warnCounter = 0
		const middleware = getCSPMiddlewareHandler(
			{
				info: () => {},
				warn: () => {
					warnCounter += 1
				},
				error: () => {},
			},
			hashes,
			{
				contentSecurityPolicy: {},
			},
			{
				enableStatic: true,
				enableMiddleware: true,
				hashesModule: undefined,
				allowInlineScripts: 'all',
				allowInlineStyles: 'all',
				scriptsAllowListUrls: [],
				stylesAllowListUrls: [],
			},
		)
		type MidParams = Parameters<typeof middleware>

		const patchedResponse = await middleware(
			undefined as unknown as MidParams[0],
			(async () => {
				return {
					text: async () => `
<html>
	<head>
		<title>My Test Page</title>
	</head>
	<body>
		<script>console.log("Hello World!")</script>
	</body>
</html>`,
					status: 200,
					statusText: 'OK',
					headers: new Headers(),
				}
			}) as MidParams[1],
		)

		expect(warnCounter).toBe(0)
		assert(patchedResponse instanceof Response)
		expect(patchedResponse.headers.has('content-security-policy')).toBe(true)
		expect(patchedResponse.headers.get('content-security-policy')).toBe(
			`script-src 'self' 'sha256-TWupyvVdPa1DyFqLnQMqRpuUWdS3nKPnz70IcS/1o3Q='; style-src 'none'`,
		)
		const responseText = await patchedResponse.text()
		expect(responseText).toBe(`
<html>
	<head>
		<title>My Test Page</title>
	</head>
	<body>
		<script integrity="sha256-TWupyvVdPa1DyFqLnQMqRpuUWdS3nKPnz70IcS/1o3Q=">console.log("Hello World!")</script>
	</body>
</html>`)
	})

	it('protects from validating disallowed inline scripts', async () => {
		const hashes = {
			scripts: new Map<string, string>(),
			styles: new Map<string, string>(),
		}

		let warnCounter = 0
		const middleware = getCSPMiddlewareHandler(
			{
				info: () => {},
				warn: () => {
					warnCounter += 1
				},
				error: () => {},
			},
			hashes,
			{ contentSecurityPolicy: {} },
			{
				enableStatic: true,
				enableMiddleware: true,
				hashesModule: undefined,
				allowInlineScripts: 'static',
				allowInlineStyles: 'static',
				scriptsAllowListUrls: [],
				stylesAllowListUrls: [],
			},
		)
		type MidParams = Parameters<typeof middleware>

		const patchedResponse = await middleware(
			undefined as unknown as MidParams[0],
			(async () => {
				return {
					text: async () => `
<html>
	<head>
		<title>My Test Page</title>
	</head>
	<body>
		<script>console.log("Hello World!")</script>
	</body>
</html>`,
					status: 200,
					statusText: 'OK',
					headers: new Headers(),
				}
			}) as MidParams[1],
		)

		expect(warnCounter).toBe(1)
		assert(patchedResponse instanceof Response)
		const responseText = await patchedResponse.text()
		expect(patchedResponse.headers.has('content-security-policy')).toBe(true)
		expect(responseText).toBe(`
<html>
	<head>
		<title>My Test Page</title>
	</head>
	<body>
		<script>console.log("Hello World!")</script>
	</body>
</html>`)
	})
})
			"form-action 'self'; frame-ancestors 'none'; script-src 'none'; style-src 'none'",
		const integration = shield({ sri: { enableStatic: true } })
		const integration = shield({ sri: { enableStatic: false } })
		const integration = shield({ sri: { enableMiddleware: true } })
				statements: 77.0,
				branches: 77.0,
				functions: 87.0,
				lines: 77.0,