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 @@ + 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 @@ + 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 @@ + 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 @@ + 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 @@ + 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 @@ + \ No newline at end of file