Skip to content
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