Skip to content

Commit 50c2cb0

Browse files
committed
fix(cors): expose the API response headers a browser client needs
Without `Access-Control-Expose-Headers` a browser can read only the six CORS-safelisted response headers, so the rate-limit budget, the `Retry-After` a 429 or 503 asks the caller to observe, and the request/run correlation ids were all on the wire but invisible to `fetch()`. Server-to-server callers were unaffected, which is why it went unnoticed. Exposed on the default `/api` policy only. The per-route `CORS_RULES` entries are wildcard-origin public endpoints and opt in individually if they ever need it, so this does not widen what an anonymous cross-origin caller can read from them.
1 parent 19e8b01 commit 50c2cb0

2 files changed

Lines changed: 21 additions & 0 deletions

File tree

apps/sim/proxy.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ describe('resolveApiCorsPolicy', () => {
128128
origin: 'https://app.sim.test',
129129
credentials: true,
130130
methods: 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS',
131+
exposeHeaders:
132+
'Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-Request-Id, X-Run-Id',
131133
headers: expect.stringContaining('Authorization'),
132134
})
133135
})

apps/sim/proxy.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export interface CorsPolicy {
1515
credentials: boolean
1616
methods: string
1717
headers: string
18+
/** Response headers a browser client may read; omitted leaves the CORS default. */
19+
exposeHeaders?: string
1820
}
1921

2022
/**
@@ -33,6 +35,19 @@ export interface CorsPolicy {
3335
*/
3436
const DEFAULT_API_ALLOWED_METHODS = 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS'
3537

38+
/**
39+
* Response headers the `/api` surface sets that a browser client must be able to read.
40+
*
41+
* Without `Access-Control-Expose-Headers` a browser can read only the six
42+
* CORS-safelisted response headers, so everything here is on the wire but
43+
* invisible to `fetch()` — the rate-limit budget, the retry delay a 429 or 503
44+
* asks the caller to observe, and the ids needed to correlate a run or a support
45+
* report. Server-to-server callers are unaffected, which is why the gap is easy
46+
* to miss.
47+
*/
48+
const DEFAULT_API_EXPOSED_HEADERS =
49+
'Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-Request-Id, X-Run-Id'
50+
3651
const DEFAULT_API_ALLOWED_HEADERS =
3752
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization'
3853

@@ -127,6 +142,7 @@ export function resolveApiCorsPolicy(request: NextRequest): CorsPolicy {
127142
credentials: true,
128143
methods: DEFAULT_API_ALLOWED_METHODS,
129144
headers: DEFAULT_API_ALLOWED_HEADERS,
145+
exposeHeaders: DEFAULT_API_EXPOSED_HEADERS,
130146
}
131147
}
132148

@@ -137,6 +153,9 @@ function applyCorsHeaders(response: NextResponse, policy: CorsPolicy): void {
137153
response.headers.set('Access-Control-Allow-Credentials', String(policy.credentials))
138154
response.headers.set('Access-Control-Allow-Methods', policy.methods)
139155
response.headers.set('Access-Control-Allow-Headers', policy.headers)
156+
if (policy.exposeHeaders) {
157+
response.headers.set('Access-Control-Expose-Headers', policy.exposeHeaders)
158+
}
140159
if (policy.origin !== '*') {
141160
response.headers.set('Vary', 'Origin')
142161
}

0 commit comments

Comments
 (0)