Skip to content

security

Nik edited this page May 30, 2026 · 2 revisions

Security

Purpose

DroidProxy runs two local HTTP servers that sit between Factory Droid and the OAuth subscription endpoints of Claude, Codex, Gemini, and Kimi. Because it holds live provider credentials and forwards authenticated traffic, the security posture matters. This page describes the trust boundaries, where secrets live, how the network surface is constrained, and how the shipped binary is signed and updated. The framing is trust-boundary oriented (loosely STRIDE), but every claim below maps to specific code in this repo.

Two facts set the baseline:

  • Everything binds to localhost by default. The bundled backend listens on 127.0.0.1:8318 and ThinkingProxy listens on 127.0.0.1:8317.
  • Credential files and the generated config are written with 0o600 permissions, so only the current macOS user can read them.

Related pages: Backend supervisor, Thinking proxy, Provider authentication, Configuration, Deployment.

Trust boundaries

The trusted zone is your Mac and the loopback interface. Everything off-host is untrusted. Provider upstreams are trusted only as TLS endpoints that legitimately receive the relevant OAuth token; they never receive credentials for other providers.

graph LR
    subgraph mac["Your Mac (trusted, localhost)"]
        droid["Factory Droid CLI"]
        tp["ThinkingProxy<br/>127.0.0.1:8317"]
        backend["CLIProxyAPI<br/>127.0.0.1:8318"]
        creds["~/.cli-proxy-api/<br/>OAuth JSON + cursor.json<br/>merged-config.yaml<br/>(0o600)"]
        droid --> tp
        tp --> backend
        backend --> creds
    end

    subgraph providers["Upstream providers (untrusted network, TLS)"]
        claude["Claude / platform.claude.com"]
        codex["OpenAI / Codex"]
        gemini["Google Gemini"]
        kimi["Kimi"]
    end

    subgraph third["Other endpoints (TLS)"]
        cursor["cursor-api.standardagents.ai"]
    end

    backend --> claude
    backend --> codex
    backend --> gemini
    backend --> kimi
    tp -->|beta-gated cursor models| cursor
Loading

The proxy hop (8317 -> 8318) targets 127.0.0.1 explicitly (targetHost = "127.0.0.1" in src/Sources/ThinkingProxy.swift). The backend's own bind host comes from host: in src/Sources/Resources/config.yaml, which defaults to 127.0.0.1.

Credentials and secrets at rest

OAuth tokens live as individual JSON files under ~/.cli-proxy-api/ (src/Sources/AuthPaths.swift is the single source of truth for that path). Files written by the app are restricted to the owner:

  • saveCursorApiKey in src/Sources/SettingsView.swift writes cursor.json and then calls FileManager.default.setAttributes([.posixPermissions: 0o600], ...).
  • getConfigPath in src/Sources/ServerManager.swift writes ~/.cli-proxy-api/merged-config.yaml and applies the same 0o600 permissions after the atomic write.

merged-config.yaml is sensitive because it can carry the remote-management secret-key injected from UserDefaults. The provider OAuth tokens themselves are managed by the bundled backend's login flows (runAuthCommand in src/Sources/ServerManager.swift).

Tokens never leave localhost except to the legitimate upstream provider. The documented exception is Claude OAuth token refresh, which contacts platform.claude.com as part of the normal Claude auth lifecycle. The proxy does not log token contents; the per-request debug line in ThinkingProxy only extracts reasoning/thinking/service-tier fields, not credentials.

Network exposure and remote access

Local-only is the default and the safe path:

  • ThinkingProxy binds to 127.0.0.1:8317 unless a custom bind address is set. The bind-address override is read from AppPreferences.bindAddress (src/Sources/AppPreferences.swift), which is beta-gated (returns the default 127.0.0.1 unless betaFlag is on) and validated: the raw value is trimmed, and an empty or multiline value falls back to the default so it cannot inject extra YAML lines or produce an invalid NWEndpoint host.
  • Setting the bind address to 0.0.0.0 exposes the proxy to other devices on the network (for example over Tailscale or LAN). The Settings UI shows this only under the beta flag and warns that it allows access from other devices (src/Sources/SettingsView.swift, "Bind address" field).

