Skip to content

fix(windows): keep the proxy auth token off srt-win exec's command line - #478

Open
ig-ant wants to merge 5 commits into
mainfrom
ig/windows-proxy-token-pipe
Open

fix(windows): keep the proxy auth token off srt-win exec's command line#478
ig-ant wants to merge 5 commits into
mainfrom
ig/windows-proxy-token-pipe

Conversation

@ig-ant

@ig-ant ig-ant commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

The per-session proxy auth token rode srt-win exec's argv inside the --env proxy URL values. Windows command lines are freely readable by same-user sibling processes, so a concurrent local process could lift the token and ride that session's proxy allowlist.

  • Token-bearing overlay entries now travel over srt-win exec's own stdin as a <u32 LE length><JSON [[KEY,VALUE],…]> frame behind an opt-in --env-stdin flag; everything tokenless stays on --env for debuggability. generateProxyEnvEntries tags secret entries at the source (no substring heuristics), and the broker's spawn env deletes every secret-tagged key value-blind (closing the nested-session stale-token case).
  • exec reads the frame BEFORE any ACE stamping, rejects a console stdin outright, and bounds the read with a 5s deadline — a caller that ignores the new contract fails fast and loudly, never hangs, and wedges nothing but itself. The frame forwards over the existing runner spec pipe, so the token reaches only the sandboxed child's environment block.
  • New exported spawnWrappedCommandWindows owns spawn+frame-write as the sanctioned path; wrapWithSandboxArgv returns stdinPayload for callers that spawn themselves (README documents the contract and the old-binary skew failure mode: clean clap unknown-argument error).
  • Runner-side merge enforces channel disjointness (error on case-insensitive overlap) instead of silent merge policy.

Test plan

Unit: wire-format round-trips both sides (shared encodeEnvStdinFrame/encode_frame, 4 MiB cap enforced with a config-facing error); token absent from argv and broker env (including a planted foreign-token case-variant HtTp_PrOxY); channel disjointness both arms. Cross-target clippy (-D warnings) both MSVC targets, fmt, typecheck, eslint clean; winsrt suites 39/0.

Live Windows 11 arm64 verification: in-guest lib tests pin frame→spec→env-block delivery (2/2); new CI smoke ci/smoke-env-stdin.ps1 ALL OK — planted-token control proves the sweep detects argv tokens (S1), exec merges the frame (S2b), the real token appears on no command line host-side (S3) or sandbox-side (S5) and IS present in the child environment (S4, file-based oracle). The smoke runs on both Windows CI legs.

Known follow-up (separate PR): on Linux, bwrap --setenv argv exposes the token to other local users via world-readable /proc/pid/cmdline; macOS argv==environ for same-uid readers and seatbelt already confines sandboxed peers.

ig-ant added 5 commits August 14, 2026 20:51
The per-session proxy auth token rode srt-win exec's argv inside the
--env proxy URL values (HTTPS_PROXY=http://user:<token>@localhost:...).
Windows command lines are freely readable by same-user sibling
processes, so a concurrent local process could lift the token and ride
that session's proxy allowlist.

Token-bearing overlay entries now travel over srt-win exec's own stdin
as a length-prefixed JSON frame, gated by a new --env-stdin flag:

- TS: wrapCommandWithSandboxWindows splits the overlay - entries whose
  value embeds the token become a stdinPayload (u32 LE length + JSON
  [[KEY,VALUE],...]) the caller writes to the spawned broker's stdin;
  everything else stays on --env for debuggability. The token-bearing
  entries are also dropped from the broker's spawn env. The srt CLI and
  test helpers write the payload after spawning.
- Rust: exec reads the frame from its stdin before spawning the runner
  (runner::decode_env_frame / merge_env_overlay; frame entries win over
  same-key --env duplicates) and forwards it to the child via the
  existing runner spec pipe, so the token still reaches only the
  sandboxed child's environment block.

The broker's stdin was previously unused (the runner spec travels over
a dedicated anonymous pipe, and the child's stdin is that pipe at EOF),
so nothing is displaced. The flag is opt-in: an older host that never
passes --env-stdin gets the old behavior unchanged.

New smoke-env-stdin.ps1 proves end to end on the Windows CI legs that
the token reaches the child's environment while appearing on no command
line, host-side or sandbox-side, with a planted-token decoy validating
the sweep. Unit tests cover the frame wire format on both sides and the
argv/env partition.
Follow-up to the previous commit, which moved token-bearing proxy env
entries off srt-win exec's argv onto a stdin frame. This makes the
contract structural instead of conventional:

- Misuse cannot hang: exec reads the frame BEFORE any per-exec side
  effect (previously it read after stamping per-exec deny ACEs, so a
  wedged caller left live ACEs on real paths), rejects a console
  stdin outright, and fails after 5s naming the stdinPayload
  contract if the frame never arrives. New
  spawnWrappedCommandWindows() owns the spawn+write so callers do
  not hand-roll the stdio contract; the srt CLI uses it.
- Secrets are tagged at the source: generateProxyEnvEntries (the one
  builder that interpolates the token) returns secretKeys, and both
  the argv/stdin partition and the broker-env handling key off the
  tags - the two hand-copied value-matching predicates are gone.
- Broker env scrubs by KEY, not value: every secret-tagged name is
  deleted from the ambient env in any case variant, so a stale
  export or an outer nested-sandbox session's token-bearing
  HTTPS_PROXY no longer rides the broker's spawn env (test added).
- Channel keys must be disjoint: srt-win now errors on a
  case-insensitive overlap between --env and the frame instead of
  silently merging; the wire format has one writer definition per
  side (encodeEnvStdinFrame with the 4 MiB cap surfaced as a typed
  error, shared encode_frame in Rust, test-only encode_env_frame).
- Stdin writes absorb only EPIPE (child exit is the real
  diagnostic); other stream errors surface via the child's error
  event, in the CLI, the srt-win spawn helper, and the test helper.

The smoke script's planted-token control now diagnoses itself: it
queries the decoy directly by PID and names which link broke (process
gone, WMI lacks command-line rights + whoami, or a quoting bug with
the actual command line), avoids -WindowStyle (can fail outside an
interactive session) with a Diagnostics.Process fallback, waits for
the decoy to actually die before the clean sweep, and checks exec's
own exit state before interpreting sweep results.

README documents the spawn contract and the version-skew failure
mode (an older pinned srt-win.exe rejects --env-stdin with a clean
unknown-argument error; prebuilt binaries ship from source at
release time).
…delivery in tests

The smoke's S4 check (token present in the sandboxed child's
environment) failed through two observation bugs, not delivery bugs:
the child's bulk stdout dump can go missing in some contexts while
trailing single lines arrive, and Windows PowerShell 5.1 inside the
sandbox produced zero entries from `Get-ChildItem env:` with no
trace, because the loop sat outside any try/catch. The in-guest lib
unit tests pin the actual delivery path (frame -> spec -> env block)
as correct.

- Probe: the child writes its full dump to cenv.txt in the probe dir
  (explicit icacls modify grants for BUILTIN\Users and the sandbox
  SID); the host prefers the file over stdout parsing and reports
  which oracle ran plus the child's FILEOK/FILEERR breadcrumb.
- Env enumeration is double-sourced with distinct prefixes - CENVD=
  from [Environment]::GetEnvironmentVariables() (pure .NET, no PS
  provider) and CENV= from Get-ChildItem env: - each wrapped in
  try/catch that records CENVDERR=/CENVERR= in the dump, so an
  enumeration failure can never be silent again. S4 accepts either
  prefix and prints the entire dump verbatim on failure.
- Earlier hardening in this change: exec's 'overlay=4 var(s)' stderr
  line asserted (S2b); minimal literal PATH/PATHEXT and a Quote-Arg
  that refuses embedded quotes keep the hand-rolled Arguments string
  sound; the decoy control diagnoses itself by direct PID query and
  stays alive (ping-first - cmd's rem swallows the & operator).
- Tests (library unittest target, cargo test --lib): cli.rs drives a
  hand-rolled probe-shaped frame through decode -> merge ->
  RunnerSpec -> encode and asserts the token var in the spec JSON;
  launch.rs asserts a spec overlay entry lands in the
  CREATE_UNICODE_ENVIRONMENT block with its lowercase case-twin.

Verified live on a Windows 11 arm64 guest: lib tests 2/2 in-guest;
smoke-env-stdin ALL OK (S1 control detects a planted argv token; S3
and S5 sweeps find the real token on no command line host- or
sandbox-side; S4 finds it in the child environment via the file
oracle).
… sites

Three integration-test sites missed the --env-stdin contract sweep
and failed on Windows CI:

- rexecSandboxed (H5/H6) and the H-glob restore re-check spawned the
  wrapped argv without writing stdinPayload, so srt-win exec read EOF
  where the secret-env frame belongs and failed with "read length
  prefix: failed to fill whole buffer". Both now pass the frame as
  spawnAsync input.
- The "argv carrying the --env overlay" row asserted the token-bearing
  proxy URLs (HTTP_PROXY/ALL_PROXY/DOCKER_HTTP_PROXY) ride --env; with
  a session auth token they now ride the stdin frame instead. The row
  asserts the split: those keys absent from argv, present in the
  decoded frame (ports still range-checked), with tokenless RSYNC_PROXY
  proving --env still carries the plain set.

No-Verification-Needed: test-only diff
…scrub test

The scrub test planted a fake HTTPS_PROXY (dead port) in process.env
and restored it in a finally, but Bun latches HTTP(S)_PROXY into its
HTTP client the moment the variable is SET - even with no request in
flight and even after deletion. Every later in-process request then
dialed the dead proxy, so whichever TLS-terminating e2e suite ran
next 502'd: credential-mask-sigv4 on one CI leg, tls-terminate-proxy
on another, and the same file's G/P schannel rows on Windows - the
failing set varied with test-file order, which is per-runner.

Exercise the same delete-by-key scrub path with
CLOUDSDK_PROXY_PASSWORD instead: it is secret-tagged by
generateProxyEnvEntries (the property under test) but meaningless to
Bun's networking, verified by probe not to latch. A comment marks the
hazard so no future test replants the latched names.

No-Verification-Needed: test-only diff
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