VYPR
High severityNVD Advisory· Published Apr 4, 2024· Updated Aug 2, 2024

In Astro-Shield, setting a correct `integrity` attribute to injected code allows to bypass the allow-lists

CVE-2024-30250

Description

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.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Astro-Shield versions 1.2.0 to 1.3.1 allow bypass of cross-origin resource allow-lists via injected SRI hashes, fixed in 1.3.2.

The vulnerability in Astro-Shield arises from improper handling of the integrity attribute for injected script and style elements. In versions 1.2.0 through 1.3.1, the library unconditionally adds the integrity attribute to every replaced element, even when no hash was provided (the old code used hash !== null to decide). This allows an attacker to inject a resource with a valid integrity attribute, which then gets included in the generated Content-Security-Policy (CSP) header. As a result, the browser trusts the resource as legitimate, effectively bypassing configured allow-lists for cross-origin resources [1].

To exploit this, an attacker needs the ability to inject HTML into a page processed by Astro-Shield, such as through a stored cross-site scripting (XSS) vulnerability. By providing a valid integrity value (e.g., a hash matching the injected content), the injected script or style is treated as trusted by the CSP, despite not being in the original allow-list [2]. The attack requires no special network position beyond being able to deliver the malicious payload to the application.

The impact is a complete bypass of the security mechanism intended to restrict cross-origin resources. An attacker can execute arbitrary scripts or styles that appear to be from an allowed origin, potentially leading to data theft, session hijacking, or other malicious actions. The integrity check subverts the CSP policy, making the protection ineffective [4].

The issue is fully patched in Astro-Shield version 1.3.2. Users are strongly advised to upgrade immediately. The fix enforces that only resources with a genuine hash generated by the library are given the integrity attribute, restoring the intended security guarantees [2][4]. There are no known workarounds for earlier versions.

AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
@kindspells/astro-shieldnpm
>= 1.2.0, < 1.3.21.3.2

Affected products

3

Patches

2
5ae8b8ef4f68

fix: do not trust integrity attribute when undeserved

https://github.com/KindSpells/astro-shieldAndrés Correa CasablancaMar 31, 2024via ghsa
4 files changed · +265 56
  • @kindspells/astro-shield/e2e/e2e.test.mts+38 0 modified
    @@ -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=',
    +			),
    +		)
    +	})
     })
    
  • @kindspells/astro-shield/src/core.mjs+85 42 modified
    @@ -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
     	}
    
  • @kindspells/astro-shield/tests/core.test.mts+141 13 modified
    @@ -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,15 +657,15 @@ 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>
     			<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>`
     
    @@ -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>`)
     	})
    
  • @kindspells/astro-shield/vitest.config.unit.mts+1 1 modified
    @@ -20,7 +20,7 @@ export default defineConfig({
     			],
     			thresholds: {
     				statements: 77.0,
    -				branches: 77.0,
    +				branches: 78.0,
     				functions: 87.0,
     				lines: 77.0,
     			},
    
1221019306f5

fix: ensure that allowed scripts are in hashes module

https://github.com/KindSpells/astro-shieldAndrés Correa CasablancaMar 31, 2024via ghsa
6 files changed · +132 43
  • .editorconfig+3 1 modified
    @@ -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
    
  • @kindspells/astro-shield/e2e/e2e.test.mts+21 0 modified
    @@ -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',
    +			),
    +		)
    +	})
     })
    
  • @kindspells/astro-shield/e2e/fixtures/hybrid3/astro.config.mjs+9 3 modified
    @@ -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: {
    
  • @kindspells/astro-shield/e2e/fixtures/hybrid3/src/pages/injected.astro+32 0 added
    @@ -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>
    
  • @kindspells/astro-shield/src/core.mjs+65 37 modified
    @@ -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 (
    
  • @kindspells/astro-shield/tests/core.test.mts+2 2 modified
    @@ -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)
    

Vulnerability mechanics

AI mechanics synthesis has not run for this CVE yet.

References

6

News mentions

0

No linked articles in our index yet.