Skip to content

Commit

Permalink
feat: generate per-page sri hashes
Browse files Browse the repository at this point in the history
Signed-off-by: Andres Correa Casablanca <[email protected]>
  • Loading branch information
castarco committed Feb 27, 2024
1 parent 8c6aa64 commit cb7eb95
Show file tree
Hide file tree
Showing 3 changed files with 173 additions and 17 deletions.
114 changes: 101 additions & 13 deletions core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>; styles: Set<String> }} PerPageHashes
* @typedef {Map<string, PerPageHashes>} PerPageHashesCollection
*
* @typedef {{
* inlineScriptHashes: Set<string>,
* inlineStyleHashes: Set<string>,
* extScriptHashes: Set<string>,
* extStyleHashes: Set<string>,
* perPageSriHashes: PerPageHashesCollection,
* }} HashesCollection
*/

Expand Down Expand Up @@ -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<string>}
*/
export const updateSriHashes = async (logger, distDir, content, h) => {
export const updateSriHashes = async (
logger,
distDir,
relativeFilepath,
content,
h,
) => {
const processors = /** @type {const} */ ([
{
t: 'Script',
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
}
}
Expand All @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -247,6 +281,36 @@ export const arraysEqual = (a, b) => {
return true
}

/**
* @param {Record<string, { scripts: string[], styles: string[] }>} a
* @param {Record<string, { scripts: string[], styles: string[] }>} 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
Expand Down Expand Up @@ -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
Expand All @@ -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<string, { scripts: string[]; styles: string [] }>} */ ({})
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>
? string[] | undefined
: Record<string, { scripts: string[]; styles: string [] }>
}} */ (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
}
Expand All @@ -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<string, { scripts: string[]; styles: string [] }>} */ ({${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)
}
Expand Down
4 changes: 2 additions & 2 deletions main.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ export type Integration = {
hooks: { 'astro:build:done': (opts: AstroBuildDoneOpts) => Promise<void> }
}

export function sriCSP(sriCspOptions: ShieldOptions): Integration
export function shield(sriCspOptions: ShieldOptions): Integration

export default sriCSP
export default shield
72 changes: 70 additions & 2 deletions tests/core.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
arraysEqual,
doesFileExist,
generateSRIHash,
pageHashesEqual,
updateSriHashes,
} from '../core.mjs'
import { AstroIntegrationLogger } from 'astro'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -105,6 +163,10 @@ describe('updateSriHashes', () => {
inlineStyleHashes: new Set<string>(),
extScriptHashes: new Set<string>(),
extStyleHashes: new Set<string>(),
perPageSriHashes: new Map<
string,
{ scripts: Set<string>; styles: Set<string> }
>(),
})

it('adds sri hash to inline script', async () => {
Expand All @@ -130,6 +192,7 @@ describe('updateSriHashes', () => {
const updated = await updateSriHashes(
console as unknown as AstroIntegrationLogger,
testsDir,
'index.html',
content,
h,
)
Expand Down Expand Up @@ -169,6 +232,7 @@ describe('updateSriHashes', () => {
const updated = await updateSriHashes(
console as unknown as AstroIntegrationLogger,
testsDir,
'index.html',
content,
h,
)
Expand Down Expand Up @@ -212,6 +276,7 @@ describe('updateSriHashes', () => {
const updated = await updateSriHashes(
console as unknown as AstroIntegrationLogger,
testsDir,
'index.html',
content,
h,
)
Expand Down Expand Up @@ -243,14 +308,15 @@ describe('updateSriHashes', () => {
<title>My Test Page</title>
</head>
<body>
<script type="module" src="/core.mjs" integrity="sha256-x7uRLb8+NGUBpUesXYoSUG1jjxycYKTUPY6xv7DFVRM="></script>
<script type="module" src="/core.mjs" integrity="sha256-kw3sUNwwIbNJd5X5nyEclIhbb9UoOHAC0ouWE6pUUKU="></script>
</body>
</html>`

const h = getEmptyHashes()
const updated = await updateSriHashes(
console as unknown as AstroIntegrationLogger,
rootDir,
'index.html',
content,
h,
)
Expand All @@ -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)
Expand Down Expand Up @@ -292,6 +358,7 @@ describe('updateSriHashes', () => {
const updated = await updateSriHashes(
console as unknown as AstroIntegrationLogger,
rootDir,
'index.html',
content,
h,
)
Expand Down Expand Up @@ -337,6 +404,7 @@ describe('updateSriHashes', () => {
const updated = await updateSriHashes(
console as unknown as AstroIntegrationLogger,
testsDir,
'index.html',
content,
h,
)
Expand Down

0 comments on commit cb7eb95

Please sign in to comment.