Skip to content

Commit

Permalink
feat: experimental - set headers for static content on vercel
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 Sep 30, 2024
1 parent 3b8fa67 commit 5887b4d
Show file tree
Hide file tree
Showing 13 changed files with 792 additions and 234 deletions.
2 changes: 1 addition & 1 deletion @kindspells/astro-shield/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@kindspells/astro-shield",
"version": "1.5.3",
"version": "1.6.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
82 changes: 59 additions & 23 deletions @kindspells/astro-shield/src/core.mts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { doesFileExist, scanDirectory } from './fs.mts'
import { patchHeaders } from './headers.mts'
import type {
HashesCollection,
IntegrationState,
Logger,
MiddlewareHashes,
PerPageHashes,
Expand All @@ -25,6 +26,9 @@ import type {
} from './types.mts'
import { patchNetlifyHeadersConfig } from './netlify.mts'
import { exhaustiveGuard } from './utils.mts'
import { patchVercelHeadersConfig } from './vercel.mts'

type AstroHooks = AstroIntegration['hooks']

export type HashesModule = {
[k in keyof HashesCollection]: HashesCollection[k] extends Set<string>
Expand Down Expand Up @@ -741,7 +745,7 @@ const newHashesCollection = (): HashesCollection => ({
// TODO: TEST CASE!
export const processStaticFiles = async (
logger: Logger,
{ distDir, sri, securityHeaders }: StrictShieldOptions,
{ state, distDir, sri, securityHeaders }: StrictShieldOptions,
): Promise<void> => {
const h = newHashesCollection()

Expand All @@ -761,23 +765,23 @@ export const processStaticFiles = async (
const provider = securityHeaders.enableOnStaticPages.provider
switch (provider) {
case 'netlify': {
if (
(securityHeaders.enableOnStaticPages.mode ?? '_headers') !==
'_headers'
) {
throw new Error(
'Netlify provider only supports "_headers" mode for now',
)
}
await patchNetlifyHeadersConfig(
resolve(distDir, '_headers'),
securityHeaders,
h,
h.perPageSriHashes,
)
break
}
case 'vercel': {
await patchVercelHeadersConfig(
logger,
distDir,
state.config,
securityHeaders,
h.perPageSriHashes,
)
break
}
case 'vercel':
throw new Error('Vercel provider is still not supported')
default:
exhaustiveGuard(provider, 'provider')
}
Expand Down Expand Up @@ -992,29 +996,61 @@ export const getViteMiddlewarePlugin = (
}
}

const getAstroBuildDone = (
state: IntegrationState,
sri: Required<SRIOptions>,
securityHeaders: SecurityHeadersOptions | undefined,
): NonNullable<AstroHooks['astro:build:done']> =>
(async ({ dir, logger }) => {
if (sri.enableStatic) {
await processStaticFiles(logger, {
state,
distDir: fileURLToPath(dir),
sri,
securityHeaders,
})
}
}) satisfies NonNullable<AstroHooks['astro:build:done']>

/**
* @param {Required<SRIOptions>} sri
* @param {SecurityHeadersOptions | undefined} securityHeaders
* @returns
*/
export const getAstroConfigSetup = (
state: IntegrationState,
sri: Required<SRIOptions>,
securityHeaders: SecurityHeadersOptions | undefined,
): Required<AstroIntegration['hooks']>['astro:config:setup'] => {
// biome-ignore lint/suspicious/useAwait: We have to conform to the Astro API
return async ({ logger, addMiddleware, config, updateConfig }) => {
const publicDir = fileURLToPath(config.publicDir)
const plugin = getViteMiddlewarePlugin(
logger,
sri,
securityHeaders,
publicDir,
)
updateConfig({ vite: { plugins: [plugin] } })
state.config = config

if (sri.enableMiddleware) {
const publicDir = fileURLToPath(config.publicDir)
const plugin = getViteMiddlewarePlugin(
logger,
sri,
securityHeaders,
publicDir,
)
updateConfig({ vite: { plugins: [plugin] } })

addMiddleware({
order: 'post',
entrypoint: 'virtual:@kindspells/astro-shield/middleware',
})
}

addMiddleware({
order: 'post',
entrypoint: 'virtual:@kindspells/astro-shield/middleware',
updateConfig({
integrations: [
{
name: '@kindspells/astro-shield-post-config-setup',
hooks: {
'astro:build:done': getAstroBuildDone(state, sri, securityHeaders),
},
},
],
})
}
}
36 changes: 5 additions & 31 deletions @kindspells/astro-shield/src/main.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,10 @@
* SPDX-License-Identifier: MIT
*/

import { fileURLToPath } from 'node:url'

import type { AstroIntegration } from 'astro'

import { getAstroConfigSetup, processStaticFiles } from '#as/core'
import type {
SecurityHeadersOptions,
ShieldOptions,
SRIOptions,
} from './types.mts'

type AstroHooks = AstroIntegration['hooks']

const getAstroBuildDone = (
sri: Required<SRIOptions>,
securityHeaders: SecurityHeadersOptions | undefined,
): NonNullable<AstroHooks['astro:build:done']> =>
(async ({ dir, logger }) =>
await processStaticFiles(logger, {
distDir: fileURLToPath(dir),
sri,
securityHeaders,
})) satisfies NonNullable<AstroHooks['astro:build:done']>
import { getAstroConfigSetup } from '#as/core'
import type { IntegrationState, ShieldOptions, SRIOptions } from './types.mts'

const logWarn = (msg: string): void =>
console.warn(`\nWARNING (@kindspells/astro-shield):\n\t${msg}\n`)
Expand Down Expand Up @@ -54,19 +35,12 @@ export const shield = ({
logWarn('`sri.hashesModule` is ignored when `sri.enableStatic` is `false`')
}

const state: IntegrationState = { config: {} }

return {
name: '@kindspells/astro-shield',
hooks: {
...(_sri.enableStatic === true
? {
'astro:build:done': getAstroBuildDone(_sri, securityHeaders),
}
: undefined),
...(_sri.enableMiddleware === true
? {
'astro:config:setup': getAstroConfigSetup(_sri, securityHeaders),
}
: undefined),
'astro:config:setup': getAstroConfigSetup(state, _sri, securityHeaders),
},
} satisfies AstroIntegration
}
Expand Down
26 changes: 13 additions & 13 deletions @kindspells/astro-shield/src/netlify.mts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import { readFile, writeFile } from 'node:fs/promises'

import type {
CSPDirectives,
HashesCollection,
PerPageHashes,
PerPageHashesCollection,
SecurityHeadersOptions,
} from './types.mts'
import { serialiseCspDirectives, setSrcDirective } from './headers.mts'
import { doesFileExist } from './fs.mts'

type HeaderEntry = {
headerName: string
key: string
value: string
}

Expand Down Expand Up @@ -81,7 +81,7 @@ const pushHeader = (
}

currentPath?.entries.push({
headerName: match.groups.name,
key: match.groups.name,
value: match.groups.value,
})
}
Expand Down Expand Up @@ -188,7 +188,7 @@ export const serializeNetlifyHeadersConfig = (
.map(e =>
'comment' in e
? `${indent}${e.comment}`
: `${indent}${e.headerName}: ${e.value}`,
: `${indent}${e.key}: ${e.value}`,
)
.join('\n')}\n`
}
Expand Down Expand Up @@ -219,9 +219,9 @@ export const comparePathEntries = (
// We leave comments in place
return 'comment' in a || 'comment' in b
? 0
: a.headerName < b.headerName
: a.key < b.key
? -1
: a.headerName > b.headerName
: a.key > b.key
? 1
: a.value < b.value // headers can have many values
? -1
Expand All @@ -238,24 +238,24 @@ export const comparePathEntriesSimplified = (
// We leave comments in place
return 'comment' in a || 'comment' in b
? 0
: a.headerName < b.headerName
: a.key < b.key
? -1
: a.headerName > b.headerName
: a.key > b.key
? 1
: 0
}

export const buildNetlifyHeadersConfig = (
securityHeadersOptions: SecurityHeadersOptions,
resourceHashes: Pick<HashesCollection, 'perPageSriHashes'>,
perPageSriHashes: PerPageHashesCollection,
): NetlifyHeadersRawConfig => {
const config: NetlifyHeadersRawConfig = {
indentWith: '\t',
entries: [],
}

const pagesToIterate: [string, PerPageHashes][] = []
for (const [page, hashes] of resourceHashes.perPageSriHashes) {
for (const [page, hashes] of perPageSriHashes.entries()) {
if (page === 'index.html' || page.endsWith('/index.html')) {
pagesToIterate.push([page.slice(0, -10), hashes])
}
Expand Down Expand Up @@ -286,7 +286,7 @@ export const buildNetlifyHeadersConfig = (
}

pathEntries.push({
headerName: 'content-security-policy',
key: 'content-security-policy',
value: serialiseCspDirectives(directives),
})
}
Expand Down Expand Up @@ -446,15 +446,15 @@ export const mergeNetlifyHeadersConfig = (
export const patchNetlifyHeadersConfig = async (
configPath: string,
securityHeadersOptions: SecurityHeadersOptions,
resourceHashes: Pick<HashesCollection, 'perPageSriHashes'>,
perPageSriHashes: PerPageHashesCollection,
): Promise<void> => {
const baseConfig = (await doesFileExist(configPath))
? await readNetlifyHeadersFile(configPath)
: { indentWith: '\t', entries: [] }

const patchConfig = buildNetlifyHeadersConfig(
securityHeadersOptions,
resourceHashes,
perPageSriHashes,
)

const mergedConfig = mergeNetlifyHeadersConfig(baseConfig, patchConfig)
Expand Down
29 changes: 29 additions & 0 deletions @kindspells/astro-shield/src/tests/fixtures/vercel_config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"version": 3,
"routes": [
{
"src": "/es",
"headers": {
"Location": "/es/"
},
"status": 308
},
{
"src": "/new",
"headers": {
"Location": "/new/"
},
"status": 308
},
{
"src": "^/_astro/(.*)$",
"headers": {
"cache-control": "public, max-age=31536000, immutable"
},
"continue": true
},
{
"handle": "filesystem"
}
]
}
10 changes: 6 additions & 4 deletions @kindspells/astro-shield/src/tests/main.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('sriCSP', () => {

const checkIntegration = (
integration: AstroIntegration,
keys: (keyof AstroIntegration['hooks'])[] = ['astro:build:done' as const],
keys: (keyof AstroIntegration['hooks'])[] = ['astro:config:setup'] as const,
) => {
expect(Object.keys(integration).sort()).toEqual(['hooks', 'name'])
expect(integration.name).toBe('@kindspells/astro-shield')
Expand All @@ -40,14 +40,16 @@ describe('sriCSP', () => {
checkIntegration(integration)
})

it('returns an "empty" integration when we disable all features', () => {
it('returns an integration even when we disable all features', () => {
const integration = shield({ sri: { enableStatic: false } })
checkIntegration(integration, [])

// NOTE: it is too much work to verify that those hooks will do nothing
checkIntegration(integration, ['astro:config:setup'])
})

it('returns hooks for static & dynamic content when we enable middleware', () => {
const integration = shield({ sri: { enableMiddleware: true } })
checkIntegration(integration, ['astro:build:done', 'astro:config:setup'])
checkIntegration(integration, ['astro:config:setup'])
})

it('returns hooks only for dynamic content when we enable middleware and disable static sri', () => {
Expand Down
Loading

0 comments on commit 5887b4d

Please sign in to comment.