diff --git a/docs/content/1.documentation/3.middleware/4.cors-handler.md b/docs/content/1.documentation/3.middleware/4.cors-handler.md
index f4a53280..f34155a9 100644
--- a/docs/content/1.documentation/3.middleware/4.cors-handler.md
+++ b/docs/content/1.documentation/3.middleware/4.cors-handler.md
@@ -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[];
@@ -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`
diff --git a/src/runtime/server/middleware/corsHandler.ts b/src/runtime/server/middleware/corsHandler.ts
index 5b7fbb87..fd8b88ab 100644
--- a/src/runtime/server/middleware/corsHandler.ts
+++ b/src/runtime/server/middleware/corsHandler.ts
@@ -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
+ })
}
})
diff --git a/src/types/middlewares.ts b/src/types/middlewares.ts
index a5207a78..7359aaa6 100644
--- a/src/types/middlewares.ts
+++ b/src/types/middlewares.ts
@@ -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[];
diff --git a/test/cors.test.ts b/test/cors.test.ts
new file mode 100644
index 00000000..31ff2365
--- /dev/null
+++ b/test/cors.test.ts
@@ -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')
+ })
+})
diff --git a/test/fixtures/cors/.nuxtrc b/test/fixtures/cors/.nuxtrc
new file mode 100644
index 00000000..3c8c6a11
--- /dev/null
+++ b/test/fixtures/cors/.nuxtrc
@@ -0,0 +1 @@
+imports.autoImport=true
\ No newline at end of file
diff --git a/test/fixtures/cors/app.vue b/test/fixtures/cors/app.vue
new file mode 100644
index 00000000..2b1be090
--- /dev/null
+++ b/test/fixtures/cors/app.vue
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/test/fixtures/cors/nuxt.config.ts b/test/fixtures/cors/nuxt.config.ts
new file mode 100644
index 00000000..0005cab2
--- /dev/null
+++ b/test/fixtures/cors/nuxt.config.ts
@@ -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
+ }
+ }
+ },
+ }
+})
diff --git a/test/fixtures/cors/package.json b/test/fixtures/cors/package.json
new file mode 100644
index 00000000..decd4334
--- /dev/null
+++ b/test/fixtures/cors/package.json
@@ -0,0 +1,5 @@
+{
+ "private": true,
+ "name": "basic",
+ "type": "module"
+}
diff --git a/test/fixtures/cors/pages/index.vue b/test/fixtures/cors/pages/index.vue
new file mode 100644
index 00000000..8371b274
--- /dev/null
+++ b/test/fixtures/cors/pages/index.vue
@@ -0,0 +1,3 @@
+
+ basic
+
diff --git a/test/fixtures/cors/pages/multi.vue b/test/fixtures/cors/pages/multi.vue
new file mode 100644
index 00000000..7ccd8292
--- /dev/null
+++ b/test/fixtures/cors/pages/multi.vue
@@ -0,0 +1,3 @@
+
+ multi
+
diff --git a/test/fixtures/cors/pages/regexp-multi.vue b/test/fixtures/cors/pages/regexp-multi.vue
new file mode 100644
index 00000000..0bc7574a
--- /dev/null
+++ b/test/fixtures/cors/pages/regexp-multi.vue
@@ -0,0 +1,3 @@
+
+ regexp-multi
+
diff --git a/test/fixtures/cors/pages/regexp-single.vue b/test/fixtures/cors/pages/regexp-single.vue
new file mode 100644
index 00000000..6e248fda
--- /dev/null
+++ b/test/fixtures/cors/pages/regexp-single.vue
@@ -0,0 +1,3 @@
+
+ regexp-single
+
diff --git a/test/fixtures/cors/pages/single.vue b/test/fixtures/cors/pages/single.vue
new file mode 100644
index 00000000..4ef807ec
--- /dev/null
+++ b/test/fixtures/cors/pages/single.vue
@@ -0,0 +1,3 @@
+
+ single
+
diff --git a/test/fixtures/cors/pages/star.vue b/test/fixtures/cors/pages/star.vue
new file mode 100644
index 00000000..f932b8a4
--- /dev/null
+++ b/test/fixtures/cors/pages/star.vue
@@ -0,0 +1,3 @@
+
+ star
+
\ No newline at end of file