diff --git a/@kindspells/astro-shield/package.json b/@kindspells/astro-shield/package.json index 5017f67..99fd47c 100644 --- a/@kindspells/astro-shield/package.json +++ b/@kindspells/astro-shield/package.json @@ -1,6 +1,6 @@ { "name": "@kindspells/astro-shield", - "version": "1.3.7", + "version": "1.3.8", "description": "Astro integration to enhance your website's security with SubResource Integrity hashes, Content-Security-Policy headers, and other techniques.", "private": false, "type": "module", diff --git a/@kindspells/astro-shield/src/core.mts b/@kindspells/astro-shield/src/core.mts index 1d946ca..631bf4f 100644 --- a/@kindspells/astro-shield/src/core.mts +++ b/@kindspells/astro-shield/src/core.mts @@ -87,7 +87,8 @@ const linkStyleReplacer: ElemReplacer = (hash, attrs, setCrossorigin) => setCrossorigin ? ' crossorigin="anonymous"' : '' }/>` -// TODO: Support more algorithms (different ones, and many for the same element) +const anonymousCrossOriginRegex = + /crossorigin\s*=\s*("anonymous"|'anonymous'|anonymous)/i const integrityRegex = /^integrity\s*=\s*("(?sha256-[a-z0-9+\/]{43}=)"|'(?sha256-[a-z0-9+\/]{43}=)')$/i const relStylesheetRegex = @@ -166,10 +167,10 @@ export const updateStaticPageSriHashes = async ( const pageHashes = h.perPageSriHashes.get(relativeFilepath) ?? - /** @type {PerPageHashes} */ ({ + ({ scripts: new Set(), styles: new Set(), - }) + } satisfies PerPageHashes) h.perPageSriHashes.set(relativeFilepath, pageHashes) let updatedContent = content @@ -277,12 +278,14 @@ export const updateStaticPageSriHashes = async ( } if (sriHash) { + const hasAnonymousCrossOrigin = anonymousCrossOriginRegex.test(attrs) + updatedContent = updatedContent.replace( match[0], replacer( sriHash, attrs ? ` ${attrs}` : '', - setCrossorigin, + setCrossorigin && !hasAnonymousCrossOrigin, elemContent, ), ) @@ -323,8 +326,7 @@ export const updateDynamicPageSriHashes = async ( const attrs = match.groups?.attrs?.trim() ?? '' const elemContent = match.groups?.content ?? '' - /** @type {string | undefined} */ - let sriHash = undefined + let sriHash: string | undefined = undefined let setCrossorigin = false if (attrs) { @@ -452,12 +454,14 @@ export const updateDynamicPageSriHashes = async ( } if (sriHash) { + const hasAnonymousCrossOrigin = anonymousCrossOriginRegex.test(attrs) + updatedContent = updatedContent.replace( match[0], replacer( sriHash, attrs ? ` ${attrs}` : '', - setCrossorigin, + setCrossorigin && !hasAnonymousCrossOrigin, elemContent, ), ) @@ -663,8 +667,8 @@ export async function generateSRIHashesModule( } if (await doesFileExist(sriHashesModule)) { - const hModule: HashesModule = ( - await import(/* @vite-ignore */ sriHashesModule) + const hModule: HashesModule = await import( + /* @vite-ignore */ sriHashesModule ) extResourceHashesChanged = !sriHashesEqual( @@ -779,8 +783,7 @@ export const getMiddlewareHandler = ( globalHashes: MiddlewareHashes, sri: Required, ): MiddlewareHandler => { - /** @satisfies {import('astro').MiddlewareHandler} */ - return async (_ctx, next) => { + return (async (_ctx, next) => { const response = await next() const content = await response.text() @@ -797,7 +800,7 @@ export const getMiddlewareHandler = ( headers: response.headers, }) return patchedResponse - } + }) satisfies MiddlewareHandler } /** @@ -809,8 +812,7 @@ export const getCSPMiddlewareHandler = ( securityHeadersOpts: SecurityHeadersOptions, sri: Required, ): MiddlewareHandler => { - /** @satisfies {import('astro').MiddlewareHandler} */ - return async (_ctx, next) => { + return (async (_ctx, next) => { const response = await next() const content = await response.text() @@ -827,7 +829,7 @@ export const getCSPMiddlewareHandler = ( headers: patchHeaders(response.headers, pageHashes, securityHeadersOpts), }) return patchedResponse - } + }) satisfies MiddlewareHandler } const middlewareVirtualModuleId = 'virtual:@kindspells/astro-shield/middleware' @@ -847,8 +849,8 @@ const loadVirtualMiddlewareModule = async ( if (!shouldRegenerateHashesModule) { try { - const hashesModule: HashesModule = ( - await import(/* @vite-ignore */ sri.hashesModule) + const hashesModule: HashesModule = await import( + /* @vite-ignore */ sri.hashesModule ) for (const allowedScript of sri.scriptsAllowListUrls) { diff --git a/@kindspells/astro-shield/tests/core.test.mts b/@kindspells/astro-shield/src/tests/core.test.mts similarity index 93% rename from @kindspells/astro-shield/tests/core.test.mts rename to @kindspells/astro-shield/src/tests/core.test.mts index d7abcf3..cfce1de 100644 --- a/@kindspells/astro-shield/tests/core.test.mts +++ b/@kindspells/astro-shield/src/tests/core.test.mts @@ -39,7 +39,7 @@ type PageHashesCollection = Record< const testsDir = new URL('.', import.meta.url).pathname const fixturesDir = resolve(testsDir, 'fixtures') -const rootDir = resolve(testsDir, '..') +const rootDir = resolve(testsDir, '..', '..') const distDir = resolve(rootDir, 'dist') const getEmptyHashes = () => ({ @@ -624,7 +624,7 @@ describe('updateStaticPageSriHashes', () => { My Test Page - + ` @@ -642,7 +642,7 @@ describe('updateStaticPageSriHashes', () => { expect( h.extScriptHashes.has( - 'sha256-e91QMz4oDk+n/vnPGAOmoNDYdO61N9wDM5iFlll+6r8=', + 'sha256-57NR9VGwX5U1svn4FZBRRffMg+4n3Fquhfcn6lEtk9Q=', ), ).toBe(true) expect(h.inlineScriptHashes.size).toBe(0) @@ -692,6 +692,48 @@ describe('updateStaticPageSriHashes', () => { expect(h.extStyleHashes.size).toBe(0) }) + it('adds sri hash to external script without duplicating the crossorigin attribute (cross origin)', async () => { + const remoteScript = + 'https://raw.githubusercontent.com/KindSpells/astro-shield/ae9521048f2129f633c075b7f7ef24e11bbd1884/main.mjs' + const content = ` + + My Test Page + + + + + ` + + const expected = ` + + My Test Page + + + + + ` + + const h = getEmptyHashes() + const updated = await updateStaticPageSriHashes( + console, + rootDir, + 'index.html', + content, + h, + ) + + expect(updated).toEqual(expected) + expect(h.extScriptHashes.size).toBe(1) + expect( + h.extScriptHashes.has( + 'sha256-i4WR4ifasidZIuS67Rr6Knsy7/hK1xbVTc8ZAmnAv1Q=', + ), + ).toBe(true) + expect(h.inlineScriptHashes.size).toBe(0) + expect(h.inlineStyleHashes.size).toBe(0) + expect(h.extStyleHashes.size).toBe(0) + }) + it('adds sri hash to external style (same origin)', async () => { const content = ` @@ -1013,6 +1055,53 @@ describe('updateDynamicPageSriHashes', () => { expect(pageHashes.styles.size).toBe(0) }) + it('adds sri hash to external script without duplicating the crossorigin attribute when allow-listed (cross origin)', async () => { + const remoteScript = + 'https://raw.githubusercontent.com/KindSpells/astro-shield/ae9521048f2129f633c075b7f7ef24e11bbd1884/main.mjs' + const content = ` + + My Test Page + + + + + ` + + const expected = ` + + My Test Page + + + + + ` + + const h = getMiddlewareHashes() + h.scripts.set( + remoteScript, + 'sha256-i4WR4ifasidZIuS67Rr6Knsy7/hK1xbVTc8ZAmnAv1Q=', + ) + const { pageHashes, updatedContent } = await updateDynamicPageSriHashes( + console, + content, + h, + ) + + expect(updatedContent).toEqual(expected) + expect(h.scripts.size).toBe(1) + expect(h.styles.size).toBe(0) + expect(h.scripts.get(remoteScript)).toEqual( + 'sha256-i4WR4ifasidZIuS67Rr6Knsy7/hK1xbVTc8ZAmnAv1Q=', + ) + expect(pageHashes.scripts.size).toBe(1) + expect( + pageHashes.scripts.has( + 'sha256-i4WR4ifasidZIuS67Rr6Knsy7/hK1xbVTc8ZAmnAv1Q=', + ), + ).toBe(true) + expect(pageHashes.styles.size).toBe(0) + }) + it('adds sri hash to external style (same origin)', async () => { const content = ` diff --git a/@kindspells/astro-shield/tests/fixtures/fake.css b/@kindspells/astro-shield/src/tests/fixtures/fake.css similarity index 100% rename from @kindspells/astro-shield/tests/fixtures/fake.css rename to @kindspells/astro-shield/src/tests/fixtures/fake.css diff --git a/@kindspells/astro-shield/tests/fixtures/fake.js b/@kindspells/astro-shield/src/tests/fixtures/fake.js similarity index 100% rename from @kindspells/astro-shield/tests/fixtures/fake.js rename to @kindspells/astro-shield/src/tests/fixtures/fake.js diff --git a/@kindspells/astro-shield/tests/fixtures/nested/nested.js b/@kindspells/astro-shield/src/tests/fixtures/nested/nested.js similarity index 100% rename from @kindspells/astro-shield/tests/fixtures/nested/nested.js rename to @kindspells/astro-shield/src/tests/fixtures/nested/nested.js diff --git a/@kindspells/astro-shield/tests/fs.test.mts b/@kindspells/astro-shield/src/tests/fs.test.mts similarity index 96% rename from @kindspells/astro-shield/tests/fs.test.mts rename to @kindspells/astro-shield/src/tests/fs.test.mts index b9b37da..8d316e9 100644 --- a/@kindspells/astro-shield/tests/fs.test.mts +++ b/@kindspells/astro-shield/src/tests/fs.test.mts @@ -16,7 +16,7 @@ import { doesFileExist, scanDirectory } from '#as/fs.mts' const testsDir = new URL('.', import.meta.url).pathname describe('doesFileExist', () => { - it.each([['./core.test.mts'], ['../src/core.mts'], ['../src/main.mts']])( + it.each([['./core.test.mts'], ['../core.mts'], ['../main.mts']])( 'returns true for existing files', async (filename: string) => { expect(await doesFileExist(resolve(testsDir, filename))).toBe(true) diff --git a/@kindspells/astro-shield/tests/headers.test.mts b/@kindspells/astro-shield/src/tests/headers.test.mts similarity index 100% rename from @kindspells/astro-shield/tests/headers.test.mts rename to @kindspells/astro-shield/src/tests/headers.test.mts diff --git a/@kindspells/astro-shield/tests/main.test.mts b/@kindspells/astro-shield/src/tests/main.test.mts similarity index 100% rename from @kindspells/astro-shield/tests/main.test.mts rename to @kindspells/astro-shield/src/tests/main.test.mts diff --git a/@kindspells/astro-shield/tests/state.test.mts b/@kindspells/astro-shield/src/tests/state.test.mts similarity index 100% rename from @kindspells/astro-shield/tests/state.test.mts rename to @kindspells/astro-shield/src/tests/state.test.mts diff --git a/@kindspells/astro-shield/tsconfig.json b/@kindspells/astro-shield/tsconfig.json index 2738177..0ae270d 100644 --- a/@kindspells/astro-shield/tsconfig.json +++ b/@kindspells/astro-shield/tsconfig.json @@ -35,5 +35,5 @@ "skipLibCheck": true }, - "include": ["src/*", "tests/**/*.mts", "e2e/**/*.mts"] + "include": ["src/*", "e2e/**/*.mts"] } diff --git a/@kindspells/astro-shield/vitest.config.unit.mts b/@kindspells/astro-shield/vitest.config.unit.mts index c21f2a0..cb8a949 100644 --- a/@kindspells/astro-shield/vitest.config.unit.mts +++ b/@kindspells/astro-shield/vitest.config.unit.mts @@ -26,6 +26,6 @@ export default defineConfig({ }, reportsDirectory: 'coverage-unit', }, - include: ['tests/**/*.test.mts'], + include: ['src/**/tests/**/*.test.mts'], }, })