CVE-2024-30250 (Other, published 2024-04-04T15:15Z)
Description: 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.
Severity: nan (CVSS v3 base score: )
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/kindspells/astro-shield
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 message: fix: do not trust integrity attribute when undeserved

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


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,