fix(gateway): AUTO scope grants all models — restrict to routing mode only

A key with scope=[AUTO] could previously:
1. request ANY concrete model id directly (hasScopeModel/checkModelScope
   treated AUTO as a wildcard)
2. see the full 56-model list on /v1/models (intersectModels considered
   AUTO as grant-everything)

AUTO now only authorizes the AUTO routing mode. Direct requests to a
specific model require an explicit scope entry.

Also carries agentrouter.lua WAF fingerprint headers (Origin/Referer/
X-Requested-With) already staged on this branch.

Tests: TestHasScopeModelWithSourcePrefix updated; full suite green.
This commit is contained in:
JianFeeeee
2026-09-10 12:51:40 +08:00
parent 6a34cd6f86
commit 7fb8f96b82
3 changed files with 41 additions and 34 deletions

View File

@ -115,13 +115,15 @@ func (g *Gateway) resolveCands(ctx context.Context, req *chatRequest) ([]*provid
} }
// filterCandsByModels keeps only providers exposing at least one model of the // filterCandsByModels keeps only providers exposing at least one model of the
// scope (used for user keys with a restricted model scope). An "AUTO" scope // scope (used for user keys with a restricted model scope). Scope entries with
// entry means the key is allowed to use any model. Scope entries with a // a Source pinned to a specific upstream narrow the candidates to that source
// Source pinned to a specific upstream narrow the candidates to that source
// for the matching model. // for the matching model.
//
// An "AUTO" scope entry only allows the AUTO routing mode; it does NOT grant
// access to specific models.
func filterCandsByModels(cands []*provider.Provider, allow []config.ModelScope) []*provider.Provider { func filterCandsByModels(cands []*provider.Provider, allow []config.ModelScope) []*provider.Provider {
for _, m := range allow { for _, m := range allow {
if m.Model == "" || strings.EqualFold(m.Model, "AUTO") { if m.Model == "" {
return cands return cands
} }
} }
@ -152,20 +154,18 @@ func filterCandsByModels(cands []*provider.Provider, allow []config.ModelScope)
return out return out
} }
// intersectModels restricts a model list to the scope (preserving order). An // intersectModels restricts a model list to the scope (preserving order).
// "AUTO" scope entry grants every model. // An "AUTO" scope entry only allows the AUTO routing mode; it does NOT grant
// every model.
func intersectModels(models []string, allow []config.ModelScope) []string { func intersectModels(models []string, allow []config.ModelScope) []string {
allowed := make(map[string]bool, len(allow)) allowed := make(map[string]bool, len(allow))
any := false
for _, m := range allow { for _, m := range allow {
if m.Model == "" || strings.EqualFold(m.Model, "AUTO") { if m.Model == "" {
any = true return models
break }
if !strings.EqualFold(m.Model, "AUTO") {
allowed[m.Model] = true
} }
allowed[m.Model] = true
}
if any {
return models
} }
out := make([]string, 0, len(models)) out := make([]string, 0, len(models))
seen := map[string]bool{} seen := map[string]bool{}
@ -179,24 +179,16 @@ func intersectModels(models []string, allow []config.ModelScope) []string {
} }
// checkModelScope validates the effective model against the key's model scope // checkModelScope validates the effective model against the key's model scope
// and token quota. Returns an error message when rejected. A scope entry with // and token quota. Returns an error message when rejected.
// model "AUTO" grants all models; its quota caps the key's total tokens. //
// A scope entry with model "AUTO" only allows requests where the effective
// model is AUTO (the routing mode). It does NOT grant access to specific model
// ids — that requires an explicit scope entry for the model.
func (g *Gateway) checkModelScope(ctx context.Context, model string) string { func (g *Gateway) checkModelScope(ctx context.Context, model string) string {
allow := g.allowedModels(ctx) allow := g.allowedModels(ctx)
if allow == nil { if allow == nil {
return "" return ""
} }
for _, sc := range allow {
if sc.Model != "" && strings.EqualFold(sc.Model, "AUTO") {
if sc.TokenQuota > 0 {
used := g.scopeTokens(ctx, sc)
if used >= sc.TokenQuota {
return fmt.Sprintf("token quota exceeded (%d/%d)", used, sc.TokenQuota)
}
}
return ""
}
}
for _, sc := range allow { for _, sc := range allow {
if sc.Model != model { if sc.Model != model {
continue continue
@ -229,12 +221,15 @@ func (g *Gateway) scopeTokens(ctx context.Context, sc config.ModelScope) int64 {
// source that actually serves the bare model (via Registry.EffectiveModel), so // source that actually serves the bare model (via Registry.EffectiveModel), so
// model ids that themselves contain separators (e.g. "deepseek-v4-flash-free") // model ids that themselves contain separators (e.g. "deepseek-v4-flash-free")
// are never corrupted (P10-2). // are never corrupted (P10-2).
//
// A scope entry with model "AUTO" only matches the literal AUTO routing mode;
// it does NOT grant access to specific model ids.
func (g *Gateway) hasScopeModel(list []config.ModelScope, s string) bool { func (g *Gateway) hasScopeModel(list []config.ModelScope, s string) bool {
if r := g.core.Registry(); r != nil { if r := g.core.Registry(); r != nil {
s = r.EffectiveModel(s) s = r.EffectiveModel(s)
} }
for _, x := range list { for _, x := range list {
if x.Model == s || (x.Model != "" && strings.EqualFold(x.Model, "AUTO")) { if x.Model == s {
return true return true
} }
} }

View File

@ -670,8 +670,11 @@ func TestHasScopeModelWithSourcePrefix(t *testing.T) {
if g.hasScopeModel(scope, "deepseek-v4-flash-free-extra") { if g.hasScopeModel(scope, "deepseek-v4-flash-free-extra") {
t.Error("hasScopeModel returned true for unrelated model") t.Error("hasScopeModel returned true for unrelated model")
} }
if !g.hasScopeModel([]config.ModelScope{{Model: "AUTO"}}, "zen:anything") { if g.hasScopeModel([]config.ModelScope{{Model: "AUTO"}}, "zen:deepseek-v4-flash-free") {
t.Error("AUTO scope should allow any prefixed model") t.Error("AUTO scope must NOT grant specific model ids even with prefix")
}
if g.hasScopeModel([]config.ModelScope{{Model: "AUTO"}}, "deepseek-v4-pro") {
t.Error("AUTO scope must NOT grant specific model ids")
} }
} }

View File

@ -5,10 +5,12 @@ adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions" adapter.endpoint = "/chat/completions"
adapter.headers = {} adapter.headers = {}
-- AgentRouter 的 WAF 会按客户端指纹白名单校验,只放行官方客户端。 -- AgentRouter 的阿里云 WAF 按三重维度校验:出口 IP必须海外
-- 这里通过 build_headers 注入官方客户端 User-Agent 以通过校验 -- TLS 指纹JS/Go HTTP 层与 curl 不同)、浏览器指纹 header 集
-- 默认 QwenCode 指纹(实测可通);可在 source.meta.user_agent 覆盖成 Claude Code 指纹。 -- 纯 UA 已不够WAF 拦 12 种 agent CLI UA 中的 10 种(全 405 HTML
-- 只有同时带 Origin: qwen.ai + Referer + X-Requested-With 才放行,
-- 且同 IP 短时间连续 3+ 次请求会被速率封禁,靠 adapter 侧低频调用 +
-- llmsproxy 的 prefFailStep 冷却自然限制,来源无法控制速率。
local default_ua = "QwenCode/0.2.0 (linux; x64)" local default_ua = "QwenCode/0.2.0 (linux; x64)"
-- AgentRouter fronts Claude models (claude-opus-4-8), and Claude upstreams -- AgentRouter fronts Claude models (claude-opus-4-8), and Claude upstreams
@ -65,6 +67,13 @@ function adapter.build_headers(meta)
["Content-Type"] = "application/json", ["Content-Type"] = "application/json",
["Authorization"] = "Bearer " .. meta.api_key, ["Authorization"] = "Bearer " .. meta.api_key,
["User-Agent"] = ua, ["User-Agent"] = ua,
-- QwenCode client fingerprint: WAF rejects bare UA without these.
["Accept"] = "application/json, text/plain, */*",
["Accept-Language"] = "zh-CN,zh;q=0.9",
["Origin"] = "https://qwen.ai",
["Referer"] = "https://qwen.ai/",
["X-Requested-With"] = "XMLHttpRequest",
["Connection"] = "keep-alive",
} }
end end