codex - ✅(Solved) Fix [Security] High: Responses-API proxy forwards unauthenticated browser/local requests with operator bearer token [1 pull requests, 2 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openai/codex#17648Fetched 2026-04-14 05:41:45
View on GitHub
Comments
2
Participants
2
Timeline
14
Reactions
0
Timeline (top)
labeled ×4commented ×2mentioned ×2subscribed ×2

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 / Referer validation,
  • 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.

Fix Action

Fixed

PR fix notes

PR #2: Fix responses-api-proxy CSRF / local piggyback (#17648)

Description (problem / solution / changelog)

Summary

Closes the High/Confirmed CWE-352 (CSRF) and CWE-306 (missing auth) findings against codex-responses-api-proxy reported in #17648. The proxy holds the operator's OPENAI_API_KEY in memory and listens on 127.0.0.1:<port>. Today it accepts any request that reaches the socket, so a web page in the operator's browser or any local co-tenant process can spend the operator's token at will.

This change introduces caller authentication and browser-facing hardening:

  • Per-launch shared secret. run_main() generates a 256-bit random auth_token (base64url, no padding) at startup. Every inbound request to /v1/responses and /shutdown must present it via Authorization: Bearer <token> or X-Proxy-Token: <token>. Comparison is constant-time (constant_time_eq). Missing or wrong token → 401.
  • Origin gate. Any request carrying an Origin header is rejected with 403. Browsers always send Origin on cross-origin POSTs; CLIs do not. This kills the CSRF path even if the attacker somehow learns the token.
  • Content-Type gate. Body-bearing requests must declare Content-Type: application/json (allowing ; charset=…). Anything else → 415. This forces cross-origin browser POSTs through a CORS preflight, which the proxy (no Access-Control-Allow-Origin, 403 on OPTIONS) does not satisfy.
  • server-info.json hardened. New auth_token field. On Unix the file is opened O_CREAT|O_TRUNC with mode 0600, then chmoded to 0600 again to defeat umask interactions. The proxy refuses to follow a pre-existing symlink or overwrite a file whose permissions are looser than 0600 (blocks symlink-plant attacks against /tmp/server-info.json). When --server-info is omitted, the token is printed once to stderr.
  • Header sanitization. X-Proxy-Token is stripped from the upstream request alongside the existing Authorization/Host overrides, and dump.rs redacts it in dump files.
  • Shutdown endpoint. /shutdown now requires the same token and also rejects Origin-bearing requests, so a web page can no longer DoS the proxy.

Order of checks is deliberate: Origin → Content-Type → Auth → method/path. An unauthenticated browser never learns whether a given URL exists, and a token-guessing attacker is forced to also strip Origin (which a browser won't let them).

Plumbing for the Codex client lives behind the existing model_providers.<name>.http_headers config, so the integration test simply injects X-Proxy-Token there. Authorization is reserved for the upstream credential and is stripped before forwarding, so X-Proxy-Token takes precedence when both are present.

Files touched:

  • codex-rs/responses-api-proxy/Cargo.toml — add base64, constant_time_eq, rand deps; add assert_cmd, tempfile, tiny_http, reqwest dev-deps.
  • codex-rs/responses-api-proxy/src/lib.rs — token generation, AuthOutcome enum, hardened write_server_info, request gate, shutdown gate, header stripping, unit tests.
  • codex-rs/responses-api-proxy/src/dump.rs — redact X-Proxy-Token.
  • codex-rs/responses-api-proxy/README.md — document auth_token, the 0600 file mode, the new client invocation, the Origin/Content-Type requirements, and the shutdown auth requirement.
  • codex-rs/responses-api-proxy/tests/auth_gate.rs — new end-to-end test covering all gate outcomes (no header → 401, wrong token → 401, bad Origin → 403, bad Content-Type → 415, valid → 2xx with upstream called once carrying the operator's bearer and not X-Proxy-Token, /shutdown auth, server-info.json mode 0600).
  • codex-rs/core/tests/suite/responses_api_proxy_headers.rs — read auth_token from server info, plumb it through model_provider.http_headers, and add a smoke assertion that an unauthenticated POST is rejected with 401.

Intentionally out of scope: the exec-server/app-server WebSocket findings (separate tracking), secret rotation mid-run (per-launch is sufficient), and rate limiting (loopback-only socket + 256-bit token).

Test plan

  • cargo test -p codex-responses-api-proxy — new unit tests for authorize, write_server_info, and dump.rs redaction all pass.
  • cargo test -p codex-responses-api-proxy --test auth_gate — new end-to-end test covers all gate outcomes (401 / 403 / 415 / 2xx / /shutdown) and verifies the operator's bearer (not the proxy token) is what reaches upstream.
  • cargo test -p codex-core --test suite responses_api_proxy_headers — existing integration test still passes after the client wires X-Proxy-Token through model_provider.http_headers, and the new unauthenticated-POST sub-assertion returns 401.
  • Manual repro from the issue against a locally built proxy:
    • Browser-style CSRF (curl -H 'Origin: https://evil' …) → 403.
    • Local piggyback (no token) → 401.
    • Legitimate client (token + Content-Type: application/json) → 2xx and upstream call.
  • On Unix, stat -c '%a' /tmp/server-info.json shows 600 after launch; pre-creating the file as a symlink causes the proxy to refuse to start.

🤖 Generated with Claude Code

Changed files

  • codex-rs/Cargo.lock (modified, +5/-0)
  • codex-rs/core/tests/suite/responses_api_proxy_headers.rs (modified, +27/-1)
  • codex-rs/responses-api-proxy/Cargo.toml (modified, +8/-0)
  • codex-rs/responses-api-proxy/README.md (modified, +22/-15)
  • codex-rs/responses-api-proxy/src/dump.rs (modified, +8/-0)
  • codex-rs/responses-api-proxy/src/lib.rs (modified, +416/-14)
  • codex-rs/responses-api-proxy/tests/auth_gate.rs (added, +1246/-0)
  • codex-rs/tui/src/chatwidget.rs (modified, +1/-1)

Code Example

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"}'

---

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" }),
});

---

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"}'
RAW_BUFFERClick to expand / collapse

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 / Referer validation,
  • 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:

  1. Browser CSRF. Any web page the operator visits can issue a simple cross-origin POST that 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.
  2. 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

  1. Per-launch shared secret. Generate a random token at proxy startup, write it alongside the port in server-info.json (file mode 0600), and reject every request whose Authorization / X-Proxy-Token header does not match via constant-time compare.
  2. Harden the browser surface independently. Reject requests whose Origin header is set and not on an explicit allowlist, and require Content-Type: application/json so that browsers must preflight. Combine with (1) so CLI clients still work after presenting the shared secret.
  3. Tighten write_server_info(). Create the output file with mode 0600 and 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.

extent analysis

TL;DR

Implement a per-launch shared secret and harden the browser surface to prevent Cross-Site Request Forgery (CSRF) and local co-tenant piggyback attacks.

Guidance

  • Generate a random token at proxy startup and write it alongside the port in server-info.json with file mode 0600 to prevent unauthorized access.
  • Reject requests whose Origin header is set and not on an explicit allowlist to prevent CSRF attacks.
  • Require Content-Type: application/json to force browsers to preflight requests, making it harder for attackers to send malicious requests.
  • Create the output file with mode 0600 and refuse world-readable target paths to prevent information disclosure.

Example

To implement the suggested fix, you can modify the write_server_info() function to generate a random token and write it to server-info.json with the correct file mode. For example:

use std::fs;
use std::path::Path;

fn write_server_info(port: u16, pid: u32, token: &str) -> std::io::Result<()> {
    let server_info = format!("{{ \"port\": {}, \"pid\": {}, \"token\": \"{}\" }}", port, pid, token);
    let mut file = fs::OpenOptions::new()
        .write(true)
        .create(true)
        .mode(0o600)
        .open("server-info.json")?;
    file.write_all(server_info.as_bytes())?;
    Ok(())
}

Notes

The suggested fix assumes that the server-info.json file is used only by trusted local processes. If this is not the case, additional measures may be necessary to prevent unauthorized access.

Recommendation

Apply the suggested fix, which includes generating a per-launch shared secret and hardening the browser surface, to prevent CSRF and local co-tenant piggyback attacks. This fix provides a robust solution to the identified security vulnerabilities.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

codex - ✅(Solved) Fix [Security] High: Responses-API proxy forwards unauthenticated browser/local requests with operator bearer token [1 pull requests, 2 comments, 2 participants]