Skip to content

fix!: stop leaking credentials, parse SSE events properly in the tool wall, one error writer - #162

Merged
fylorn merged 1 commit into
mainfrom
fix/review-security-dialect
Sep 24, 2026
Merged

fylorn merged 1 commit into
mainfrom
fix/review-security-dialect

Conversation

@fylorn

@fylorn fylorn commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Fixes from a review. One PR, two halves.

Security

  • WebSocket forwarding leaked the gateway key: ws::upstream_url appended the raw query. It now goes through forward::upstream_url (which strips key=) and only swaps the scheme.
  • Redirects carried upstream credentials: base_client_builder now sets redirect::Policy::none(). That builder is behind every credential-bearing client in core (data plane, per-provider clients, OAuth refresh, model list, probes, ChatGPT / Z.ai login, replay), so a 3xx goes back to the client. The price-sheet fetch carries nothing secret and uses the new tw_gateway::public_client(): follows redirects, refuses https→http. Integration test with two local servers (localhost vs 127.0.0.1).
  • Secret files created 0644 then chmod 0600: now created with mode 0600 at open time: write_atomic (config.yaml + history), adopted client configs (tmp file is 0600; the original file's mode is restored afterwards when it existed, since those are other apps' files), adoption sidecars, requests.db (pre-created 0600 before SQLite opens it; -wal/-shm inherit the main file's mode), request-body blobs and their directories (created 0700). A new unix data directory is created 0700; existing ones are left alone. Windows ACL path unchanged.
  • detect.rs runs /bin/ps instead of ps from PATH.

Tool-call wall

  • data: without a space and events spread over several data: lines were invisible to the wall. Lines are still checked one by one (early detection on unfinished frames), and when no single line parsed, the whole event's joined data is checked at the frame boundary. Frame boundaries come from tw_dialect::frame::frame_end (now public, along with frame::parse and frame::data_of); both copies of find_frame_end are gone. Wall's public API is unchanged.

Errors and shared dialect helpers

  • error::Dialect removed. GatewayError uses tw_dialect::ir::Dialect, tw_dialect::convert::error_body and the new tw_dialect::convert::error_frame(client, status, message) -> String (a standalone in-stream error frame; Responses gets response.failed instead of a Chat-shaped frame). KeyPosition::dialect() replaces Dialect::from_key_position; ClientApi::error_dialect is gone (use dialect()).
  • Shape changes visible to clients: OpenAI-shaped bodies now use tw-dialect's vocabulary (401 authentication_error, 403 permission_error, 429 rate_limit_error, code: null), same as converted upstream errors. Gemini's status follows the code (500 INTERNAL, 502 UNAVAILABLE). The x-thinkwatch-error header still carries the slug.
  • New tw_dialect::official: is_official_host(url) (also rejects ?@, #@, \@ spoofs), ANTHROPIC_VERSION, fallback_max_output_tokens(model). Provider::is_official_endpoint is removed; the desktop keeps its price-table layer in translate::default_max_tokens.
  • tw_guard::redact::replace::redact_plain removed (test-only).

Checked: fmt, clippy --all-targets and --lib, bedrock clippy + test, full workspace tests.

🤖 Generated with Claude Code

… wall, one error writer

Security
- WebSocket forwarding builds its URL through forward::upstream_url, so the
  gateway key in `?key=` is stripped there too.
- Every credential-bearing reqwest client (data plane, OAuth refresh, model
  list, probes, ChatGPT/Z.ai login, replay) no longer follows redirects; 3xx
  goes back to the client. The price-sheet fetch carries no credentials and
  uses tw_gateway::public_client(), which follows redirects but refuses
  https -> http.
- Secret files are created 0600 at open time instead of chmod after write:
  config.yaml and history (write_atomic), adopted client configs and their
  sidecars, requests.db (pre-created before SQLite opens it), request bodies.
  A new unix data directory is created 0700. Adopted configs keep the
  original file's mode when it existed.
- Process detection runs /bin/ps, not ps from PATH.

Tool-call wall
- SSE parsing follows the spec: `data:` without a space, and events spread
  over several data lines (joined with \n). Frame boundaries come from
  tw_dialect::frame::frame_end; the duplicated find_frame_end in wall.rs and
  redact/sse.rs is gone.

Errors and dialect helpers
- error::Dialect is removed; GatewayError uses tw_dialect::ir::Dialect and
  tw_dialect::convert::{error_body, error_frame}. New error_frame writes a
  standalone in-stream error per client format (Responses gets
  response.failed, not a Chat-shaped frame). Gemini 502 maps to UNAVAILABLE.
- New tw_dialect::official: is_official_host (also rejects ?/#/\ userinfo
  spoofs), ANTHROPIC_VERSION, fallback_max_output_tokens. Provider::
  is_official_endpoint is removed.
- tw_guard::redact::replace::redact_plain removed (test-only).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@fylorn
fylorn force-pushed the fix/review-security-dialect branch from a466c21 to df86012 Compare September 24, 2026 06:10
@fylorn
fylorn merged commit 0d3387d into main Sep 24, 2026
2 checks passed
@fylorn
fylorn deleted the fix/review-security-dialect branch September 24, 2026 06:20
@fylorn fylorn mentioned this pull request Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant