Skip to content

Commit

Permalink
feat: cross-origin resources allow-lists
Browse files Browse the repository at this point in the history
Signed-off-by: Andres Correa Casablanca <andreu@kindspells.dev>
  • Loading branch information
castarco committed Mar 27, 2024
1 parent 4f73ba8 commit 41b8457
Show file tree
Hide file tree
Showing 9 changed files with 485 additions and 52 deletions.
68 changes: 56 additions & 12 deletions README.md
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
@@ -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",
Expand Down
81 changes: 64 additions & 17 deletions src/core.mjs
Expand Up @@ -41,7 +41,7 @@ export const generateSRIHash = data => {

/**
* @typedef {(
* hash: string,
* hash: string | null,
* attrs: string,
* setCrossorigin: boolean,
* content?: string | undefined,
Expand All @@ -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"' : ''
}/>`

Expand Down Expand Up @@ -242,7 +242,7 @@ export const updateDynamicPageSriHashes = async (
logger,
content,
globalHashes,
sri
sri,
) => {
const processors = getRegexProcessors()

Expand Down Expand Up @@ -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}"`)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, {
Expand All @@ -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, {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)})`
})
})()
`
Expand Down
4 changes: 4 additions & 0 deletions src/headers.mjs
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions src/main.mjs
Expand Up @@ -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),
}
Expand Down

0 comments on commit 41b8457

Please sign in to comment.