[Security] High: Responses-API proxy forwards unauthenticated browser/local requests with operator bearer token
Severity
High — Confirmed (working PoC against default config)
CWE / References
- CWE-352 (Cross-Site Request Forgery)
- CWE-306 (Missing Authentication for Critical Function)
- OWASP Top 10 A01:2021 — Broken Access Control
Affected file
codex-rs/responses-api-proxy/src/lib.rs — functions bind_listener(), forward_request(), write_server_info() (entry point around codex-rs/responses-api-proxy/src/lib.rs:138).
Audited commit: 49ca7c9f24ede84ce50de837516070761385c1a9 (HEAD of main).
Root cause
The responses-api-proxy binary binds 127.0.0.1:<port> and accepts any POST /v1/responses. It strips the inbound Authorization and Host headers, forwards all other headers verbatim, and injects the operator's bearer token (read once from stdin at startup) before forwarding upstream.
There is:
- no CSRF token,
- no
Origin/Referervalidation, - no content-type gate that would force a CORS preflight,
- no shared-secret or ACL for local clients.
The documented --server-info <file> option writes { "port": ..., "pid": ... } and makes port discovery trivial for any local process.
Impact
Two concrete attack paths share this root cause:
- Browser CSRF. Any web page the operator visits can issue a simple cross-origin
POSTthat the browser will send with the attacker's prompt. The proxy attaches the operator's OpenAI bearer token and forwards it upstream. CORS only blocks the attacker from reading the response — the request still executes. Result: arbitrary billed LLM calls, quota burn, audit-log pollution, feature-flag triggers. - Local co-tenant piggyback. Any local process that learns the loopback port (via
server-info.json,ss/lsof, supervisor logs, etc.) can spend the operator's bearer token directly.
Reproduction
Browser-origin CSRF (confirmed end-to-end against a local proxy instance):
curl -i -s -X POST 'http://127.0.0.1:18080/v1/responses' \
-H 'Origin: https://attacker.example' \
-H 'Content-Type: text/plain;charset=UTF-8' \
--data '{"model":"gpt-4o","input":"burn quota"}'
Browser equivalent (a page at https://attacker.example can run this):
fetch("http://127.0.0.1:18080/v1/responses", {
method: "POST",
headers: { "Content-Type": "text/plain;charset=UTF-8" },
body: JSON.stringify({ model: "gpt-4o", input: "burn quota" }),
});
Local co-tenant piggyback:
PROXY_PORT=$(jq .port /tmp/server-info.json)
curl -s -X POST "http://127.0.0.1:${PROXY_PORT}/v1/responses" \
-H 'Content-Type: application/json' \
--data '{"model":"gpt-4o","input":"whoami"}'
Suggested fix
- Per-launch shared secret. Generate a random token at proxy startup, write it alongside the port in
server-info.json(file mode0600), and reject every request whoseAuthorization/X-Proxy-Tokenheader does not match via constant-time compare. - Harden the browser surface independently. Reject requests whose
Originheader is set and not on an explicit allowlist, and requireContent-Type: application/jsonso that browsers must preflight. Combine with (1) so CLI clients still work after presenting the shared secret. - Tighten
write_server_info(). Create the output file with mode0600and refuse world-readable target paths.
After the fix, add an integration test that asserts the proxy rejects unauthenticated requests by default, so regressions fail CI rather than ship.
---
Filed as part of a third-party security audit of openai/codex at commit 49ca7c9. This finding is the only item classified Confirmed at High severity; two additional Likely High-severity issues on exec-server and app-server WebSocket listeners are being tracked separately.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