The remote management API is disabled by default and gated by two conditions in src/Sources/Resources/config.yaml:

remote-management:
  allow-remote: false
  secret-key: ""  # Leave empty to disable management API

getConfigPath injects the user's allowRemote and secretKey values from UserDefaults into the merged config. The Settings UI ("Remote Management" section in src/Sources/SettingsView.swift) surfaces a warning whenever allowRemote is on while secretKey is empty ("Set a secret key to secure remote access"), so an open management API without a key is hard to enable accidentally.

ThinkingProxy always adds Connection: close to forwarded requests and does not support keep-alive or pipelining, so each request is an isolated, fully read connection.

Code signing, notarization, and updates

.github/workflows/release.yml produces a signed, notarized, single-arch (arm64) build:

  • Code signing uses an Apple Developer ID certificate imported into the runner, with hardened runtime. entitlements.plist enables com.apple.security.cs.allow-unsigned-executable-memory and com.apple.security.cs.disable-library-validation (required by the bundled Go binary), so library validation is relaxed within an otherwise hardened, notarized app.
  • Notarization runs through xcrun notarytool submit ... --wait, and the result is stapled and validated (xcrun stapler staple / validate).
  • Sparkle updates are signed with EdDSA. The signing step runs sign_update with the SPARKLE_PRIVATE_KEY secret; the matching public key SUPublicEDKey (sB98dHKSN9fEe3vmVAufZoI4TbRWE6hHvAGSbzKweYM=) is embedded in src/Info.plist, so the app only accepts updates signed by the holder of that private key.
  • The update feed SUFeedURL points at https://raw.githubusercontent.com/anand-92/droidproxy/main/appcast.xml over HTTPS, and release enclosures are downloaded from the GitHub releases host. Automatic install is off (SUAutomaticallyUpdate is false) while scheduled checks are on.

Supply chain

  • The proxy backend is the bundled cli-proxy-api binary (src/Sources/Resources/cli-proxy-api, roughly 40 MB). It is vendored into the repo rather than downloaded at build time. The release workflow explicitly verifies the committed binary exists and reads its version string (cli-proxy-api -h) instead of re-downloading it.
  • The binary is built from mainline github.com/router-for-me/CLIProxyAPI. The committed copy is refreshed by the Update CLIProxyAPI workflow (.github/workflows/update-cliproxyapi.yml), which downloads the darwin_aarch64 release tarball every 12 hours and opens a reviewed bump PR. Vendoring it (rather than downloading at build time) keeps each release reproducible from a deliberate, reviewed binary bump.
  • Sparkle (2.5+) is the only notable third-party runtime dependency and is the update mechanism described above.

Because the backend binary is a prebuilt Go executable that cannot be reviewed line by line, trust in it rests on its provenance and the fact that all of its traffic stays on localhost except for the provider/Cursor TLS connections listed above.

Hardening checklist and entry points

Network entry points to keep in mind:

Entry point Default exposure Guard
ThinkingProxy :8317 127.0.0.1 Bind override beta-gated and validated in AppPreferences.bindAddress
Backend :8318 127.0.0.1 host: in config.yaml; proxy targets 127.0.0.1 directly
Remote management API Disabled allow-remote=false and empty secret-key; UI warns if remote on with empty key

Practical hardening guidance:

  • Leave the bind address at 127.0.0.1 unless you specifically need LAN or Tailscale access, and if you change it, set a strong secret-key first.
  • Never enable allow-remote with an empty secret key; the UI warning exists for exactly this case.
  • Treat ~/.cli-proxy-api/ as sensitive. The app keeps its files at 0o600; avoid loosening those permissions or copying tokens elsewhere.
  • Install only signed, notarized builds and let Sparkle's EdDSA check gate updates; do not bypass it with unsigned archives.

See also Configuration for the config fields referenced here and Backend supervisor for how the merged config is produced.

Clone this wiki locally