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: .editorconfig, @kindspells/astro-shield/e2e/e2e.test.mts, @kindspells/astro-shield/e2e/fixtures/hybrid3/astro.config.mjs, @kindspells/astro-shield/e2e/fixtures/hybrid3/src/pages/injected.astro, @kindspells/astro-shield/src/core.mjs, @kindspells/astro-shield/tests/core.test.mts
Commit date: 2024-03-31 03:37:15 +0200
Commit message: fix: ensure that allowed scripts are in hashes module

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

Diff: diff --git a/.editorconfig b/.editorconfig
index 6b95311..f6ce70f 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -12,8 +12,10 @@ insert_final_newline = true
 charset = utf-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
diff --git a/@kindspells/astro-shield/e2e/e2e.test.mts b/@kindspells/astro-shield/e2e/e2e.test.mts
index 14b7cf8..8c9e138 100644
--- a/@kindspells/astro-shield/e2e/e2e.test.mts
+++ b/@kindspells/astro-shield/e2e/e2e.test.mts
@@ -21,10 +21,11 @@ import {
 	describe,
 	expect,
 	it,
 } from 'vitest'
 
+import type { HashesModule } from '#as/core.mjs'
 import { generateSRIHash } from '#as/core.mjs'
 import { doesFileExist } from '#as/fs.mjs'
 
 const execFile = promisify(_execFile)
 
@@ -528,6 +529,26 @@ describe('middleware (hybrid 3)', () => {
 
 		expect(cspHeader).toBe(
 			"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',
+			),
+		)
+	})
 })
diff --git a/@kindspells/astro-shield/e2e/fixtures/hybrid3/astro.config.mjs b/@kindspells/astro-shield/e2e/fixtures/hybrid3/astro.config.mjs
index af377da..8ba141b 100644
--- a/@kindspells/astro-shield/e2e/fixtures/hybrid3/astro.config.mjs
+++ b/@kindspells/astro-shield/e2e/fixtures/hybrid3/astro.config.mjs
@@ -22,13 +22,19 @@ export default defineConfig({
 	output: 'hybrid',
 	trailingSlash: 'always',
 	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: {
 						'default-src': "'none'",
 						'frame-ancestors': "'none'",
diff --git a/@kindspells/astro-shield/e2e/fixtures/hybrid3/src/pages/injected.astro b/@kindspells/astro-shield/e2e/fixtures/hybrid3/src/pages/injected.astro
new file mode 100644
index 0000000..1425901
--- /dev/null
+++ b/@kindspells/astro-shield/e2e/fixtures/hybrid3/src/pages/injected.astro
@@ -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>
diff --git a/@kindspells/astro-shield/src/core.mjs b/@kindspells/astro-shield/src/core.mjs
index a8d2601..e9615c0 100644
--- a/@kindspells/astro-shield/src/core.mjs
+++ b/@kindspells/astro-shield/src/core.mjs
@@ -19,10 +19,17 @@ import { patchHeaders } from './headers.mjs'
  * @typedef {import('./core.js').PerPageHashesCollection} PerPageHashesCollection
  * @typedef {import('./core.js').HashesCollection} HashesCollection
  * @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
  */
 
 /**
  * @param {string | ArrayBuffer | Buffer} data
  * @returns {string}
@@ -527,24 +534,24 @@ export const scanForNestedResources = async (logger, dirPath, h) => {
 		_filePath => ['.js', '.mjs', '.css'].includes(extname(_filePath)),
 	)
 }
 
 /**
- * @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)
 
 		h.extScriptHashes.add(sriHash)
 		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)
 
 		h.extStyleHashes.add(sriHash)
@@ -589,18 +596,13 @@ export async function generateSRIHashesModule(
 	for (const [k, v] of h.perResourceSriHashes.styles.entries()) {
 		perResourceHashes.styles[k] = v
 	}
 
 	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,
 			hModule.perResourceSriHashes ?? { scripts: {}, styles: {} },
 		)
@@ -681,21 +683,22 @@ export const processStaticFiles = async (logger, { distDir, sri }) => {
 		perResourceSriHashes: {
 			scripts: new Map(),
 			styles: new Map(),
 		},
 	}
+	await scanAllowLists(sri, h)
+	await scanForNestedResources(logger, distDir, h)
 	await scanDirectory(
 		logger,
 		distDir,
 		distDir,
 		h,
 		processHTMLFile,
 		file => extname(file) === '.html',
 		sri,
 	)
 
-	await scanForNestedResources(logger, distDir, h)
 
 	if (!sri.hashesModule) {
 		return
 	}
 
@@ -788,37 +791,62 @@ const loadVirtualMiddlewareModule = async (
 	publicDir,
 ) => {
 	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 (
 		sri.enableStatic &&
 		sri.hashesModule &&
diff --git a/@kindspells/astro-shield/tests/core.test.mts b/@kindspells/astro-shield/tests/core.test.mts
index 2892fdd..38caa2d 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-iozyX5cgvSGJZLKhhN7CRl6tn/jC3vYkBm8jfGv4x78="></script>
+				<script type="module" src="/core.mjs" integrity="sha256-zOEqmAz4SCAi+TcSQgdhUuurJfrfnwWqtmdTOP+bBkc="></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-iozyX5cgvSGJZLKhhN7CRl6tn/jC3vYkBm8jfGv4x78=',
+				'sha256-zOEqmAz4SCAi+TcSQgdhUuurJfrfnwWqtmdTOP+bBkc=',
 			),
 		).toBe(true)
 		expect(h.inlineScriptHashes.size).toBe(0)
 		expect(h.inlineStyleHashes.size).toBe(0)
 		expect(h.extStyleHashes.size).toBe(0)

Vulnerable code: [*.md]
			enableStatic_SRI: true,
			enableMiddleware_SRI: true,
			sriHashesModule,
 * @param {Required<Pick<SRIOptions, 'scriptsAllowListUrls' | 'stylesAllowListUrls'>>} sri
	for (const scriptUrl of sri.scriptsAllowListUrls) {
	for (const styleUrl of sri.stylesAllowListUrls) {
		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))
	await scanForNestedResources(logger, distDir, h)
	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(),
			},
		// 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
		)
				<script type="module" src="/core.mjs" integrity="sha256-iozyX5cgvSGJZLKhhN7CRl6tn/jC3vYkBm8jfGv4x78="></script>
				'sha256-iozyX5cgvSGJZLKhhN7CRl6tn/jC3vYkBm8jfGv4x78=',
Fixed code: [*.{md,mdx}]
charset = utf-8
trim_trailing_whitespace = true
import type { HashesModule } from '#as/core.mjs'

	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',
			),
		)
	})
			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',
				],
			},
---
/*
 * 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>
 * @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
 * @param {Pick<SRIOptions, 'scriptsAllowListUrls' | 'stylesAllowListUrls'>} sri
	for (const scriptUrl of sri.scriptsAllowListUrls ?? []) {
	for (const styleUrl of sri.stylesAllowListUrls ?? []) {
		const hModule = /** @type {HashesModule} */ (
			await import(/* @vite-ignore */ sriHashesModule)
		)
	await scanAllowLists(sri, h)
	await scanForNestedResources(logger, distDir, h)
	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
			}
		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
			)
		}
				<script type="module" src="/core.mjs" integrity="sha256-zOEqmAz4SCAi+TcSQgdhUuurJfrfnwWqtmdTOP+bBkc="></script>
				'sha256-zOEqmAz4SCAi+TcSQgdhUuurJfrfnwWqtmdTOP+bBkc=',