Skip to content

Chore/2.4.0 #650

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’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Aug 21, 2025
Merged
2 changes: 1 addition & 1 deletion docs/content/2.headers/8.strictTransportSecurity.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ You can also disable this header by `strictTransportSecurity: false`.
By default, Nuxt Security will set the following value for this header.

```http
Strict-Transport-Security: max-age=15552000; includeSubDomains;
Strict-Transport-Security: max-age=15552000; includeSubDomains
```

## Available values
Expand Down
7 changes: 7 additions & 0 deletions docs/content/3.middleware/1.rate-limiter.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ type RateLimiter = {
name: string;
options: Record<string, any>;
};
ipHeader: string;
};
```

Expand Down Expand Up @@ -118,3 +119,9 @@ rateLimiter: {
}
}
```

### `ipHeader`

- Default: `undefined`

A custom header name (string) that will be used to determine the IP address of the request. Useful when the default `x-forwarded-for` header can not be used, and you want to use an alternative like [cf-connecting-ip](https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-connecting-ip).
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "nuxt-security",
"version": "2.3.0",
"version": "2.4.0",
"license": "MIT",
"type": "module",
"engines": {
Expand Down
1 change: 1 addition & 0 deletions src/defaultConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const defaultSecurityConfig = (serverlUrl: string, strict: boolean) => {
name: 'lruCache'
},
whiteList: undefined,
ipHeader: undefined,
...defaultThrowErrorValue
},
xssValidator: {
Expand Down
8 changes: 4 additions & 4 deletions src/runtime/server/middleware/rateLimiter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { defineEventHandler, createError, setResponseHeader, useStorage, getRequestIP } from '#imports'
import { defineEventHandler, createError, setResponseHeader, useStorage, getRequestIP, getRequestHeader } from '#imports'
import type { H3Event } from 'h3'
import { resolveSecurityRoute, resolveSecurityRules } from '../../nitro/context'
import type { RateLimiter } from '../../../types/middlewares'
Expand Down Expand Up @@ -27,7 +27,7 @@ export default defineEventHandler(async(event) => {
rules.rateLimiter,
defaultRateLimiter
)
const ip = getIP(event)
const ip = getIP(event, rateLimiter.ipHeader)
if(rateLimiter.whiteList && rateLimiter.whiteList.includes(ip)){
return
}
Expand Down Expand Up @@ -89,8 +89,8 @@ async function setStorageItem(rateLimiter: Required<RateLimiter>, url: string) {
await storage.setItem(url, rateLimitedObject)
}

function getIP (event: H3Event) {
const ip = getRequestIP(event, { xForwardedFor: true }) || ''
function getIP (event: H3Event, customIpHeader?: string) {
const ip = customIpHeader ? getRequestHeader(event, customIpHeader) || '' : getRequestIP(event, { xForwardedFor: true }) || ''
return ip
}

1 change: 1 addition & 0 deletions src/types/middlewares.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type RateLimiter = {
headers?: boolean;
whiteList?: string[];
throwError?: boolean;
ipHeader?: string;
};

export type XssValidator = {
Expand Down
26 changes: 12 additions & 14 deletions src/utils/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,25 @@ const KEYS_TO_NAMES: Record<OptionKey, HeaderName> = {
const NAMES_TO_KEYS = Object.fromEntries(Object.entries(KEYS_TO_NAMES).map(([key, name]) => ([name, key]))) as Record<HeaderName, OptionKey>

/**
*
*
* Converts a valid OptionKey into its corresponding standard header name
*/
export function getNameFromKey(key: OptionKey) {
return KEYS_TO_NAMES[key]
}

/**
*
* Converts a standard header name to its corresponding OptionKey name, or undefined if not found
*
* Converts a standard header name to its corresponding OptionKey name, or undefined if not found
*/
export function getKeyFromName(headerName: string) {
const [, key] = Object.entries(NAMES_TO_KEYS).find(([name]) => name.toLowerCase() === headerName.toLowerCase()) || []
return key
}

/**
*
* Gigen a valid OptionKey, converts a header object value into its corresponding string format
*
* Gigen a valid OptionKey, converts a header object value into its corresponding string format
*/
export function headerStringFromObject(optionKey: OptionKey, optionValue: Exclude<SecurityHeaders[OptionKey], undefined>) {
// False value translates into empty header
Expand Down Expand Up @@ -74,11 +74,10 @@ export function headerStringFromObject(optionKey: OptionKey, optionValue: Exclud
} else if (optionKey === 'strictTransportSecurity') {
const policies = optionValue as StrictTransportSecurityValue
return [
`max-age=${policies.maxAge};`,
policies.includeSubdomains && 'includeSubDomains;',
policies.preload && 'preload;'
].filter(Boolean).join(' ')

`max-age=${policies.maxAge}`,
policies.includeSubdomains && 'includeSubDomains',
policies.preload && 'preload'
].filter(Boolean).join('; ')
} else if (optionKey === 'permissionsPolicy') {
const policies = optionValue as PermissionsPolicyValue
return Object.entries(policies)
Expand All @@ -99,7 +98,7 @@ export function headerStringFromObject(optionKey: OptionKey, optionValue: Exclud
}

/**
*
*
* Given a valid OptionKey, converts a header value string into its corresponding object format
*/
export function headerObjectFromString(optionKey: OptionKey, headerValue: string) {
Expand All @@ -112,7 +111,7 @@ export function headerObjectFromString(optionKey: OptionKey, headerValue: string
const directives = headerValue.split(';').map(directive => directive.trim()).filter(directive => directive)
const objectForm = {} as ContentSecurityPolicyValue
for (const directive of directives) {
const [type, ...sources] = directive.split(' ').map(token => token.trim()) as [keyof ContentSecurityPolicyValue, ...string[]]
const [type, ...sources] = directive.split(' ').map(token => token.trim()) as [keyof ContentSecurityPolicyValue, ...string[]]
if (type === 'upgrade-insecure-requests') {
objectForm[type] = true
} else {
Expand Down Expand Up @@ -163,7 +162,6 @@ function appliesToAllResources(optionKey: OptionKey) {
case 'xPermittedCrossDomainPolicies':
case 'xXSSProtection':
return true
break
default:
return false
}
Expand Down Expand Up @@ -242,4 +240,4 @@ export function backwardsCompatibleSecurity(securityHeaders?: SecurityHeaders |
}
})
return securityHeadersAsObject
}
}
6 changes: 2 additions & 4 deletions test/defaultHeaders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ describe('[nuxt-security] Default Headers', async () => {
await setup({
rootDir: fileURLToPath(new URL('./fixtures/defaultHeaders', import.meta.url)),
})
let res: Response
let res: Response

it ('fetches the homepage', async () => {
res = await fetch('/')
Expand Down Expand Up @@ -114,7 +114,7 @@ describe('[nuxt-security] Default Headers', async () => {
const stsHeaderValue = headers.get('strict-transport-security')

expect(stsHeaderValue).toBeTruthy()
expect(stsHeaderValue).toBe('max-age=15552000; includeSubDomains;')
expect(stsHeaderValue).toBe('max-age=15552000; includeSubDomains')
})

it('has `x-content-type-options` header set with default value', async () => {
Expand Down Expand Up @@ -183,5 +183,3 @@ describe('[nuxt-security] Default Headers', async () => {
expect(xxpHeaderValue).toBe('0')
})
})


6 changes: 3 additions & 3 deletions test/fixtures/perRoute/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export default defineNuxtConfig({
'/provided-as-standard': {
headers: {
'Cross-Origin-Resource-Policy': 'cross-origin',
'Strict-Transport-Security': 'max-age=1; preload;',
'Strict-Transport-Security': 'max-age=1; preload',
'Permissions-Policy': 'fullscreen=*, camera=(self)',
'Content-Security-Policy':
"script-src 'self' https:; media-src 'none';",
Expand All @@ -60,7 +60,7 @@ export default defineNuxtConfig({
'Cross-Origin-Resource-Policy': 'same-site',
'Cross-Origin-Opener-Policy': 'cross-origin',
'Cross-Origin-Embedder-Policy': 'unsafe-none',
'Strict-Transport-Security': 'max-age=1; preload;',
'Strict-Transport-Security': 'max-age=1; preload',
'Permissions-Policy': 'fullscreen=*',
foo: 'baz',
foo2: 'baz2'
Expand Down Expand Up @@ -95,7 +95,7 @@ export default defineNuxtConfig({
},
'/resolve-conflict/deep/page': {
headers: {
'Strict-Transport-Security': 'max-age=1; preload;',
'Strict-Transport-Security': 'max-age=1; preload',
'X-Frame-Options': 'DENY'
},
security: {
Expand Down
9 changes: 9 additions & 0 deletions test/fixtures/rateLimiter/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,14 @@ export default defineNuxtConfig({
}
}
},
'/customIpHeader': {
security: {
rateLimiter: {
tokensPerInterval: 0,
interval: 300000,
ipHeader: 'X-Custom-IP'
}
}
},
}
})
3 changes: 3 additions & 0 deletions test/fixtures/rateLimiter/pages/customIpHeader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<div>custom ipHeader test</div>
</template>
Loading