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

Already on GitHub? Sign in to your account

feat: support using regular expressions as CORS origin #509

Merged
merged 6 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions docs/content/1.documentation/3.middleware/4.cors-handler.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ You can also disable the middleware globally or per route by setting `corsHandle
CORS handler accepts following configuration options:

```ts
interface H3CorsOptions {
origin?: '*' | 'null' | (string | RegExp)[] | ((origin: string) => boolean);
interface CorsOptions = {
origin?: '*' | string | string[];
useRegExp?: boolean;
methods?: '*' | HTTPMethod[];
allowHeaders?: '*' | string[];
exposeHeaders?: '*' | string[];
Expand All @@ -65,7 +66,16 @@ interface H3CorsOptions {

- Default: `${serverUrl}`

The Access-Control-Allow-Origin response header indicates whether the response can be shared with requesting code from the given origin.
The Access-Control-Allow-Origin response header indicates whether the response can be shared with requesting code from the given origin. Use `'*'` to allow all origins. You can pass a single origin, or a list of origins.

### `useRegExp`

Set to `true` to parse all origin values into a regular expression using `new RegExp(origin, 'i')`.
You cannot use RegExp instances directly as origin values, because the nuxt config needs to be serializable.
When using regular expressions, make sure to escape dots in origins correctly. Otherwise a dot will match every character.

The following matches `https://1.foo.example.com`, `https://a.b.c.foo.example.com`, but not `https://foo.example.com`.
`'(.*)\\.foo.example\\.com'`

### `methods`

Expand Down
22 changes: 21 additions & 1 deletion src/runtime/server/middleware/corsHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,27 @@ export default defineEventHandler((event) => {

if (rules.enabled && rules.corsHandler) {
const { corsHandler } = rules
handleCors(event, corsHandler as H3CorsOptions)

let origin: H3CorsOptions['origin']
if (typeof corsHandler.origin === 'string' && corsHandler.origin !== '*') {
origin = [corsHandler.origin]
} else {
origin = corsHandler.origin
}

if (origin && origin !== '*' && corsHandler.useRegExp) {
origin = origin.map((o) => new RegExp(o, 'i'))
}

handleCors(event, {
origin,
methods: corsHandler.methods,
allowHeaders: corsHandler.allowHeaders,
exposeHeaders: corsHandler.exposeHeaders,
credentials: corsHandler.credentials,
maxAge: corsHandler.maxAge,
preflight: corsHandler.preflight
})
}

})
5 changes: 3 additions & 2 deletions src/types/middlewares.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,10 @@ export type BasicAuth = {

export type HTTPMethod = 'GET' | 'POST' | 'DELETE' | 'PATCH' | 'PUT' | 'TRACE' | 'OPTIONS' | 'CONNECT' | 'HEAD';

// Cannot use the H3CorsOptions from `h3` as it breaks the build process for some reason :(
// Cannot use the H3CorsOptions, because it allows unserializable types, such as functions or RegExp.
export type CorsOptions = {
origin?: '*' | 'null' | string | (string | RegExp)[] | ((origin: string) => boolean);
origin?: '*' | string | string[];
useRegExp?: boolean;
methods?: '*' | HTTPMethod[];
allowHeaders?: '*' | string[];
exposeHeaders?: '*' | string[];
Expand Down
87 changes: 87 additions & 0 deletions test/cors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest'
import { fileURLToPath } from 'node:url'
import { setup, fetch } from '@nuxt/test-utils'

describe('[nuxt-security] CORS', async () => {
await setup({
rootDir: fileURLToPath(new URL('./fixtures/cors', import.meta.url)),
})

it ('should allow requests from serverUrl by default', async () => {
const res = await fetch('/', { headers: { origin: 'http://localhost:3000' } })
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('http://localhost:3000')
})

it ('should block requests from other origins by default', async () => {
const res = await fetch('/', { headers: { origin: 'http://example.com' } })
expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull()
})

it('should allow requests from all origins when * is set', async () => {
let res = await fetch('/star', { headers: { origin: 'http://example.com' } })
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*')

res = await fetch('/star', { headers: { origin: 'http://a.b.c.example.com' } })
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*')
})

it('should allow requests if origin matches', async () => {
const res = await fetch('/single', { headers: { origin: 'https://example.com' } })
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://example.com')
})

it('should block requests when origin does not match', async () => {
const res = await fetch('/single', { headers: { origin: 'https://foo.example.com' } })
expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull()
})

it('should support multiple origins', async () => {
let res = await fetch('/multi', { headers: { origin: 'https://a.example.com' } })
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://a.example.com')

res = await fetch('/multi', { headers: { origin: 'https://b.example.com' } })
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://b.example.com')

res = await fetch('/multi', { headers: { origin: 'https://c.example.com' } })
expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull()
})

it('should support regular expressions', async () => {
let res = await fetch('/regexp-single', { headers: { origin: 'https://a.example.com' } })
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://a.example.com')

res = await fetch('/regexp-single', { headers: { origin: 'https://b.example.com' } })
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://b.example.com')

res = await fetch('/regexp-single', { headers: { origin: 'https://c.example.com' } })
expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull()
})

it('should match origins with regular expressions in a case-insensitive way', async () => {
const res = await fetch('/regexp-single', { headers: { origin: 'https://A.EXAMPLE.COM' } })
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://A.EXAMPLE.COM')
})

it('should support multiple regular expressions', async () => {
let res = await fetch('/regexp-multi', { headers: { origin: 'https://a.example.com' } })
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://a.example.com')

res = await fetch('/regexp-multi', { headers: { origin: 'https://b.example.com' } })
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://b.example.com')

res = await fetch('/regexp-multi', { headers: { origin: 'https://c.example.com' } })
expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull()

res = await fetch('/regexp-multi', { headers: { origin: 'https://c.example.com' } })
expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull()

res = await fetch('/regexp-multi', { headers: { origin: 'https://foo.example.com' } })
expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull()

res = await fetch('/regexp-multi', { headers: { origin: 'https://1.foo.example.com' } })
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://1.foo.example.com')

res = await fetch('/regexp-multi', { headers: { origin: 'https://a.b.c.foo.example.com' } })
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://a.b.c.foo.example.com')
})
})
1 change: 1 addition & 0 deletions test/fixtures/cors/.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/cors/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<div>
<NuxtPage />
</div>
</template>
55 changes: 55 additions & 0 deletions test/fixtures/cors/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
export default defineNuxtConfig({
modules: [
'../../../src/module'
],
security: {
},
routeRules: {
'/empty': {
security: {
corsHandler: {
origin: ''
}
}
},
'/star': {
security: {
corsHandler: {
origin: '*'
}
}
},
'/single': {
security: {
corsHandler: {
origin: 'https://example.com'
}
}
},
'/multi': {
security: {
corsHandler: {
origin: ['https://a.example.com', 'https://b.example.com']
}
}
},
'/regexp-single': {
security: {
corsHandler: {
// eslint-disable-next-line no-useless-escape -- This is parsed as a regular expression, so the escape is required.
origin: '(a|b)\\.example\\.com',
useRegExp: true
}
}
},
'/regexp-multi': {
security: {
corsHandler: {
// eslint-disable-next-line no-useless-escape -- This is parsed as a regular expression, so the escape is required.
origin: ['(a|b)\.example\.com', '(.*)\\.foo.example\\.com'],
useRegExp: true
}
}
},
}
})
5 changes: 5 additions & 0 deletions test/fixtures/cors/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"private": true,
"name": "basic",
"type": "module"
}
3 changes: 3 additions & 0 deletions test/fixtures/cors/pages/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<div>basic</div>
</template>
3 changes: 3 additions & 0 deletions test/fixtures/cors/pages/multi.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<div>multi</div>
</template>
3 changes: 3 additions & 0 deletions test/fixtures/cors/pages/regexp-multi.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<div>regexp-multi</div>
</template>
3 changes: 3 additions & 0 deletions test/fixtures/cors/pages/regexp-single.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<div>regexp-single</div>
</template>
3 changes: 3 additions & 0 deletions test/fixtures/cors/pages/single.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<div>single</div>
</template>
3 changes: 3 additions & 0 deletions test/fixtures/cors/pages/star.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<div>star</div>
</template>
Loading