Skip to content

Commit 61f4c00

Browse files
authored
fix: env-file ownership + response panic recovery + oauth header injection (#38)
* fix(container): chown env file back to agent runtime user Sluice's docker exec runs the env-injection script as the image's USER (root for the upstream openclaw and hermes images). The awk pre-pass renames the file (new file inherits the writer's UID) and the heredoc append writes the new managed block; both leave the file owned by root. The agent runtime later runs as a non-root user (UID 10000 for the upstream hermes image, configurable via HERMES_UID), and a root-owned env file blocks agent-side write paths like `hermes claw migrate` or in-agent secret edits with EACCES. Inheriting the parent directory's owner is portable: every agent's entrypoint chowns its data dir to its runtime user before sluice ever exec's in. The chown is best-effort — a `stat` or `chown` failure does not abort the script (a read-only env file still satisfies sluice's own usage), so containers without GNU stat degrade gracefully. The chown step is appended on the SAME line as the heredoc `<<` operator with `&&` short-circuiting, because shell will not let `&&` at the start of a new line attach to a command whose heredoc body sits in between. New unit test asserts the chown is present both when the managed block has content and when it is empty. * fix: response handler panic guard, srm nil-input, bsd stat fallback Three fixes that turned up while validating v0.13.1's OAuth flow on the live deployment. - proxy/addon: add a top-level recover in Response and StreamResponseModifier with full stack capture. The OAuth Codex device-code flow surfaced a "nil pointer dereference" panic from somewhere downstream that go-mitmproxy's outer recover swallowed, leaving the agent reading an empty response body. The panic source itself remains unidentified, but escaping it into go-mitmproxy means losing the response — both handlers now log the stack and fall back to safe defaults (Response leaves f.Response unchanged at the panic point; StreamResponseModifier hands back the original input reader). Real tokens cannot leak: any panic in Response runs after processOAuthResponseIfMatching's own snapshot/rollback, so f.Response.Body is either fully phantom-swapped or untouched at the time of the panic. - proxy/addon: defensive nil check on the input reader for StreamResponseModifier. go-mitmproxy normally passes a non-nil reader, but a nil here causes io.LimitReader + io.ReadAll to nil-deref on the first Read call. When in is nil we now return nil, which makes go-mitmproxy's reply() skip its copyStream branch and fall through to writing f.Response.Body (already phantom-swapped by the Response handler). - container/types: portable stat fallback. The chown step from the earlier commit only worked under GNU stat (Linux containers alpine/debian); BSD stat (macOS guests booted via the tart backend) uses `-f`. We now try `-c` first and fall back to `-f` on non-zero exit so the chown succeeds across both userlands. * test(proxy): exercise response panic recovery + srm nil-input - Add a test-only responsePanicHook field to SluiceAddon. When non-nil it fires between processOAuthResponseIfMatching and the DLP scan, letting tests force the downstream-of-OAuth panic shape we observed in production without engineering a malformed Flow that triggers a real nil deref. Always nil in production. - TestAddonResponse_RecoversFromDownstreamPanic uses the hook to panic between OAuth swap and DLP scan, then asserts: the test itself doesn't see the panic propagate, real tokens stayed out of f.Response.Body, and the phantom token from the swap is still present. - TestAddonStreamResponseModifier_HandlesNilInput passes nil to StreamResponseModifier and asserts no panic + the handler returns nil so go-mitmproxy's reply() falls through to writing f.Response.Body. - Drop the line-broken hyphen in the Response handler's comment ("processOAuthResponseIfMatching" was wrapping with a hyphen inside the identifier). * fix(proxy): extract access_token before applying header binding template OAuth credentials are stored in the vault as JSON-marshalled OAuthCredential structs (access_token + refresh_token + token_url + expires_at). The header-injection path in addon.injectHeader and quic.go was substituting the binding's `{value}` placeholder with the entire JSON blob, producing requests like Authorization: Bearer {"access_token":"<jwt>","refresh_token":...} that upstream APIs reject with 401 "Could not parse your authentication token" (reproduced live against chatgpt.com via the OpenAI Codex device-code OAuth flow). Static credentials are stored as plain strings so they were unaffected; the bug only manifested for OAuth-type credentials with a header binding that uses a template. This bug has been latent since the bidirectional OAuth phantom swap was introduced (commit 0be82d4); before that, OAuth tokens were typically loaded into the vault as raw strings via `sluice cred update` and the JSON envelope shape never reached the injection path. The buildPhantomPairs / phantom-swap path was already OAuth-aware via vault.IsOAuth, so phantom-token replace in the request body kept working; only the header-injection path was broken. Fix: detect a JSON envelope by shape (first byte `{`, parses as OAuthCredential, has non-empty access_token) and substitute just the access_token. Static credentials and any other plain string pass through unchanged. Both the buffered-HTTP path (internal/proxy/addon.go) and the QUIC/HTTP3 path (internal/proxy/quic.go) call the same helper. New test asserts pass-through for static/empty/malformed values and access_token extraction for an OAuth blob. * fix(proxy): metadata-driven OAuth dispatch for header injection The previous extractInjectableSecret inferred OAuth-vs-static from the secret's JSON shape (first byte `{`, parses as OAuthCredential). Two issues with that: - A static credential whose value happened to be OAuth-shaped JSON (e.g. an upstream that already stores access_token+token_url) would be silently treated as OAuth, with only access_token injected. Other code paths in sluice already key on credential_meta.cred_type to avoid this exact misclassification. - Leading whitespace or newlines before the brace bypassed the shape check entirely, even though json.Unmarshal would still parse it. The OAuth envelope would round-trip through the injection unchanged. Fix: dispatch on the credential's metadata type via the OAuthIndex.Has(credName) lookup. The OAuthIndex is rebuilt from credential_meta on startup and SIGHUP, so the injection path now asks the same source-of-truth as the response interceptor. A new free function extractInjectableSecret(idx, name, secret) takes the index explicitly so the QUIC path (which carries its own pointer) shares the same logic with the addon. Also wires the QUIC proxy with its own oauthIndex pointer plus a SetOAuthIndex setter; Server.UpdateOAuthIndex now mirrors index updates into both the addon and the QUIC proxy so HTTP/3 header injection follows the same dispatch rules as HTTP/1+2. Test coverage exercises: - Static cred with plain string -> pass-through. - Static cred whose value is OAuth-shaped JSON -> still pass-through (because it's not in the OAuth index). - OAuth cred with normal JSON -> access_token extraction. - OAuth cred with leading whitespace -> still extracts access_token (json.Unmarshal handles whitespace). - OAuth cred with corrupted vault payload -> falls back to raw secret with a diagnostic log line. - nil index -> every credential treated as static (early-startup path before UpdateOAuthIndex fires). * fix: copilot review round 4 on PR #38 - proxy/addon: extractInjectableSecret log uses generic [INJECT] prefix because the helper now serves both the HTTP/1+2 addon path (internal/proxy/addon.go) and the HTTP/3 (QUIC) path (internal/proxy/quic.go). [ADDON-INJECT] would mislead a reader who saw the line in a deployment that uses HTTP/3 exclusively. - proxy/addon: StreamResponseModifier now returns http.NoBody (an empty reader) instead of nil when the input reader is nil. The nil case only fires under f.Stream=true (where the Response addon callback was skipped, so f.Response.Body is empty), so a literal nil return left reply() with no body source AND no body bytes — undefined-length responses trip some HTTP clients. http.NoBody is well-framed: reply() copies its EOF immediately and the agent sees a zero-byte body. The nil-input test was updated to assert the new behavior. * fix(proxy): srm recover uses buffered bytes when stream is drained The deferred recover on StreamResponseModifier previously fell back to `out = in` on panic. After io.ReadAll consumes the input stream (line 901), `in` is drained — a panic later in swapOAuthTokens or the body-replacement steps would hand the agent an empty reader even though we already had the bytes in memory. Capture the bytes into a closure-scoped `bufferedBody` slice immediately after the read completes. The recover prefers bufferedBody if it is set, otherwise falls back to `in` (the read hasn't started yet, stream is intact). Either way the agent gets a usable body. * fix(proxy): cache oauth metas so quic init picks them up Two PR #38 round 6 follow-ups. Server now caches the latest credential_meta slice it saw via UpdateOAuthIndex. setupInjection re-applies the cache to the QUIC proxy right after assigning s.quicProxy, so an UpdateOAuthIndex call that arrives before the proxy exists is not silently dropped. Without the cache, a future reorder of the startup sequence would re-introduce the OAuth-envelope header injection bug for HTTP/3 traffic. Response addon also gets a nil-flow guard mirroring StreamResponseModifier so the deferred recover does not dereference a nil flow when tests build a minimal Response addon path. * fix(proxy): never fall back to raw oauth body on stream panic Copilot round 7: the deferred recover in StreamResponseModifier returned the buffered raw upstream bytes when a panic fired after io.ReadAll. For an OAuth-matched token endpoint those bytes contain real access and refresh tokens, so a panic in swapOAuthTokens (or anywhere between the read and the swap) would have leaked real credentials to the agent. Replace bufferedBody with safeFallback. We assign it ONLY after a successful swap, when the buffer is the phantom-only modified body. A panic before that point falls back to http.NoBody. The agent sees an empty 2xx token response and surfaces the failure as a parse error, which is strictly preferable to handing over a real bearer. The non-panic error path (swap parse failure on a non-OAuth body, e.g. an HTML error page from a misconfigured token endpoint) keeps the historical pass-through behavior but no longer assigns safeFallback so a late panic would still hit http.NoBody rather than echo whatever that body was. * fix(proxy): guard nil input before flow checks in stream modifier Copilot round 8: the nil-input guard ran AFTER the early return for nil flow / nil request, so a call where both `f` and `in` were nil would skip the guard, leave `out = in` (nil), and trip a nil io.Reader nil-deref in go-mitmproxy's downstream copy. Move the nil-input check to the top of the function so http.NoBody is returned regardless of the flow's shape.
1 parent d27b05e commit 61f4c00

7 files changed

Lines changed: 488 additions & 39 deletions

File tree

internal/container/agent_profile_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,31 @@ func TestBuildEnvInjectionScript_QuotesValuesForSourcing(t *testing.T) {
242242
}
243243
}
244244

245+
func TestBuildEnvInjectionScript_ChownsEnvFileToDirOwner(t *testing.T) {
246+
// Sluice's docker exec runs as the image's USER (root for the
247+
// upstream openclaw and hermes images), so the awk rename and
248+
// heredoc append leave the file root-owned. The agent runtime
249+
// runs as a non-root user, so without a chown back to the dir
250+
// owner the agent cannot run `hermes claw migrate` or in-agent
251+
// secret edits. Verify the script emits a chown step.
252+
for _, hasValues := range []bool{true, false} {
253+
envMap := map[string]string{}
254+
if hasValues {
255+
envMap["KEY"] = "value"
256+
}
257+
script, err := BuildEnvInjectionScript(envMap, false, true)
258+
if err != nil {
259+
t.Fatalf("hasValues=%v: build script: %v", hasValues, err)
260+
}
261+
if !strings.Contains(script, `stat -c '%u:%g'`) {
262+
t.Errorf("hasValues=%v: script must stat the parent dir to derive owner: %s", hasValues, script)
263+
}
264+
if !strings.Contains(script, `chown "$DIR_OWNER" "$ENV_FILE"`) {
265+
t.Errorf("hasValues=%v: script must chown the env file to dir owner: %s", hasValues, script)
266+
}
267+
}
268+
}
269+
245270
func TestBuildEnvInjectionScript_RejectsNewlineInValue(t *testing.T) {
246271
// A newline in the value would split the env-file entry across two
247272
// lines. The second line would either be silently lost or interpreted

internal/container/types.go

Lines changed: 67 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -295,37 +295,76 @@ func BuildEnvInjectionScriptForProfile(profile *AgentProfile, envMap map[string]
295295
break
296296
}
297297
}
298-
if !hasContent {
299-
return script.String(), nil
300-
}
301298

302-
// Append the new block via a quoted-tag heredoc. The single quotes
303-
// around the tag (`<<'TAG'`) tell the shell to perform NO expansion
304-
// on the body — every byte we write between the heredoc start and
305-
// the end tag lands in the file verbatim. That lets us emit
306-
// `KEY='value'` lines whose contents are safe under both shell
307-
// `source` and dotenv parsing.
308-
script.WriteString(fmt.Sprintf(" && cat >> \"$ENV_FILE\" <<'%s'\n", envHeredocTag))
309-
script.WriteString(EnvBlockBegin)
310-
script.WriteString("\n")
311-
for _, k := range keys {
312-
v := envMap[k]
313-
if v == "" {
314-
continue
299+
// Match the env file's ownership to its parent directory. The
300+
// docker exec runs the script as the image's USER (typically
301+
// root for agent containers), so both the awk rename above and
302+
// the heredoc `cat >>` leave the file root-owned. The agent
303+
// runtime runs as a non-root user (UID 10000 for the upstream
304+
// hermes image, configurable via HERMES_UID); a root-owned env
305+
// file blocks agent-side write paths like `hermes claw migrate`
306+
// or in-agent secret edits with EACCES. Inheriting the parent
307+
// directory's owner is portable across agents because each
308+
// agent's entrypoint chowns its data dir to its runtime user
309+
// before sluice ever exec's in. The chown is best-effort: a
310+
// read-only env file still satisfies sluice's own usage, so a
311+
// stat or chown failure does not abort the script.
312+
//
313+
// Stat flag portability: GNU stat (Linux containers like
314+
// alpine, debian) uses `-c`, BSD stat (macOS guests booted via
315+
// the tart backend) uses `-f`. The two `stat` calls are run as
316+
// fallbacks under a single subshell so whichever userland is
317+
// in the agent's container resolves the directory owner.
318+
const chownStep = ` && { DIR_OWNER=$(stat -c '%u:%g' "$(dirname "$ENV_FILE")" 2>/dev/null` +
319+
` || stat -f '%u:%g' "$(dirname "$ENV_FILE")" 2>/dev/null);` +
320+
` [ -n "$DIR_OWNER" ] && chown "$DIR_OWNER" "$ENV_FILE" 2>/dev/null;` +
321+
` true; }`
322+
323+
if hasContent {
324+
// Append the new block via a quoted-tag heredoc. The single
325+
// quotes around the tag (`<<'TAG'`) tell the shell to perform
326+
// NO expansion on the body. Every byte between the heredoc
327+
// start and the end tag lands in the file verbatim, so we
328+
// can emit KEY='value' lines whose contents are safe under
329+
// both shell source and dotenv parsing.
330+
//
331+
// The chownStep is appended to the SAME line as the heredoc
332+
// `<<` operator (before the heredoc body) because shell will
333+
// not let a `&&` at the start of a new line attach to a
334+
// command whose heredoc body is in between. With this
335+
// placement, the parser sees one logical line:
336+
// cat >> file <<TAG && { chown ...; }
337+
// whose heredoc body follows. The `&&` short-circuits if
338+
// cat fails (disk full, permissions, etc.) so we do not
339+
// chown a file that did not get the new managed block.
340+
script.WriteString(fmt.Sprintf(" && cat >> \"$ENV_FILE\" <<'%s'%s\n", envHeredocTag, chownStep))
341+
script.WriteString(EnvBlockBegin)
342+
script.WriteString("\n")
343+
for _, k := range keys {
344+
v := envMap[k]
345+
if v == "" {
346+
continue
347+
}
348+
// Escape embedded single quotes via the four-character
349+
// idiom (apostrophe, backslash, apostrophe, apostrophe).
350+
// Wrapped in single quotes, the result is one
351+
// well-formed dotenv/shell string with no expansion.
352+
escaped := strings.ReplaceAll(v, "'", `'\''`)
353+
script.WriteString(k)
354+
script.WriteString("='")
355+
script.WriteString(escaped)
356+
script.WriteString("'\n")
315357
}
316-
// Escape embedded single quotes: 'value' -> 'val'\''ue'.
317-
// The result, wrapped in single quotes, is one well-formed
318-
// dotenv/shell string with no expansion.
319-
escaped := strings.ReplaceAll(v, "'", `'\''`)
320-
script.WriteString(k)
321-
script.WriteString("='")
322-
script.WriteString(escaped)
323-
script.WriteString("'\n")
358+
script.WriteString(EnvBlockEnd)
359+
script.WriteString("\n")
360+
script.WriteString(envHeredocTag)
361+
script.WriteString("\n")
362+
} else {
363+
// No managed-block emission. The awk pre-pass still
364+
// rewrote the file (removing any prior block), so we
365+
// still want to fix ownership of the result.
366+
script.WriteString(chownStep)
324367
}
325-
script.WriteString(EnvBlockEnd)
326-
script.WriteString("\n")
327-
script.WriteString(envHeredocTag)
328-
script.WriteString("\n")
329368

330369
return script.String(), nil
331370
}

internal/proxy/addon.go

Lines changed: 152 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"log"
88
"net"
99
"net/http"
10+
"runtime/debug"
1011
"sort"
1112
"strconv"
1213
"strings"
@@ -127,6 +128,14 @@ type SluiceAddon struct {
127128
// time.Sleep-based synchronization. Nil in production.
128129
persistDone chan struct{}
129130

131+
// responsePanicHook is a test injection point for the Response
132+
// handler's deferred recover. When non-nil it is invoked between
133+
// the OAuth swap and the DLP scan, so a test can force the
134+
// downstream-of-OAuth panic shape we observed in production
135+
// without having to engineer a malformed Flow that triggers a
136+
// real nil deref. Always nil in production.
137+
responsePanicHook func()
138+
130139
// auditLog, when non-nil, receives per-request deny/inject events.
131140
auditLog *audit.FileLogger
132141

@@ -547,11 +556,53 @@ func (a *SluiceAddon) injectHeaders(f *mitmproxy.Flow, host string, port int) {
547556
}
548557
defer secret.Release()
549558

550-
f.Request.Header.Set(binding.Header, binding.FormatValue(secret.String()))
559+
f.Request.Header.Set(binding.Header, binding.FormatValue(extractInjectableSecret(a.oauthIndex.Load(), binding.Credential, secret.String())))
551560
log.Printf("[ADDON-INJECT] injected header %q for %s:%d (credential %q)",
552561
binding.Header, host, port, binding.Credential)
553562
}
554563

564+
// extractInjectableSecret returns the value to substitute into a binding's
565+
// `{value}` template.
566+
//
567+
// Static credentials are plain strings stored as-is in the vault; the
568+
// value to inject is the string itself. OAuth credentials are
569+
// JSON-marshalled OAuthCredential structs (access_token + refresh_token
570+
// + token_url + expires_at); the value to inject is just the
571+
// access_token, so a binding like `Authorization: Bearer {value}`
572+
// produces `Bearer <jwt>` rather than `Bearer {"access_token":...}`.
573+
//
574+
// We dispatch on the credential's metadata type (looked up via the
575+
// supplied OAuthIndex, populated from credential_meta on startup and
576+
// SIGHUP) rather than inferring from the secret's JSON shape. Shape
577+
// inference would mis-handle a static credential whose value happens
578+
// to be OAuth-shaped JSON. The credential_meta table is the single
579+
// source of truth for cred_type elsewhere in sluice; the injection
580+
// path follows the same rule.
581+
//
582+
// If the metadata says oauth but parsing fails (corrupted vault
583+
// entry, schema drift, etc.) we fall back to the raw secret. That
584+
// preserves the previous behavior on broken state instead of
585+
// returning an empty string and silently producing `Bearer ` headers.
586+
//
587+
// A nil index (no oauth credentials registered yet, or the QUIC
588+
// path running before UpdateOAuthIndex fires) means every
589+
// credential is treated as static.
590+
func extractInjectableSecret(idx *OAuthIndex, credName, secret string) string {
591+
if idx == nil || !idx.Has(credName) {
592+
return secret
593+
}
594+
cred, err := vault.ParseOAuth([]byte(secret))
595+
if err != nil || cred == nil || cred.AccessToken == "" {
596+
// Generic [INJECT] prefix because both the HTTP/1+2 and the
597+
// HTTP/3 (QUIC) header-injection paths share this helper.
598+
// An [ADDON-INJECT] tag would mislead a reader who saw the
599+
// line in a deployment that uses HTTP/3 exclusively.
600+
log.Printf("[INJECT] credential %q registered as oauth but vault payload not parseable; injecting raw secret", credName)
601+
return secret
602+
}
603+
return cred.AccessToken
604+
}
605+
555606
// Request performs Pass 2 (scoped phantom replacement) and Pass 3 (strip
556607
// unbound phantoms) on the fully-buffered request body, headers, URL query,
557608
// and URL path. Called by go-mitmproxy after the request body has been read
@@ -647,12 +698,52 @@ func (a *SluiceAddon) StreamRequestModifier(f *mitmproxy.Flow, in io.Reader) io.
647698
// redact patterns (see SetRedactRules) so credential strings in upstream
648699
// responses are scrubbed before being relayed to the agent.
649700
func (a *SluiceAddon) Response(f *mitmproxy.Flow) {
701+
// Top-level recover so a panic inside any sub-step (OAuth swap,
702+
// DLP scan, future hooks) cannot escape into go-mitmproxy's
703+
// generic recover, which abandons the response body and leaves
704+
// the agent reading an empty stream. We log the full stack so
705+
// the underlying bug can be diagnosed later, but the response
706+
// continues with whatever state f.Response was in at the time
707+
// of the panic. Real tokens cannot leak: processOAuthResponseIfMatching
708+
// has its own snapshot/rollback, and any panic in DLP runs AFTER
709+
// OAuth swap (so tokens are already phantoms by then).
710+
defer func() {
711+
if r := recover(); r != nil {
712+
host := "unknown"
713+
method := ""
714+
if f != nil && f.Request != nil {
715+
if f.Request.URL != nil {
716+
host = f.Request.URL.Host
717+
}
718+
method = f.Request.Method
719+
}
720+
log.Printf("[ADDON] PANIC in Response handler for %s %s: %v\n%s", method, host, r, debug.Stack())
721+
}
722+
}()
723+
724+
// Nil-flow guard. The deferred recover above dereferences f to
725+
// build the log line; without this early return, a nil flow
726+
// (which go-mitmproxy never produces in practice but tests can)
727+
// would hit the recover path on every call. Mirror what
728+
// StreamResponseModifier does so both entry points handle nil
729+
// flows uniformly.
730+
if f == nil {
731+
return
732+
}
650733
if f.Response == nil || f.Request == nil {
651734
return
652735
}
653736

654737
a.processOAuthResponseIfMatching(f)
655738

739+
// Test-only panic injection. Always nil in production. Lets a
740+
// regression test exercise the deferred recover above without
741+
// having to construct a Flow that triggers a real downstream
742+
// nil deref.
743+
if a.responsePanicHook != nil {
744+
a.responsePanicHook()
745+
}
746+
656747
// Outbound DLP: scan response body and headers for credential
657748
// patterns that should not reach the agent. Runs after OAuth
658749
// processing so real tokens are already swapped to phantoms.
@@ -711,10 +802,56 @@ func (a *SluiceAddon) processOAuthResponseIfMatching(f *mitmproxy.Flow) {
711802
// emit a single WARNING per client connection so operators notice the
712803
// gap without log spam from multi-chunk streams. The dedup state lives
713804
// on dlpStreamWarned, scoped to the client connection id.
714-
func (a *SluiceAddon) StreamResponseModifier(f *mitmproxy.Flow, in io.Reader) io.Reader {
715-
if f.Request == nil {
716-
return in
717-
}
805+
func (a *SluiceAddon) StreamResponseModifier(f *mitmproxy.Flow, in io.Reader) (out io.Reader) {
806+
// Default to passing the input through unchanged; named return
807+
// lets the deferred recover ensure we always hand SOMETHING
808+
// usable back to go-mitmproxy on a panic, instead of letting
809+
// the panic escape into mitmproxy's outer recover (which
810+
// abandons the response body entirely).
811+
out = in
812+
813+
// Defensive nil-input guard up front, BEFORE the flow checks
814+
// below. If both `f` and `in` are nil (rare but possible in tests
815+
// or on an unusual go-mitmproxy code path), the f-nil early
816+
// return below would otherwise hand back a nil io.Reader, which
817+
// the proxy's downstream copy would nil-deref on. http.NoBody
818+
// keeps the response well-framed (zero bytes) and the panic is
819+
// avoided regardless of what `f` looks like.
820+
if in == nil {
821+
out = http.NoBody
822+
return out
823+
}
824+
825+
if f == nil || f.Request == nil {
826+
return out
827+
}
828+
829+
// Known-safe fallback for the panic recover. Set ONLY after the
830+
// OAuth phantom swap has produced a clean buffer that contains
831+
// no real tokens. Critically: we never assign the raw upstream
832+
// bytes here, because a matched OAuth token-endpoint response
833+
// contains real access and refresh tokens, and a panic between
834+
// io.ReadAll and a successful swapOAuthTokens would otherwise
835+
// leak those tokens straight to the agent. If the panic fires
836+
// before safeFallback is set, the recover hands back http.NoBody
837+
// instead. The agent sees an empty 2xx token body and surfaces
838+
// the failure as a parse error, which is the strictly safer
839+
// outcome compared to leaking a real bearer.
840+
var safeFallback []byte
841+
defer func() {
842+
if r := recover(); r != nil {
843+
host := "unknown"
844+
if f.Request != nil && f.Request.URL != nil {
845+
host = f.Request.URL.Host
846+
}
847+
log.Printf("[ADDON] PANIC in StreamResponseModifier for %s: %v\n%s", host, r, debug.Stack())
848+
if safeFallback != nil {
849+
out = bytes.NewReader(safeFallback)
850+
} else {
851+
out = http.NoBody
852+
}
853+
}
854+
}()
718855

719856
// Warn when DLP rules are configured but the response is streamed.
720857
// Dedupe by client connection id so we emit at most one warning per
@@ -782,10 +919,20 @@ func (a *SluiceAddon) StreamResponseModifier(f *mitmproxy.Flow, in io.Reader) io
782919

783920
modified, err := a.swapOAuthTokens(body, contentType, credName)
784921
if err != nil {
922+
// The body did not parse as an OAuth token response. This is
923+
// usually an HTML error page from a misconfigured token
924+
// endpoint, not a credentials envelope, so passing it through
925+
// is the historical behavior. We deliberately do NOT set
926+
// safeFallback here: if a later panic somehow fires while
927+
// returning, the recover defaults to http.NoBody rather than
928+
// leaking whatever this body contains.
785929
log.Printf("[ADDON-OAUTH] stream token parse error for credential %q: %v", credName, err)
786930
return bytes.NewReader(body)
787931
}
788932

933+
// Swap completed. The modified buffer is phantom-only and safe to
934+
// hand back on a late panic.
935+
safeFallback = modified
789936
return bytes.NewReader(modified)
790937
}
791938

0 commit comments

Comments
 (0)