Skip to content
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
106 changes: 99 additions & 7 deletions packages/server/src/plugins/cors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe('corsHandlerPlugin', () => {
})

it('sets allowed origin only when custom origin function approves', async () => {
const customOrigin = (origin: string | undefined) => origin === 'https://allowed.com' ? origin : null
const customOrigin = (origin: string) => origin === 'https://allowed.com' ? origin : null
const customRouter = {
custom: os.handler(() => 'ok'),
}
Expand Down Expand Up @@ -126,7 +126,7 @@ describe('corsHandlerPlugin', () => {
})

it('handles timingOrigin option correctly', async () => {
const customTimingOrigin = (origin: string | undefined) => origin === 'https://timing.com' ? origin : null
const customTimingOrigin = (origin: string) => origin === 'https://timing.com' ? origin : null
const customRouter = {
timing: os.handler(() => 'ok'),
}
Expand All @@ -146,8 +146,10 @@ describe('corsHandlerPlugin', () => {
body: JSON.stringify({ json: null }),
}))
expect(response!.headers.get('timing-allow-origin')).toBe('https://timing.com')
expect(response!.headers.get('vary')).toBe('Origin')

// Request with not allowed timing origin should not have the header
// Request with not allowed timing origin should not have the header,
// but a function may answer differently per origin so the response still varies
const { response: response2 } = await handler.handle(new Request('https://example.com/timing', {
method: 'POST',
headers: {
Expand All @@ -157,6 +159,7 @@ describe('corsHandlerPlugin', () => {
body: JSON.stringify({ json: null }),
}))
expect(response2!.headers.get('timing-allow-origin')).toBeNull()
expect(response2!.headers.get('vary')).toBe('Origin')
})

it('sets credentials and exposeHeaders when specified in options', async () => {
Expand Down Expand Up @@ -196,7 +199,8 @@ describe('corsHandlerPlugin', () => {
body: JSON.stringify({ json: null }),
}))
expect(response!.headers.get('access-control-allow-origin')).toBe('*')
expect(response!.headers.get('vary')).toBeNull()
// a function may answer differently per origin, so the response is always marked as varying
expect(response!.headers.get('vary')).toBe('Origin')
})

it('returns "*" for timing-allow-origin when timingOrigin returns "*"', async () => {
Expand All @@ -214,6 +218,56 @@ describe('corsHandlerPlugin', () => {
body: JSON.stringify({ json: null }),
}))
expect(response!.headers.get('timing-allow-origin')).toBe('*')
expect(response!.headers.get('vary')).toBe('Origin')
})

it('adds Vary: Origin when timingOrigin is origin-specific even though origin is "*"', async () => {
const plugin = new CORSHandlerPlugin({ timingOrigin: ['https://timing.com'] })
const handler = new RPCHandler(router, {
plugins: [plugin],
})

const { response } = await handler.handle(new Request('https://example.com/ping', {
method: 'POST',
headers: {
'origin': 'https://timing.com',
'content-type': 'application/json',
},
body: JSON.stringify({ json: null }),
}))
expect(response!.headers.get('access-control-allow-origin')).toBe('*')
expect(response!.headers.get('timing-allow-origin')).toBe('https://timing.com')
expect(response!.headers.get('vary')).toBe('Origin')

// a request without an origin gets no timing header, but must still be marked as varying by origin
const { response: response2 } = await handler.handle(new Request('https://example.com/ping', {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({ json: null }),
}))
expect(response2!.headers.get('access-control-allow-origin')).toBe('*')
expect(response2!.headers.get('timing-allow-origin')).toBeNull()
expect(response2!.headers.get('vary')).toBe('Origin')
})

it('does not add Vary: Origin when timingOrigin is null', async () => {
const plugin = new CORSHandlerPlugin({ timingOrigin: null })
const handler = new RPCHandler(router, {
plugins: [plugin],
})

const { response } = await handler.handle(new Request('https://example.com/ping', {
method: 'POST',
headers: {
'origin': 'https://timing.com',
'content-type': 'application/json',
},
body: JSON.stringify({ json: null }),
}))
expect(response!.headers.get('timing-allow-origin')).toBeNull()
expect(response!.headers.get('vary')).toBeNull()
})

it('falls back to access-control-request-headers when allowHeaders is not set', async () => {
Expand All @@ -232,9 +286,11 @@ describe('corsHandlerPlugin', () => {
expect(response!.headers.get('access-control-allow-headers')).toBe('X-Requested-With, Content-Type')
})

it('does not set access-control-allow-origin when reflecting and request has no origin header', async () => {
it('only runs origin functions for requests that carry an Origin header', async () => {
const originFn = vi.fn((origin: string) => origin)
const timingOriginFn = vi.fn((origin: string) => origin)
const handler = new RPCHandler(router, {
plugins: [new CORSHandlerPlugin({ origin: origin => origin })],
plugins: [new CORSHandlerPlugin({ origin: originFn, timingOrigin: timingOriginFn })],
})

const { response } = await handler.handle(new Request('https://example.com/ping', {
Expand All @@ -245,9 +301,45 @@ describe('corsHandlerPlugin', () => {
body: JSON.stringify({ json: null }),
}))

// the reflect origin function receives undefined, so there is no origin to reflect
// nothing to reflect, but the response still varies by origin so caches keep it apart from allowed origins
expect(originFn).not.toHaveBeenCalled()
expect(timingOriginFn).not.toHaveBeenCalled()
expect(response!.headers.get('access-control-allow-origin')).toBeNull()
expect(response!.headers.get('timing-allow-origin')).toBeNull()
expect(response!.headers.get('vary')).toBe('Origin')

const { response: response2 } = await handler.handle(new Request('https://example.com/ping', {
method: 'POST',
headers: {
'origin': 'https://app.com',
'content-type': 'application/json',
},
body: JSON.stringify({ json: null }),
}))

expect(originFn).toHaveBeenCalledExactlyOnceWith('https://app.com', expect.objectContaining({ request: expect.any(Object) }))
expect(timingOriginFn).toHaveBeenCalledExactlyOnceWith('https://app.com', expect.objectContaining({ request: expect.any(Object) }))
expect(response2!.headers.get('access-control-allow-origin')).toBe('https://app.com')
expect(response2!.headers.get('timing-allow-origin')).toBe('https://app.com')
expect(response2!.headers.get('vary')).toBe('Origin')
})

it('emits static wildcards even when the request has no Origin header', async () => {
const handler = new RPCHandler(router, {
plugins: [new CORSHandlerPlugin({ timingOrigin: '*' })],
})

const { response } = await handler.handle(new Request('https://example.com/ping', {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({ json: null }),
}))

expect(response!.headers.get('access-control-allow-origin')).toBe('*')
expect(response!.headers.get('timing-allow-origin')).toBe('*')
expect(response!.headers.get('vary')).toBeNull()
})

it('supports an async origin function resolved per request', async () => {
Expand Down
63 changes: 44 additions & 19 deletions packages/server/src/plugins/cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Promisable, Value } from '@orpc/shared'
import type { StandardHeaders } from '@standard-server/core'
import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor, StandardHandlerRoutingInterceptorOptions } from '../adapters/standard'
import type { Context } from '../context'
import { toArray, value } from '@orpc/shared'
import { toArray } from '@orpc/shared'
import { flattenStandardHeader } from '@standard-server/core'

export interface CORSHandlerPluginOptions<T extends Context> {
Expand All @@ -12,13 +12,13 @@ export interface CORSHandlerPluginOptions<T extends Context> {
*
* @default '*'
*/
origin?: Value<Promisable<string | readonly string[] | null | undefined>, [origin: string | undefined, options: StandardHandlerRoutingInterceptorOptions<T>]>
origin?: Value<Promisable<string | readonly string[] | null | undefined>, [origin: string, options: StandardHandlerRoutingInterceptorOptions<T>]>

/**
* Configures the `Timing-Allow-Origin` header.
* Can be a string, an array of allowed origins, or a function (optionally async) that returns the allowed origin(s).
*/
timingOrigin?: Value<Promisable<string | readonly string[] | null | undefined>, [origin: string | undefined, options: StandardHandlerRoutingInterceptorOptions<T>]>
timingOrigin?: Value<Promisable<string | readonly string[] | null | undefined>, [origin: string, options: StandardHandlerRoutingInterceptorOptions<T>]>

/**
* Configures the `Access-Control-Allow-Methods` header for preflight requests.
Expand Down Expand Up @@ -91,31 +91,24 @@ export class CORSHandlerPlugin<T extends Context> implements StandardHandlerPlug

const origin = flattenStandardHeader(interceptorOptions.request.headers.origin)

const allowedOrigins = toArray(await value(this.options.origin, origin, interceptorOptions))
const allowOrigin = await this.resolveOrigin(this.options.origin, origin, interceptorOptions)
const timingAllowOrigin = await this.resolveOrigin(this.options.timingOrigin, origin, interceptorOptions)

if (allowedOrigins.includes('*')) {
resHeaders['access-control-allow-origin'] = '*'
if (allowOrigin.value !== undefined) {
resHeaders['access-control-allow-origin'] = allowOrigin.value
}

if (timingAllowOrigin.value !== undefined) {
resHeaders['timing-allow-origin'] = timingAllowOrigin.value
}
else {
if (origin !== undefined && allowedOrigins.includes(origin)) {
resHeaders['access-control-allow-origin'] = origin
}

if (allowOrigin.varies || timingAllowOrigin.varies) {
const existingVary = flattenStandardHeader(resHeaders.vary)
if (!existingVary?.split(',').some(v => v.trim().toLowerCase() === 'origin')) {
resHeaders.vary = existingVary ? `${existingVary}, Origin` : 'Origin'
}
}

const allowedTimingOrigins = toArray(await value(this.options.timingOrigin, origin, interceptorOptions))

if (allowedTimingOrigins.includes('*')) {
resHeaders['timing-allow-origin'] = '*'
}
else if (origin !== undefined && allowedTimingOrigins.includes(origin)) {
resHeaders['timing-allow-origin'] = origin
}

if (this.options.credentials) {
resHeaders['access-control-allow-credentials'] = 'true'
}
Expand Down Expand Up @@ -167,4 +160,36 @@ export class CORSHandlerPlugin<T extends Context> implements StandardHandlerPlug
],
}
}

private async resolveOrigin(
option: CORSHandlerPluginOptions<T>['origin'],
origin: string | undefined,
interceptorOptions: StandardHandlerRoutingInterceptorOptions<T>,
): Promise<{ value: string | undefined, varies: boolean }> {
if (typeof option === 'function') {
if (origin === undefined) {
return { value: undefined, varies: true }
}

const allowed = toArray(await option(origin, interceptorOptions))

if (allowed.includes('*')) {
return { value: '*', varies: true }
}

return { value: allowed.includes(origin) ? origin : undefined, varies: true }
}

const allowed = toArray(await option)

if (allowed.includes('*')) {
return { value: '*', varies: false }
}

if (origin !== undefined && allowed.includes(origin)) {
return { value: origin, varies: true }
}

return { value: undefined, varies: allowed.length > 0 }
}
}
Loading