fix(proxy): restore header filtering and API matcher consistency

- Canonicalize filtered header deny-lists so Cloudflare and CDN headers are still removed

- Normalize incomplete API repo paths to stable owner-level matcher output regardless of trailing slash or query

- Add regression tests covering header canonicalization and incomplete API repo path parsing
This commit is contained in:
wjqserver 2026-04-12 07:17:59 +08:00
parent ba3dcf7624
commit e9e48fcefd
4 changed files with 73 additions and 9 deletions

View file

@ -65,6 +65,31 @@ func TestCopyHeaderFiltered(t *testing.T) {
} }
} }
func TestCopyHeaderFiltered_CanonicalizesDenylist(t *testing.T) {
src := http.Header{
"Cf-Ipcountry": {"CN"},
"Cf-Ray": {"abc123"},
"Cf-Ew-Via": {"edge"},
"X-Forwarded-For": {"127.0.0.1"},
}
dst := make(http.Header)
copyHeaderFiltered(dst, src, reqHeadersToRemove)
if got := dst.Values("Cf-Ipcountry"); len(got) != 0 {
t.Fatalf("Cf-Ipcountry should be filtered, got %v", got)
}
if got := dst.Values("Cf-Ray"); len(got) != 0 {
t.Fatalf("Cf-Ray should be filtered, got %v", got)
}
if got := dst.Values("Cf-Ew-Via"); len(got) != 0 {
t.Fatalf("Cf-Ew-Via should be filtered, got %v", got)
}
if got := dst.Values("X-Forwarded-For"); !reflect.DeepEqual(got, []string{"127.0.0.1"}) {
t.Fatalf("X-Forwarded-For = %v, want [127.0.0.1]", got)
}
}
func TestCopyHeaderFiltered_AllowsAllWhenDenylistEmpty(t *testing.T) { func TestCopyHeaderFiltered_AllowsAllWhenDenylistEmpty(t *testing.T) {
src := http.Header{ src := http.Header{
"X-Test": {"one", "two"}, "X-Test": {"one", "two"},

View file

@ -161,21 +161,28 @@ func Matcher(rawPath string, cfg *config.Config) (string, string, string, *GHPro
var user, repo string var user, repo string
if strings.HasPrefix(remaining, "repos/") { if strings.HasPrefix(remaining, "repos/") {
remaining = remaining[6:] remaining = remaining[6:]
if q := strings.IndexByte(remaining, '?'); q != -1 {
remaining = remaining[:q]
}
if remaining != "" && !strings.ContainsRune(remaining, '/') {
user = remaining
return user, "", "api", nil
}
i := strings.IndexByte(remaining, '/') i := strings.IndexByte(remaining, '/')
if i > 0 { if i > 0 {
userCandidate := remaining[:i] user = remaining[:i]
rest := remaining[i+1:] rest := remaining[i+1:]
if rest != "" {
if j := strings.IndexByte(rest, '/'); j != -1 { if j := strings.IndexByte(rest, '/'); j != -1 {
repo = rest[:j] repo = rest[:j]
} else { } else {
repo = rest repo = rest
} }
user = userCandidate
}
} }
} else if strings.HasPrefix(remaining, "users/") { } else if strings.HasPrefix(remaining, "users/") {
remaining = remaining[6:] remaining = remaining[6:]
if q := strings.IndexByte(remaining, '?'); q != -1 {
remaining = remaining[:q]
}
if remaining != "" { if remaining != "" {
if i := strings.IndexByte(remaining, '/'); i != -1 { if i := strings.IndexByte(remaining, '/'); i != -1 {
user = remaining[:i] user = remaining[:i]

View file

@ -151,7 +151,19 @@ func TestMatcher_Compatibility(t *testing.T) {
name: "API Repos Path (missing repo)", name: "API Repos Path (missing repo)",
rawPath: "https://api.github.com/repos/owner", rawPath: "https://api.github.com/repos/owner",
config: cfgWithAuth, config: cfgWithAuth,
expectedUser: "", expectedRepo: "", expectedMatcher: "api", expectedUser: "owner", expectedRepo: "", expectedMatcher: "api",
},
{
name: "API Repos Path (trailing slash)",
rawPath: "https://api.github.com/repos/owner/",
config: cfgWithAuth,
expectedUser: "owner", expectedRepo: "", expectedMatcher: "api",
},
{
name: "API Repos Path (missing repo with query)",
rawPath: "https://api.github.com/repos/owner?per_page=1",
config: cfgWithAuth,
expectedUser: "owner", expectedRepo: "", expectedMatcher: "api",
}, },
{ {
name: "API Users Path (exact user)", name: "API Users Path (exact user)",

View file

@ -60,6 +60,26 @@ func copyHeader(dst, src http.Header) {
} }
} }
func canonicalizeHeaderSet(headers map[string]struct{}) map[string]struct{} {
canonicalized := make(map[string]struct{}, len(headers))
for key := range headers {
canonicalized[http.CanonicalHeaderKey(key)] = struct{}{}
}
return canonicalized
}
func init() {
reqHeadersToRemove = canonicalizeHeaderSet(reqHeadersToRemove)
cloneHeadersToRemove = canonicalizeHeaderSet(cloneHeadersToRemove)
respHeadersToRemove = canonicalizeHeaderSet(respHeadersToRemove)
defaultHeaders = map[string]string{
"Accept": "*/*",
"Accept-Encoding": "",
"Transfer-Encoding": "chunked",
"User-Agent": "GHProxy/1.0",
}
}
func copyHeaderFiltered(dst, src http.Header, denylist map[string]struct{}) { func copyHeaderFiltered(dst, src http.Header, denylist map[string]struct{}) {
for k, vv := range src { for k, vv := range src {
if _, denied := denylist[k]; denied { if _, denied := denylist[k]; denied {