Astro-Shield's Content-Security-Policy header generation in middleware could be compromised by malicious injections
Description
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.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Astro-Shield vulnerability allows malicious scripts to be allow-listed in CSP headers when generating for SSR with user-controlled content, fixed in 1.3.0.
Vulnerability
Overview The vulnerability in Astro-Shield lies in its automated Content Security Policy (CSP) header generation feature for server-side rendered (SSR) content. When this feature is enabled, and the web application serves content that can be partially controlled by external users, the library may inadvertently allow-list malicious injected resources, such as inline JavaScript or references to external scripts [1].
Exploitation
Scenario An attacker who can inject arbitrary content into the SSR output (e.g., through user input reflected in the page) can manipulate the CSP generation process. The library fails to validate or filter these user-controlled contributions, leading to the inclusion of malicious resources in the generated CSP header [1].
Impact
Successful exploitation bypasses the CSP protections, potentially allowing cross-site scripting (XSS) or other injection attacks. This undermines the security guarantees that CSP headers are meant to provide [1].
Mitigation
The issue is fixed in version 1.3.0. The fixes include changes to handle null hashes for non-allow-listed external resources and refactoring of SRI options [2][3]. Users should upgrade to the latest version to mitigate the risk.
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.
| Package | Affected versions | Patched versions |
|---|---|---|
@kindspells/astro-shieldnpm | >= 1.2.0, < 1.3.0 | 1.3.0 |
Affected products
3- Range: <1.3.0
- KindSpells/astro-shieldv5Range: = 1.2.0
Patches
241b84576d37ffeat: cross-origin resources allow-lists
9 files changed · +485 −52
package.json+1 −1 modified@@ -1,6 +1,6 @@ { "name": "@kindspells/astro-shield", - "version": "1.2.0", + "version": "1.3.0", "description": "Astro integration to enhance your website's security with SubResource Integrity hashes, Content-Security-Policy headers, and other techniques.", "private": false, "type": "module",
README.md+56 −12 modified@@ -53,18 +53,62 @@ const rootDir = new URL('.', import.meta.url).pathname export default defineConfig({ integrations: [ shield({ - // 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'), + 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', + }, // - If set, it controls how the security headers will be generated in the // middleware.
src/core.mjs+64 −17 modified@@ -41,7 +41,7 @@ export const generateSRIHash = data => { /** * @typedef {( - * hash: string, + * hash: string | null, * attrs: string, * setCrossorigin: boolean, * content?: string | undefined, @@ -50,19 +50,19 @@ export const generateSRIHash = data => { /** @type {ElemReplacer} */ const scriptReplacer = (hash, attrs, setCrossorigin, content) => - `<script${attrs} integrity="${hash}"${ + `<script${attrs}${hash !== null ? ` integrity="${hash}"` : ''}${ setCrossorigin ? ' crossorigin="anonymous"' : '' }>${content ?? ''}</script>` /** @type {ElemReplacer} */ const styleReplacer = (hash, attrs, setCrossorigin, content) => - `<style${attrs} integrity="${hash}"${ + `<style${attrs}${hash !== null ? ` integrity="${hash}"` : ''}${ setCrossorigin ? ' crossorigin="anonymous"' : '' }>${content ?? ''}</style>` /** @type {ElemReplacer} */ const linkStyleReplacer = (hash, attrs, setCrossorigin) => - `<link${attrs} integrity="${hash}"${ + `<link${attrs}${hash !== null ? ` integrity="${hash}"` : ''}${ setCrossorigin ? ' crossorigin="anonymous"' : '' }/>` @@ -242,7 +242,7 @@ export const updateDynamicPageSriHashes = async ( logger, content, globalHashes, - sri + sri, ) => { const processors = getRegexProcessors() @@ -331,12 +331,23 @@ export const updateDynamicPageSriHashes = async ( if (sriHash) { pageHashes[t2].add(sriHash) } else { - const resourceResponse = await fetch(src, { method: 'GET' }) - const resourceContent = await resourceResponse.arrayBuffer() + logger.warn( + `Detected reference to not-allow-listed external resource "${src}"`, + ) + if (setCrossorigin) { + updatedContent = updatedContent.replace( + match[0], + replacer(null, attrs, true, ''), + ) + } + continue - sriHash = generateSRIHash(resourceContent) - globalHashes[t2].set(src, sriHash) - pageHashes[t2].add(sriHash) + // 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}"`) @@ -517,6 +528,30 @@ export const scanForNestedResources = async (logger, dirPath, h) => { ) } +/** + * @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 {HashesCollection} h @@ -673,19 +708,22 @@ export const processStaticFiles = async (logger, { distDir, sri }) => { } /** + * @param {Logger} logger * @param {MiddlewareHashes} globalHashes + * @param {Required<SRIOptions>} sri * @returns {import('astro').MiddlewareHandler} */ -export const getMiddlewareHandler = globalHashes => { +export const getMiddlewareHandler = (logger, globalHashes, sri) => { /** @satisfies {import('astro').MiddlewareHandler} */ return async (_ctx, next) => { const response = await next() const content = await response.text() const { updatedContent } = await updateDynamicPageSriHashes( - console, + logger, content, globalHashes, + sri, ) const patchedResponse = new Response(updatedContent, { @@ -700,20 +738,28 @@ export const getMiddlewareHandler = globalHashes => { /** * Variant of `getMiddlewareHandler` that also applies security headers. * + * @param {Logger} logger * @param {MiddlewareHashes} globalHashes * @param {SecurityHeadersOptions} securityHeadersOpts + * @param {Required<SRIOptions>} sri * @returns {import('astro').MiddlewareHandler} */ -export const getCSPMiddlewareHandler = (globalHashes, securityHeadersOpts) => { +export const getCSPMiddlewareHandler = ( + logger, + globalHashes, + securityHeadersOpts, + sri, +) => { /** @satisfies {import('astro').MiddlewareHandler} */ return async (_ctx, next) => { const response = await next() const content = await response.text() const { updatedContent, pageHashes } = await updateDynamicPageSriHashes( - console, + logger, content, globalHashes, + sri, ) const patchedResponse = new Response(updatedContent, { @@ -764,6 +810,7 @@ const loadVirtualMiddlewareModule = async ( // 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, @@ -821,10 +868,10 @@ export const onRequest = await (async () => { return defineMiddleware(${ securityHeadersOptions !== undefined - ? `getCSPMiddlewareHandler(globalHashes, ${JSON.stringify( + ? `getCSPMiddlewareHandler(console, globalHashes, ${JSON.stringify( securityHeadersOptions, - )})` - : 'getMiddlewareHandler(globalHashes)' + )}, ${JSON.stringify(sri)})` + : `getMiddlewareHandler(console, globalHashes, ${JSON.stringify(sri)})` }) })() `
src/headers.mjs+4 −0 modified@@ -96,9 +96,13 @@ export const patchCspHeader = (plainHeaders, pageHashes, cspOpts) => { if (pageHashes.scripts.size > 0) { setSrcDirective(directives, 'script-src', pageHashes.scripts) + } else { + directives['script-src'] = "'none'" } if (pageHashes.styles.size > 0) { setSrcDirective(directives, 'style-src', pageHashes.styles) + } else { + directives['style-src'] = "'none'" } if (Object.keys(directives).length > 0) { plainHeaders['content-security-policy'] = serialiseCspDirectives(directives)
src/main.mjs+2 −2 modified@@ -78,12 +78,12 @@ export const shield = ({ return /** @satisfies {AstroIntegration} */ { name: '@kindspells/astro-shield', hooks: { - ...((enableStatic_SRI ?? true) === true + ...(_sri.enableStatic === true ? { 'astro:build:done': getAstroBuildDone(_sri), } : undefined), - ...(enableMiddleware_SRI === true + ...(_sri.enableMiddleware === true ? { 'astro:config:setup': getAstroConfigSetup(_sri, securityHeaders), }
tests/core.test.mts+350 −12 modified@@ -7,12 +7,15 @@ import { resolve } from 'node:path' import { readdir, rm } from 'node:fs/promises' -import { beforeEach, describe, expect, it } from 'vitest' +import { assert, beforeEach, describe, expect, it } from 'vitest' import { arraysEqual, generateSRIHash, generateSRIHashesModule, + getCSPMiddlewareHandler, + getMiddlewareHandler, pageHashesEqual, + scanAllowLists, scanForNestedResources, sriHashesEqual, updateDynamicPageSriHashes, @@ -362,7 +365,7 @@ describe('updateStaticPageSriHashes', () => { <title>My Test Page</title> </head> <body> - <script type="module" src="/core.mjs" integrity="sha256-vSvqa4zN5DZN/gOtz1s6Xuw0MUYNKQXvUPL8pXWgHGo="></script> + <script type="module" src="/core.mjs" integrity="sha256-Xbdu1jxIAqCjb78wAdgir+Swc5faxBuLHPm0DC/lG80="></script> </body> </html>` @@ -379,7 +382,7 @@ describe('updateStaticPageSriHashes', () => { expect(h.extScriptHashes.size).toBe(1) expect( h.extScriptHashes.has( - 'sha256-vSvqa4zN5DZN/gOtz1s6Xuw0MUYNKQXvUPL8pXWgHGo=', + 'sha256-Xbdu1jxIAqCjb78wAdgir+Swc5faxBuLHPm0DC/lG80=', ), ).toBe(true) expect(h.inlineScriptHashes.size).toBe(0) @@ -656,7 +659,56 @@ describe('updateDynamicPageSriHashes', () => { expect(pageHashes.styles.size).toBe(0) }) - it('adds sri hash to external script (cross origin)', async () => { + 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 () => { const remoteScript = 'https://raw.githubusercontent.com/KindSpells/astro-shield/ae9521048f2129f633c075b7f7ef24e11bbd1884/main.mjs' const content = `<html> @@ -678,6 +730,10 @@ describe('updateDynamicPageSriHashes', () => { </html>` const h = getMiddlewareHashes() + h.scripts.set( + remoteScript, + 'sha256-i4WR4ifasidZIuS67Rr6Knsy7/hK1xbVTc8ZAmnAv1Q=', + ) const { pageHashes, updatedContent } = await updateDynamicPageSriHashes( console, content, @@ -778,16 +834,11 @@ describe('updateDynamicPageSriHashes', () => { let warnCalls = 0 const testLogger = { - info(msg: string) { - return console.info(msg) - }, - warn(msg: string) { + info(_msg: string) {}, + warn(_msg: string) { warnCalls += 1 - return console.warn(msg) - }, - error(msg: string) { - return console.error(msg) }, + error(_msg: string) {}, } const h = getMiddlewareHashes() @@ -846,6 +897,33 @@ describe('updateDynamicPageSriHashes', () => { }) }) +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('scanForNestedResources', () => { it('populates our hashes collection with hashes from nested resources', async () => { const h = getEmptyHashes() @@ -901,3 +979,263 @@ describe('generateSRIHashesModule', () => { expect(hashesModule).toHaveProperty('perResourceSriHashes') }) }) + +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>`) + }) +})
tests/headers.test.mts+1 −1 modified@@ -154,7 +154,7 @@ describe('patchHeaders', () => { const patchedHeaders = patchHeaders(headers, pageHashes, settings) expect(patchedHeaders.get('content-security-policy')).toBe( - "form-action 'self'; frame-ancestors 'none'", + "form-action 'self'; frame-ancestors 'none'; script-src 'none'; style-src 'none'", ) })
tests/main.test.mts+3 −3 modified@@ -36,17 +36,17 @@ describe('sriCSP', () => { }) it('returns a valid AstroIntegration object for almost-default config', () => { - const integration = shield({ enableStatic_SRI: true }) + const integration = shield({ sri: { enableStatic: true } }) checkIntegration(integration) }) it('returns an "empty" integration when we disable all features', () => { - const integration = shield({ enableStatic_SRI: false }) + const integration = shield({ sri: { enableStatic: false } }) checkIntegration(integration, []) }) it('returns hooks for static & dynamic content when we enable middleware', () => { - const integration = shield({ enableMiddleware_SRI: true }) + const integration = shield({ sri: { enableMiddleware: true } }) checkIntegration(integration, ['astro:build:done', 'astro:config:setup']) })
vitest.config.unit.mts+4 −4 modified@@ -19,10 +19,10 @@ export default defineConfig({ 'coverage-unit/**/*', ], thresholds: { - statements: 72.0, - branches: 76.0, - functions: 80.0, - lines: 72.0, + statements: 77.0, + branches: 77.0, + functions: 87.0, + lines: 77.0, }, reportsDirectory: 'coverage-unit', },
ad3abf5577barefactor: introduce new sri options
4 files changed · +59 −30
src/core.mjs+17 −28 modified@@ -701,26 +701,24 @@ const resolvedMiddlewareVirtualModuleId = `\0${middlewareVirtualModuleId}` /** * @param {Logger} logger - * @param {boolean} enableStatic_SRI - * @param {string | undefined} sriHashesModule + * @param {Required<SRIOptions>} sri * @param {SecurityHeadersOptions | undefined} securityHeadersOptions * @param {string} publicDir * @returns {Promise<string>} */ const loadVirtualMiddlewareModule = async ( logger, - enableStatic_SRI, - sriHashesModule, + sri, securityHeadersOptions, publicDir, ) => { let extraImports = '' let staticHashesModuleLoader = '' if ( - enableStatic_SRI && - sriHashesModule && - !(await doesFileExist(sriHashesModule)) + sri.enableStatic && + sri.hashesModule && + !(await doesFileExist(sri.hashesModule)) ) { const h = /** @satisfies {HashesCollection} */ { inlineScriptHashes: new Set(), @@ -740,17 +738,17 @@ const loadVirtualMiddlewareModule = async ( await generateSRIHashesModule( logger, h, - sriHashesModule, + sri.hashesModule, false, // So we don't get redundant warnings ) } if ( - enableStatic_SRI && - sriHashesModule && - (await doesFileExist(sriHashesModule)) + sri.enableStatic && + sri.hashesModule && + (await doesFileExist(sri.hashesModule)) ) { - extraImports = `import { perResourceSriHashes } from '${sriHashesModule}'` + extraImports = `import { perResourceSriHashes } from '${sri.hashesModule}'` staticHashesModuleLoader = ` try { if (perResourceSriHashes) { @@ -769,11 +767,11 @@ try { console.error('Failed to load static hashes module:', err) } ` - } else if (enableStatic_SRI && sriHashesModule) { + } else if (sri.enableStatic && sri.hashesModule) { // Highly unlikely that this happens because of the provisional hashes // module, but the world is a strange place. logger.warn( - `The SRI hashes module "${sriHashesModule}" did not exist at build time. You may have to run the build step again`, + `The SRI hashes module "${sri.hashesModule}" did not exist at build time. You may have to run the build step again`, ) } @@ -805,19 +803,12 @@ export const onRequest = await (async () => { /** * @param {Logger} logger - * @param {boolean} enableStatic_SRI - * @param {string | undefined} sriHashesModule + * @param {Required<SRIOptions>} sri * @param {SecurityHeadersOptions | undefined} securityHeaders * @param {string} publicDir * @return {import('vite').Plugin} */ -const getViteMiddlewarePlugin = ( - logger, - enableStatic_SRI, - sriHashesModule, - securityHeaders, - publicDir, -) => { +const getViteMiddlewarePlugin = (logger, sri, securityHeaders, publicDir) => { return { name: 'vite-plugin-astro-shield', resolveId(id) { @@ -831,8 +822,7 @@ const getViteMiddlewarePlugin = ( case resolvedMiddlewareVirtualModuleId: return await loadVirtualMiddlewareModule( logger, - enableStatic_SRI, - sriHashesModule, + sri, securityHeaders, publicDir, ) @@ -844,7 +834,7 @@ const getViteMiddlewarePlugin = ( } /** - * @param {SRIOptions} sri + * @param {Required<SRIOptions>} sri * @param {SecurityHeadersOptions | undefined} securityHeaders * @returns */ @@ -854,8 +844,7 @@ export const getAstroConfigSetup = (sri, securityHeaders) => { const publicDir = fileURLToPath(config.publicDir) const plugin = getViteMiddlewarePlugin( logger, - sri.enableStatic ?? true, - sri.hashesModule, + sri, securityHeaders, publicDir, )
src/main.d.ts+34 −0 modified@@ -83,6 +83,40 @@ export type SRIOptions = { * artifact. */ hashesModule?: string | undefined + + /** + * 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. + * + * Defaults to 'all'. + */ + allowInlineStyles?: 'all' | 'static' | false + + /** + * 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. + * + * Defaults to 'all'. + */ + allowInlineScripts?: 'all' | 'static' | false + + /** + * Cross-Origin scripts must be explicitly allow-listed by URL in order to be + * allowed by the Content Security Policy. + */ + scriptsAllowListUrls?: string[] + + /** + * Cross-Origin styles must be explicitly allow-listed by URL in order to be + * allowed by the Content Security Policy. + */ + stylesAllowListUrls?: string[] } export type SecurityHeadersOptions = {
src/main.mjs+6 −0 modified@@ -63,6 +63,12 @@ export const shield = ({ enableMiddleware: sri?.enableMiddleware ?? enableMiddleware_SRI ?? false, enableStatic: sri?.enableStatic ?? enableStatic_SRI ?? true, hashesModule: sri?.hashesModule ?? sriHashesModule, + + allowInlineScripts: sri?.allowInlineScripts ?? 'all', + allowInlineStyles: sri?.allowInlineStyles ?? 'all', + + scriptsAllowListUrls: sri?.scriptsAllowListUrls ?? [], + stylesAllowListUrls: sri?.stylesAllowListUrls ?? [], } if (_sri.hashesModule && _sri.enableStatic === false) {
tests/core.test.mts+2 −2 modified@@ -362,7 +362,7 @@ describe('updateStaticPageSriHashes', () => { <title>My Test Page</title> </head> <body> - <script type="module" src="/core.mjs" integrity="sha256-KrxzzNH5AjdyG84oIMGj043N5e4ZnvFjIC7HKOVJMv4="></script> + <script type="module" src="/core.mjs" integrity="sha256-n5QiD5rG5p3P6N6SMn4S3Oc0MRSrqJdbCxTiOQHNdiU="></script> </body> </html>` @@ -379,7 +379,7 @@ describe('updateStaticPageSriHashes', () => { expect(h.extScriptHashes.size).toBe(1) expect( h.extScriptHashes.has( - 'sha256-KrxzzNH5AjdyG84oIMGj043N5e4ZnvFjIC7HKOVJMv4=', + 'sha256-n5QiD5rG5p3P6N6SMn4S3Oc0MRSrqJdbCxTiOQHNdiU=', ), ).toBe(true) expect(h.inlineScriptHashes.size).toBe(0)
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-w387-5qqw-7g8mghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-29896ghsaADVISORY
- github.com/KindSpells/astro-shield/commit/41b84576d37fa486a57005ea297658d0bc38566dghsax_refsource_MISCWEB
- github.com/KindSpells/astro-shield/commit/ad3abf5577bae9be420b7ddf376337a5b8817869ghsaWEB
- github.com/KindSpells/astro-shield/compare/1.2.0...1.3.0ghsaWEB
- github.com/KindSpells/astro-shield/security/advisories/GHSA-w387-5qqw-7g8mghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.