Skip to content

Add two per-run proxy primitives: LocalResponder and BearerRewriter #29

Description

@dpup

Summary

Add two general, per-run extension points to the credential-injecting proxy so a
consumer can:

  1. Answer a synthetic endpoint for a run without forwarding upstream — e.g. a
    token endpoint that returns a proxy-generated bootstrap value (LocalResponder).
  2. Swap an outbound bearer for a freshly minted, rotating real token on
    requests to a matching host, where the decision of which bearers to swap is
    supplied by the caller (BearerRewriter).

Both are resolved per run from RunContextData (the existing per-token context),
alongside Credentials, TokenSubstitutions, ResponseTransformers, and
AWSHandler. They generalize the dynamic-credential pattern the existing static
TokenSubstitution cannot express (its realToken is fixed; here the real token
rotates and is fetched through a provider closure).

Motivation

A credential-injection consumer wants a sandboxed client to authenticate to an
upstream API without any real secret entering the sandbox. The client is
seeded with a placeholder credential; it bootstraps by calling a token endpoint
(answered by LocalResponder with a placeholder access token) and then calls the
API with that placeholder bearer (swapped for a real, host-minted, rotating token
by BearerRewriter). The real token lives only in the proxy. This is the same
isolation model as the existing header-injection grants, extended to clients that
refuse to make an API call until they believe they hold a credential.

⚠️ Critical: both primitives dispatch on the MITM interception path

The motivating traffic is HTTPS, so requests arrive as CONNECT and are handled
inside handleConnectWithInterception (proxy/proxy.go ~1532+), which
TLS-terminates and reads inner requests in a loop with http.ReadRequest. Those
decrypted inner requests never re-enter ServeHTTP.
The existing AWSHandler
dispatch in ServeHTTP (/_aws/credentials, ~1181) works only because that
consumer reaches the proxy over plain HTTP via a NO_PROXY bypass — a different
path. Do not wire these primitives in ServeHTTP; wire them in the
interception loop:

  • Resolve run context from the outer CONNECT request r (getRunContext(r)).
    The decrypted inner req has no context — getRunContext(req) returns nil.
    The loop already does this for getTokenSubstitutionForRequest(r, host) (~1711).
  • The CONNECT host is host:443; strip the port (net.SplitHostPort) before
    host-matching, and match against the inner req.URL.Path.
  • Write a responder's synthetic response back to the TLS client connection using
    whatever the surrounding code uses (mirror the blockedResp.Write(tlsClientConn)
    pattern ~1631), then continue (do not forward upstream).

API

proxy/responders.go

// LocalResponder fully answers a request for a matching host + path prefix
// without forwarding upstream. Generalizes the AWS credential handler.
type LocalResponder struct {
	Host       string // exact host match, port already stripped (e.g. "token.example.com")
	PathPrefix string // path prefix match (e.g. "/token")
	Handler    http.Handler
}

func (rc *RunContextData) matchResponder(host, path string) http.Handler // first match or nil

Add LocalResponders []LocalResponder to RunContextData.

proxy/bearer.go

// BearerRewriter replaces the Authorization bearer on requests to hosts matching
// HostPattern with Provider()'s freshly minted token, when Match reports the
// incoming bearer (sans "Bearer ") should be swapped. gatekeeper owns prefix
// parsing and host matching; the caller owns match policy and the minter.
// Provider is called per request and MUST be cheap/cached and concurrency-safe.
//
// SECURITY INVARIANT: Provider MUST be bound to ONE run's own credential source.
// Because RunContextData is resolved per proxy-auth token, each run carries its
// own rewriter and can only ever mint its own configured credential. Match may
// accept an unverified token (a consumer may match a JWT by issuer without
// checking the signature); the worst case is a sandboxed client triggering a mint
// of the credential it is already authorized to use — never another run's. Never
// wire a shared/global Provider.
type BearerRewriter struct {
	HostPattern string                   // e.g. "*.example.com"
	Match       func(bearer string) bool // bearer is the token sans "Bearer "
	Provider    func() (string, error)
}

Add BearerRewriters []BearerRewriter to RunContextData.

rewrite(authHeader): ignore non-Bearer auth; if Match(token) is true, call
Provider() and replace the header; fail closed on Provider error or empty
token (leave the original bearer — the request 401s rather than leaking).

applyBearerRewriters(req, rewriters): read Authorization; split host/port from
req.Host/req.URL.Host; pass the real port to MatchesHostPattern
(intercepted HTTPS is 443). NB: matchesPattern returns false for port 0 unless
the port is 80/443 — passing 0 would make the rewriter never fire.
ParseHostPattern/MatchesHostPattern are the exported wrappers at
proxy.go:275/280.

Wiring (in handleConnectWithInterception, ~1685–1712)

// after the inner req is read and req.URL.Host is set, alongside the existing
// getTokenSubstitutionForRequest(r, host) handling:
if rc := getRunContext(r); rc != nil {
	hostOnly, _, err := net.SplitHostPort(host)
	if err != nil {
		hostOnly = host
	}
	if h := rc.matchResponder(hostOnly, req.URL.Path); h != nil {
		// write synthetic response back to tlsClientConn (mirror existing pattern), then:
		continue
	}
	applyBearerRewriters(req, rc.BearerRewriters)
}

Also add applyBearerRewriters at the plain-HTTP applyTokenSubstitution site
(handleHTTP, ~1378) for completeness, using that path's own context.

Acceptance criteria

  • LocalResponder + matchResponder with unit tests (neutral host/path
    values; assert host mismatch and path mismatch do not match).
  • BearerRewriter + applyBearerRewriters with unit tests, including the
    port defaulting (an HTTPS request with no explicit port still matches a
    *.example.com pattern) and non-matching-host pass-through.
  • Load-bearing integration test: a real CONNECT + TLS handshake against a
    CA-backed proxy proving (a) the responder short-circuits a host/path on the
    MITM path and (b) a matched bearer is swapped to Provider()'s value on
    the wire to a captured upstream
    (assert upstream never sees the original
    bearer). A ServeHTTP-level test alone is insufficient — it would pass even
    if the dispatch were dead, since it does not exercise the interception path.
  • Auth before dispatch: a request to the responder host/path with no/invalid
    proxy auth returns 407, never a synthetic response.
  • No token leakage in logs: when a bearer is rewritten, the real token must
    not appear in RequestLogData/traces (capture headers before the rewrite, or
    mark authorization injected so the existing scrubber redacts it). Test it.
    Neither the responder handler nor the rewriter logs token values.
  • go test ./... green.

Scope / non-goals

  • No JWT/OAuth knowledge in gatekeeper. Match policy (static value, JWT issuer,
    etc.) is entirely caller-supplied via Match.
  • AWSHandler is not migrated here. AWS reaches the proxy over plain HTTP
    (/_aws/, dispatched in ServeHTTP); these primitives dispatch on the MITM
    path. Unifying them (dispatch LocalResponder on the plaintext path too, then
    retire AWSHandler) is a separate follow-up issue — intentionally out of
    scope to keep this change off the AWS credential path.

Pointers

  • proxy/proxy.go: RunContextData (~285), getRunContext, SetContextResolver
    (~428), applyTokenSubstitution (1378 + 1712), handleConnectWithInterception
    (1532+), ParseHostPattern/MatchesHostPattern (275/280); matchesPattern
    port semantics (hosts.go).
  • Test patterns: proxy/mcp_regression_test.go TestServeHTTP_DirectAWSCredentials
    (context-resolver + Proxy-Authorization setup); the interception/CA test harness
    in proxy_test.go / mcp_test.go for the integration test.

Line numbers reference gatekeeper v0.2.0; symbol names are the durable anchors if
they have drifted.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions