CVE-2024-29896 (Other, published 2024-03-28T13:15Z)
Description: value: Astro-Shield is a library to compute the subresource integrity hashes for your JS scripts and CSS stylesheets. When automated CSP headers generation for SSR content is enabled and the web application serves content that can be partially controlled by external users, then it is possible that the CSP headers generation feature might be "allow-listing" malicious injected resources like inlined JS, or references to external malicious scripts. The fix is available in version 1.3.0.
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:
README.md
package.json
src/core.mjs
src/headers.mjs
src/main.mjs
tests/core.test.mts
tests/headers.test.mts
tests/main.test.mts
vitest.config.unit.mts
Commit message: feat: cross-origin resources allow-lists

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


Vulnerable code:
      // Enables SRI hashes generation for statically generated pages
      enableStatic_SRI: true, // true by default

      // Enables a middleware that generates SRI hashes for dynamically
      // generated pages
      enableMiddleware_SRI: false, // false by default

      // This is the path where we'll generate the module containing the SRI
      // hashes for your scripts and styles. There's no need to pass this
      // parameter if you don't need this data, but it can be useful to
      // configure your CSP policies.
      sriHashesModule: resolve(rootDir, 'src', 'utils', 'sriHashes.mjs'),
	"version": "1.2.0",
 *   hash: string,
	`<script${attrs} integrity="${hash}"${
	`<style${attrs} integrity="${hash}"${
	`<link${attrs} integrity="${hash}"${
	sri
							const resourceResponse = await fetch(src, { method: 'GET' })
							const resourceContent = await resourceResponse.arrayBuffer()
							sriHash = generateSRIHash(resourceContent)
							globalHashes[t2].set(src, sriHash)
							pageHashes[t2].add(sriHash)
export const getMiddlewareHandler = globalHashes => {
			console,
export const getCSPMiddlewareHandler = (globalHashes, securityHeadersOpts) => {
			console,
			? `getCSPMiddlewareHandler(globalHashes, ${JSON.stringify(
				)})`
			: 'getMiddlewareHandler(globalHashes)'
			...((enableStatic_SRI ?? true) === true
			...(enableMiddleware_SRI === true
import { beforeEach, describe, expect, it } from 'vitest'
				<script type="module" src="/core.mjs" integrity="sha256-vSvqa4zN5DZN/gOtz1s6Xuw0MUYNKQXvUPL8pXWgHGo="></script>
				'sha256-vSvqa4zN5DZN/gOtz1s6Xuw0MUYNKQXvUPL8pXWgHGo=',
	it('adds sri hash to external script (cross origin)', async () => {
			info(msg: string) {
				return console.info(msg)
			},
			warn(msg: string) {
				return console.warn(msg)
			},
			error(msg: string) {
				return console.error(msg)
			"form-action 'self'; frame-ancestors 'none'",
		const integration = shield({ enableStatic_SRI: true })
		const integration = shield({ enableStatic_SRI: false })
		const integration = shield({ enableMiddleware_SRI: true })
				statements: 72.0,
				branches: 76.0,
				functions: 80.0,
				lines: 72.0,

Fixed code:
      sri: {
        // Enables SRI hashes generation for statically generated pages
        enableStatic: true, // true by default

        // Enables a middleware that generates SRI hashes for dynamically
        // generated pages
        enableMiddleware: false, // false by default

        // This is the path where we'll generate the module containing the SRI
        // hashes for your scripts and styles. There's no need to pass this
        // parameter if you don't need this data, but it can be useful to
        // configure your CSP policies.
        hashesModule: resolve(rootDir, 'src', 'utils', 'sriHashes.mjs'),

        // For SSR content, Cross-Origin scripts must be explicitly allow-listed
        // by URL in order to be allowed by the Content Security Policy.
        //
        // Defaults to []
        scriptsAllowListUrls: [
          'https://code.jquery.com/jquery-3.7.1.slim.min.js',
        ],

        // For SSR content, Cross-Origin styles must be explicitly allow-listed
        // by URL in order to be allowed by the Content Security Policy.
        //
        // Defaults to []
        stylesAllowListUrls: [
          'https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css',
        ],

        /**
         * Inline styles are usually considered unsafe because they could make it
         * easier for an attacker to inject CSS rules in dynamic pages. However, they
         * don't pose a serious security risk for _most_ static pages.
         *
         * You can disable this option in case you want to enforce a stricter policy.
         *
         * @type {'all' | 'static' | false}
         *
         * Defaults to 'all'.
         */
        allowInlineStyles: 'all',

        /**
         * Inline scripts are usually considered unsafe because they could make it
         * easier for an attacker to inject JS code in dynamic pages. However, they
         * don't pose a serious security risk for _most_ static pages.
         *
         * You can disable this option in case you want to enforce a stricter policy.
         *
         * @type {'all' | 'static' | false}
         *
         * Defaults to 'all'.
         */
        allowInlineScript: 'all',
      },
	"version": "1.3.0",
 *   hash: string | null,
	`<script${attrs}${hash !== null ? ` integrity="${hash}"` : ''}${
	`<style${attrs}${hash !== null ? ` integrity="${hash}"` : ''}${
	`<link${attrs}${hash !== null ? ` integrity="${hash}"` : ''}${
	sri,
							logger.warn(
								`Detected reference to not-allow-listed external resource "${src}"`,
							)
							if (setCrossorigin) {
								updatedContent = updatedContent.replace(
									match[0],
									replacer(null, attrs, true, ''),
								)
							}
							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)
/**
 * @param {Required<Pick<SRIOptions, 'scriptsAllowListUrls' | 'stylesAllowListUrls'>>} sri
 * @param {HashesCollection} h
 */
export const scanAllowLists = async (sri, h) => {
	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) {
		const resourceResponse = await fetch(styleUrl, { method: 'GET' })
		const resourceContent = await resourceResponse.arrayBuffer()
		const sriHash = generateSRIHash(resourceContent)

		h.extStyleHashes.add(sriHash)
		h.perResourceSriHashes.styles.set(styleUrl, sriHash)
	}
}

 * @param {Logger} logger
 * @param {Required<SRIOptions>} sri
export const getMiddlewareHandler = (logger, globalHashes, sri) => {
			logger,
			sri,
 * @param {Logger} logger
 * @param {Required<SRIOptions>} sri
export const getCSPMiddlewareHandler = (
	logger,
	globalHashes,
	securityHeadersOpts,
	sri,
) => {
			logger,
			sri,
		await scanAllowLists(sri, h)
			? `getCSPMiddlewareHandler(console, globalHashes, ${JSON.stringify(
				)}, ${JSON.stringify(sri)})`
			: `getMiddlewareHandler(console, globalHashes, ${JSON.stringify(sri)})`
	} else {
		directives['script-src'] = "'none'"
	} else {
		directives['style-src'] = "'none'"
			...(_sri.enableStatic === true
			...(_sri.enableMiddleware === true
import { assert, beforeEach, describe, expect, it } from 'vitest'
	getCSPMiddlewareHandler,
	getMiddlewareHandler,
	scanAllowLists,
				<script type="module" src="/core.mjs" integrity="sha256-Xbdu1jxIAqCjb78wAdgir+Swc5faxBuLHPm0DC/lG80="></script>
				'sha256-Xbdu1jxIAqCjb78wAdgir+Swc5faxBuLHPm0DC/lG80=',
	it('avoids adding sri hash to external script when not allow-listed (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>
			</body>
		</html>`

		const expected = `<html>
			<head>
				<title>My Test Page</title>
			</head>
			<body>
				<script type="module" src="${remoteScript}" crossorigin="anonymous"></script>
			</body>
		</html>`

		const h = getMiddlewareHashes()
		let warnCounter = 0
		const { pageHashes, updatedContent } = await updateDynamicPageSriHashes(
			{
				info: () => {},
				warn: () => {
					warnCounter += 1
				},
				error: () => {},
			},
			content,
			h,
		)

		expect(warnCounter).toBe(1)
		expect(updatedContent).toEqual(expected)
		expect(h.scripts.size).toBe(0)
		expect(h.styles.size).toBe(0)
		expect(h.scripts.get(remoteScript)).toBeUndefined()
		expect(pageHashes.scripts.size).toBe(0)
		expect(
			pageHashes.scripts.has(
				'sha256-i4WR4ifasidZIuS67Rr6Knsy7/hK1xbVTc8ZAmnAv1Q=',
			),
		).toBe(false)
		expect(pageHashes.styles.size).toBe(0)
	})

	it('adds sri hash to external script when allow-listed (cross origin)', async () => {
		h.scripts.set(
			remoteScript,
			'sha256-i4WR4ifasidZIuS67Rr6Knsy7/hK1xbVTc8ZAmnAv1Q=',
		)
			info(_msg: string) {},
			warn(_msg: string) {
			error(_msg: string) {},
describe('scanAllowLists', () => {
	it('populates hashes collection with hashes from allow-listed resources', async () => {
		const scriptUrl =
			'https://raw.githubusercontent.com/KindSpells/astro-shield/ae9521048f2129f633c075b7f7ef24e11bbd1884/main.mjs'
		const styleUrl =
			'https://raw.githubusercontent.com/KindSpells/astro-shield/26fdf5399d79baa3a8ea70ded526116b0bfc06ed/e2e/fixtures/hybrid2/src/styles/normalize.css'

		const h = getEmptyHashes()
		await scanAllowLists(
			{
				scriptsAllowListUrls: [scriptUrl],
				stylesAllowListUrls: [styleUrl],
			},
			h,
		)

		expect(h.extScriptHashes.size).toBe(1)
		expect(h.extStyleHashes.size).toBe(1)
		expect(h.perResourceSriHashes.scripts.get(scriptUrl)).toBe(
			'sha256-i4WR4ifasidZIuS67Rr6Knsy7/hK1xbVTc8ZAmnAv1Q=',
		)
		expect(h.perResourceSriHashes.styles.get(styleUrl)).toBe(
			'sha256-7o69ZgSUx++S5DC0Ek7X2CbY4GnxxUkwGZDdybWxSG8=',
		)
	})
})


describe('getMiddlewareHandler', () => {
	it('returns a working middleware handler', async () => {
		const hashes = {
			scripts: new Map<string, string>(),
			styles: new Map<string, string>(),
		}
		let warnCounter = 0
		const middleware = getMiddlewareHandler(
			{
				info: () => {},
				warn: () => {
					warnCounter += 1
				},
				error: () => {},
			},
			hashes,
			{
				enableStatic: true,
				enableMiddleware: true,
				hashesModule: undefined,
				allowInlineScripts: 'all',
				allowInlineStyles: 'all',
				scriptsAllowListUrls: [],
				stylesAllowListUrls: [],
			},
		)
		type MidParams = Parameters<typeof middleware>

		const patchedResponse = await middleware(
			undefined as unknown as MidParams[0],
			(async () => {
				return {
					text: async () => `
<html>
	<head>
		<title>My Test Page</title>
	</head>
	<body>
		<script>console.log("Hello World!")</script>
	</body>
</html>`,
					status: 200,
					statusText: 'OK',
					headers: new Headers(),
				}
			}) as MidParams[1],
		)

		expect(warnCounter).toBe(0)
		assert(patchedResponse instanceof Response)
		const responseText = await patchedResponse.text()
		expect(responseText).toBe(`
<html>
	<head>
		<title>My Test Page</title>
	</head>
	<body>
		<script integrity="sha256-TWupyvVdPa1DyFqLnQMqRpuUWdS3nKPnz70IcS/1o3Q=">console.log("Hello World!")</script>
	</body>
</html>`)
	})

	it('protects from validating disallowed inline scripts', async () => {
		const hashes = {
			scripts: new Map<string, string>(),
			styles: new Map<string, string>(),
		}

		let warnCounter = 0
		const middleware = getMiddlewareHandler(
			{
				info: () => {},
				warn: () => {
					warnCounter += 1
				},
				error: () => {},
			},
			hashes,
			{
				enableStatic: true,
				enableMiddleware: true,
				hashesModule: undefined,
				allowInlineScripts: 'static',
				allowInlineStyles: 'static',
				scriptsAllowListUrls: [],
				stylesAllowListUrls: [],
			},
		)
		type MidParams = Parameters<typeof middleware>

		const patchedResponse = await middleware(
			undefined as unknown as MidParams[0],
			(async () => {
				return {
					text: async () => `
<html>
	<head>
		<title>My Test Page</title>
	</head>
	<body>
		<script>console.log("Hello World!")</script>
	</body>
</html>`,
					status: 200,
					statusText: 'OK',
					headers: new Headers(),
				}
			}) as MidParams[1],
		)

		expect(warnCounter).toBe(1)
		assert(patchedResponse instanceof Response)
		const responseText = await patchedResponse.text()
		expect(patchedResponse.headers.has('content-security-policy')).toBe(false)
		expect(responseText).toBe(`
<html>
	<head>
		<title>My Test Page</title>
	</head>
	<body>
		<script>console.log("Hello World!")</script>
	</body>
</html>`)
	})
})

describe('getCSPMiddlewareHandler', () => {
	it('returns a working middleware handler', async () => {
		const hashes = {
			scripts: new Map<string, string>(),
			styles: new Map<string, string>(),
		}
		let warnCounter = 0
		const middleware = getCSPMiddlewareHandler(
			{
				info: () => {},
				warn: () => {
					warnCounter += 1
				},
				error: () => {},
			},
			hashes,
			{
				contentSecurityPolicy: {},
			},
			{
				enableStatic: true,
				enableMiddleware: true,
				hashesModule: undefined,
				allowInlineScripts: 'all',
				allowInlineStyles: 'all',
				scriptsAllowListUrls: [],
				stylesAllowListUrls: [],
			},
		)
		type MidParams = Parameters<typeof middleware>

		const patchedResponse = await middleware(
			undefined as unknown as MidParams[0],
			(async () => {
				return {
					text: async () => `
<html>
	<head>
		<title>My Test Page</title>
	</head>
	<body>
		<script>console.log("Hello World!")</script>
	</body>
</html>`,
					status: 200,
					statusText: 'OK',
					headers: new Headers(),
				}
			}) as MidParams[1],
		)

		expect(warnCounter).toBe(0)
		assert(patchedResponse instanceof Response)
		expect(patchedResponse.headers.has('content-security-policy')).toBe(true)
		expect(patchedResponse.headers.get('content-security-policy')).toBe(
			`script-src 'self' 'sha256-TWupyvVdPa1DyFqLnQMqRpuUWdS3nKPnz70IcS/1o3Q='; style-src 'none'`,
		)
		const responseText = await patchedResponse.text()
		expect(responseText).toBe(`
<html>
	<head>
		<title>My Test Page</title>
	</head>
	<body>
		<script integrity="sha256-TWupyvVdPa1DyFqLnQMqRpuUWdS3nKPnz70IcS/1o3Q=">console.log("Hello World!")</script>
	</body>
</html>`)
	})

	it('protects from validating disallowed inline scripts', async () => {
		const hashes = {
			scripts: new Map<string, string>(),
			styles: new Map<string, string>(),
		}

		let warnCounter = 0
		const middleware = getCSPMiddlewareHandler(
			{
				info: () => {},
				warn: () => {
					warnCounter += 1
				},
				error: () => {},
			},
			hashes,
			{ contentSecurityPolicy: {} },
			{
				enableStatic: true,
				enableMiddleware: true,
				hashesModule: undefined,
				allowInlineScripts: 'static',
				allowInlineStyles: 'static',
				scriptsAllowListUrls: [],
				stylesAllowListUrls: [],
			},
		)
		type MidParams = Parameters<typeof middleware>

		const patchedResponse = await middleware(
			undefined as unknown as MidParams[0],
			(async () => {
				return {
					text: async () => `
<html>
	<head>
		<title>My Test Page</title>
	</head>
	<body>
		<script>console.log("Hello World!")</script>
	</body>
</html>`,
					status: 200,
					statusText: 'OK',
					headers: new Headers(),
				}
			}) as MidParams[1],
		)

		expect(warnCounter).toBe(1)
		assert(patchedResponse instanceof Response)
		const responseText = await patchedResponse.text()
		expect(patchedResponse.headers.has('content-security-policy')).toBe(true)
		expect(responseText).toBe(`
<html>
	<head>
		<title>My Test Page</title>
	</head>
	<body>
		<script>console.log("Hello World!")</script>
	</body>
</html>`)
	})
})
			"form-action 'self'; frame-ancestors 'none'; script-src 'none'; style-src 'none'",
		const integration = shield({ sri: { enableStatic: true } })
		const integration = shield({ sri: { enableStatic: false } })
		const integration = shield({ sri: { enableMiddleware: true } })
				statements: 77.0,
				branches: 77.0,
				functions: 87.0,
				lines: 77.0,