Skip to content

Commit

Permalink
Merge pull request #509 from P4sca1/fix/regexp-origin
Browse files Browse the repository at this point in the history
feat: support using regular expressions as CORS origin
  • Loading branch information
Baroshem authored Sep 19, 2024
2 parents 0c48ec5 + 6a04128 commit 85e5c91
Show file tree
Hide file tree
Showing 14 changed files with 208 additions and 6 deletions.
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>

0 comments on commit 85e5c91

Please sign in to comment.