-
Notifications
You must be signed in to change notification settings - Fork 12
security
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:8318andThinkingProxylistens on127.0.0.1:8317. - Credential files and the generated config are written with
0o600permissions, so only the current macOS user can read them.
Related pages: Backend supervisor, Thinking proxy, Provider authentication, Configuration, Deployment.
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
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.
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:
-
saveCursorApiKeyinsrc/Sources/SettingsView.swiftwritescursor.jsonand then callsFileManager.default.setAttributes([.posixPermissions: 0o600], ...). -
getConfigPathinsrc/Sources/ServerManager.swiftwrites~/.cli-proxy-api/merged-config.yamland applies the same0o600permissions 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.
Local-only is the default and the safe path:
-
ThinkingProxybinds to127.0.0.1:8317unless a custom bind address is set. The bind-address override is read fromAppPreferences.bindAddress(src/Sources/AppPreferences.swift), which is beta-gated (returns the default127.0.0.1unlessbetaFlagis 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 invalidNWEndpointhost. - Setting the bind address to
0.0.0.0exposes 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 APIgetConfigPath 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.
.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.plistenablescom.apple.security.cs.allow-unsigned-executable-memoryandcom.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_updatewith theSPARKLE_PRIVATE_KEYsecret; the matching public keySUPublicEDKey(sB98dHKSN9fEe3vmVAufZoI4TbRWE6hHvAGSbzKweYM=) is embedded insrc/Info.plist, so the app only accepts updates signed by the holder of that private key. - The update feed
SUFeedURLpoints athttps://raw.githubusercontent.com/anand-92/droidproxy/main/appcast.xmlover HTTPS, and release enclosures are downloaded from the GitHub releases host. Automatic install is off (SUAutomaticallyUpdateis false) while scheduled checks are on.
- The proxy backend is the bundled
cli-proxy-apibinary (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 theUpdate CLIProxyAPIworkflow (.github/workflows/update-cliproxyapi.yml), which downloads thedarwin_aarch64release 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.
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.1unless you specifically need LAN or Tailscale access, and if you change it, set a strongsecret-keyfirst. - Never enable
allow-remotewith an empty secret key; the UI warning exists for exactly this case. - Treat
~/.cli-proxy-api/as sensitive. The app keeps its files at0o600; 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.