From cb7eb95bc9b9b8727cc5065f72a70005c966bb0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Correa=20Casablanca?= Date: Tue, 27 Feb 2024 12:56:05 +0100 Subject: [PATCH] feat: generate per-page sri hashes Signed-off-by: Andres Correa Casablanca --- core.mjs | 114 +++++++++++++++++++++++++++++++++++++++----- main.d.ts | 4 +- tests/core.test.mts | 72 +++++++++++++++++++++++++++- 3 files changed, 173 insertions(+), 17 deletions(-) diff --git a/core.mjs b/core.mjs index 425bdfc..acead2a 100644 --- a/core.mjs +++ b/core.mjs @@ -6,14 +6,18 @@ import { createHash } from 'node:crypto' import { readFile, readdir, stat, writeFile } from 'node:fs/promises' -import { extname, resolve } from 'node:path' +import { extname, resolve, relative } from 'node:path' /** + * @typedef {{ scripts: Set; styles: Set }} PerPageHashes + * @typedef {Map} PerPageHashesCollection + * * @typedef {{ * inlineScriptHashes: Set, * inlineStyleHashes: Set, * extScriptHashes: Set, * extStyleHashes: Set, + * perPageSriHashes: PerPageHashesCollection, * }} HashesCollection */ @@ -74,11 +78,18 @@ const relStylesheetRegex = /\s+rel\s*=\s*('stylesheet'|"stylesheet")/i * * @param {import('astro').AstroIntegrationLogger} logger * @param {string} distDir + * @param {string} relativeFilepath * @param {string} content * @param {HashesCollection} h * @returns {Promise} */ -export const updateSriHashes = async (logger, distDir, content, h) => { +export const updateSriHashes = async ( + logger, + distDir, + relativeFilepath, + content, + h, +) => { const processors = /** @type {const} */ ([ { t: 'Script', @@ -106,6 +117,14 @@ export const updateSriHashes = async (logger, distDir, content, h) => { }, ]) + const pageHashes = + h.perPageSriHashes.get(relativeFilepath) ?? + /** @type {PerPageHashes} */ ({ + scripts: new Set(), + styles: new Set(), + }) + h.perPageSriHashes.set(relativeFilepath, pageHashes) + let updatedContent = content let match @@ -135,6 +154,9 @@ export const updateSriHashes = async (logger, distDir, content, h) => { ;(srcMatch ? h[`ext${t}Hashes`] : h[`inline${t}Hashes`]).add( sriHash, ) + pageHashes[ + /** @type {'styles' | 'scripts'} */ (`${t.toLowerCase()}s`) + ].add(sriHash) continue } } @@ -158,12 +180,18 @@ export const updateSriHashes = async (logger, distDir, content, h) => { sriHash = generateSRIHash(resourceContent) h[`ext${t}Hashes`].add(sriHash) + pageHashes[ + /** @type {'styles' | 'scripts'} */ (`${t.toLowerCase()}s`) + ].add(sriHash) } } if (hasContent && !sriHash) { sriHash = generateSRIHash(content) h[`inline${t}Hashes`].add(sriHash) + pageHashes[ + /** @type {'styles' | 'scripts'} */ (`${t.toLowerCase()}s`) + ].add(sriHash) } if (sriHash) { @@ -186,7 +214,13 @@ export const updateSriHashes = async (logger, distDir, content, h) => { */ const processHTMLFile = async (logger, filePath, distDir, h) => { const content = await readFile(filePath, 'utf8') - const updatedContent = await updateSriHashes(logger, distDir, content, h) + const updatedContent = await updateSriHashes( + logger, + distDir, + relative(distDir, filePath), + content, + h, + ) if (updatedContent !== content) { await writeFile(filePath, updatedContent) @@ -247,6 +281,36 @@ export const arraysEqual = (a, b) => { return true } +/** + * @param {Record} a + * @param {Record} b + * @returns {boolean} + */ +export const pageHashesEqual = (a, b) => { + const aKeys = Object.keys(a).sort() + const bKeys = Object.keys(b).sort() + + if (!arraysEqual(aKeys, bKeys)) { + return false + } + + for (const [aKey, aValue] of Object.entries(a)) { + const bValue = b[aKey] + if (!bValue) { + return false + } + + if ( + !arraysEqual(aValue.scripts, bValue.scripts) || + !arraysEqual(aValue.styles, bValue.styles) + ) { + return false + } + } + + return true +} + /** * This is a hack to scan for nested scripts in the `_astro` directory, but they * should be detected in a recursive way, when we process the JS files that are @@ -285,12 +349,13 @@ export const generateSRIHashes = async ( logger, { distDir, sriHashesModule }, ) => { - const h = { + const h = /** @type {HashesCollection} */ ({ inlineScriptHashes: new Set(), inlineStyleHashes: new Set(), extScriptHashes: new Set(), extStyleHashes: new Set(), - } + perPageSriHashes: new Map(), + }) await scanDirectory(logger, distDir, distDir, h) // TODO: Remove temporary hack @@ -306,20 +371,29 @@ export const generateSRIHashes = async ( const inlineStyleHashes = Array.from(h.inlineStyleHashes).sort() const extScriptHashes = Array.from(h.extScriptHashes).sort() const extStyleHashes = Array.from(h.extStyleHashes).sort() + const perPageHashes = + /** @type {Record} */ ({}) + for (const [k, v] of h.perPageSriHashes.entries()) { + perPageHashes[k] = { + scripts: Array.from(v.scripts).sort(), + styles: Array.from(v.styles).sort(), + } + } if (await doesFileExist(sriHashesModule)) { - const hModule = /** @type {{ - inlineScriptHashes?: string[] | undefined - inlineStyleHashes?: string[] | undefined - extScriptHashes?: string[] | undefined - extStyleHashes?: string[] | undefined - }} */ (await import(sriHashesModule)) + const hModule = /** + @type {{ + [k in keyof HashesCollection]: HashesCollection[k] extends Set + ? string[] | undefined + : Record + }} */ (await import(sriHashesModule)) persistHashes = !arraysEqual(inlineScriptHashes, hModule.inlineScriptHashes ?? []) || !arraysEqual(inlineStyleHashes, hModule.inlineStyleHashes ?? []) || !arraysEqual(extScriptHashes, hModule.extScriptHashes ?? []) || - !arraysEqual(extStyleHashes, hModule.extStyleHashes ?? []) + !arraysEqual(extStyleHashes, hModule.extStyleHashes ?? []) || + !pageHashesEqual(perPageHashes, hModule.perPageSriHashes ?? {}) } else { persistHashes = true } @@ -337,7 +411,21 @@ export const generateSRIHashes = async ( .join('')}${extScriptHashes.length > 0 ? '\n' : ''}])\n\n` hashesFileContent += `export const extStyleHashes = /** @type {string[]} */ ([${extStyleHashes .map(h => `\n\t'${h}',`) - .join('')}${extStyleHashes.length > 0 ? '\n' : ''}])\n` + .join('')}${extStyleHashes.length > 0 ? '\n' : ''}])\n\n` + hashesFileContent += `export const perPageSriHashes =\n\t/** @type {Record} */ ({${Object.entries( + perPageHashes, + ) + .sort() + .map(([k, v]) => { + return `\n\t\t'${k}': {\n\t\t\tscripts: [${v.scripts + .map(h => `\n\t\t\t\t'${h}',`) + .join('')}${ + v.scripts.length > 0 ? '\n\t\t\t' : '' + }],\n\t\t\tstyles: [${v.styles.map(h => `\n\t\t\t\t'${h}',`).join('')}${ + v.styles.length > 0 ? '\n\t\t\t' : '' + }],\n\t\t}` + }) + .join(',')}}\n)` await writeFile(sriHashesModule, hashesFileContent) } diff --git a/main.d.ts b/main.d.ts index b8e75fb..01ef2d7 100644 --- a/main.d.ts +++ b/main.d.ts @@ -28,6 +28,6 @@ export type Integration = { hooks: { 'astro:build:done': (opts: AstroBuildDoneOpts) => Promise } } -export function sriCSP(sriCspOptions: ShieldOptions): Integration +export function shield(sriCspOptions: ShieldOptions): Integration -export default sriCSP +export default shield diff --git a/tests/core.test.mts b/tests/core.test.mts index 4a552fe..3a3be8d 100644 --- a/tests/core.test.mts +++ b/tests/core.test.mts @@ -11,6 +11,7 @@ import { arraysEqual, doesFileExist, generateSRIHash, + pageHashesEqual, updateSriHashes, } from '../core.mjs' import { AstroIntegrationLogger } from 'astro' @@ -52,6 +53,63 @@ describe('arraysEqual', () => { ) }) +type PageHashesCollection = Record< + string, + { + scripts: string[] + styles: string[] + } +> + +describe('pageHashesEqual', () => { + it.each([ + [{}, {}], + [ + { 'index.html': { scripts: [], styles: [] } }, + { 'index.html': { scripts: [], styles: [] } }, + ], + [ + { 'index.html': { scripts: ['abcdefg'], styles: [] } }, + { 'index.html': { scripts: ['abcdefg'], styles: [] } }, + ], + [ + { + 'index.html': { scripts: ['abcdefg'], styles: [] }, + 'about.html': { scripts: [], styles: ['xyz'] }, + }, + { + 'index.html': { scripts: ['abcdefg'], styles: [] }, + 'about.html': { scripts: [], styles: ['xyz'] }, + }, + ], + ])( + 'returns true for equal hash collections', + (a: PageHashesCollection, b: PageHashesCollection) => { + expect(pageHashesEqual(a, b)).toBe(true) + }, + ) + + it.each([ + [{}, { 'index.html': { scripts: [], styles: [] } }], + [ + { 'index.html': { scripts: [], styles: [] } }, + { 'index.html': { scripts: ['abcdefg'], styles: [] } }, + ], + [ + { 'index.html': { scripts: ['abcdefg'], styles: [] } }, + { + 'index.html': { scripts: ['abcdefg'], styles: [] }, + 'about.html': { scripts: [], styles: ['xyz'] }, + }, + ], + ])( + 'returns false for non-equal hash collections', + (a: PageHashesCollection, b: PageHashesCollection) => { + expect(pageHashesEqual(a, b)).toBe(false) + }, + ) +}) + describe('doesFileExist', () => { it.each([['./core.test.mts'], ['../core.mjs'], ['../main.mjs']])( 'returns true for existing files', @@ -105,6 +163,10 @@ describe('updateSriHashes', () => { inlineStyleHashes: new Set(), extScriptHashes: new Set(), extStyleHashes: new Set(), + perPageSriHashes: new Map< + string, + { scripts: Set; styles: Set } + >(), }) it('adds sri hash to inline script', async () => { @@ -130,6 +192,7 @@ describe('updateSriHashes', () => { const updated = await updateSriHashes( console as unknown as AstroIntegrationLogger, testsDir, + 'index.html', content, h, ) @@ -169,6 +232,7 @@ describe('updateSriHashes', () => { const updated = await updateSriHashes( console as unknown as AstroIntegrationLogger, testsDir, + 'index.html', content, h, ) @@ -212,6 +276,7 @@ describe('updateSriHashes', () => { const updated = await updateSriHashes( console as unknown as AstroIntegrationLogger, testsDir, + 'index.html', content, h, ) @@ -243,7 +308,7 @@ describe('updateSriHashes', () => { My Test Page - + ` @@ -251,6 +316,7 @@ describe('updateSriHashes', () => { const updated = await updateSriHashes( console as unknown as AstroIntegrationLogger, rootDir, + 'index.html', content, h, ) @@ -259,7 +325,7 @@ describe('updateSriHashes', () => { expect(h.extScriptHashes.size).toBe(1) expect( h.extScriptHashes.has( - 'sha256-x7uRLb8+NGUBpUesXYoSUG1jjxycYKTUPY6xv7DFVRM=', + 'sha256-kw3sUNwwIbNJd5X5nyEclIhbb9UoOHAC0ouWE6pUUKU=', ), ).toBe(true) expect(h.inlineScriptHashes.size).toBe(0) @@ -292,6 +358,7 @@ describe('updateSriHashes', () => { const updated = await updateSriHashes( console as unknown as AstroIntegrationLogger, rootDir, + 'index.html', content, h, ) @@ -337,6 +404,7 @@ describe('updateSriHashes', () => { const updated = await updateSriHashes( console as unknown as AstroIntegrationLogger, testsDir, + 'index.html', content, h, )