Skip to content

Commit c103667

Browse files
authored
fix: custom OPTIONS endpoints are intercepted and cannot set custom CORS headers (#15153)
### What? Fixes an issue where custom OPTIONS endpoint handlers were never called because Payload intercepted all OPTIONS requests early and returned default CORS headers. ### Why? Users need to define custom CORS headers for specific endpoints in some cases (like allowing different origins for different routes). The previous behavior made this impossible since OPTIONS requests were handled globally before checking if a custom handler existed. ### How? Moved the default OPTIONS/CORS handling to after the endpoint matching logic. If a custom OPTIONS handler is found, it gets called and can set its own CORS headers. If no custom handler exists, Payload falls back to the default CORS response. Also updated `headersWithCors` to only set CORS headers if they haven't already been set by the custom handler, preventing them from being overwritten. Fixes #14551
1 parent 14ac061 commit c103667

6 files changed

Lines changed: 92 additions & 33 deletions

File tree

packages/payload/src/utilities/handleEndpoints.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -140,19 +140,6 @@ export const handleEndpoints = async ({
140140
request,
141141
})
142142

143-
if (req.method?.toLowerCase() === 'options') {
144-
return Response.json(
145-
{},
146-
{
147-
headers: headersWithCors({
148-
headers: new Headers(),
149-
req,
150-
}),
151-
status: 200,
152-
},
153-
)
154-
}
155-
156143
const { payload } = req
157144
const { config } = payload
158145

@@ -257,6 +244,21 @@ export const handleEndpoints = async ({
257244
}
258245

259246
if (!handler) {
247+
// If no custom handler found and this is an OPTIONS request,
248+
// return default CORS response for preflight requests
249+
if (req.method?.toLowerCase() === 'options') {
250+
return Response.json(
251+
{},
252+
{
253+
headers: headersWithCors({
254+
headers: new Headers(),
255+
req,
256+
}),
257+
status: 200,
258+
},
259+
)
260+
}
261+
260262
return notFoundResponse(req, pathname)
261263
}
262264

packages/payload/src/utilities/headersWithCors.ts

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,28 +20,35 @@ export const headersWithCors = ({ headers, req }: CorsArgs): Headers => {
2020
'X-Payload-HTTP-Method-Override',
2121
]
2222

23-
headers.set('Access-Control-Allow-Methods', 'PUT, PATCH, POST, GET, DELETE, OPTIONS')
23+
// Only set default CORS headers if they haven't been set by custom handler
24+
if (!headers.has('Access-Control-Allow-Methods')) {
25+
headers.set('Access-Control-Allow-Methods', 'PUT, PATCH, POST, GET, DELETE, OPTIONS')
26+
}
2427

25-
if (typeof cors === 'object' && 'headers' in cors) {
26-
headers.set(
27-
'Access-Control-Allow-Headers',
28-
[...defaultAllowedHeaders, ...cors.headers].filter(Boolean).join(', '),
29-
)
30-
} else {
31-
headers.set('Access-Control-Allow-Headers', defaultAllowedHeaders.join(', '))
28+
if (!headers.has('Access-Control-Allow-Headers')) {
29+
if (typeof cors === 'object' && 'headers' in cors) {
30+
headers.set(
31+
'Access-Control-Allow-Headers',
32+
[...defaultAllowedHeaders, ...cors.headers].filter(Boolean).join(', '),
33+
)
34+
} else {
35+
headers.set('Access-Control-Allow-Headers', defaultAllowedHeaders.join(', '))
36+
}
3237
}
3338

34-
if (cors === '*' || (typeof cors === 'object' && 'origins' in cors && cors.origins === '*')) {
35-
headers.set('Access-Control-Allow-Origin', '*')
36-
} else if (
37-
(Array.isArray(cors) && cors.indexOf(requestOrigin!) > -1) ||
38-
(!Array.isArray(cors) &&
39-
typeof cors === 'object' &&
40-
'origins' in cors &&
41-
cors.origins.indexOf(requestOrigin!) > -1)
42-
) {
43-
headers.set('Access-Control-Allow-Credentials', 'true')
44-
headers.set('Access-Control-Allow-Origin', requestOrigin!)
39+
if (!headers.has('Access-Control-Allow-Origin')) {
40+
if (cors === '*' || (typeof cors === 'object' && 'origins' in cors && cors.origins === '*')) {
41+
headers.set('Access-Control-Allow-Origin', '*')
42+
} else if (
43+
(Array.isArray(cors) && cors.indexOf(requestOrigin!) > -1) ||
44+
(!Array.isArray(cors) &&
45+
typeof cors === 'object' &&
46+
'origins' in cors &&
47+
cors.origins.indexOf(requestOrigin!) > -1)
48+
) {
49+
headers.set('Access-Control-Allow-Credentials', 'true')
50+
headers.set('Access-Control-Allow-Origin', requestOrigin!)
51+
}
4552
}
4653
}
4754

test/endpoints/endpoints/root.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Config } from 'payload'
22

3-
import { applicationEndpoint, rootEndpoint } from '../shared.js'
3+
import { applicationEndpoint, customCorsEndpoint, rootEndpoint } from '../shared.js'
44

55
export const endpoints: Config['endpoints'] = [
66
{
@@ -38,4 +38,21 @@ export const endpoints: Config['endpoints'] = [
3838
method: 'post',
3939
path: `/${rootEndpoint}`,
4040
},
41+
{
42+
handler: () => {
43+
return Response.json(
44+
{ message: 'Custom OPTIONS handler' },
45+
{
46+
headers: {
47+
'Access-Control-Allow-Origin': 'https://custom-domain.com',
48+
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
49+
'Access-Control-Allow-Headers': 'X-Custom-Header',
50+
},
51+
status: 200,
52+
},
53+
)
54+
},
55+
method: 'options',
56+
path: `/${customCorsEndpoint}`,
57+
},
4158
]

test/endpoints/int.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { initPayloadInt } from '../helpers/initPayloadInt.js'
99
import {
1010
applicationEndpoint,
1111
collectionSlug,
12+
customCorsEndpoint,
1213
globalEndpoint,
1314
globalSlug,
1415
noEndpointsCollectionSlug,
@@ -113,5 +114,19 @@ describe('Endpoints', () => {
113114
expect(response.status).toBe(200)
114115
expect(params).toMatchObject(data)
115116
})
117+
118+
it('should call custom OPTIONS endpoint with custom CORS headers', async () => {
119+
const response = await restClient.OPTIONS(`/${customCorsEndpoint}`)
120+
const data = await response.json()
121+
122+
// Custom OPTIONS handler should be called and return custom response
123+
expect(response.status).toBe(200)
124+
expect(data.message).toBe('Custom OPTIONS handler')
125+
126+
// Custom CORS headers should be present
127+
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://custom-domain.com')
128+
expect(response.headers.get('Access-Control-Allow-Methods')).toBe('POST, GET, OPTIONS')
129+
expect(response.headers.get('Access-Control-Allow-Headers')).toBe('X-Custom-Header')
130+
})
116131
})
117132
})

test/endpoints/shared.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@ export const rootEndpoint = 'root'
1111
export const noEndpointsCollectionSlug = 'no-endpoints'
1212

1313
export const noEndpointsGlobalSlug = 'global-no-endpoints'
14+
15+
export const customCorsEndpoint = 'custom-cors'

test/helpers/NextRESTClient.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,22 @@ export class NextRESTClient {
213213
return result
214214
}
215215

216+
async OPTIONS(
217+
path: ValidPath,
218+
options: Omit<RequestInit, 'body'> & RequestOptions = {},
219+
): Promise<Response> {
220+
const { slug, params, url } = this.generateRequestParts(path)
221+
const { query, ...rest } = options || {}
222+
const queryParams = generateQueryString(query, params)
223+
224+
const request = new Request(`${url}${queryParams}`, {
225+
...rest,
226+
headers: this.buildHeaders(options),
227+
method: 'OPTIONS',
228+
})
229+
return this._GET(request, { params: Promise.resolve({ slug }) })
230+
}
231+
216232
async PATCH(path: ValidPath, options: FileArg & RequestInit & RequestOptions): Promise<Response> {
217233
const { slug, params, url } = this.generateRequestParts(path)
218234
const { query, ...rest } = options

0 commit comments

Comments
 (0)