Skip to content

Password-auth + reverse-proxy host header can make /codex-api/* return HTML login page with HTTP 200, causing 'RPC ... malformed envelope' #45

@Jackten

Description

@Jackten

Title: Password-auth + reverse-proxy host header can make /codex-api/* return HTML login page with HTTP 200, which frontend surfaces as “RPC … returned malformed envelope”

Summary

When codexUI is run with --password behind a reverse proxy / Tailscale Serve that forwards to 127.0.0.1, unauthorized API requests to /codex-api/* can return the HTML login page with HTTP 200 instead of a 401 JSON/auth response.

The frontend RPC client expects JSON shaped like { result: ... }, so the next RPC call fails with:

  • RPC turn/start returned malformed envelope

This is easy to hit after a restart or any flow where the browser does not have a valid auth cookie for the codexUI process.

Why this matters

From the browser, the page can look like codexUI is loaded normally, but the next action fails with a misleading low-level RPC error instead of a clear “login required / session expired” message.

For remote/mobile use behind Tailscale Serve, this looks like the app randomly breaks after a restart.

Actual behavior

  • /codex-api/rpc responds with HTTP 200
  • Content-Type: text/html; charset=utf-8
  • body is the login page HTML
  • frontend tries response.json() and/or expects a top-level result
  • frontend throws RPC <method> returned malformed envelope

Expected behavior

For unauthorized API requests under /codex-api/* (especially /codex-api/rpc), codexUI should return a machine-readable auth failure, e.g.:

  • HTTP 401 or 403
  • JSON such as { "error": "Unauthorized" } or { "code": "auth_required", ... }

The frontend should then show a clear auth/session-expired state instead of an RPC envelope error.

Reproduction

My deployment is:

  • codexUI 0.1.78
  • running on 127.0.0.1:15999
  • fronted by Tailscale Serve: https://<machine>.tail*.ts.net:3443 -> http://127.0.0.1:15999
  • codexUI started with --password ... --no-open --no-tunnel --no-login

Minimal repro:

  1. Start codexUI with --password.
  2. Put it behind a reverse proxy / Tailscale Serve that forwards to 127.0.0.1:15999 while preserving a non-localhost Host header.
  3. Make an API request without a valid auth cookie.
  4. Observe that /codex-api/rpc or /codex-api/accounts returns the login page HTML with HTTP 200.
  5. In the browser UI, the next action fails with RPC ... returned malformed envelope.

Concrete proof from the live deployment:

python3 - <<'PY'
import urllib.request, json
host='netcup-clawd.tail5bf34d.ts.net:3443'
body=json.dumps({'method':'thread/start','params':{}}).encode()
req=urllib.request.Request(
    'http://127.0.0.1:15999/codex-api/rpc',
    data=body,
    headers={
        'Host': host,
        'Content-Type': 'application/json',
        'Accept': 'application/json',
    },
)
with urllib.request.urlopen(req, timeout=20) as r:
    print(r.status)
    print(r.headers.get('content-type'))
    print(r.read(200).decode('utf-8', 'replace'))
PY

Observed result:

  • status: 200
  • content-type: text/html; charset=utf-8
  • body starts with <!DOCTYPE html> ... <title>Codex Web</title> ...

The same behavior reproduces for /codex-api/accounts.

Root cause in code

  1. Frontend RPC client treats any non-{ result: ... } JSON as a malformed envelope

    • packaged frontend bundle: dist/assets/index-DRmRl2NH.js
    • function Mv(...)
    • if parsed response does not contain top-level result, it throws:
      • RPC ${method} returned malformed envelope
  2. Backend /codex-api/rpc handler normally does wrap RPC responses correctly

    • packaged backend bundle: dist-cli/index.js:5505-5523
    • normal success path is setJson4(res, 200, { result })
  3. Password auth middleware returns HTML login page with HTTP 200 for unauthorized requests

    • dist-cli/index.js:6243-6253
      • request is only auto-authorized for:
        • localhost remote + localhost host header, or
        • trusted Tailscale remote, or
        • cookie token found in validTokens
    • dist-cli/index.js:6296-6339
      • validTokens is an in-memory Set()
      • unauthorized requests that do not hit /auth/login fall through to:
        • res.status(200).send(LOGIN_PAGE_HTML)
  4. Because the token store is in-memory, a restart recreates the empty Set()

    • so any browser that no longer has a currently-valid cookie for this process instance can hit the HTML-200 path again
  5. Reverse-proxy / Tailscale-Serve shape makes this easier to hit

    • when codexUI is bound to 127.0.0.1 and fronted by a reverse proxy, the app may see:
      • local/proxy-side socket address
      • non-localhost Host header
    • that means it can fail the localhost fast-path while still serving a remote browser session

Suggested fix direction

Primary fix:

  • For unauthorized requests under /codex-api/*, never return HTML with HTTP 200.
  • Return a proper auth error (401/403 JSON) instead.

Recommended follow-up:

  • Teach the frontend RPC wrapper to recognize auth failures cleanly and show a login/session-expired state.
  • Consider using a signed stateless cookie or another restart-safe session mechanism if password auth is meant to survive process restarts more gracefully.

Why this is not a duplicate

I searched the current issues before filing.
The closest things I found were:

I did not find an existing issue covering:

  • HTML login page returned from /codex-api/rpc with HTTP 200
  • frontend surfacing that as RPC ... returned malformed envelope
  • the reverse-proxy / Tailscale Serve interaction

Environment

  • codexUI: 0.1.78
  • Codex CLI: 0.120.0
  • Node: v22.22.2
  • OS: Linux netcup-clawd 6.17.0-14-generic x86_64 (Ubuntu)
  • service start shape:
    • /usr/bin/node .../dist-cli/index.js --port 15999 --password <set> --no-open --no-tunnel --no-login /root/.hermes/workspace
  • proxy shape:
    • https://netcup-clawd.tail5bf34d.ts.net:3443 (tailnet only)
    • proxied to http://127.0.0.1:15999

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions