In Astro-Shield, setting a correct `integrity` attribute to injected code allows to bypass the allow-lists
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.
| Package | Affected versions | Patched versions |
|---|---|---|
@kindspells/astro-shieldnpm | >= 1.2.0, < 1.3.2 | 1.3.2 |
Affected products
3- Range: 1.2.0 - 1.3.1
- kindspells/astro-shieldv5Range: >= 1.2.0, < 1.3.2
Patches
25ae8b8ef4f68fix: do not trust integrity attribute when undeserved
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, },
1221019306f5fix: ensure that allowed scripts are in hashes module
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- github.com/advisories/GHSA-c4gr-q97g-ppwcghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-30250ghsaADVISORY
- github.com/kindspells/astro-shield/commit/1221019306f501bf5fa9bcfb5a23a2321d34ba0aghsax_refsource_MISCWEB
- github.com/kindspells/astro-shield/commit/5ae8b8ef4f681d3a81431ee7e79d5dec545c6e1fghsax_refsource_MISCWEB
- github.com/kindspells/astro-shield/releases/tag/1.3.2ghsax_refsource_MISCWEB
- github.com/kindspells/astro-shield/security/advisories/GHSA-c4gr-q97g-ppwcghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.