CVE-2024-30250: Insufficient Information (NVD-CWE-noinfo)
value: Astro-Shield is an integration to enhance website security with SubResource Integrity hashes, Content-Security-Policy headers, and other techniques. Versions from 1.2.0 to 1.3.1 of Astro-Shield allow bypass to the allow-lists for cross-origin resources by introducing valid `integrity` attributes to the injected code. This implies that the injected SRI hash would be added to the generated CSP header, which would lead the browser to believe that the injected resource is legit. This vulnerability is patched in version 1.3.2.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-04-04T15:15Z
Repository: https://github.com/kindspells/astro-shield
Language: Other
Files changed: @kindspells/astro-shield/e2e/e2e.test.mts, @kindspells/astro-shield/src/core.mjs, @kindspells/astro-shield/tests/core.test.mts, @kindspells/astro-shield/vitest.config.unit.mts
Commit date: 2024-03-31 13:01:52 +0200
Commit message: fix: do not trust integrity attribute when undeserved

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

Diff: diff --git a/@kindspells/astro-shield/e2e/e2e.test.mts b/@kindspells/astro-shield/e2e/e2e.test.mts
index 8c9e138..82084fe 100644
--- a/@kindspells/astro-shield/e2e/e2e.test.mts
+++ b/@kindspells/astro-shield/e2e/e2e.test.mts
@@ -549,6 +549,44 @@ describe('middleware (hybrid 3)', () => {
 				hashesModule.perResourceSriHashes.scripts,
 				'https://code.jquery.com/ui/1.13.2/jquery-ui.min.js',
 			),
 		)
 	})
+
+	it('does not "validate" sri signatures for cross-origin scripts that are not in the allow list', async () => {
+		const response = await fetch(`${baseUrl}/injected/`)
+		const cspHeader = response.headers.get('content-security-policy')
+
+		assert(cspHeader !== null)
+		assert(cspHeader)
+
+		const scriptDirective = cspHeader
+			.split(/;\s*/)
+			.filter(directive => directive.startsWith('script-src'))[0]
+		assert(scriptDirective)
+
+		// This hash belongs to an allowed script that included its integrity
+		// attribute as well (https://code.jquery.com/jquery-3.7.1.slim.min.js).
+		assert(
+			scriptDirective.includes(
+				'sha256-kmHvs0B+OpCW5GVHUNjv9rOmY0IvSIRcf7zGUDTDQM8=',
+			),
+		)
+
+		// This hash belongs to an allowed script that did not include its
+		// integrity attribute (https://code.jquery.com/ui/1.13.2/jquery-ui.min.js).
+		assert(
+			scriptDirective.includes(
+				'sha256-lSjKY0/srUM9BE3dPm+c4fBo1dky2v27Gdjm2uoZaL0=',
+			),
+		)
+
+		// The MOST IMPORTANT assertionf of this test:
+		// This hash belongs to the script that is "injected" in the page
+		// (more precisely, that is not in the allow list)
+		assert(
+			!scriptDirective.includes(
+				'sha256-BbhdlvQf/xTY9gja0Dq3HiwQF8LaCRTXxZKRutelT44=',
+			),
+		)
+	})
 })
diff --git a/@kindspells/astro-shield/src/core.mjs b/@kindspells/astro-shield/src/core.mjs
index e9615c0..7490c9e 100644
--- a/@kindspells/astro-shield/src/core.mjs
+++ b/@kindspells/astro-shield/src/core.mjs
@@ -46,36 +46,35 @@ export const generateSRIHash = data => {
 	return `sha256-${hash.digest('base64')}`
 }
 
 /**
  * @typedef {(
- *   hash: string | null,
+ *   hash: string,
  *   attrs: string,
  *   setCrossorigin: boolean,
  *   content?: string | undefined,
  * ) => string} ElemReplacer
  */
 
 /** @type {ElemReplacer} */
 const scriptReplacer = (hash, attrs, setCrossorigin, content) =>
