Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow configuring headers in runtime #298

Merged
merged 10 commits into from
Jan 18, 2024
34 changes: 34 additions & 0 deletions docs/content/1.documentation/1.getting-started/3.usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,37 @@ export default defineNuxtConfig({
}
})
```

## Runtime configuration

If you need to change the headers configuration at runtime, it is possible to do it through `nuxt-security:headers` hook.

### Enabling the option

This feature is optional, you can enable it with

```ts
export default defineNuxtConfig({
modules: ['nuxt-security'],
security: {
runtimeHooks: true
}
})
```

### Usage

Within your nitro plugin. You can override the previous configuration of a route with `nuxt-security:headers`.

```ts
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('nuxt-security:ready', () => {
nitroApp.hooks.callHook('nuxt-security:headers', '/**' ,{
contentSecurityPolicy: {
"script-src": ["'self'", "'unsafe-inline'"],
},
xFrameOptions: false
})
})
})
```
3 changes: 2 additions & 1 deletion playground/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export default defineNuxtConfig({
rateLimiter: {
tokensPerInterval: 10,
interval: 10000
}
},
runtimeHooks: true
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: I wonder if this should actually be enabled by default. I think that majority of the users will just use the nuxt.config.ts config or route rules while this runtime configuration is a bit of an edge case (at least IMO).

With these kind of configuration, I usually mark them as optional and disabled by default (to not ship unused code to other users).

So, if you want to have a runtime configuration, just set this config value security.runtimeHooks = false and it will work for you.

WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh i didn't remove this since the removal of the runtimeHooks option

@harlan-zw think we don't really need this option to always have a runtime configuration of headers. So should we set back runtimeHooks in the options ?

If we set back runtimeHooks option, i think it should be default to false. Then if true, we'll add the nitro plugin

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally would recommend to add an option and make it disabled by default to not add additional code for users that may not use it.

So add an option but make it disabled by default and make a note in the docs that in order to use this feature, set this config option to true and then, call the hook (like you have explained in the docs)

I think this delivers the functionality to the user that may need it without forcing every user of the module to have some dead code.

}
})
7 changes: 7 additions & 0 deletions playground/server/api/runtime-hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineEventHandler } from "#imports"

export default defineEventHandler((event) => {
return {
csp: getResponseHeader(event, 'Content-Security-Policy')
}
})
14 changes: 14 additions & 0 deletions playground/server/plugins/headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@

export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('nuxt-security:ready', () => {
nitroApp.hooks.callHook('nuxt-security:headers',
{
route: '/api/runtime-hooks',
headers: {
contentSecurityPolicy: {
"script-src": ["'self'", "'unsafe-inline'"],
}
}
})
})
})
11 changes: 10 additions & 1 deletion src/module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { fileURLToPath } from 'node:url'
import { resolve, normalize } from 'pathe'
import { defineNuxtModule, addServerHandler, installModule, addVitePlugin } from '@nuxt/kit'
import { defineNuxtModule, addServerHandler, installModule, addVitePlugin, addServerPlugin } from '@nuxt/kit'
import { defu } from 'defu'
import type { Nuxt } from '@nuxt/schema'
import viteRemove from 'unplugin-remove/vite'
Expand Down Expand Up @@ -126,6 +126,15 @@ export default defineNuxtModule<ModuleOptions>({
}


if(nuxt.options.security.runtimeHooks) {
addServerPlugin(resolve(runtimeDir, 'nitro/plugins/00-context'))
addServerHandler({
handler: normalize(
resolve(runtimeDir, 'server/middleware/headers')
)
})
}

const allowedMethodsRestricterConfig = nuxt.options.security
.allowedMethodsRestricter
if (
Expand Down
33 changes: 33 additions & 0 deletions src/runtime/nitro/plugins/00-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { getNameFromKey, headerStringFromObject} from "../../utils/headers"
import { createRouter} from "radix3"
import { defineNitroPlugin } from '#imports'
import { OptionKey } from "~/src/module"

export default defineNitroPlugin((nitroApp) => {
const router = createRouter()

nitroApp.hooks.hook('nuxt-security:headers', ({route, headers: headersConfig}) => {
const headers: Record<string, string |false > = {}

for (const [header, headerOptions] of Object.entries(headersConfig)) {
const headerName = getNameFromKey(header as OptionKey)
if(headerName) {
const value = headerStringFromObject(header as OptionKey, headerOptions)
if(value) {
headers[headerName] = value
} else {
delete headers[headerName]
}
}
}

router.insert(route, headers)
})

nitroApp.hooks.hook('request', (event) => {
event.context.security = event.context.security || {}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd use the module name to avoid conflicts

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i used security because it's the module configKey, but yeah we can change it to nuxtSecurity !

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if ends users will use this context object it could make sense with this name, otherwise if it's just internal module name is better.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would stick to security instead of nuxt-security purely because this is a config key for both nuxt.config.ts file and for route rules so it would be better IMHO to keep this consistent :)

event.context.security.headers = router.lookup(event.path)
})

nitroApp.hooks.callHook('nuxt-security:ready')
})
13 changes: 13 additions & 0 deletions src/runtime/server/middleware/headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { defineEventHandler, setHeader, removeResponseHeader } from '#imports'

export default defineEventHandler((event) => {
if(event.context.security.headers) {
Object.entries(event.context.security.headers).forEach(([header, value]) => {
if (value === false) {
removeResponseHeader(event, header)
} else {
setHeader(event, header, value)
}
})
}
})
2 changes: 1 addition & 1 deletion src/runtime/utils/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
SecurityHeaders
} from '../../types/headers'

const KEYS_TO_NAMES: Record<OptionKey, HeaderName> = {
export const KEYS_TO_NAMES: Record<OptionKey, HeaderName> = {
contentSecurityPolicy: 'Content-Security-Policy',
crossOriginEmbedderPolicy: 'Cross-Origin-Embedder-Policy',
crossOriginOpenerPolicy: 'Cross-Origin-Opener-Policy',
Expand Down
9 changes: 9 additions & 0 deletions src/types/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,12 @@ export interface SecurityHeaders {
xXSSProtection?: string | false;
permissionsPolicy?: PermissionsPolicyValue | false;
}


declare module 'h3' {
interface H3EventContext {
security: {
headers: SecurityHeaders
}
}
}
20 changes: 20 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export interface ModuleOptions {
nonce: boolean;
removeLoggers: RemoveOptions | false;
ssg: Ssg | false;
/**
* enable runtime nitro hooks to configure some options at runtime
* Current configuration editable at runtime: headers
*/
runtimeHooks: boolean;
huang-julien marked this conversation as resolved.
Show resolved Hide resolved
sri: boolean
}

Expand All @@ -36,3 +41,18 @@ export type NuxtSecurityRouteRules = Pick<ModuleOptions,
'sri' |
'ssg'>

declare module 'nitropack' {
interface NitroRuntimeHooks {
'nuxt-security:headers': (config: {
/**
* The route for which the headers are being configured
*/
route: string,
/**
* The headers configuration for the route
*/
headers: SecurityHeaders
}) => void
'nuxt-security:ready': () => void
}
}
1 change: 1 addition & 0 deletions test/fixtures/runtime-hooks/.nuxtrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
imports.autoImport=true
5 changes: 5 additions & 0 deletions test/fixtures/runtime-hooks/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<div>
<NuxtPage />
</div>
</template>
21 changes: 21 additions & 0 deletions test/fixtures/runtime-hooks/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import MyModule from '../../../src/module'

export default defineNuxtConfig({
modules: [
MyModule
],
routeRules:{
'/test': {
headers: {
'x-xss-protection': '1',
}
}
},
security: {
nonce: false,
runtimeHooks: true,
headers: {
contentSecurityPolicy: false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@huang-julien is it required to turn off nonces and content security policy headers here?

When I keep contentSecurityPolicy headers enabled here, the headers set in the hook are not applied it seems.
When I disable contentSecurityPolicy headers here, the headers set in the hook are applied but without {{nonce}} replacement. Is that intended?

}
}
})
5 changes: 5 additions & 0 deletions test/fixtures/runtime-hooks/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"private": true,
"name": "runtime-hooks",
"type": "module"
}
3 changes: 3 additions & 0 deletions test/fixtures/runtime-hooks/pages/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<div>runtime hooks</div>
</template>
7 changes: 7 additions & 0 deletions test/fixtures/runtime-hooks/server/api/runtime-hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { getResponseHeader } from "h3"

export default defineEventHandler((event) => {
return {
csp: getResponseHeader(event, 'Content-Security-Policy')
}
})
11 changes: 11 additions & 0 deletions test/fixtures/runtime-hooks/server/plugins/headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('nuxt-security:ready', () => {
nitroApp.hooks.callHook('nuxt-security:headers', {
route: '/api/runtime-hooks', headers: {
contentSecurityPolicy: {
"script-src": ["'self'", "'unsafe-inline'", '*.azure.com'],
}
}
})
})
})
19 changes: 19 additions & 0 deletions test/runtime-hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { fileURLToPath } from 'node:url'
import { describe, it, expect } from 'vitest'
import { setup, fetch } from '@nuxt/test-utils'

await setup({
rootDir: fileURLToPath(new URL('./fixtures/runtime-hooks', import.meta.url))
})

describe('[nuxt-security] runtime hooks', () => {
it('expect csp to be set by a runtime hook', async () => {
const res = await fetch('/api/runtime-hooks')
expect(await res.json()).toMatchInlineSnapshot(`
{
"csp": "script-src 'self' 'unsafe-inline' *.azure.com;",
}
`)
expect(res.headers.get('Content-Security-Policy')).toMatchInlineSnapshot('"script-src \'self\' \'unsafe-inline\' *.azure.com;"')
})
})
Loading