fix(auth): decode rest before matching the passthrough path allowlist

Close the %2f allowlist bypass (security review finding 3, round 3,
2026-08-22): allowsPassthroughPath and hasTraversalSegment (routes_
passthrough.go) reasoned about rest asymmetrically -- the traversal check
decodes it first, the allowlist matcher ran path.Match against the raw
escaped form. path.Match("v1/*", "v1/chat%2fcompletions") reports true
(one escaped segment, no literal "/" to split it), while the decoded
equivalent, "v1/chat/completions", is two segments and does not match
"v1/*". A group restricted to PassthroughPaths: ["v1/*"] therefore let
"/openai/v1/fine_tuning%2Fjobs" through -- a path an upstream that
itself decodes %2F would read as a different, unauthorized endpoint.

allowsPassthroughPath now url.PathUnescape's rest before matching -- the
same decode hasTraversalSegment already applies -- and denies a rest
that fails to unescape at all, mirroring hasTraversalSegment's own
fail-closed rule for malformed encoding. The empty-pattern fast path
(matchesGlob's own empty-means-all contract) is checked first, before
any decoding: a group with no PassthroughPaths configured -- every live
group today -- keeps allowing every request exactly as before, malformed
encoding included; hasTraversalSegment (unconditional, later in
handlePassthrough) remains the gate for that case.

Tests: a direct unit test proving the decoded three-segment form is
denied (both via the encoded and literal spellings), a single-segment
encoded value still matches, malformed encoding denies for a restricted
group and is unaffected for an unrestricted one, and an end-to-end
ServeHTTP test reproducing the exact finding scenario.
This commit is contained in:
2026-08-22 14:49:47 +01:00
parent a221b4d2eb
commit d70e5fc1df
3 changed files with 129 additions and 1 deletions
+41
View File
@@ -1610,6 +1610,47 @@ func TestHandlePassthrough_PassthroughPaths_Restrictive(t *testing.T) {
}
}
// TestHandlePassthrough_PassthroughPaths_EncodedSlashBypass_Returns403 is
// the end-to-end regression test for security review finding 3, round 3,
// 2026-08-22: a group restricted to PassthroughPaths: ["v1/*"] must deny
// "/openai/v1/fine_tuning%2Fjobs" (an encoded "/" hiding a second
// segment from the escaped-form matcher) exactly as it would deny the
// literal, decoded three-segment path. Before this fix, path.Match
// evaluated the escaped rest directly and reported a match, letting the
// request reach the upstream — which, if it decodes %2F itself, would
// have read the same bytes as "v1/fine_tuning/jobs", a path this group's
// glob was never meant to authorize.
func TestHandlePassthrough_PassthroughPaths_EncodedSlashBypass_Returns403(t *testing.T) {
var upstreamCalled bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamCalled = true
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up"}}
cfg.Groups = map[string]*GroupConfig{"default": {PassthroughPaths: []string{"v1/*"}}}
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice"}}}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/openai/v1/fine_tuning%2Fjobs", nil)
req.Header.Set("Authorization", "Bearer sk-alice")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (the encoded slash must not defeat the \"v1/*\" allowlist), body=%s", rec.Code, rec.Body.String())
}
if upstreamCalled {
t.Error("upstream must never be called: the decoded rest is a three-segment path \"v1/*\" does not authorize")
}
}
// TestHandlePassthrough_DangerousHeadersStripped proves every
// client-supplied identity/forwarding header, plus the provider-billing-
// retargeting headers, are stripped before the request reaches the