fix: harden proxy security, account selection, and scheduler lifecycle#81
Merged
Conversation
Findings from a full-codebase review (fanned out across subsystems, each finding verified against the code before fixing). Security (MITM/CONNECT path): - Gate the CONNECT handler with the proxy apiKey, mirroring the HTTP path (loopback-exempt, else require it via Proxy-Authorization). Without this a remote client could CONNECT api.anthropic.com and have an ACCOUNT TOKEN injected (token theft), or blind-tunnel to any host (open relay / SSRF). - Bind the listener to 127.0.0.1 by default; opt into a wider bind with TEAMCLAUDE_HOST / config.proxy.host. Defense-in-depth for the above. - Constant-time proxy-key comparison (safeKeyEqual) on both gates. - chmod 0600 config/state on every save (mode is ignored when the file exists). Correctness: - account-manager: the soonest-reset fallback in _selectNext no longer resurrects a disabled/errored account (it was force-clearing their state and routing a live request to them). Both fallback paths (_selectNext + probe) now honor model-ownership so an owned model never lands on a non-owner. _accountOwnsModel returns a real boolean; the match predicate is shared. addAccount now sets throttledAt (drift vs the constructor). - server: a non-transient error after headers are sent now destroys the socket instead of hanging the client; a pinned request whose account is unavailable short-circuits instead of doing a fleet-wide wait; the backpressure wait no longer leaks a 'close' listener per drain cycle on long SSE; the idle-timeout read can't surface as an unhandledRejection. - upstream-fetch: the per-call sx Agent uses keepAlive:false (it was leaking one tunneled socket per request; the per-call agent gives no reuse anyway). - mitm: reset the cert/server memo on failure so a transient error doesn't wedge the MITM path until restart; tear down both tunnel sockets on either side's error/close (+ a connect/idle timeout). Lifecycle: - Register SIGINT/SIGTERM in BOTH modes (TUI mode had no SIGTERM path, so a systemd/kill lost unsaved quota state and orphaned schedulers), with a forced-exit backstop. - warmer: stop() aborts an in-flight sweep and kills the child (no orphaned `claude`, no shutdown hang); a signal-killed child is an error, not a success; an interval CHANGE no longer triggers an extra quota-spending sweep (only off->on does). Same off->on fix in prober. - status-renderer surfaces the keep-warm summary (it was only in --json). Tests: connect-auth (the CONNECT gate + safeKeyEqual), selection-hardening (the resurrection + ownership-fallback guards), and warmer stop/abort + no-extra-sweep. 194 tests pass, lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Full-codebase review (fanned out across subsystems; every finding verified against the code before fixing). Grouped by area.
🔒 Security — MITM/CONNECT path
CONNECT api.anthropic.com:443and get one of your account tokens injected (curl -k --proxy victim:3456 …), or blind-tunnel to any host (open relay / SSRF to cloud metadata). Fix: gate the CONNECT handler with the same key check the HTTP path uses (loopback-exempt, elseProxy-Authorization).127.0.0.1by default (opt into a wider bind withTEAMCLAUDE_HOST/config.proxy.host) — defense-in-depth so the port isn't reachable off-box unless you ask for it.chmod 0600config/state on every save (modeis ignored when the file already exists).🐞 Correctness
_selectNextfallback resurrected disabled/errored accounts — the "all unavailable → soonest reset" path force-cleared their state and routed a live request to an operator-disabled or dead-token account. Now skips hard-state accounts.modelsownership, so an owned model could be routed to a non-owner (which just rejects it). Both paths now honor ownership._accountOwnsModelreturns a real boolean; the match predicate is shared;addAccountsetsthrottledAt.closelistener per drain cycle on long SSE → mutual cleanup; the idle-timeout read could surface as anunhandledRejection→ swallowed.AgentusedkeepAlive:true, leaking a tunneled socket per request with no reuse benefit →keepAlive:false.♻️ Lifecycle
systemd/killbypassed cleanup, losing up to ~1 min of unsaved quota state and orphaning schedulers. Now both modes register SIGINT/SIGTERM with a forced-exit backstop.stop()now aborts an in-flight sweep and kills theclaudechild (no orphan, no shutdown hang); a signal-killed child is an error, not a success; an interval change no longer triggers an extra quota-spending sweep (only off→on does). Same off→on fix in prober.--json-only).Tests
connect-auth.test.js(the CONNECT gate +safeKeyEqual),selection-hardening.test.js(resurrection + ownership-fallback guards), warmer stop/abort + no-extra-sweep. 194 tests pass, lint clean. Boot smoke-tested: binds127.0.0.1, status carrieswarm, pin 404s.Not fixed (noted, low/edge or deferred)
AccountUuidPatcher36-byte overwrite assumes a 36-char value (real Claude Code always sends one) — left the delicate state machine untouched.warmer.js↔prober.jsshare a scheduler skeleton — worth extracting a base class, but deferred (didn't want a structural refactor mixed into these fixes)._withTimeoutdoesn't abort the fetch — low, opt-in path.🤖 Generated with Claude Code