-	`<script${attrs}${hash !== null ? ` integrity="${hash}"` : ''}${
+	`<script${attrs} integrity="${hash}"${
 		setCrossorigin ? ' crossorigin="anonymous"' : ''
 	}>${content ?? ''}</script>`
 
 /** @type {ElemReplacer} */
 const styleReplacer = (hash, attrs, setCrossorigin, content) =>
-	`<style${attrs}${hash !== null ? ` integrity="${hash}"` : ''}${
+	`<style${attrs} integrity="${hash}"${
 		setCrossorigin ? ' crossorigin="anonymous"' : ''
 	}>${content ?? ''}</style>`
 
 /** @type {ElemReplacer} */
 const linkStyleReplacer = (hash, attrs, setCrossorigin) =>
-	`<link${attrs}${hash !== null ? ` integrity="${hash}"` : ''}${
+	`<link${attrs} integrity="${hash}"${
 		setCrossorigin ? ' crossorigin="anonymous"' : ''
 	}/>`
 
-const srcRegex = /\s+(src|href)\s*=\s*("(?<src1>.*?)"|'(?<src2>.*?)')/i
 const integrityRegex =
 	/\s+integrity\s*=\s*("(?<integrity1>.*?)"|'(?<integrity2>.*?)')/i
 const relStylesheetRegex = /\s+rel\s*=\s*('stylesheet'|"stylesheet")/i
 
 const getRegexProcessors = () => {
@@ -83,28 +82,31 @@ const getRegexProcessors = () => {
 		{
 			t: 'Script',
 			t2: 'scripts',
 			regex:
 				/<script(?<attrs>(\s+[a-z][a-z0-9\-_]*(=('[^']*?'|"[^"]*?"))?)*?)\s*>(?<content>[\s\S]*?)<\/\s*script\s*>/gi,
+			srcRegex: /\s+src\s*=\s*("(?<src1>.*?)"|'(?<src2>.*?)')/i,
 			replacer: scriptReplacer,
 			hasContent: true,
 			attrsRegex: undefined,
 		},
 		{
 			t: 'Style',
 			t2: 'styles',
 			regex:
 				/<style(?<attrs>(\s+[a-z][a-z0-9\-_]*(=('[^']*?'|"[^"]*?"))?)*?)\s*>(?<content>[\s\S]*?)<\/\s*style\s*>/gi,
+			srcRegex: /\s+(href|src)\s*=\s*("(?<src1>.*?)"|'(?<src2>.*?)')/i, // not really used
 			replacer: styleReplacer,
 			hasContent: true,
 			attrsRegex: undefined,
 		},
 		{
 			t: 'Style',
 			t2: 'styles',
 			regex:
 				/<link(?<attrs>(\s+[a-z][a-z0-9\-_]*(=('[^']*?'|"[^"]*?"))?)*?)\s*\/?>/gi,
+			srcRegex: /\s+href\s*=\s*("(?<src1>.*?)"|'(?<src2>.*?)')/i,
 			replacer: linkStyleReplacer,
 			hasContent: false,
 			attrsRegex: relStylesheetRegex,
 		},
 	])
@@ -146,15 +148,23 @@ export const updateStaticPageSriHashes = async (
 	h.perPageSriHashes.set(relativeFilepath, pageHashes)
 
 	let updatedContent = content
 	let match
 
-	for (const { attrsRegex, hasContent, regex, replacer, t, t2 } of processors) {
+	for (const {
+		attrsRegex,
+		hasContent,
+		regex,
+		srcRegex,
+		replacer,
+		t,
+		t2,
+	} of processors) {
 		// biome-ignore lint/suspicious/noAssignInExpressions: safe
 		while ((match = regex.exec(content)) !== null) {
 			const attrs = match.groups?.attrs ?? ''
-			const content = match.groups?.content ?? ''
+			const elemContent = match.groups?.content ?? ''
 
 			/** @type {string | undefined} */
 			let sriHash = undefined
 			let setCrossorigin = false
 
@@ -165,10 +175,18 @@ export const updateStaticPageSriHashes = async (
 
 				const srcMatch = srcRegex.exec(attrs)
 				const integrityMatch = integrityRegex.exec(attrs)
 				const src = srcMatch?.groups?.src1 ?? srcMatch?.groups?.src2 ?? ''
 
+				if (elemContent && src) {
+					logger.warn(
+						`${t} "${src}" must have either a src/href attribute or content, but not both. Removing it.`,
+					)
+					updatedContent = updatedContent.replace(match[0], '')
+					continue
+				}
+
 				if (integrityMatch) {
 					sriHash =
 						integrityMatch.groups?.integrity1 ??
 						integrityMatch.groups?.integrity2
 					if (sriHash) {
@@ -215,24 +233,26 @@ export const updateStaticPageSriHashes = async (
 			if (hasContent && !sriHash) {
 				if (
 					!(allowInlineScripts === false && t === 'Script') &&
 					!(allowInlineStyles === false && t === 'Style')
 				) {
-					sriHash = generateSRIHash(content)
+					sriHash = generateSRIHash(elemContent)
 					h[`inline${t}Hashes`].add(sriHash)
 					pageHashes[t2].add(sriHash)
 				} else {
 					logger.warn(
-						`Skipping SRI hash generation for inline ${t.toLowerCase()} "${relativeFilepath}" (inline ${t2} are disabled)`,
+						`Removing inline ${t.toLowerCase()} block (inline ${t2} are disabled).`,
 					)
+					updatedContent = updatedContent.replace(match[0], '')
+					continue
 				}
 			}
 
 			if (sriHash) {
 				updatedContent = updatedContent.replace(
 					match[0],
-					replacer(sriHash, attrs, setCrossorigin, content),
+					replacer(sriHash, attrs, setCrossorigin, elemContent),
 				)
 			}
 		}
 	}
 
@@ -259,56 +279,89 @@ export const updateDynamicPageSriHashes = async (
 	const pageHashes = /** @type {PerPageHashes} */ ({
 		scripts: new Set(),
 		styles: new Set(),
 	})
 
-	for (const { attrsRegex, hasContent, regex, replacer, t, t2 } of processors) {
+	for (const {
+		attrsRegex,
+		hasContent,
+		regex,
+		srcRegex,
+		replacer,
+		t,
+		t2,
+	} of processors) {
 		// biome-ignore lint/suspicious/noAssignInExpressions: safe
 		while ((match = regex.exec(content)) !== null) {
 			const attrs = match.groups?.attrs ?? ''
-			const content = match.groups?.content ?? ''
+			const elemContent = match.groups?.content ?? ''
 
 			/** @type {string | undefined} */
 			let sriHash = undefined
 			let setCrossorigin = false
 
 			if (attrs) {
+				// This is to skip <link> elements that are not stylesheets
 				if (attrsRegex && !attrsRegex.test(attrs)) {
 					continue
 				}
 
 				const srcMatch = srcRegex.exec(attrs)
 				const integrityMatch = integrityRegex.exec(attrs)
 				const src = srcMatch?.groups?.src1 ?? srcMatch?.groups?.src2
 
-				if (content && src) {
+				if (elemContent && src) {
 					logger.warn(
-						`scripts must have either a src attribute or content, but not both "${src}"`,
+						`${t} "${src}" must have either a src/href attribute or content, but not both. Removing it.`,
 					)
+					updatedContent = updatedContent.replace(match[0], '')
 					continue
 				}
 
 				if (integrityMatch) {
-					sriHash =
+					const givenSriHash =
 						integrityMatch.groups?.integrity1 ??
 						integrityMatch.groups?.integrity2
-					if (sriHash) {
+					if (givenSriHash) {
 						if (src) {
 							const globalHash = globalHashes[t2].get(src)
 							if (globalHash) {
-								if (globalHash !== sriHash) {
-									throw new Error(
-										`SRI hash mismatch for "${src}", expected "${globalHash}" but got "${sriHash}"`,
+								if (globalHash !== givenSriHash) {
+									logger.warn(
+										`Detected integrity hash mismatch for resource "${src}". Removing it.`,
 									)
+									updatedContent = updatedContent.replace(match[0], '')
+								} else {
+									sriHash = givenSriHash
+									pageHashes[t2].add(sriHash)
 								}
 							} else {
-								globalHashes[t2].set(src, sriHash)
+								logger.warn(
+									`Detected reference to not explicitly allowed external resource "${src}". Removing it.`,
+								)
+								updatedContent = updatedContent.replace(match[0], '')
+							}
+						} else if (elemContent) {
+							if (
+								(t2 === 'scripts' &&
+									(sri?.allowInlineScripts ?? 'all') === 'all') ||
+								(t2 === 'styles' && (sri?.allowInlineStyles ?? 'all') === 'all')
+							) {
+								sriHash = givenSriHash
+								pageHashes[t2].add(sriHash)
+							} else {
+								logger.warn(
+									`Removing inline ${t.toLowerCase()} block (inline ${t2} are disabled).`,
+								)
+								updatedContent = updatedContent.replace(match[0], '')
 							}
 						}
-						pageHashes[t2].add(sriHash)
 					} else {
-						logger.warn('Found empty integrity attribute, skipping...')
+						logger.warn(
+							`Found empty integrity attribute, removing inline ${t.toLowerCase()} block.`,
+						)
+						updatedContent = updatedContent.replace(match[0], '')
 					}
 					continue
 				}
 
 				if (src) {
@@ -323,10 +376,11 @@ export const updateDynamicPageSriHashes = async (
 									src.startsWith('/@vite/') ||
 									src.startsWith('/@fs/') ||
 									src.indexOf('?astro&type=') >= 0
 								)
 							) {
+								// TODO: Perform fetch operation when running in dev mode
 								logger.warn(
 									`Unable to obtain SRI hash for local resource: "${src}"`,
 								)
 							}
 							continue
@@ -337,53 +391,43 @@ export const updateDynamicPageSriHashes = async (
 
 						if (sriHash) {
 							pageHashes[t2].add(sriHash)
 						} else {
 							logger.warn(
-								`Detected reference to not-allow-listed external resource "${src}"`,
+								`Detected reference to not explicitly allowed external resource "${src}". Removing it.`,
 							)
-							if (setCrossorigin) {
-								updatedContent = updatedContent.replace(
-									match[0],
-									replacer(null, attrs, true, ''),
-								)
-							}
+							updatedContent = updatedContent.replace(match[0], '')
 							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)
 						}
 					} else {
-						logger.warn(`Unable to process external resource: "${src}"`)
+						// TODO: Introduce flag to decide if external resources using unknown protocols should be removed
+						logger.warn(`Unable to process external resource: "${src}".`)
 						continue
 					}
 				}
 			}
 
 			if (hasContent && !sriHash) {
-				// TODO: port logic from `updateStaticPageSriHashes` to handle inline resources
 				if (
 					((sri?.allowInlineScripts ?? 'all') === 'all' && t === 'Script') ||
 					((sri?.allowInlineStyles ?? 'all') === 'all' && t === 'Style')
 				) {
-					sriHash = generateSRIHash(content)
+					sriHash = generateSRIHash(elemContent)
 					pageHashes[t2].add(sriHash)
 				} else {
 					logger.warn(
-						`Skipping SRI hash generation for inline ${t.toLowerCase()} (inline ${t2} are disabled)`,
+						`Removing inline ${t.toLowerCase()} block (inline ${t2} are disabled)`,
 					)
+					updatedContent = updatedContent.replace(match[0], '')
+					continue
 				}
 			}
 
 			if (sriHash) {
 				updatedContent = updatedContent.replace(
 					match[0],
-					replacer(sriHash, attrs, setCrossorigin, content),
+					replacer(sriHash, attrs, setCrossorigin, elemContent),
 				)
 			}
 		}
 	}
 
@@ -695,11 +739,10 @@ export const processStaticFiles = async (logger, { distDir, sri }) => {
 		processHTMLFile,
 		file => extname(file) === '.html',
 		sri,
 	)
 
-
 	if (!sri.hashesModule) {
 		return
 	}
 
 	await generateSRIHashesModule(
diff --git a/@kindspells/astro-shield/tests/core.test.mts b/@kindspells/astro-shield/tests/core.test.mts
index 38caa2d..28e0e09 100644
--- a/@kindspells/astro-shield/tests/core.test.mts
+++ b/@kindspells/astro-shield/tests/core.test.mts
@@ -363,11 +363,11 @@ describe('updateStaticPageSriHashes', () => {
 		const expected = `<html>
 			<head>
 				<title>My Test Page</title>
 			</head>
 			<body>
-				<script type="module" src="/core.mjs" integrity="sha256-zOEqmAz4SCAi+TcSQgdhUuurJfrfnwWqtmdTOP+bBkc="></script>
+				<script type="module" src="/core.mjs" integrity="sha256-XJRisMK9wQvjjOmHgwTyaPbBdQ7sIaEh6BiqErhW4f8="></script>
 			</body>
 		</html>`
 
 		const h = getEmptyHashes()
 		const updated = await updateStaticPageSriHashes(
@@ -380,11 +380,11 @@ describe('updateStaticPageSriHashes', () => {
 
 		expect(updated).toEqual(expected)
 		expect(h.extScriptHashes.size).toBe(1)
 		expect(
 			h.extScriptHashes.has(
-				'sha256-zOEqmAz4SCAi+TcSQgdhUuurJfrfnwWqtmdTOP+bBkc=',
+				'sha256-XJRisMK9wQvjjOmHgwTyaPbBdQ7sIaEh6BiqErhW4f8=',
 			),
 		).toBe(true)
 		expect(h.inlineScriptHashes.size).toBe(0)
 		expect(h.inlineStyleHashes.size).toBe(0)
 		expect(h.extStyleHashes.size).toBe(0)
@@ -475,12 +475,10 @@ describe('updateStaticPageSriHashes', () => {
 		).toBe(true)
 		expect(h.inlineScriptHashes.size).toBe(0)
 		expect(h.extScriptHashes.size).toBe(0)
 		expect(h.inlineStyleHashes.size).toBe(0)
 	})
-
-	// TODO: Add tests for external styles
 })
 
 describe('updateDynamicPageSriHashes', () => {
 	const getMiddlewareHashes = () => {
 		return {
@@ -657,28 +655,28 @@ describe('updateDynamicPageSriHashes', () => {
 			),
 		).toBe(true)
 		expect(pageHashes.styles.size).toBe(0)
 	})
 
-	it('avoids adding sri hash to external script when not allow-listed (cross origin)', async () => {
+	it('removes external script when not explicitly allowed (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>
+				<script type="module" src="${remoteScript}"></script><!-- This script will be removed -->
 			</body>
 		</html>`
 
 		const expected = `<html>
 			<head>
 				<title>My Test Page</title>
 			</head>
 			<body>
-				<script type="module" src="${remoteScript}" crossorigin="anonymous"></script>
+				<!-- This script will be removed -->
 			</body>
 		</html>`
 
 		const h = getMiddlewareHashes()
 		let warnCounter = 0
@@ -893,10 +891,140 @@ describe('updateDynamicPageSriHashes', () => {
 		expect(warnCalls).toEqual(1)
 		expect(lastWarnMsg).toEqual(
 			'Unable to obtain SRI hash for local resource: "/problematic/local/script.js"',
 		)
 	})
+
+	it('removes scripts with both src and content', async () => {
+		const content = `<html>
+			<head>
+				<title>My Test Page</title>
+			</head>
+			<body>
+				<script type="module" src="/core.mjs">
+					console.log("This should not be here")
+				</script><!-- This script will be removed-->
+			</body>
+		</html>`
+
+		const expected = `<html>
+			<head>
+				<title>My Test Page</title>
+			</head>
+			<body>
+				<!-- This script will be removed-->
+			</body>
+		</html>`
+
+		// "pre-loaded" (its value does not matter in this case)
+		const h = getMiddlewareHashes()
+		h.scripts.set(
+			'/core.mjs',
+			'sha256-6vcZ3jYR5LROXY5VlgX+tgNuIUVynHfMRQFXUnXSf64=',
+		)
+
+		const { pageHashes, updatedContent } = await updateDynamicPageSriHashes(
+			console,
+			content,
+			h,
+		)
+
+		expect(updatedContent).toEqual(expected)
+
+		// "no changes"
+		expect(h.scripts.size).toBe(1)
+		expect(h.scripts.get('/core.mjs')).toEqual(
+			'sha256-6vcZ3jYR5LROXY5VlgX+tgNuIUVynHfMRQFXUnXSf64=',
+		)
+
+		expect(h.styles.size).toBe(0)
+		expect(pageHashes.scripts.size).toBe(0)
+		expect(pageHashes.styles.size).toBe(0)
+	})
+
+	it('removes external scripts with integrity mismatch', async () => {
+		// "pre-loaded" (its value will differ from the one in the content)
+		const h = getMiddlewareHashes()
+		h.scripts.set(
+			'/core.mjs',
+			'sha256-1111111111111111111111111111111111111111111=',
+		)
+
+		const content = `<html>
+			<head>
+				<title>My Test Page</title>
+			</head>
+			<body>
+				<script type="module" src="/core.mjs" integrity="sha256-2222222222222222222222222222222222222222222="></script><!-- This script will be removed-->
+			</body>
+		</html>`
+
+		const expected = `<html>
+			<head>
+				<title>My Test Page</title>
+			</head>
+			<body>
+				<!-- This script will be removed-->
+			</body>
+		</html>`
+
+		const { pageHashes, updatedContent } = await updateDynamicPageSriHashes(
+			console,
+			content,
+			h,
+		)
+
+		expect(updatedContent).toEqual(expected)
+
+		// "no changes"
+		expect(h.scripts.size).toBe(1)
+		expect(h.scripts.get('/core.mjs')).toEqual(
+			'sha256-1111111111111111111111111111111111111111111=',
+		)
+
+		expect(h.styles.size).toBe(0)
+		expect(pageHashes.scripts.size).toBe(0)
+		expect(pageHashes.styles.size).toBe(0)
+	})
+
+	it('removes external (cross-origin) scripts with integrity attribute but not explicitly allowed', async () => {
+		// No "pre-loaded" hashes
+		const h = getMiddlewareHashes()
+
+		const content = `<html>
+			<head>
+				<title>My Test Page</title>
+			</head>
+			<body>
+				<script type="module" src="https://external.com/script.js" integrity="sha256-1111111111111111111111111111111111111111111="></script><!-- This script will be removed-->
+			</body>
+		</html>`
+
+		const expected = `<html>
+			<head>
+				<title>My Test Page</title>
+			</head>
+			<body>
+				<!-- This script will be removed-->
+			</body>
+		</html>`
+
+		const { pageHashes, updatedContent } = await updateDynamicPageSriHashes(
+			console,
+			content,
+			h,
+			// We do not pass an allow-list
+		)
+
+		expect(updatedContent).toEqual(expected)
+
+		// "no changes"
+		expect(h.scripts.size).toBe(0)
+		expect(h.styles.size).toBe(0)
+		expect(pageHashes.scripts.size).toBe(0)
+		expect(pageHashes.styles.size).toBe(0)
+	})
 })
 
 describe('scanAllowLists', () => {
 	it('populates hashes collection with hashes from allow-listed resources', async () => {
 		const scriptUrl =
@@ -1043,11 +1171,11 @@ describe('getMiddlewareHandler', () => {
 		<script integrity="sha256-TWupyvVdPa1DyFqLnQMqRpuUWdS3nKPnz70IcS/1o3Q=">console.log("Hello World!")</script>
 	</body>
 </html>`)
 	})
 
-	it('protects from validating disallowed inline scripts', async () => {
+	it('removes inline scripts when they are not allowed', async () => {
 		const hashes = {
 			scripts: new Map<string, string>(),
 			styles: new Map<string, string>(),
 		}
 
@@ -1081,11 +1209,11 @@ describe('getMiddlewareHandler', () => {
 <html>
 	<head>
 		<title>My Test Page</title>
 	</head>
 	<body>
-		<script>console.log("Hello World!")</script>
+		<script>console.log("Hello World!")</script><!-- The script will be removed -->
 	</body>
 </html>`,
 					status: 200,
 					statusText: 'OK',
 					headers: new Headers(),
@@ -1101,11 +1229,11 @@ describe('getMiddlewareHandler', () => {
 <html>
 	<head>
 		<title>My Test Page</title>
 	</head>
 	<body>
-		<script>console.log("Hello World!")</script>
+		<!-- The script will be removed -->
 	</body>
 </html>`)
 	})
 })
 
@@ -1176,11 +1304,11 @@ describe('getCSPMiddlewareHandler', () => {
 		<script integrity="sha256-TWupyvVdPa1DyFqLnQMqRpuUWdS3nKPnz70IcS/1o3Q=">console.log("Hello World!")</script>
 	</body>
 </html>`)
 	})
 
-	it('protects from validating disallowed inline scripts', async () => {
+	it('removes inline scripts when they are not allowed', async () => {
 		const hashes = {
 			scripts: new Map<string, string>(),
 			styles: new Map<string, string>(),
 		}
 
@@ -1215,11 +1343,11 @@ describe('getCSPMiddlewareHandler', () => {
 <html>
 	<head>
 		<title>My Test Page</title>
 	</head>
 	<body>
-		<script>console.log("Hello World!")</script>
+		<script>console.log("Hello World!")</script><!-- The script will be removed -->
 	</body>
 </html>`,
 					status: 200,
 					statusText: 'OK',
 					headers: new Headers(),
@@ -1235,10 +1363,10 @@ describe('getCSPMiddlewareHandler', () => {
 <html>
 	<head>
 		<title>My Test Page</title>
 	</head>
 	<body>
-		<script>console.log("Hello World!")</script>
+		<!-- The script will be removed -->
 	</body>
 </html>`)
 	})
 })
diff --git a/@kindspells/astro-shield/vitest.config.unit.mts b/@kindspells/astro-shield/vitest.config.unit.mts
index 3cdbb6d..b362970 100644
--- a/@kindspells/astro-shield/vitest.config.unit.mts
+++ b/@kindspells/astro-shield/vitest.config.unit.mts
@@ -18,11 +18,11 @@ export default defineConfig({
 				'coverage-e2e/**/*',
 				'coverage-unit/**/*',
 			],
 			thresholds: {
 				statements: 77.0,
-				branches: 77.0,
+				branches: 78.0,
 				functions: 87.0,
 				lines: 77.0,
 			},
 			reportsDirectory: 'coverage-unit',
 		},

Vulnerable code:  *   hash: string | null,
	`<script${attrs}${hash !== null ? ` integrity="${hash}"` : ''}${
	`<style${attrs}${hash !== null ? ` integrity="${hash}"` : ''}${
	`<link${attrs}${hash !== null ? ` integrity="${hash}"` : ''}${
const srcRegex = /\s+(src|href)\s*=\s*("(?<src1>.*?)"|'(?<src2>.*?)')/i
	for (const { attrsRegex, hasContent, regex, replacer, t, t2 } of processors) {
			const content = match.groups?.content ?? ''
					sriHash = generateSRIHash(content)
						`Skipping SRI hash generation for inline ${t.toLowerCase()} "${relativeFilepath}" (inline ${t2} are disabled)`,
					replacer(sriHash, attrs, setCrossorigin, content),
	for (const { attrsRegex, hasContent, regex, replacer, t, t2 } of processors) {
			const content = match.groups?.content ?? ''
				if (content && src) {
						`scripts must have either a src attribute or content, but not both "${src}"`,
					sriHash =
					if (sriHash) {
								if (globalHash !== sriHash) {
									throw new Error(
										`SRI hash mismatch for "${src}", expected "${globalHash}" but got "${sriHash}"`,
								globalHashes[t2].set(src, sriHash)
						pageHashes[t2].add(sriHash)
						logger.warn('Found empty integrity attribute, skipping...')
								`Detected reference to not-allow-listed external resource "${src}"`,
							if (setCrossorigin) {
								updatedContent = updatedContent.replace(
									match[0],
									replacer(null, attrs, true, ''),
								)
							}

							// 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)
						logger.warn(`Unable to process external resource: "${src}"`)
				// TODO: port logic from `updateStaticPageSriHashes` to handle inline resources
					sriHash = generateSRIHash(content)
						`Skipping SRI hash generation for inline ${t.toLowerCase()} (inline ${t2} are disabled)`,
					replacer(sriHash, attrs, setCrossorigin, content),

				<script type="module" src="/core.mjs" integrity="sha256-zOEqmAz4SCAi+TcSQgdhUuurJfrfnwWqtmdTOP+bBkc="></script>
				'sha256-zOEqmAz4SCAi+TcSQgdhUuurJfrfnwWqtmdTOP+bBkc=',

	// TODO: Add tests for external styles
	it('avoids adding sri hash to external script when not allow-listed (cross origin)', async () => {
				<script type="module" src="${remoteScript}"></script>
				<script type="module" src="${remoteScript}" crossorigin="anonymous"></script>
	it('protects from validating disallowed inline scripts', async () => {
		<script>console.log("Hello World!")</script>
		<script>console.log("Hello World!")</script>
	it('protects from validating disallowed inline scripts', async () => {
		<script>console.log("Hello World!")</script>
		<script>console.log("Hello World!")</script>
				branches: 77.0,
Fixed code: 
	it('does not "validate" sri signatures for cross-origin scripts that are not in the allow list', async () => {
		const response = await fetch(`${baseUrl}/injected/`)
		const cspHeader = response.headers.get('content-security-policy')

		assert(cspHeader !== null)
		assert(cspHeader)

		const scriptDirective = cspHeader
			.split(/;\s*/)
			.filter(directive => directive.startsWith('script-src'))[0]
		assert(scriptDirective)

		// This hash belongs to an allowed script that included its integrity
		// attribute as well (https://code.jquery.com/jquery-3.7.1.slim.min.js).
		assert(
			scriptDirective.includes(
				'sha256-kmHvs0B+OpCW5GVHUNjv9rOmY0IvSIRcf7zGUDTDQM8=',
			),
		)

		// This hash belongs to an allowed script that did not include its
		// integrity attribute (https://code.jquery.com/ui/1.13.2/jquery-ui.min.js).
		assert(
			scriptDirective.includes(
				'sha256-lSjKY0/srUM9BE3dPm+c4fBo1dky2v27Gdjm2uoZaL0=',
			),
		)

		// The MOST IMPORTANT assertionf of this test:
		// This hash belongs to the script that is "injected" in the page
		// (more precisely, that is not in the allow list)
		assert(
			!scriptDirective.includes(
				'sha256-BbhdlvQf/xTY9gja0Dq3HiwQF8LaCRTXxZKRutelT44=',
			),
		)
	})
 *   hash: string,
	`<script${attrs} integrity="${hash}"${
	`<style${attrs} integrity="${hash}"${
	`<link${attrs} integrity="${hash}"${
			srcRegex: /\s+src\s*=\s*("(?<src1>.*?)"|'(?<src2>.*?)')/i,
			srcRegex: /\s+(href|src)\s*=\s*("(?<src1>.*?)"|'(?<src2>.*?)')/i, // not really used
			srcRegex: /\s+href\s*=\s*("(?<src1>.*?)"|'(?<src2>.*?)')/i,
	for (const {
		attrsRegex,
		hasContent,
		regex,
		srcRegex,
		replacer,
		t,
		t2,
	} of processors) {
			const elemContent = match.groups?.content ?? ''
				if (elemContent && src) {
					logger.warn(
						`${t} "${src}" must have either a src/href attribute or content, but not both. Removing it.`,
					)
					updatedContent = updatedContent.replace(match[0], '')
					continue
				}

					sriHash = generateSRIHash(elemContent)
						`Removing inline ${t.toLowerCase()} block (inline ${t2} are disabled).`,
					updatedContent = updatedContent.replace(match[0], '')
					continue
					replacer(sriHash, attrs, setCrossorigin, elemContent),
	for (const {
		attrsRegex,
		hasContent,
		regex,
		srcRegex,
		replacer,
		t,
		t2,
	} of processors) {
			const elemContent = match.groups?.content ?? ''
				// This is to skip <link> elements that are not stylesheets
				if (elemContent && src) {
						`${t} "${src}" must have either a src/href attribute or content, but not both. Removing it.`,
					updatedContent = updatedContent.replace(match[0], '')
					const givenSriHash =
					if (givenSriHash) {
								if (globalHash !== givenSriHash) {
									logger.warn(
										`Detected integrity hash mismatch for resource "${src}". Removing it.`,
									updatedContent = updatedContent.replace(match[0], '')
								} else {
									sriHash = givenSriHash
									pageHashes[t2].add(sriHash)
								logger.warn(
									`Detected reference to not explicitly allowed external resource "${src}". Removing it.`,
								)
								updatedContent = updatedContent.replace(match[0], '')
							}
						} else if (elemContent) {
							if (
								(t2 === 'scripts' &&
									(sri?.allowInlineScripts ?? 'all') === 'all') ||
								(t2 === 'styles' && (sri?.allowInlineStyles ?? 'all') === 'all')
							) {
								sriHash = givenSriHash
								pageHashes[t2].add(sriHash)
							} else {
								logger.warn(
									`Removing inline ${t.toLowerCase()} block (inline ${t2} are disabled).`,
								)
								updatedContent = updatedContent.replace(match[0], '')
						logger.warn(
							`Found empty integrity attribute, removing inline ${t.toLowerCase()} block.`,
						)
						updatedContent = updatedContent.replace(match[0], '')
								// TODO: Perform fetch operation when running in dev mode
								`Detected reference to not explicitly allowed external resource "${src}". Removing it.`,
							updatedContent = updatedContent.replace(match[0], '')
						// TODO: Introduce flag to decide if external resources using unknown protocols should be removed
						logger.warn(`Unable to process external resource: "${src}".`)
					sriHash = generateSRIHash(elemContent)
						`Removing inline ${t.toLowerCase()} block (inline ${t2} are disabled)`,
					updatedContent = updatedContent.replace(match[0], '')
					continue
					replacer(sriHash, attrs, setCrossorigin, elemContent),
				<script type="module" src="/core.mjs" integrity="sha256-XJRisMK9wQvjjOmHgwTyaPbBdQ7sIaEh6BiqErhW4f8="></script>
				'sha256-XJRisMK9wQvjjOmHgwTyaPbBdQ7sIaEh6BiqErhW4f8=',
	it('removes external script when not explicitly allowed (cross origin)', async () => {
				<script type="module" src="${remoteScript}"></script><!-- This script will be removed -->
				<!-- This script will be removed -->

	it('removes scripts with both src and content', async () => {
		const content = `<html>
			<head>
				<title>My Test Page</title>
			</head>
			<body>
				<script type="module" src="/core.mjs">
					console.log("This should not be here")
				</script><!-- This script will be removed-->
			</body>
		</html>`

		const expected = `<html>
			<head>
				<title>My Test Page</title>
			</head>
			<body>
				<!-- This script will be removed-->
			</body>
		</html>`

		// "pre-loaded" (its value does not matter in this case)
		const h = getMiddlewareHashes()
		h.scripts.set(
			'/core.mjs',
			'sha256-6vcZ3jYR5LROXY5VlgX+tgNuIUVynHfMRQFXUnXSf64=',
		)

		const { pageHashes, updatedContent } = await updateDynamicPageSriHashes(
			console,
			content,
			h,
		)

		expect(updatedContent).toEqual(expected)

		// "no changes"
		expect(h.scripts.size).toBe(1)
		expect(h.scripts.get('/core.mjs')).toEqual(
			'sha256-6vcZ3jYR5LROXY5VlgX+tgNuIUVynHfMRQFXUnXSf64=',
		)

		expect(h.styles.size).toBe(0)
		expect(pageHashes.scripts.size).toBe(0)
		expect(pageHashes.styles.size).toBe(0)
	})

	it('removes external scripts with integrity mismatch', async () => {
		// "pre-loaded" (its value will differ from the one in the content)
		const h = getMiddlewareHashes()
		h.scripts.set(
			'/core.mjs',
			'sha256-1111111111111111111111111111111111111111111=',
		)

		const content = `<html>
			<head>
				<title>My Test Page</title>
			</head>
			<body>
				<script type="module" src="/core.mjs" integrity="sha256-2222222222222222222222222222222222222222222="></script><!-- This script will be removed-->
			</body>
		</html>`

		const expected = `<html>
			<head>
				<title>My Test Page</title>
			</head>
			<body>
				<!-- This script will be removed-->
			</body>
		</html>`

		const { pageHashes, updatedContent } = await updateDynamicPageSriHashes(
			console,
			content,
			h,
		)

		expect(updatedContent).toEqual(expected)

		// "no changes"
		expect(h.scripts.size).toBe(1)
		expect(h.scripts.get('/core.mjs')).toEqual(
			'sha256-1111111111111111111111111111111111111111111=',
		)

		expect(h.styles.size).toBe(0)
		expect(pageHashes.scripts.size).toBe(0)
		expect(pageHashes.styles.size).toBe(0)
	})

	it('removes external (cross-origin) scripts with integrity attribute but not explicitly allowed', async () => {
		// No "pre-loaded" hashes
		const h = getMiddlewareHashes()

		const content = `<html>
			<head>
				<title>My Test Page</title>
			</head>
			<body>
				<script type="module" src="https://external.com/script.js" integrity="sha256-1111111111111111111111111111111111111111111="></script><!-- This script will be removed-->
			</body>
		</html>`

		const expected = `<html>
			<head>
				<title>My Test Page</title>
			</head>
			<body>
				<!-- This script will be removed-->
			</body>
		</html>`

		const { pageHashes, updatedContent } = await updateDynamicPageSriHashes(
			console,
			content,
			h,
			// We do not pass an allow-list
		)

		expect(updatedContent).toEqual(expected)

		// "no changes"
		expect(h.scripts.size).toBe(0)
		expect(h.styles.size).toBe(0)
		expect(pageHashes.scripts.size).toBe(0)
		expect(pageHashes.styles.size).toBe(0)
	})
	it('removes inline scripts when they are not allowed', async () => {
		<script>console.log("Hello World!")</script><!-- The script will be removed -->
		<!-- The script will be removed -->
	it('removes inline scripts when they are not allowed', async () => {
		<script>console.log("Hello World!")</script><!-- The script will be removed -->
		<!-- The script will be removed -->
				branches: 78.0,