Broken access control / cross-tenant channel theft via the oneapi/proxy channelid URL param (admin gate missing on one of two channel-pinning paths)
Summary
one-api lets an admin configure "channels", each holding a secret upstream provider API key and restricted to a group and a model allowlist. Pinning a request to a specific channel is meant to be admin-only, and the key-suffix path enforces that. The URL-parameter channel-pinning path applies no role check at all, so any authenticated low-privilege user can pin an arbitrary channel by integer id, causing one-api to forward their request upstream bearing that channel's secret key, bypassing per-group isolation and the per-channel model allowlist. Confirmed on the built binary: a common user who was denied the model by normal routing and by the suffix path successfully pinned the admin's channel and had the admin's secret upstream key forwarded on their behalf.
Details
middleware/auth.go has two channel-pinning paths and gates only one:
// ~135-142: sk-<key>-<channelId> suffix path -- GATED
if model.IsAdmin(token.UserId) {
c.Set(ctxkey.SpecificChannelId, parts[1])
} else {
abortWithMessage(c, http.StatusForbidden, "普通用户不支持指定渠道")
return
}
...
// ~145-147: URL-param path -- NO role check
if channelId := c.Param("channelid"); channelId != "" {
c.Set(ctxkey.SpecificChannelId, channelId)
}The param is supplied by the route router/relay.go:23: relayV1Router.Any("/oneapi/proxy/:channelid/*target", controller.Relay), behind only TokenAuth (any valid user API token). SpecificChannelId flows into middleware/distributor.go:28-43, which calls model.GetChannelById(id, true) (model/channel.go:74) loading any channel by arbitrary integer id with no ownership/group/user scoping, then SetupContextForSelectedChannel (distributor.go:73) sets c.Request.Header.Set("Authorization", "Bearer "+channel.Key) and the channel base URL, and controller.Relay forwards upstream.
The untrusted source is the channelid URL segment from any authenticated common user; the sink is an upstream request bearing the admin's secret channel key. The boundary crossed is cross-tenant/privilege IDOR: a role=1 user reaches channels, provider keys, groups, and model allowlists reserved for other groups/admins. Channels are enumerable by incrementing the id. Self-registration (RegisterEnabled) is on by default, so in a default deployment any anonymous attacker can register, obtain a token, and exploit.
PoC
POST /v1/oneapi/proxy/1/v1/chat/completions
Authorization: Bearer <common user's one-api token>
{ "model": "gpt-4", "messages":[...] }
-> one-api forwards upstream with Authorization: Bearer <channel 1's secret key>Validated on the built binary (SQLite default), with channel id=1 (group vip, models gpt-4, key sk-SECRET-ADMIN-UPSTREAM-KEY-9999, base_url = mock upstream on :4099) and common user alice (role=1):
negative control (normal routing): POST /v1/chat/completions {"model":"gpt-4"} as alice (group default)
-> {"error":"当前分组 default 下对于模型 gpt-4 无可用渠道..."} (upstream NOT hit)
admin-gated suffix path: Authorization: Bearer sk-<alice-key>-1
-> {"error":"普通用户不支持指定渠道..."}
exploit (URL-param path): POST /v1/oneapi/proxy/1/v1/chat/completions (alice's common token)
-> 200 MOCK_UPSTREAM_REPLY
upstream log: {"path":"/v1/oneapi/proxy/1/v1/chat/completions",
"auth":"Bearer sk-SECRET-ADMIN-UPSTREAM-KEY-9999"}The admin's secret channel key was forwarded upstream on behalf of a common user who was explicitly denied that channel by every other path. Pinning the channel while requesting a model outside its allowlist also succeeded, confirming the model-allowlist bypass.
Impact
Any authenticated low-privilege user (self-registerable by default) steals and abuses the operator's paid upstream provider API keys at the operator's expense, bypasses per-group channel isolation and per-channel model allowlists, and enumerates all configured channels by id. Scope is changed: the vulnerability compromises the operator's upstream provider account/keys, a separate security authority.
Remediation
Apply the same admin gate to the URL-param path in middleware/auth.go:
if channelId := c.Param("channelid"); channelId != "" {
if !model.IsAdmin(c.GetInt(ctxkey.Id)) {
abortWithMessage(c, http.StatusForbidden, "普通用户不支持指定渠道")
return
}
c.Set(ctxkey.SpecificChannelId, channelId)
}Defense in depth: have Distribute verify the pinned channel actually serves the caller's group and the requested model even for admins, and tighten the *target segment that is currently passed through verbatim to the upstream.
Source: songquanpeng/one-api