@@ -25,9 +25,16 @@ func FlattenHeaders(h http.Header) map[string]string {
2525// BuildMCPCallbackBaseURL extracts the base URL set on the BifrostContext by
2626// the HTTP middleware (e.g. "https://host"). Per-user OAuth and per-user
2727// headers resolvers append their respective paths on top.
28+ //
29+ // Trailing slashes are stripped defensively. The sole writer today
30+ // (lib/ctx.go BuildBaseURL) already normalizes, but OAuth providers match
31+ // redirect URIs exactly — a `https://host//api/oauth/callback` produced by
32+ // a future writer that forgets to trim would silently break every per-user
33+ // OAuth flow. Guarding once on the read side keeps that invariant local
34+ // to this function rather than spread across every potential writer.
2835func BuildMCPCallbackBaseURL (ctx * schemas.BifrostContext ) string {
2936 if base , ok := ctx .Value (schemas .BifrostContextKeyMCPCallbackBaseURL ).(string ); ok && base != "" {
30- return base
37+ return strings . TrimRight ( base , "/" )
3138 }
3239 return ""
3340}
@@ -91,6 +98,69 @@ func matchesPerUserHeaderKey(name string, perUserKeys []string) bool {
9198 return false
9299}
93100
101+ // Canonical-form invariant for per-user-headers data
102+ // =====================================================
103+ // HTTP header names are case-insensitive on the wire (RFC 7230 §3.2),
104+ // so anywhere the per-user-headers feature compares a schema key against
105+ // a stored or submitted header name we'd need EqualFold lookups. Doing
106+ // that defensively at every read site is fragile — a single missed call
107+ // site re-introduces the bug (stored `authorization` looking missing
108+ // against schema `Authorization`, etc.).
109+ //
110+ // Instead we enforce a write-time invariant: every external boundary
111+ // that accepts a header key (or a credential header map) lowercases and
112+ // trims via the helpers below before persisting. Downstream code can
113+ // then assume canonical form and use plain map lookups.
114+ //
115+ // Write boundaries that MUST call these:
116+ // - createMCPClient / updateMCPClient / resolvePerUserHeaderKeys
117+ // (handlers/mcp.go) for MCPClientConfig.PerUserHeaderKeys
118+ // - flowSubmit (handlers/mcp_per_user_headers.go) for the
119+ // user-submitted credential.Headers map
120+ // - loadMCPClientConfigFromFile (lib/config.go) for the config.json
121+ // load path
122+ //
123+ // New write paths added in the future must canonicalize too — there is
124+ // no defensive case-folding on the read side anymore.
125+
126+ // CanonicalizeHeaderKey returns the canonical lowercase + trimmed form
127+ // of a single header key. Empty input returns empty.
128+ func CanonicalizeHeaderKey (key string ) string {
129+ return strings .ToLower (strings .TrimSpace (key ))
130+ }
131+
132+ // CanonicalizeHeaderKeys returns a new slice with every entry passed
133+ // through CanonicalizeHeaderKey. Nil in → nil out so a caller that
134+ // uses "nil means preserve existing" semantics (e.g.
135+ // resolvePerUserHeaderKeys, UpdateMCPClientConfig) keeps that signal.
136+ // The input slice is not mutated.
137+ func CanonicalizeHeaderKeys (keys []string ) []string {
138+ if keys == nil {
139+ return nil
140+ }
141+ out := make ([]string , len (keys ))
142+ for i , k := range keys {
143+ out [i ] = CanonicalizeHeaderKey (k )
144+ }
145+ return out
146+ }
147+
148+ // CanonicalizeHeaderMap returns a new map whose keys are passed through
149+ // CanonicalizeHeaderKey. On collision (e.g. "Authorization" and
150+ // "authorization" both present in the input), the last value wins —
151+ // callers that need duplicate detection should run it on the raw input
152+ // before calling this. Nil in → nil out.
153+ func CanonicalizeHeaderMap (m map [string ]string ) map [string ]string {
154+ if m == nil {
155+ return nil
156+ }
157+ out := make (map [string ]string , len (m ))
158+ for k , v := range m {
159+ out [CanonicalizeHeaderKey (k )] = v
160+ }
161+ return out
162+ }
163+
94164// ExtractFilteredExtras returns just the per-request "extra" headers carried
95165// in the BifrostContext (BifrostContextKeyMCPExtraHeaders), scoped by the
96166// client's AllowedExtraHeaders. Static config headers are NOT included here —
0 commit comments