From 9a02ed8d1988fbfc59777540bc9ad7c147a3ec08 Mon Sep 17 00:00:00 2001 From: Andy Anderson Date: Mon, 17 Aug 2026 20:04:49 -0400 Subject: [PATCH] feat(proxy): inject GitHub authorization at the MITM proxy so agents never hold usable tokens (#1861) Opt-in via HIVE_PROXY_INJECT_GH_AUTH (default OFF, byte-identical behavior when unset). When enabled: - WriteAgentToken diverts the real tier-scoped token to an in-memory registry the proxy injects from, and writes the visibly-fake placeholder hive-proxy-injected- to the agent-readable cache (the single choke point gh-wrapper.sh, git-credential-hive.sh, and the manager's GITHUB_TOKEN env push all read). - The MITM proxy strips any agent-supplied Authorization header and injects the UID-identified agent's hub-held scoped token: token scheme for REST/GraphQL, Basic x-access-token for git smart HTTP (with Connection: close so keep-alive reuse cannot bypass the rewrite via the raw git relay). - MITM widens to every GitHub-family host (github.com, registered GHE) under the flag, because an opaque tunnel would carry the placeholder to GitHub un-replaced. - Unknown agent = no injection, no fallback: the request proceeds unauthenticated and fails loud at GitHub. The hive's own control-plane calls (internalCallerName, UID-attributed) pass through untouched. - OAuth device-flow endpoints get strip-only treatment. - Logs carry agent name + injected yes/no, never token bytes. Part of #1861 Signed-off-by: Andy Anderson --- src/cmd/hive/main.go | 7 + src/pkg/config/proxy_inject.go | 36 +++ src/pkg/config/proxy_inject_test.go | 28 ++ src/pkg/github/app.go | 27 +- src/pkg/github/proxy_token_source.go | 71 +++++ src/pkg/github/proxy_token_source_test.go | 105 +++++++ src/pkg/proxy/auth_inject_test.go | 331 ++++++++++++++++++++++ src/pkg/proxy/github_proxy.go | 173 ++++++++++- 8 files changed, 773 insertions(+), 5 deletions(-) create mode 100644 src/pkg/config/proxy_inject.go create mode 100644 src/pkg/config/proxy_inject_test.go create mode 100644 src/pkg/github/proxy_token_source.go create mode 100644 src/pkg/github/proxy_token_source_test.go create mode 100644 src/pkg/proxy/auth_inject_test.go diff --git a/src/cmd/hive/main.go b/src/cmd/hive/main.go index a06ba6a43..b884e32be 100644 --- a/src/cmd/hive/main.go +++ b/src/cmd/hive/main.go @@ -2750,6 +2750,13 @@ func main() { logger.Error("failed to create github proxy", "error", err) } else { githubProxy.SetCanaryScanner(cfg.Ioscan.IsEnabled() && cfg.Ioscan.Canaries, cfg.Ioscan.FailClosed(), ioscan.DefaultCanaries, canaryLeakHandler) + // #1861: the proxy resolves an identified agent to its hub-held scoped + // token via the package-level registry WriteAgentToken feeds (NOT via + // the appAuth instance, which is replaced on key rotation — a closure + // over it would strand the proxy on the stale instance). Wired + // unconditionally: with HIVE_PROXY_INJECT_GH_AUTH unset (the default) + // the proxy never consults the source and the registry stays empty. + githubProxy.SetAgentTokenSource(github.AgentProxyToken) dashboard.SetProxyViolationsProvider(githubProxy.Violations) // Lets the dashboard narrow the LiteLLM model dropdown to the set the // configured key is entitled to, learned by the proxy from a key-info diff --git a/src/pkg/config/proxy_inject.go b/src/pkg/config/proxy_inject.go new file mode 100644 index 000000000..8f2427935 --- /dev/null +++ b/src/pkg/config/proxy_inject.go @@ -0,0 +1,36 @@ +package config + +import ( + "os" + "strings" +) + +// ProxyInjectGHAuthEnv is the opt-in switch for proxy-side GitHub credential +// injection (#1861): when "true", the hub keeps every agent's tier-scoped +// GitHub App token to itself and the MITM proxy attaches it to each proxied +// GitHub request, while the agent-visible token cache receives a clearly-fake +// placeholder instead (see github.AgentDummyToken). The attack this closes: +// a prompt-injected agent exfiltrating its OWN credential — today the scoped +// token sits in the agent-readable cache / gh credential env, so any agent +// that can be talked into printing it hands out a usable token. With +// injection on, nothing an agent holds authenticates anywhere. +// +// Default OFF: with the flag unset the token delivery and proxy behavior are +// byte-identical to before this flag existed. It must stay opt-in until the +// injection path has soaked on a real spoke; the follow-up that removes +// agent-side token delivery entirely (and closes #1861) flips it. +const ProxyInjectGHAuthEnv = "HIVE_PROXY_INJECT_GH_AUTH" + +// proxyInjectGHAuthEnabledValue is the only value that enables injection — +// the same strict "true" match HIVE_PROXY_ADVISORY_OK uses, so a typo fails +// safe (injection stays off and behavior stays exactly as today). +const proxyInjectGHAuthEnabledValue = "true" + +// ProxyInjectGHAuth reports whether proxy-side GitHub credential injection +// (#1861) is enabled for this process. Read live from the environment so the +// token-minting path (pkg/github) and any test can gate on it without extra +// plumbing; the proxy itself snapshots it once at construction, mirroring how +// it treats HIVE_PROXY_ADVISORY_OK (a boot-time deployment choice). +func ProxyInjectGHAuth() bool { + return strings.TrimSpace(os.Getenv(ProxyInjectGHAuthEnv)) == proxyInjectGHAuthEnabledValue +} diff --git a/src/pkg/config/proxy_inject_test.go b/src/pkg/config/proxy_inject_test.go new file mode 100644 index 000000000..7ff1a0685 --- /dev/null +++ b/src/pkg/config/proxy_inject_test.go @@ -0,0 +1,28 @@ +package config + +import "testing" + +// TestProxyInjectGHAuth: the #1861 injection flag must be a strict opt-in — +// only the exact value "true" (whitespace-trimmed) enables it, so a typo or a +// truthy-looking value fails safe with injection OFF and fleet behavior +// unchanged. +func TestProxyInjectGHAuth(t *testing.T) { + cases := []struct { + value string + want bool + }{ + {"", false}, + {"true", true}, + {" true \n", true}, + {"TRUE", false}, + {"1", false}, + {"false", false}, + {"yes", false}, + } + for _, tc := range cases { + t.Setenv(ProxyInjectGHAuthEnv, tc.value) + if got := ProxyInjectGHAuth(); got != tc.want { + t.Errorf("ProxyInjectGHAuth() with %s=%q = %v, want %v", ProxyInjectGHAuthEnv, tc.value, got, tc.want) + } + } +} diff --git a/src/pkg/github/app.go b/src/pkg/github/app.go index 889315df0..a5095af81 100644 --- a/src/pkg/github/app.go +++ b/src/pkg/github/app.go @@ -14,6 +14,8 @@ import ( "github.com/golang-jwt/jwt/v5" gh "github.com/google/go-github/v72/github" + + "github.com/kubestellar/hive/pkg/config" ) const ( @@ -352,6 +354,23 @@ func (a *AppAuth) WriteAgentToken(ctx context.Context, agentName, tier string, a return fmt.Errorf("minting scoped token for %s: %w", agentName, err) } + // #1861 (proxy-side credential injection, opt-in): with the flag on, the + // real scoped token never reaches anything the agent can read. It goes into + // the in-memory registry the MITM proxy injects from, and the cache file — + // the single choke point every agent-side consumer reads (gh-wrapper.sh's + // GH_TOKEN, git-credential-hive.sh's password, the manager's GITHUB_TOKEN + // env push for the MCP server) — receives a visibly-fake placeholder + // instead. The attack this closes: a prompt-injected agent exfiltrating its + // own credential; after this divert, everything in the agent's reach is the + // inert `hive-proxy-injected-` string. Flag off (the default): the + // registry is never populated and the file receives the real token, + // byte-identical to the pre-#1861 behavior. + fileToken := token + if config.ProxyInjectGHAuth() { + storeAgentProxyToken(agentName, token) + fileToken = AgentDummyToken(agentName) + } + if err := os.MkdirAll(agentTokenCacheDir, agentTokenCacheDirPerms); err != nil { return fmt.Errorf("creating agent token dir: %w", err) } @@ -369,7 +388,7 @@ func (a *AppAuth) WriteAgentToken(ctx context.Context, agentName, tier string, a if err != nil { return fmt.Errorf("opening agent token cache %s: %w", cachePath, err) } - if _, err := f.WriteString(token); err != nil { + if _, err := f.WriteString(fileToken); err != nil { f.Close() return fmt.Errorf("writing agent token cache %s: %w", cachePath, err) } @@ -387,7 +406,11 @@ func (a *AppAuth) WriteAgentToken(ctx context.Context, agentName, tier string, a "agent", agentName, "uid", agentUID, "path", cachePath) } - a.logger.Info("per-agent token cached", "agent", agentName, "tier", tier, "uid", agentUID, "pre_created", preCreated) + // proxy_injected says which delivery mode produced this cache write (#1861): + // true = the file holds the inert placeholder and the real token went to the + // proxy's in-memory registry. Never log token material itself — not even a + // prefix — on either path. + a.logger.Info("per-agent token cached", "agent", agentName, "tier", tier, "uid", agentUID, "pre_created", preCreated, "proxy_injected", fileToken != token) return nil } diff --git a/src/pkg/github/proxy_token_source.go b/src/pkg/github/proxy_token_source.go new file mode 100644 index 000000000..0ed117e69 --- /dev/null +++ b/src/pkg/github/proxy_token_source.go @@ -0,0 +1,71 @@ +package github + +import "sync" + +// This file is the hub-side half of proxy-side GitHub credential injection +// (#1861). When config.ProxyInjectGHAuth() is on, WriteAgentToken diverts the +// freshly-minted tier-scoped token HERE — an in-memory, hub-process-only +// registry the MITM proxy reads per request — and writes a visibly-fake +// placeholder to the agent-readable cache file instead. The agent's tooling +// (gh-wrapper.sh, git-credential-hive.sh, the manager's GITHUB_TOKEN env +// injection) keeps functioning because each still finds a syntactically-valid +// credential where it expects one, but that credential authenticates nowhere: +// the proxy strips it off every upstream request and substitutes the real +// scoped token from this registry. +// +// The registry is package-level, NOT a field on AppAuth, deliberately: the +// hive re-creates its AppAuth at runtime (key rotation, App re-discovery — +// see the `appAuth = newAppAuth` sites in cmd/hive/main.go), while the proxy +// is wired to its token source exactly once at boot. Hanging the map off an +// AppAuth instance would silently strand the proxy on the pre-rotation +// instance's (empty, stale) map. A package-level store keyed by agent name +// survives AppAuth replacement, exactly like agentTokenCacheDir does for the +// file-based lane. +// +// Lifecycle: entries are overwritten on every mint for the same agent (launch, +// relaunch, and the hourly refreshAgentTokens sweep — the same #3967 cadence +// that keeps the file cache fresh, reused rather than duplicated). Entries for +// removed agents linger until process restart; that is accepted — the +// underlying installation token expires within the hour regardless, so a +// lingering entry decays into a useless string, and it never leaves this +// process. +var ( + agentProxyTokensMu sync.RWMutex + agentProxyTokens = make(map[string]string) +) + +// agentDummyTokenPrefix is the prefix of the placeholder written to the +// agent-visible token cache under injection mode. It is deliberately NOT a +// GitHub token shape (no ghs_/ghp_ prefix) and self-describing, so that if an +// agent leaks it — into a log, a PR body, a prompt transcript — the leak is +// inert AND immediately diagnosable as the injection placeholder rather than +// mistaken for a live credential. +const agentDummyTokenPrefix = "hive-proxy-injected-" + +// AgentDummyToken returns the placeholder credential delivered to an agent in +// place of its real scoped token when proxy-side injection (#1861) is active. +// Including the agent name makes any leak attributable at a glance. +func AgentDummyToken(agentName string) string { + return agentDummyTokenPrefix + agentName +} + +// storeAgentProxyToken records an agent's freshly-minted scoped token for the +// proxy to inject. Called only from WriteAgentToken under the injection flag. +func storeAgentProxyToken(agentName, token string) { + agentProxyTokensMu.Lock() + agentProxyTokens[agentName] = token + agentProxyTokensMu.Unlock() +} + +// AgentProxyToken resolves an agent name to its hub-held scoped token for +// proxy-side injection (#1861). ok is false when no token has been minted for +// that agent in this process's lifetime — the proxy then injects NOTHING and +// lets the request fail loud at GitHub (401), never falling back to a shared +// or ambient token (that would recreate the pre-#3888 identity hole where an +// unattributed caller could ride another identity's credential). +func AgentProxyToken(agentName string) (string, bool) { + agentProxyTokensMu.RLock() + token, ok := agentProxyTokens[agentName] + agentProxyTokensMu.RUnlock() + return token, ok && token != "" +} diff --git a/src/pkg/github/proxy_token_source_test.go b/src/pkg/github/proxy_token_source_test.go new file mode 100644 index 000000000..6fa8a0671 --- /dev/null +++ b/src/pkg/github/proxy_token_source_test.go @@ -0,0 +1,105 @@ +package github + +// Tests for the hub-side half of proxy credential injection (#1861): under +// the opt-in flag, WriteAgentToken must divert the real scoped token into the +// proxy's in-memory registry and hand the agent-visible cache file only the +// inert placeholder; with the flag off (the fleet default), delivery must be +// byte-identical to before — real token in the file, registry untouched. + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/kubestellar/hive/pkg/config" +) + +// resetProxyTokenRegistry empties the package-level registry so tests do not +// observe each other's entries. +func resetProxyTokenRegistry(t *testing.T) { + t.Helper() + agentProxyTokensMu.Lock() + agentProxyTokens = make(map[string]string) + agentProxyTokensMu.Unlock() + t.Cleanup(func() { + agentProxyTokensMu.Lock() + agentProxyTokens = make(map[string]string) + agentProxyTokensMu.Unlock() + }) +} + +// TestWriteAgentToken_InjectionDivertsRealTokenToRegistry: with the flag on, +// nothing the agent can read may hold the real credential. The attack this +// closes is #1861's core: a prompt-injected agent cat'ing its own token cache +// (or echoing $GH_TOKEN) and posting the value somewhere public — after the +// divert, all it can leak is the self-describing placeholder. +func TestWriteAgentToken_InjectionDivertsRealTokenToRegistry(t *testing.T) { + const realToken = "ghs-real-scoped-token" + const agentName = "guide" + t.Setenv(config.ProxyInjectGHAuthEnv, "true") + resetProxyTokenRegistry(t) + + auth, _, closeFn := newFakeAppAuth(t, realToken) + defer closeFn() + useTempCacheDir(t) + + if err := auth.WriteAgentToken(context.Background(), agentName, "advisor", 2001); err != nil { + t.Fatalf("WriteAgentToken: %v", err) + } + + fileBytes, err := os.ReadFile(AgentTokenCachePath(agentName)) + if err != nil { + t.Fatalf("reading agent cache file: %v", err) + } + fileContent := string(fileBytes) + + if strings.Contains(fileContent, realToken) { + t.Fatalf("agent-readable cache contains the REAL token under injection mode: %q", fileContent) + } + if want := AgentDummyToken(agentName); fileContent != want { + t.Errorf("cache file = %q, want the placeholder %q", fileContent, want) + } + // The placeholder must be visibly fake: self-describing, attributable to + // the agent, and not shaped like a GitHub token. + if !strings.Contains(fileContent, agentName) || strings.HasPrefix(fileContent, "ghs_") || strings.HasPrefix(fileContent, "ghp_") { + t.Errorf("placeholder %q is not visibly fake/attributable", fileContent) + } + + got, ok := AgentProxyToken(agentName) + if !ok || got != realToken { + t.Errorf("AgentProxyToken(%q) = (%q, %v), want the real token for the proxy to inject", agentName, got, ok) + } +} + +// TestWriteAgentToken_FlagOffDeliveryUnchanged: with the flag unset, the +// pre-#1861 lane must be untouched — the agent gets the real token in its +// cache file and the proxy registry never learns it. This is the fleet-safety +// guarantee that lets this ship dark and soak. +func TestWriteAgentToken_FlagOffDeliveryUnchanged(t *testing.T) { + const realToken = "ghs-real-scoped-token-flagoff" + const agentName = "scanner" + // Explicitly clear rather than assuming the runner env: t.Setenv also + // restores the prior value afterwards. + t.Setenv(config.ProxyInjectGHAuthEnv, "") + resetProxyTokenRegistry(t) + + auth, _, closeFn := newFakeAppAuth(t, realToken) + defer closeFn() + useTempCacheDir(t) + + if err := auth.WriteAgentToken(context.Background(), agentName, "advisor", 2001); err != nil { + t.Fatalf("WriteAgentToken: %v", err) + } + + fileBytes, err := os.ReadFile(AgentTokenCachePath(agentName)) + if err != nil { + t.Fatalf("reading agent cache file: %v", err) + } + if string(fileBytes) != realToken { + t.Errorf("flag-off cache file = %q, want the real token %q (delivery must be unchanged)", fileBytes, realToken) + } + if tok, ok := AgentProxyToken(agentName); ok { + t.Errorf("flag-off registry holds a token (%q) — the registry must only be fed under the flag", tok) + } +} diff --git a/src/pkg/proxy/auth_inject_test.go b/src/pkg/proxy/auth_inject_test.go new file mode 100644 index 000000000..835c06883 --- /dev/null +++ b/src/pkg/proxy/auth_inject_test.go @@ -0,0 +1,331 @@ +package proxy + +// Tests for proxy-side GitHub credential injection (#1861). Each test names +// the attack its guard closes and was verified to FAIL with that guard +// removed (see the PR body's mutation-check evidence): +// +// - injection: upstream must see the identified agent's hub-held scoped +// token, sourced from the #3967 lane, not anything the agent sent. +// - stripping: an agent-supplied Authorization header must NEVER reach +// upstream — even a real credential smuggled to an agent is unspendable. +// - unknown agent: no injection, no fallback token — the request goes out +// unauthenticated and fails loud at GitHub. +// - flag off: byte-identical passthrough, so the fleet is untouched until a +// spoke opts in. + +import ( + "bufio" + "bytes" + "encoding/base64" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "strings" + "testing" + "time" + + "github.com/kubestellar/hive/pkg/agent" +) + +const ( + // testScopedToken is the hub-held scoped token the fake source serves. Its + // value is arbitrary; assertions compare against it verbatim. + testScopedToken = "ghs_scoped_token_for_test" + // testStolenToken is a credential the AGENT supplies — the thing the strip + // guard must keep off the wire. + testStolenToken = "ghs_stolen_agent_credential" + // testAgentName is the UID-identified caller in the happy-path tests. + testAgentName = "quality" + // exchangeTimeout bounds each test's wait for the captured upstream + // request, so a wedged relay fails the test instead of hanging the run. + exchangeTimeout = 5 * time.Second +) + +// upstreamCapture is what the fake upstream saw: the parsed request plus the +// raw bytes (the raw form is what the security assertions grep — a parsed +// header set could mask a duplicate or misfolded header). +type upstreamCapture struct { + req *http.Request + raw string +} + +// runInjectionExchange drives one request through proxyHTTP with a fake +// client and a fake upstream, returning what the upstream received. +func runInjectionExchange(t *testing.T, p *GitHubProxy, agentName string, mode agent.AgentMode, rawReq string) upstreamCapture { + t.Helper() + + clientConn, proxyClient := net.Pipe() + upstreamConn, proxyUpstream := net.Pipe() + + // Track proxyHTTP's exit so the helper returns with NO goroutine still + // touching package state (the git-path test shortens tunnelHalfCloseDrain + // and must not restore it while a leaked relay could still read it). + proxyDone := make(chan struct{}) + go func() { + p.proxyHTTP(proxyClient, proxyUpstream, agentName, mode) + close(proxyDone) + }() + + captured := make(chan upstreamCapture, 1) + go func() { + var raw bytes.Buffer + tee := io.TeeReader(upstreamConn, &raw) + req, err := http.ReadRequest(bufio.NewReader(tee)) + if err != nil { + captured <- upstreamCapture{} + return + } + // Drain any body so raw captures it too. + if req.Body != nil { + io.Copy(io.Discard, req.Body) + req.Body.Close() + } + fmt.Fprintf(upstreamConn, "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + upstreamConn.Close() + captured <- upstreamCapture{req: req, raw: raw.String()} + }() + + go func() { + if _, err := io.WriteString(clientConn, rawReq); err != nil { + return + } + // Read whatever comes back, then hang up so both relay directions end. + io.Copy(io.Discard, clientConn) + clientConn.Close() + }() + + var c upstreamCapture + select { + case c = <-captured: + case <-time.After(exchangeTimeout): + t.Fatal("timed out waiting for the upstream to receive the request") + } + + // Tear down both conns, then wait for proxyHTTP to fully exit before + // returning — see proxyDone above. + clientConn.Close() + upstreamConn.Close() + select { + case <-proxyDone: + case <-time.After(exchangeTimeout): + t.Fatal("timed out waiting for proxyHTTP to exit") + } + + if c.req == nil { + t.Fatal("fake upstream failed to read a request") + } + return c +} + +// injectionTestProxy returns a proxy with injection ON and a token source that +// serves testScopedToken for every name, recording the names it was asked for. +func injectionTestProxy(calls *[]string) *GitHubProxy { + p := &GitHubProxy{ + logger: slog.Default(), + violations: make(map[string]int), + certCache: make(map[string]cachedCert), + injectGHAuth: true, + } + p.agentTokenSource = func(name string) (string, bool) { + if calls != nil { + *calls = append(*calls, name) + } + return testScopedToken, true + } + return p +} + +// TestInjectAuth_IdentifiedAgentGetsScopedToken: the core of #1861 — a request +// from a UID-identified agent reaches GitHub bearing that agent's hub-held +// scoped token, even though the agent itself sent no (usable) credential. +func TestInjectAuth_IdentifiedAgentGetsScopedToken(t *testing.T) { + p := injectionTestProxy(nil) + + c := runInjectionExchange(t, p, testAgentName, agent.ModeAdvisory, + "GET /repos/org/repo HTTP/1.1\r\nHost: api.github.com\r\nAuthorization: token hive-proxy-injected-quality\r\n\r\n") + + if got, want := c.req.Header.Get("Authorization"), "token "+testScopedToken; got != want { + t.Errorf("upstream Authorization = %q, want %q", got, want) + } + if strings.Contains(c.raw, "hive-proxy-injected") { + t.Errorf("the agent-side placeholder reached upstream:\n%s", c.raw) + } +} + +// TestInjectAuth_AgentSuppliedAuthorizationNeverReachesUpstream is the +// security-critical strip guard: whatever credential the agent attached must +// not appear anywhere in the bytes GitHub receives — neither when the proxy +// substitutes a hub token nor when it has none to substitute. +func TestInjectAuth_AgentSuppliedAuthorizationNeverReachesUpstream(t *testing.T) { + t.Run("replaced by hub token", func(t *testing.T) { + p := injectionTestProxy(nil) + c := runInjectionExchange(t, p, testAgentName, agent.ModeAdvisory, + "GET /repos/org/repo HTTP/1.1\r\nHost: api.github.com\r\nAuthorization: token "+testStolenToken+"\r\n\r\n") + + if strings.Contains(c.raw, testStolenToken) { + t.Fatalf("agent-supplied credential reached upstream:\n%s", c.raw) + } + if got, want := c.req.Header.Get("Authorization"), "token "+testScopedToken; got != want { + t.Errorf("upstream Authorization = %q, want %q", got, want) + } + if n := len(c.req.Header.Values("Authorization")); n != 1 { + t.Errorf("upstream saw %d Authorization headers, want exactly 1 (a second one would be the smuggled original)", n) + } + }) + + t.Run("no hub token available — stripped, nothing substituted", func(t *testing.T) { + p := injectionTestProxy(nil) + p.agentTokenSource = func(string) (string, bool) { return "", false } + c := runInjectionExchange(t, p, testAgentName, agent.ModeAdvisory, + "GET /repos/org/repo HTTP/1.1\r\nHost: api.github.com\r\nAuthorization: token "+testStolenToken+"\r\n\r\n") + + if strings.Contains(c.raw, testStolenToken) { + t.Fatalf("agent-supplied credential reached upstream:\n%s", c.raw) + } + if got := c.req.Header.Get("Authorization"); got != "" { + t.Errorf("upstream Authorization = %q, want none (fail loud at GitHub, no fallback)", got) + } + }) +} + +// TestInjectAuth_UnknownAgentNoInjectionNoFallback: identity failure must not +// be rewarded with a credential. A shared or ambient fallback here would +// recreate the pre-#3888 hole where an unattributable process rides another +// identity's token — so the token source must not even be consulted. +func TestInjectAuth_UnknownAgentNoInjectionNoFallback(t *testing.T) { + var calls []string + p := injectionTestProxy(&calls) + + c := runInjectionExchange(t, p, "", agent.ModeAdvisory, + "GET /repos/org/repo HTTP/1.1\r\nHost: api.github.com\r\nAuthorization: token "+testStolenToken+"\r\n\r\n") + + if got := c.req.Header.Get("Authorization"); got != "" { + t.Errorf("upstream Authorization = %q, want none for an unidentified caller", got) + } + if strings.Contains(c.raw, testStolenToken) { + t.Errorf("agent-supplied credential reached upstream:\n%s", c.raw) + } + if len(calls) != 0 { + t.Errorf("token source consulted for an unidentified caller (%v) — that is a fallback path and must not exist", calls) + } +} + +// TestInjectAuth_FlagOffByteIdenticalPassthrough: with the flag off (the +// fleet-wide default while this soaks) the proxy must not touch any header — +// the agent's own credential flows exactly as before #1861. +func TestInjectAuth_FlagOffByteIdenticalPassthrough(t *testing.T) { + var calls []string + p := injectionTestProxy(&calls) + p.injectGHAuth = false + + c := runInjectionExchange(t, p, testAgentName, agent.ModeAdvisory, + "GET /repos/org/repo HTTP/1.1\r\nHost: api.github.com\r\nAuthorization: token "+testStolenToken+"\r\n\r\n") + + if got, want := c.req.Header.Get("Authorization"), "token "+testStolenToken; got != want { + t.Errorf("flag-off Authorization = %q, want the agent's own untouched %q", got, want) + } + if len(calls) != 0 { + t.Errorf("token source consulted with the flag off (%v)", calls) + } +} + +// TestInjectAuth_GitPathBasicAndConnectionClose: git smart HTTP gets the +// Basic x-access-token form (what git-credential-hive.sh produced before) and +// Connection: close, so git's next request cannot ride the raw relay past the +// rewrite carrying the placeholder. +func TestInjectAuth_GitPathBasicAndConnectionClose(t *testing.T) { + origDrain := tunnelHalfCloseDrain + tunnelHalfCloseDrain = 100 * time.Millisecond + defer func() { tunnelHalfCloseDrain = origDrain }() + + p := injectionTestProxy(nil) + + c := runInjectionExchange(t, p, testAgentName, agent.ModeAdvisory, + "GET /org/repo.git/info/refs?service=git-upload-pack HTTP/1.1\r\nHost: github.com\r\nAuthorization: Basic ZHVtbXk6ZHVtbXk=\r\n\r\n") + + wantBasic := "Basic " + base64.StdEncoding.EncodeToString([]byte(gitInjectBasicUser+":"+testScopedToken)) + if got := c.req.Header.Get("Authorization"); got != wantBasic { + t.Errorf("git-path Authorization = %q, want %q", got, wantBasic) + } + if !c.req.Close && !strings.Contains(strings.ToLower(c.raw), "connection: close") { + t.Errorf("git-path request not marked Connection: close — a keep-alive follow-up would bypass the rewrite via the raw relay:\n%s", c.raw) + } +} + +// TestInjectAuth_InternalCallerPassthrough: the hive's own control-plane +// requests (App mint, heartbeat, relay fulfillment) legitimately carry the +// hive's credential and must pass untouched — and the per-agent source must +// not be consulted for them. +func TestInjectAuth_InternalCallerPassthrough(t *testing.T) { + var calls []string + p := injectionTestProxy(&calls) + + const hiveOwnAuth = "token ghs_hive_control_plane" + c := runInjectionExchange(t, p, internalCallerName, agent.ModeAdvisory, + "GET /app/installations HTTP/1.1\r\nHost: api.github.com\r\nAuthorization: "+hiveOwnAuth+"\r\n\r\n") + + if got := c.req.Header.Get("Authorization"); got != hiveOwnAuth { + t.Errorf("internal caller Authorization = %q, want untouched %q", got, hiveOwnAuth) + } + if len(calls) != 0 { + t.Errorf("token source consulted for the internal caller (%v)", calls) + } +} + +// TestInjectAuth_LoginPathStripOnly: OAuth device-flow endpoints authenticate +// via their form body; the rewrite must strip any agent-attached header but +// must NOT attach an App token to an OAuth flow. +func TestInjectAuth_LoginPathStripOnly(t *testing.T) { + p := injectionTestProxy(nil) + + c := runInjectionExchange(t, p, testAgentName, agent.ModeAdvisory, + "POST /login/device/code HTTP/1.1\r\nHost: github.com\r\nAuthorization: token "+testStolenToken+"\r\nContent-Length: 0\r\n\r\n") + + if got := c.req.Header.Get("Authorization"); got != "" { + t.Errorf("login-path Authorization = %q, want none (strip only)", got) + } + if strings.Contains(c.raw, testStolenToken) || strings.Contains(c.raw, testScopedToken) { + t.Errorf("credential material on an OAuth flow request:\n%s", c.raw) + } +} + +// TestHostNeedsMITM_InjectionWidensInterception: without injection only +// api.github.com is intercepted (historical behavior); with injection every +// GitHub-family host must be, because an opaque tunnel would carry the +// agent's placeholder credential to GitHub un-replaced (and would let a +// smuggled real credential through un-stripped). +func TestHostNeedsMITM_InjectionWidensInterception(t *testing.T) { + const gheHost = "ghe.injection-test.example.com" + RegisterGitHubHost(gheHost) + defer unregisterGitHubHost(gheHost) + + off := &GitHubProxy{injectGHAuth: false} + on := &GitHubProxy{injectGHAuth: true} + + cases := []struct { + p *GitHubProxy + host string + want bool + }{ + {off, "api.github.com", true}, + {off, "github.com", false}, + {off, gheHost, false}, + {on, "api.github.com", true}, + {on, "github.com", true}, + {on, gheHost, true}, + // Never MITM hosts the proxy does not front, flag or no flag. + {off, "example.com", false}, + {on, "example.com", false}, + } + for _, tc := range cases { + flag := "off" + if tc.p.injectGHAuth { + flag = "on" + } + if got := tc.p.hostNeedsMITM(tc.host); got != tc.want { + t.Errorf("hostNeedsMITM(%q) with injection %s = %v, want %v", tc.host, flag, got, tc.want) + } + } +} diff --git a/src/pkg/proxy/github_proxy.go b/src/pkg/proxy/github_proxy.go index ddd874ac5..32eecb639 100644 --- a/src/pkg/proxy/github_proxy.go +++ b/src/pkg/proxy/github_proxy.go @@ -27,6 +27,7 @@ import ( "time" "github.com/kubestellar/hive/pkg/agent" + "github.com/kubestellar/hive/pkg/config" "github.com/kubestellar/hive/pkg/ioscan" "github.com/kubestellar/hive/pkg/tokens" ) @@ -155,6 +156,25 @@ type GitHubProxy struct { // header as an agent's identity. proxyAdvisoryOK bool + // injectGHAuth snapshots config.ProxyInjectGHAuth() at construction (#1861, + // opt-in, default off — a boot-time deployment choice like proxyAdvisoryOK). + // When true, the proxy is the ONLY holder of usable GitHub credentials on + // the request path: every MITM'd request has any agent-supplied + // Authorization header STRIPPED and the identified agent's hub-held scoped + // token injected in its place (see rewriteGitHubAuth). Agents hold only an + // inert placeholder, so a prompt-injected agent has nothing worth + // exfiltrating. + injectGHAuth bool + + // agentTokenSource resolves an identified agent name to that agent's + // hub-held tier-scoped token (github.AgentProxyToken, wired in main). It is + // a func, not an import, to keep pkg/proxy decoupled from pkg/github's + // minting machinery — the proxy consumes tokens the existing #3967 lane + // already maintains; it never mints. May be nil (injection then never adds + // a header, and stripped requests proceed unauthenticated — fail loud at + // GitHub, never fall back to a shared token). + agentTokenSource func(agentName string) (string, bool) + mu sync.RWMutex violations map[string]int // agent name -> blocked request count @@ -211,6 +231,35 @@ func (p *GitHubProxy) SetTokenSink(sink *tokens.InferenceSink) { p.tokenSink = sink } +// SetAgentTokenSource wires the resolver from identified agent name to that +// agent's hub-held scoped token (#1861). Wired once at boot, before Start(), +// so no lock is needed on the read path. +func (p *GitHubProxy) SetAgentTokenSource(source func(agentName string) (string, bool)) { + p.agentTokenSource = source +} + +// hostNeedsMITM decides whether a GitHub-family host must be TLS-intercepted +// rather than opaquely tunneled. +// +// Without injection this is exactly the historical NeedsMITM: only +// api.github.com, where request-level ACMM inspection happens; github.com and +// registered GHE hosts are tunneled without decryption (their traffic — git +// smart HTTP, OAuth device flow — is gated elsewhere). +// +// With injection (#1861) an opaque tunnel is a hole, not an optimization: the +// agent's git credential helper now serves the inert placeholder, so a +// git-over-HTTPS push through an un-MITM'd tunnel would reach GitHub carrying +// the placeholder and 401. Every GitHub-family host must therefore be +// intercepted so rewriteGitHubAuth can replace the placeholder with the real +// scoped token — which also means an agent-supplied credential can never +// sneak to ANY GitHub host through a tunnel the proxy declined to open. +func (p *GitHubProxy) hostNeedsMITM(host string) bool { + if NeedsMITM(host) { + return true + } + return p.injectGHAuth && IsGitHubHost(host) +} + func (p *GitHubProxy) SetCanaryScanner(enabled, failClosed bool, reg *ioscan.CanaryRegistry, onLeak func(ioscan.CanaryLeak)) { p.canariesEnabled = enabled p.canaryFailClosed = failClosed @@ -256,6 +305,13 @@ func NewGitHubProxy(logger *slog.Logger, org string, repos []string) (*GitHubPro allowed[key] = true } + // #1861: snapshot the injection flag once, mirroring advisoryOK above. + injectGHAuth := config.ProxyInjectGHAuth() + if injectGHAuth { + logger.Info("proxy GitHub credential injection enabled — agent-supplied Authorization headers are stripped and hub-held scoped tokens injected per identified agent (#1861)", + "env", config.ProxyInjectGHAuthEnv) + } + p := &GitHubProxy{ listenAddr: fmt.Sprintf("127.0.0.1:%d", proxyListenPort), caCert: caCert, @@ -263,6 +319,7 @@ func NewGitHubProxy(logger *slog.Logger, org string, repos []string) (*GitHubPro logger: logger, uidMap: uidMap, proxyAdvisoryOK: advisoryOK, + injectGHAuth: injectGHAuth, allowedRepos: allowed, violations: make(map[string]int), certCache: make(map[string]cachedCert), @@ -445,7 +502,7 @@ func (p *GitHubProxy) handleTransparentTLS(conn net.Conn, peeked []byte) { return } - if !IsGitHubHost(host) || !NeedsMITM(host) { + if !IsGitHubHost(host) || !p.hostNeedsMITM(host) { // Non-GitHub or non-API GitHub host: tunnel directly. SO_MARK the socket // so the forced-egress redirect exempts this proxy-originated dial. upstream, err := markDialer(transparentProxyTimeout).Dial("tcp", host+":443") @@ -790,8 +847,9 @@ func (p *GitHubProxy) handleConnectDirect(conn net.Conn, r *http.Request) { // github.com doesn't need MITM — OAuth device flow and git smart HTTP // are handled by CLI --deny-tool flags. Only api.github.com needs - // request-level inspection for ACMM enforcement. - if !NeedsMITM(host) { + // request-level inspection for ACMM enforcement. Under #1861 injection, + // however, every GitHub-family host is intercepted (see hostNeedsMITM). + if !p.hostNeedsMITM(host) { p.tunnelDirect(conn, r) return } @@ -974,6 +1032,13 @@ func (p *GitHubProxy) proxyHTTP(client net.Conn, upstream net.Conn, agentName st continue } + // #1861: with injection enabled, the upstream credential is decided + // HERE — after the ACMM/repo/canary gates, immediately before either + // forwarding branch — so every byte that reaches GitHub has passed + // through it. Blocked requests above never reach upstream and need no + // rewrite. + p.rewriteGitHubAuth(req, agentName) + // Git smart HTTP uses chunked streaming that http.ReadResponse // can't handle reliably. After the ACMM check passes, forward // the request and switch to raw bidirectional streaming. @@ -1047,6 +1112,108 @@ func (p *GitHubProxy) proxyHTTP(client net.Conn, upstream net.Conn, agentName st } } +// gitInjectBasicUser is the username git expects alongside a GitHub App +// installation token in HTTP Basic auth — the same identity +// git-credential-hive.sh answers with, so upstream sees exactly the shape the +// pre-injection flow produced. +const gitInjectBasicUser = "x-access-token" + +// loginPathPrefix marks the GitHub OAuth/device-flow endpoints +// (/login/device/code, /login/oauth/access_token). These authenticate the +// FLOW via their form body, not a bearer credential; injecting an App token +// there would be meaningless at best and flow-corrupting at worst, so they +// get strip-only treatment. +const loginPathPrefix = "/login/" + +// rewriteGitHubAuth enforces #1861 on one MITM'd request: the proxy — not the +// agent — decides what credential GitHub sees. +// +// Threat model, per header-touching block: +// +// - STRIP: an agent-supplied Authorization header is deleted UNCONDITIONALLY +// (under the flag), never forwarded. Even though injection-mode agents are +// only ever handed the inert placeholder, an agent that somehow obtained a +// real credential (leaked from a log, smuggled via prompt, minted through +// a side channel) must not be able to spend it through this proxy. +// +// - INJECT: only for an agent identified by the UID-authoritative path +// (#3888) that has a hub-held scoped token. The two lookups this chains +// are exactly the pre-existing lanes: identity from the UID map, token +// from the #3967 per-agent scoped mint. +// +// - UNKNOWN AGENT = NO CREDENTIAL: when identity resolution failed +// (agentName == ""), the request proceeds with NO Authorization and fails +// loud at GitHub (401/403). Deliberate: any fallback — shared cache, +// hive token, "last known agent" — would let an unattributable process +// ride a real credential, recreating the pre-#3888 identity hole this +// design depends on having closed. +// +// - INTERNAL CALLER PASSTHROUGH: the hive's own control plane +// (internalCallerName) legitimately holds and sends its own App +// credentials (token mint, heartbeat, hive-open-pr fulfillment); its +// requests pass untouched. This cannot be spoofed by an agent: the name +// is assigned only via UIDMap.IsInternalUID, never from anything the +// caller sends. +// +// Logging: agent name and injected yes/no only — NEVER token bytes, not even +// a prefix (#1861 requirement; a token prefix is enough to fingerprint and +// correlate credentials across logs). +func (p *GitHubProxy) rewriteGitHubAuth(req *http.Request, agentName string) { + if !p.injectGHAuth { + // Flag off (the default): behavior byte-identical to before #1861 — + // no header is touched on any path. + return + } + if agentName == internalCallerName { + return + } + + req.Header.Del("Authorization") + + if agentName == "" { + p.logger.Debug("proxy auth injection: caller unidentified — forwarding without credentials (fail-loud, no fallback)", "injected", false) + return + } + if strings.HasPrefix(req.URL.Path, loginPathPrefix) { + p.logger.Debug("proxy auth injection: OAuth flow endpoint — strip only", "agent", agentName, "injected", false) + return + } + if p.agentTokenSource == nil { + p.logger.Warn("proxy auth injection enabled but no token source wired — forwarding without credentials", "agent", agentName, "injected", false) + return + } + token, ok := p.agentTokenSource(agentName) + if !ok { + // An identified agent with no hub-held token means the #3967 mint lane + // has not (yet) delivered for this agent — e.g. the hive restarted and + // the in-memory registry is empty until the next launch/refresh mint. + // Fail loud (upstream 401) rather than borrow any other credential. + p.logger.Warn("proxy auth injection: no hub-held token for identified agent — forwarding without credentials (check the per-agent mint lane)", "agent", agentName, "injected", false) + return + } + + if isGitPath(req.URL.Path) { + // Git smart HTTP authenticates with Basic x-access-token: — + // the same shape git-credential-hive.sh produced pre-injection. + basic := base64.StdEncoding.EncodeToString([]byte(gitInjectBasicUser + ":" + token)) + req.Header.Set("Authorization", "Basic "+basic) + // Force this exchange onto its own upstream connection. The git branch + // in proxyHTTP switches to a raw relay after forwarding this request's + // headers, so a keep-alive reuse (git's follow-up POST after GET + // /info/refs on the same conn) would stream its headers — placeholder + // credential included — straight through WITHOUT passing this rewrite, + // and fail with a confusing mid-operation 401. Connection: close makes + // git reconnect per request; each new connection re-enters proxyHTTP + // and gets its own rewrite. + req.Close = true + } else { + // REST/GraphQL (api.github.com, GHE /api/v3): the standard GitHub + // token scheme, identical to what gh sends. + req.Header.Set("Authorization", "token "+token) + } + p.logger.Debug("proxy auth injection: scoped token attached", "agent", agentName, "injected", true) +} + func (p *GitHubProxy) inspectCanaryEgress(agentName string, req *http.Request) (reason string, deny bool, detected bool) { if p == nil || !p.canariesEnabled || p.canaryRegistry == nil || req == nil || req.Body == nil { return "", false, false