Skip to content

[Bug]: CORS preflight rejects all browser SDK clients — Access-Control-Allow-Headers omits X-Stainless-* #1773

Closed

Description

@GLY2024

Client or integration

Direct HTTP/API client

Area

Proxy and routing

Summary

corsHeaders() in src/server/auth-cors.ts returns a hard-coded Access-Control-Allow-Headers list that omits the X-Stainless-* header family. Stainless-generated SDKs (Anthropic TS SDK, OpenAI TS SDK) attach X-Stainless-OS, -Lang, -Arch, -Runtime, -Runtime-Version, -Package-Version, -Retry-Count, -Timeout to every request, so any browser client built on one fails CORS preflight and can never reach the proxy — no matter how it is configured.

Expected: browser-based SDK clients can call the proxy.

The proxy already emits this same header family upstream itself (src/adapters/client-fingerprint.ts); the names just never made it into the CORS list.

This looks like a recurring pattern rather than a one-off omission: #379 appended ChatGPT-Account-Id to this same literal for ChatGPT/Codex App voice preflights, and OpenAI-Alpha … X-OAI-Attestation were appended after that for GPT-Live. Every new browser client costs another manual entry, and the failure mode each time is a request that never leaves the browser.

Suggested fix — echo the preflight's requested headers, keeping the static list as fallback:

const requestedHeaders = req?.headers.get("Access-Control-Request-Headers");
// ...
"Access-Control-Allow-Headers": requestedHeaders ?? "Content-Type, Authorization, /* …existing… */",
"Vary": "Origin, Access-Control-Request-Headers",

This is the default behaviour of the standard cors middleware. Access control is unaffected — Access-Control-Allow-Origin and the existing isAllowedRequestOrigin() check remain the gate; the header list cannot grant an origin that was already rejected. Verified: applied locally against 2.14.0, the affected client now connects and streams normally.

Reproduction

  1. ocx start --port 10100
  2. Open devtools on any HTTPS page, run:
await fetch('http://127.0.0.1:10100/v1/messages', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'anthropic-version': '2023-06-01',
    'x-api-key': '<key>',
    'x-stainless-os': 'Windows',   // every Stainless SDK sends this automatically
  },
  body: '{"model":"<any>","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}',
});
  1. Preflight is rejected; console shows Request header field x-stainless-os is not allowed by Access-Control-Allow-Headers in preflight response.
  2. Drop x-stainless-os from the headers and the same request succeeds.

Worth noting for triage: the preflight returns 204 and the identical request from curl returns 200, because only a browser compares the returned allow-list against the headers it intends to send. Every non-browser probe reports "gateway healthy", which makes this look like a connectivity problem rather than a CORS one.

Real-world trigger: the Claude for Office Word add-in (origin https://pivot.claude.ai, Anthropic TS SDK) — it surfaces the failure only as Connection error.

Version

2.14.0

Operating system

Windows 11

Provider and model

Not provider-specific — reproduced with deepseek/deepseek-v4-flash, but the request never leaves the browser, so any model behaves the same.

Logs or error output

# Browser console (Claude for Office add-in)
Access to fetch at 'http://127.0.0.1:10100/v1/messages' from origin 'https://pivot.claude.ai'
has been blocked by CORS policy: Request header field x-stainless-os is not allowed by
Access-Control-Allow-Headers in preflight response.

POST http://127.0.0.1:10100/v1/messages net::ERR_FAILED
Uncaught (in promise) Connection error.
  Caused by: TypeError: Failed to fetch (127.0.0.1:10100)

# Preflight succeeds at the HTTP level, which is why this is easy to misdiagnose
$ curl -s -o /dev/null -w "%{http_code}\n" -X OPTIONS http://127.0.0.1:10100/v1/messages \
    -H "Origin: https://pivot.claude.ai" \
    -H "Access-Control-Request-Method: POST" \
    -H "Access-Control-Request-Headers: content-type,x-api-key,x-stainless-os"
204

# Allow-list actually returned — no X-Stainless-* entry
Access-Control-Allow-Headers: Content-Type, Authorization, X-OpenCodex-API-Key, X-Api-Key,
  Anthropic-Version, Anthropic-Beta, ChatGPT-Account-Id, OpenAI-Alpha, X-Session-Id,
  Session-Id, Thread-Id, Originator, X-OAI-Attestation

Checks

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Activity

  1. coderabbitai commented on Aug 15, 2026

    @coderabbitai
    Contributor
    🔗 Related PRs

    #379 - fix(server): relay ChatGPT/Codex App voice via POST /v1/live [merged]
    #496 - fix(auth): support standard OpenAI Authorization and x-api-key headers in remote mode [open]
    #850 - fix(server): match browser extension CORS origins exactly [open]
    #1020 - fix(anthropic): surface upstream error bodies through sidecar bridges [merged]
    #1536 - fix(claude): isolate native passthrough credentials [merged]


    📝 Issue Planner

    Check the box below or use the @coderabbitai plan command to generate an implementation plan and prompts that you can use with your favorite coding assistant.

    • Create Plan

    🧪 Issue enrichment is currently in open beta.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  2. github-actions commented on Aug 15, 2026

    @github-actions
    Contributor

    Issue reopened

    The report now contains the information required by the automated check. Thanks for updating it.

  3. added
    account-poolOAuth, credentials, Codex pool, quota, failover, plans
    proxyHTTP proxy, routing, reverse-proxy / management auth
    on Aug 15, 2026
  4. added 2 commits that reference this issue on Aug 15, 2026
    f46094a
    53130de
  5. lidge-jun commented on Aug 15, 2026

    @lidge-jun
    Owner

    Fixed on dev as 53130de (#1778).

    corsHeaders() now echoes the request headers an already-allowed origin asked for, keeping the previous static list as the floor, and sets Vary: Origin, Access-Control-Request-Headers so a shared cache cannot replay one client's allow-list to another.

    The echo sits behind the origin check that already ran, so it widens which headers an admitted caller may send and never which origins are admitted — a rejected origin still gets 403 before any allow-list is built, and the preflight carries no credential and no auth side effect.

    Verified on the remote validation host: 142 pass / 0 fail across tests/server-auth.test.ts, tests/server-live.test.ts, tests/server-loopback-host-gate.test.ts, tests/loopback-listener-admission.test.ts. The new regression table-drives /v1/messages, /v1/responses, and /v1/chat/completions.

  6. added a commit that references this issue on Sep 17, 2026
    565b40f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    account-poolOAuth, credentials, Codex pool, quota, failover, plansbugSomething isn't workingproxyHTTP proxy, routing, reverse-proxy / management auth

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions