Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions internal/observability/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,20 @@ const (
// only). Rejected locally with ErrCodeOAuthResponsesOnly instead of letting
// ChatGPT's edge answer with a misleading "invalid x-api-key".
EventProxyRequestDialectUnsupported = "proxy.request.dialect_unsupported"
// Fence I13 runtime guard (2026-07-21). EventProxyRequestIdentityScrubbed:
// an outbound header carried one of the control-plane member-identity shapes
// enumerated in proxy/member_identity_guard.go (that file is the only place
// in the data plane allowed to spell them out, so the vocabulary fence
// TestProxySource_DoesNotReferenceMemberIdentityFields keeps meaning
// something) and was dropped before the upstream saw it.
// EventProxyRequestIdentityBlocked: the
// proxy was about to write such a value into the request body's
// metadata.user_id and refused. Both are WARN, never INFO: they mean code
// upstream of the guard learned a member's provider identity, which is the
// thing to go fix — the guard only stops it reaching Anthropic/OpenAI.
// Neither ever logs the offending value; only the shape name.
EventProxyRequestIdentityScrubbed = "proxy.request.identity_scrubbed"
EventProxyRequestIdentityBlocked = "proxy.request.identity_inject_blocked"
// EventProxyQuotaModelUnpriced: a completed request's model has no entry in
// the edge price summary (D-U8/P7), so its usd was NOT counted locally — the
// token quota floor backstops it and the server baseline catches up on
Expand Down
13 changes: 13 additions & 0 deletions internal/proxy/forward_and_resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,19 @@ func (p *Proxy) serveRoute(w http.ResponseWriter, r *http.Request, route *vkeys.
// X-Aikey-* ever reaches the upstream — fenced by
// TestStripAikeyRequestHeaders.
stripAikeyRequestHeaders(req.Header)
// Fence I13 second half (2026-07-21): the namespace strip above is a
// NAME rule and cannot see an identity value parked under a header
// that simply doesn't start with X-Aikey- (e.g. a future
// `X-Member-Union-Id`). This is the VALUE rule — see
// member_identity_guard.go for what it does and does not promise.
// Fail-loud: a hit means some code above learned a member's provider
// identity, so it WARNs rather than scrubbing silently.
if scrubbed := scrubMemberIdentityHeaders(req.Header); len(scrubbed) > 0 {
logger.Warn("member identity scrubbed from upstream request headers",
"event.name", observability.EventProxyRequestIdentityScrubbed,
"headers", scrubbed,
)
}
// Why: tell upstream we only accept identity (uncompressed) so the
// drainer and non-streaming token extractor can parse the body
// directly. Anthropic's OAuth endpoint in particular returns
Expand Down
133 changes: 133 additions & 0 deletions internal/proxy/member_identity_guard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package proxy

import (
"net/http"
"regexp"
)

// Member-identity shape guard — the runtime half of fence I13.
//
// # WHAT THIS FILE PROMISES, STATED EXACTLY
//
// The 2026-07-20 first-login change gave every SSO member three identifying
// values: the Feishu union_id / open_id, the tenant_key, and a synthetic
// account handle (sso+<provider>.<digest>@sso.local). All three live in the
// control plane and have no business on the wire to Anthropic or OpenAI.
//
// The promise this code can actually keep is:
//
// 🔴 AiKey ITSELF never puts member identity onto an upstream request.
//
// It is deliberately NOT the absolute "no member identity ever reaches the
// upstream". That absolute is physically unenforceable on this path and
// claiming it would be the exact "page says enabled, filter is bypassed"
// false-safety shape:
//
// - The main chat path forwards the client's body BYTE-FOR-BYTE. That is a
// correctness requirement, not an oversight — body rewrites are known to
// damage real-CLI traffic (see injectClaudeWAFFingerprintFull's guard on
// inboundClientIsClaudeCode) and the WAF fingerprint check is byte-exact.
// - oauth_inject.go's closing note is explicit that oauth_group POOL requests
// "pass the REAL client identity upstream unchanged" — deliberately, because
// a frozen synthetic persona became its own detection signal (2026-06-29).
//
// So a body allowlist on the main path would trade a level-2 stability risk
// (八级法则) for a level-1 gain we cannot complete anyway: a user who types
// their own union_id into a chat message will always ship it upstream. What we
// CAN fence is every value AiKey authors or annotates. That is what this does.
//
// # TWO CHOKEPOINTS
//
// 1. Headers — scrubMemberIdentityHeaders, called in serveRoute's Director
// right after stripAikeyRequestHeaders. The namespace stripper only covers
// X-Aikey-*; this covers identity VALUES under ANY header name, which is
// the gap a future `X-Member-Union-Id` would have walked straight through.
// 2. Body — injectMetadataUserIDIfAbsent, the single place the proxy writes an
// identifier into a request body. Guarded at the chokepoint, not at today's
// one call site, so a future caller inherits the fence.
//
// 🚫 If a shape here starts firing, do NOT relax the pattern — ask why the data
// plane is holding a member's provider identity.
var (
// syntheticHandleRe matches the control plane's synthetic account handle,
// sso+<provider>.<hex digest>@... — produced by exactly one function
// (aikey-control-master pkg/identity.syntheticEmail). The digest is
// sha256(provider\x00union_id) truncated, so shipping the handle ships a
// stable, cross-application identifier for a named employee.
syntheticHandleRe = regexp.MustCompile(`(?i)sso\+[a-z0-9_.-]+\.[0-9a-f]{8,}@`)

// ssoLocalDomainRe catches the handle's domain on its own, so a truncated,
// re-hashed or otherwise reformatted derivative still trips the fence.
ssoLocalDomainRe = regexp.MustCompile(`(?i)@sso\.local`)

// feishuActorIDRe matches a Feishu union_id / open_id: on_ / ou_ followed by
// a long unbroken alphanumeric run (the real values are a 32-char digest).
//
// No \b anchor in front, deliberately: these arrive EMBEDDED, e.g.
// "…_account_on_8ed6aa…", and `_` is a word character so \b would never fire
// there — the anchored first cut of this regex silently matched nothing,
// which is why the body fence has a case pinned on exactly this string.
// The 24-char floor is what replaces the anchor as the false-positive
// defence: the persona user_id the proxy builds
// (user_<64hex>_account_<uuid>_session_<uuid>) does contain the letters
// "on_" inside "_session_", but a dashed UUID gives it only 8 unbroken
// characters to work with. Opaque credentials are exempt entirely, below.
feishuActorIDRe = regexp.MustCompile(`(?i)(?:on|ou)_[0-9a-z]{24,}`)
)

// memberIdentityShape returns the name of the member-identity shape found in s,
// or "" when s is clean. Name (not bool) so the WARN says WHICH shape fired —
// "identity leaked" with no discriminator is not actionable at 3am.
func memberIdentityShape(s string) string {
switch {
case syntheticHandleRe.MatchString(s):
return "synthetic_account_handle"
case ssoLocalDomainRe.MatchString(s):
return "sso_local_domain"
case feishuActorIDRe.MatchString(s):
return "feishu_actor_id"
}
return ""
}

// identityScrubExemptHeaders are credential-bearing headers the shape scan skips.
//
// WHY an exemption exists at all: these carry opaque provider secrets — a Bearer
// token is a long random string, and a random string can accidentally satisfy
// feishuActorIDRe. Dropping Authorization on a false positive would fail EVERY
// request of that account (level-2 stability) to defend against a shape that
// cannot legitimately live in a credential anyway. These headers are set by the
// proxy itself from vault material; they are never an identity carrier.
var identityScrubExemptHeaders = map[string]struct{}{
"Authorization": {},
"Proxy-Authorization": {},
"X-Api-Key": {},
"Api-Key": {},
"X-Goog-Api-Key": {},
}

// scrubMemberIdentityHeaders deletes outbound headers whose VALUE carries a
// member-identity shape, regardless of header name, and returns "<name>=<shape>"
// for each removal so the caller can WARN (never the value itself — logging the
// leak to defend against the leak is not a fix).
//
// Complements stripAikeyRequestHeaders rather than replacing it: that one is a
// namespace rule (cheap, total, covers annotations that do not exist yet), this
// one is a value rule (covers header names that do not exist yet). Neither
// subsumes the other.
func scrubMemberIdentityHeaders(h http.Header) []string {
var removed []string
for name, values := range h {
if _, exempt := identityScrubExemptHeaders[http.CanonicalHeaderKey(name)]; exempt {
continue
}
for _, v := range values {
if shape := memberIdentityShape(v); shape != "" {
removed = append(removed, http.CanonicalHeaderKey(name)+"="+shape)
h.Del(name)
break
}
}
}
return removed
}
10 changes: 9 additions & 1 deletion internal/proxy/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,15 @@ const (
func stripAikeyRequestHeaders(h http.Header) {
for k := range h {
if len(k) >= 8 && strings.EqualFold(k[:8], "X-Aikey-") {
h.Del(k)
// delete(), not h.Del(): Del canonicalizes its argument before
// deleting, so for a key written straight into the map in
// non-canonical form ("x-aikey-…", as a verbatim copy from another
// hop can be) it deletes a DIFFERENT, absent key and the real entry
// survives the strip — the case-insensitive match above then reads
// as protection that isn't there. Found 2026-07-21 by the probe/
// forward convergence fence. net/http canonicalizes inbound server
// headers, so this was not a live leak; it was a live blind spot.
delete(h, k)
}
}
}
Expand Down
23 changes: 23 additions & 0 deletions internal/proxy/oauth_inject.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"

"github.com/AiKeyLabs/aikey-proxy/internal/observability"
)

// oauthInject replaces the original API Key auth with OAuth credential injection.
Expand Down Expand Up @@ -332,7 +335,27 @@ func setRequestBody(req *http.Request, bodyBytes []byte) {
// injectMetadataUserIDIfAbsent reads the request body JSON and injects
// metadata.user_id only if it's not already set. This preserves the CLI's
// own user_id when proxying Claude Code requests (forward compatibility).
//
// Fence I13 body chokepoint (2026-07-21): this is the ONLY place the proxy
// writes an identifier into an outbound body, so the member-identity guard sits
// here rather than at today's single call site — a future caller ("attribute
// upstream usage per member") inherits the fence instead of re-opening the hole.
// Refusing the write is the safe failure: metadata.user_id is optional to the
// WAF's persona check in the sense that a MISSING one degrades to a fresh
// session, whereas a leaked one is unrecoverable — the third party has it.
func injectMetadataUserIDIfAbsent(req *http.Request, userID string) {
if shape := memberIdentityShape(userID); shape != "" {
tc := traceFromContext(req.Context())
slog.Warn("refused to write member identity into upstream body",
"event.name", observability.EventProxyRequestIdentityBlocked,
"identity_shape", shape,
"path", req.URL.Path,
"trace_id", tc.TraceID,
"span_id", tc.SpanID,
"request_id", tc.RequestID,
)
return
}
mutateJSONBody(req, func(body map[string]any) bool {
// Skip if metadata.user_id already present.
if metadata, ok := body["metadata"].(map[string]any); ok {
Expand Down
32 changes: 22 additions & 10 deletions internal/proxy/probe_raw.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,18 @@ func (p *Proxy) handleProbeRaw(w http.ResponseWriter, r *http.Request, canonical
// invariant per spec §4.3).
stripAikeyHeaders(upstreamReq.Header)

// 4e. Fence I13 value rule (2026-07-21) — same guard the forward Director
// runs. Steps 4a/4d are NAME rules; neither can see a member-identity value
// parked under an allowlisted header name. Expected to be a no-op here
// (default-deny allowlist + fixed UA + proxy-set auth), so a hit is a real
// signal and is logged, not swallowed.
if scrubbed := scrubMemberIdentityHeaders(upstreamReq.Header); len(scrubbed) > 0 {
logger.Warn("member identity scrubbed from probe upstream request headers",
"event.name", observability.EventProxyRequestIdentityScrubbed,
"headers", scrubbed,
)
}

// 5. Send + time. Use p.transport so HTTP_PROXY env etc are honored
// the same way as the regular upstream path.
transport := p.currentTransport()
Expand Down Expand Up @@ -324,17 +336,17 @@ func copyAllowlistedHeaders(src, dst http.Header) {
// but a second guard ensures even an allowlist regression doesn't leak
// probe bearer to upstream. Cost: one map iteration on outbound headers
// (typically < 10 entries).
//
// 2026-07-21: this used to be a second, independently written implementation of
// the same "X-Aikey-* never reaches an upstream" invariant that
// middleware.go's stripAikeyRequestHeaders enforces on the forward path — two
// bodies of code, one invariant, and fence I13 only pinned one of them. It now
// DELEGATES, so the invariant has a single source of truth and both call sites
// are covered by TestStripperConvergence_ProbeDelegatesToForwardPath. The layer
// order on this path is unchanged and still three-deep: outboundHeaderAllowlist
// (default-deny) → namespace strip (here) → member-identity value scrub.
func stripAikeyHeaders(h http.Header) {
var toDelete []string
for name := range h {
canonical := http.CanonicalHeaderKey(name)
if strings.HasPrefix(canonical, "X-Aikey-") {
toDelete = append(toDelete, name)
}
}
for _, name := range toDelete {
h.Del(name)
}
stripAikeyRequestHeaders(h)
}

// injectProbeAuth sets the upstream Authorization (or x-api-key) header
Expand Down
Loading
Loading