CVE-2024-30250 (published 2024-04-04T15:15Z)

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.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/kindspells/astro-shield
commit_details: repo_url: https://github.com/kindspells/astro-shield
author: Andrés Correa Casablanca
author_date: 2024-03-31 03:37:15+02:00
msg: fix: ensure that allowed scripts are in hashes module

Signed-off-by: Andres Correa Casablanca <andreu@kindspells.dev>
repository: repo_url: https://github.com/kindspells/astro-shield
repo_name: kindspells/astro-shield
description: Astro integration to enhance your website's security with SubResource Integrity hashes, Content-Security-Policy headers, and other techniques.
repo_language: TypeScript
owner: kindspells
file_changes: filename: .editorconfig
change_type: ModificationType.MODIFY
diff: @@ -14,6 +14,8 @@ indent_style = tab
 indent_size = 2
 trim_trailing_whitespace = true
 
-[*.md]
+[*.{md,mdx}]
+charset = utf-8
 indent_style = space
 indent_size = 2
+trim_trailing_whitespace = true

programming_language: Haskell
filename: e2e.test.mts
change_type: ModificationType.MODIFY
diff: @@ -23,6 +23,7 @@ import {
 	it,
 } from 'vitest'
 
+import type { HashesModule } from '#as/core.mjs'
 import { generateSRIHash } from '#as/core.mjs'
 import { doesFileExist } from '#as/fs.mjs'
 
@@ -530,4 +531,24 @@ describe('middleware (hybrid 3)', () => {
 			"default-src 'none'; frame-ancestors 'none'; script-src 'self' 'sha256-X7QGGDHgf6XMoabXvV9pW7gl3ALyZhZlgKq1s3pwmME='; style-src 'self' 'sha256-9U7mv8FibD/D9IbGpXc86pz37l6/w4PCLpFIZuPrzh8=' 'sha256-ZlgyI5Bx/aeAyk/wSIypqeIM5PBhz9IiAek9HIiAjaI='",
 		)
 	})
+
+	it('incorporates the allowed scripts into the generated hashes module', async () => {
+		const hashesModulePath = resolve(hybridDir, 'src', 'generated', 'sri.mjs')
+		assert(await doesFileExist(hashesModulePath))
+
+		const hashesModule = (await import(hashesModulePath)) as HashesModule
+
+		assert(
+			Object.hasOwn(
+				hashesModule.perResourceSriHashes.scripts,
+				'https://code.jquery.com/jquery-3.7.1.slim.min.js',
+			),
+		)
+		assert(
+			Object.hasOwn(
+				hashesModule.perResourceSriHashes.scripts,
+				'https://code.jquery.com/ui/1.13.2/jquery-ui.min.js',
+			),
+		)
+	})
 })

programming_language: CoffeeScript
filename: astro.config.mjs
change_type: ModificationType.MODIFY
diff: @@ -24,9 +24,15 @@ export default defineConfig({
 	adapter: node({ mode: 'standalone' }),
 	integrations: [
 		shield({
-			enableStatic_SRI: true,
-			enableMiddleware_SRI: true,
-			sriHashesModule,
+			sri: {
+				enableStatic: true,
+				enableMiddleware: true,
+				hashesModule: sriHashesModule,
+				scriptsAllowListUrls: [
+					'https://code.jquery.com/jquery-3.7.1.slim.min.js',
+					'https://code.jquery.com/ui/1.13.2/jquery-ui.min.js',
+				],
+			},
 			securityHeaders: {
 				contentSecurityPolicy: {
 					cspDirectives: {

programming_language: TypeScript
filename: injected.astro
change_type: ModificationType.ADD
diff: @@ -0,0 +1,32 @@
+---
+/*
+ * SPDX-FileCopyrightText: 2024 KindSpells Labs S.L.
+ *
+ * SPDX-License-Identifier: MIT
+ */
+
+export const prerender = false
+import '../styles/main.css'
+---
+<!DOCTYPE html><html lang="en">
+	<head>
+		<meta charset="utf-8" />
+		<meta name="viewport" content="width=device-width, initial-scale=1" />
+		<title>My Static Test Site</title>
+		<style>h1 { color: red; }</style>
+	</head>
+	<body>
+		<!-- The next script is whitelisted -->
+		<script
+			src="https://code.jquery.com/jquery-3.7.1.slim.min.js"
+			integrity="sha256-kmHvs0B+OpCW5GVHUNjv9rOmY0IvSIRcf7zGUDTDQM8="
+			crossorigin="anonymous"
+		></script>
+        <!-- The next script is whitelisted, but we let Astro-Shield obtain its integrity hash -->
+		<script src="https://code.jquery.com/ui/1.13.2/jquery-ui.min.js" type="module"></script>
+
+		<!-- The next script, although not malicious, is used to simulate an injection -->
+		<script src="https://code.jquery.com/jquery-2.2.4.min.js" integrity="sha256-BbhdlvQf/xTY9gja0Dq3HiwQF8LaCRTXxZKRutelT44="></script>
+		<p>This document simulates a page showing an injected script</p>
+	</body>
+</html>

programming_language: HTML
filename: core.mjs
change_type: ModificationType.MODIFY
diff: @@ -21,6 +21,13 @@ import { patchHeaders } from './headers.mjs'
  * @typedef {import('./core.js').MiddlewareHashes} MiddlewareHashes
  * @typedef {import('./core.js').Logger} Logger
  * @typedef {import('astro').AstroIntegration} Integration
+ * @typedef {{
+ * 	[k in keyof HashesCollection]: HashesCollection[k] extends Set<string>
+ * 	  ? string[] | undefined
+ * 	  : (k extends 'perPageSriHashes'
+ * 	    ? Record<string, { scripts: string[]; styles: string [] }>
+ * 	    : Record<'scripts' | 'styles', Record<string, string>>)
+ * }} HashesModule
  */
 
 /**
@@ -529,11 +536,11 @@ export const scanForNestedResources = async (logger, dirPath, h) => {
 }
 
 /**
- * @param {Required<Pick<SRIOptions, 'scriptsAllowListUrls' | 'stylesAllowListUrls'>>} sri
+ * @param {Pick<SRIOptions, 'scriptsAllowListUrls' | 'stylesAllowListUrls'>} sri
  * @param {HashesCollection} h
  */
 export const scanAllowLists = async (sri, h) => {
-	for (const scriptUrl of sri.scriptsAllowListUrls) {
+	for (const scriptUrl of sri.scriptsAllowListUrls ?? []) {
 		const resourceResponse = await fetch(scriptUrl, { method: 'GET' })
 		const resourceContent = await resourceResponse.arrayBuffer()
 		const sriHash = generateSRIHash(resourceContent)
@@ -542,7 +549,7 @@ export const scanAllowLists = async (sri, h) => {
 		h.perResourceSriHashes.scripts.set(scriptUrl, sriHash)
 	}
 
-	for (const styleUrl of sri.stylesAllowListUrls) {
+	for (const styleUrl of sri.stylesAllowListUrls ?? []) {
 		const resourceResponse = await fetch(styleUrl, { method: 'GET' })
 		const resourceContent = await resourceResponse.arrayBuffer()
 		const sriHash = generateSRIHash(resourceContent)
@@ -591,14 +598,9 @@ export async function generateSRIHashesModule(
 	}
 
 	if (await doesFileExist(sriHashesModule)) {
-		const hModule = /**
-			@type {{
-					[k in keyof HashesCollection]: HashesCollection[k] extends Set<string>
-							? string[] | undefined
-							: (k extends 'perPageSriHashes'
-								? Record<string, { scripts: string[]; styles: string [] }>
-								: Record<'scripts' | 'styles', Record<string, string>>)
-		}} */ (await import(/* @vite-ignore */ sriHashesModule))
+		const hModule = /** @type {HashesModule} */ (
+			await import(/* @vite-ignore */ sriHashesModule)
+		)
 
 		extResourceHashesChanged = !sriHashesEqual(
 			perResourceHashes,
@@ -683,6 +685,8 @@ export const processStaticFiles = async (logger, { distDir, sri }) => {
 			styles: new Map(),
 		},
 	}
+	await scanAllowLists(sri, h)
+	await scanForNestedResources(logger, distDir, h)
 	await scanDirectory(
 		logger,
 		distDir,
@@ -693,7 +697,6 @@ export const processStaticFiles = async (logger, { distDir, sri }) => {
 		sri,
 	)
 
-	await scanForNestedResources(logger, distDir, h)
 
 	if (!sri.hashesModule) {
 		return
@@ -790,33 +793,58 @@ const loadVirtualMiddlewareModule = async (
 	let extraImports = ''
 	let staticHashesModuleLoader = ''
 
-	if (
-		sri.enableStatic &&
-		sri.hashesModule &&
-		!(await doesFileExist(sri.hashesModule))
-	) {
-		const h = /** @satisfies {HashesCollection} */ {
-			inlineScriptHashes: new Set(),
-			inlineStyleHashes: new Set(),
-			extScriptHashes: new Set(),
-			extStyleHashes: new Set(),
-			perPageSriHashes: new Map(),
-			perResourceSriHashes: {
-				scripts: new Map(),
-				styles: new Map(),
-			},
+	if (sri.enableStatic && sri.hashesModule) {
+		let shouldRegenerateHashesModule = !(await doesFileExist(sri.hashesModule))
+
+		if (!shouldRegenerateHashesModule) {
+			try {
+				const hashesModule = /** @type {HashesModule} */ (
+					await import(sri.hashesModule)
+				)
+
+				for (const allowedScript of sri.scriptsAllowListUrls) {
+					if (
+						!Object.hasOwn(
+							hashesModule.perResourceSriHashes.scripts,
+							allowedScript,
+						)
+					) {
+						shouldRegenerateHashesModule = true
+						break
+					}
+				}
+			} catch (err) {
+				logger.warn(
+					`Failed to load SRI hashes module "${sri.hashesModule}", it will be re-generated:\n\t${err}`,
+				)
+				shouldRegenerateHashesModule = true
+			}
 		}
 
-		// 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
-		)
+		if (shouldRegenerateHashesModule) {
+			const h = /** @satisfies {HashesCollection} */ {
+				inlineScriptHashes: new Set(),
+				inlineStyleHashes: new Set(),
+				extScriptHashes: new Set(),
+				extStyleHashes: new Set(),
+				perPageSriHashes: new Map(),
+				perResourceSriHashes: {
+					scripts: new Map(),
+					styles: new Map(),
+				},
+			}
+
+			// 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
+			)
+		}
 	}
 
 	if (

programming_language: JavaScript
filename: core.test.mts
change_type: ModificationType.MODIFY
diff: @@ -365,7 +365,7 @@ describe('updateStaticPageSriHashes', () => {
 				<title>My Test Page</title>
 			</head>
 			<body>
-				<script type="module" src="/core.mjs" integrity="sha256-iozyX5cgvSGJZLKhhN7CRl6tn/jC3vYkBm8jfGv4x78="></script>
+				<script type="module" src="/core.mjs" integrity="sha256-zOEqmAz4SCAi+TcSQgdhUuurJfrfnwWqtmdTOP+bBkc="></script>
 			</body>
 		</html>`
 
@@ -382,7 +382,7 @@ describe('updateStaticPageSriHashes', () => {
 		expect(h.extScriptHashes.size).toBe(1)
 		expect(
 			h.extScriptHashes.has(
-				'sha256-iozyX5cgvSGJZLKhhN7CRl6tn/jC3vYkBm8jfGv4x78=',
+				'sha256-zOEqmAz4SCAi+TcSQgdhUuurJfrfnwWqtmdTOP+bBkc=',
 			),
 		).toBe(true)
 		expect(h.inlineScriptHashes.size).toBe(0)

programming_language: TypeScript
repo_url: https://github.com/kindspells/astro-shield
commit_details: repo_url: https://github.com/kindspells/astro-shield
author: Andrés Correa Casablanca
author_date: 2024-03-31 13:01:52+02:00
msg: fix: do not trust integrity attribute when undeserved

Signed-off-by: Andres Correa Casablanca <andreu@kindspells.dev>
repository: repo_url: https://github.com/kindspells/astro-shield
repo_name: kindspells/astro-shield
description: Astro integration to enhance your website's security with SubResource Integrity hashes, Content-Security-Policy headers, and other techniques.
repo_language: TypeScript
owner: kindspells
file_changes: filename: e2e.test.mts
change_type: ModificationType.MODIFY
diff: @@ -551,4 +551,42 @@ describe('middleware (hybrid 3)', () => {
 			),
 		)
 	})
+
+	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=',
+			),
+		)
+	})
 })

programming_language: CoffeeScript
filename: core.mjs
change_type: ModificationType.MODIFY
diff: @@ -48,7 +48,7 @@ export const generateSRIHash = data => {
 
 /**
  * @typedef {(
- *   hash: string | null,
+ *   hash: string,
  *   attrs: string,
  *   setCrossorigin: boolean,
  *   content?: string | undefined,
@@ -57,23 +57,22 @@ export const generateSRIHash = data => {
 
 /** @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
@@ -85,6 +84,7 @@ const getRegexProcessors = () => {
 			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,
@@ -94,6 +94,7 @@ const getRegexProcessors = () => {
 			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,
@@ -103,6 +104,7 @@ const getRegexProcessors = () => {
 			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,
@@ -148,11 +150,19 @@ export const updateStaticPageSriHashes = async (
 	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
@@ -167,6 +177,14 @@ export const updateStaticPageSriHashes = async (
 				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 ??
@@ -217,20 +235,22 @@ export const updateStaticPageSriHashes = async (
 					!(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),
 				)
 			}
 		}
@@ -261,17 +281,26 @@ export const updateDynamicPageSriHashes = async (
 		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
 				}
@@ -280,33 +309,57 @@ export const updateDynamicPageSriHashes = async (
 				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
 				}
@@ -325,6 +378,7 @@ export const updateDynamicPageSriHashes = async (
 									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}"`,
 								)
@@ -339,49 +393,39 @@ export const updateDynamicPageSriHashes = async (
 							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),
 				)
 			}
 		}
@@ -697,7 +741,6 @@ export const processStaticFiles = async (logger, { distDir, sri }) => {
 		sri,
 	)
 
-
 	if (!sri.hashesModule) {
 		return
 	}

programming_language: JavaScript
filename: core.test.mts
change_type: ModificationType.MODIFY
diff: @@ -365,7 +365,7 @@ describe('updateStaticPageSriHashes', () => {
 				<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>`
 
@@ -382,7 +382,7 @@ describe('updateStaticPageSriHashes', () => {
 		expect(h.extScriptHashes.size).toBe(1)
 		expect(
 			h.extScriptHashes.has(
-				'sha256-zOEqmAz4SCAi+TcSQgdhUuurJfrfnwWqtmdTOP+bBkc=',
+				'sha256-XJRisMK9wQvjjOmHgwTyaPbBdQ7sIaEh6BiqErhW4f8=',
 			),
 		).toBe(true)
 		expect(h.inlineScriptHashes.size).toBe(0)
@@ -477,8 +477,6 @@ describe('updateStaticPageSriHashes', () => {
 		expect(h.extScriptHashes.size).toBe(0)
 		expect(h.inlineStyleHashes.size).toBe(0)
 	})
-
-	// TODO: Add tests for external styles
 })
 
 describe('updateDynamicPageSriHashes', () => {
@@ -659,7 +657,7 @@ describe('updateDynamicPageSriHashes', () => {
 		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>
@@ -667,7 +665,7 @@ describe('updateDynamicPageSriHashes', () => {
 				<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>`
 
@@ -676,7 +674,7 @@ describe('updateDynamicPageSriHashes', () => {
 				<title>My Test Page</title>
 			</head>
 			<body>
-				<script type="module" src="${remoteScript}" crossorigin="anonymous"></script>
+				<!-- This script will be removed -->
 			</body>
 		</html>`
 
@@ -895,6 +893,136 @@ describe('updateDynamicPageSriHashes', () => {
 			'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', () => {
@@ -1045,7 +1173,7 @@ describe('getMiddlewareHandler', () => {
 </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>(),
@@ -1083,7 +1211,7 @@ describe('getMiddlewareHandler', () => {
 		<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,
@@ -1103,7 +1231,7 @@ describe('getMiddlewareHandler', () => {
 		<title>My Test Page</title>
 	</head>
 	<body>
-		<script>console.log("Hello World!")</script>
+		<!-- The script will be removed -->
 	</body>
 </html>`)
 	})
@@ -1178,7 +1306,7 @@ describe('getCSPMiddlewareHandler', () => {
 </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>(),
@@ -1217,7 +1345,7 @@ describe('getCSPMiddlewareHandler', () => {
 		<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,
@@ -1237,7 +1365,7 @@ describe('getCSPMiddlewareHandler', () => {
 		<title>My Test Page</title>
 	</head>
 	<body>
-		<script>console.log("Hello World!")</script>
+		<!-- The script will be removed -->
 	</body>
 </html>`)
 	})

programming_language: TypeScript
filename: vitest.config.unit.mts
change_type: ModificationType.MODIFY
diff: @@ -20,7 +20,7 @@ export default defineConfig({
 			],
 			thresholds: {
 				statements: 77.0,
-				branches: 77.0,
+				branches: 78.0,
 				functions: 87.0,
 				lines: 77.0,
 			},

programming_language: JavaScript