diff --git a/docs/subsystems/gateway-routing.md b/docs/subsystems/gateway-routing.md index 5b50f5b9..c85cfcf5 100644 --- a/docs/subsystems/gateway-routing.md +++ b/docs/subsystems/gateway-routing.md @@ -16,7 +16,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md). | Part | Responsibility | Source | | --- | --- | --- | | Address and caller | `DefaultAddr`. The local token `magpie` and the per-agent `magpie-` (`TokenFor`). `agentOf` tells which agent asked, first by token, then by User-Agent. | [`gateway.go`](../../internal/gateway/gateway.go) | -| HTTP surface | `Handler` routes `/v1/chat/completions`, `/v1/responses`, `/v1/messages` (each also without `/v1`), `/v1/models`, Gemini's `/v1beta/models/…`, the Codex backend path, `/v1/codex/models` (Codex's catalog shape for a Codex elsewhere with a gateway key, `codexCatalog`, kept to the key's models by `keyAllowed`; #1281), `/mcp/{name}` and the `/v1/magpie/*` status endpoints. A GET on `/v1/responses` (a client opening Responses over a WebSocket) is answered 426, a plain one 405: Responses is relayed over HTTP only. Every route is wrapped in `counted`, `corsGuard`, `callerGuard`, `withCaller` and `keyLimited`; `/v1/messages`, `/messages` and `/v1/messages/count_tokens` in `keepsSignIn` too. `corsGuard` (#1051) serves CORS only to the origins listed in Settings › Network › Web pages (`settings.CORSOrigins`, cleaned to `scheme://host[:port]` by `settings.CleanOrigins`, which refuses a wildcard). For a listed origin it answers the preflight (204) and adds `Access-Control-Allow-Origin`, and every other request from it needs an enabled gateway key (`access.Authenticate`), even on loopback, or gets a 401. It takes the key from the caller `lanGuard` recorded too, since on the running server `lanGuard` has already put magpie's own token in the key's place. Any other web page is refused 403 on every route (`foreignPage`): on loopback a call needs no key and the body's Content-Type isn't checked, so a page the user visits could otherwise send a "simple" `text/plain` POST and spend their subscriptions blind. Only an `http`/`https` Origin is judged, and one on this computer (`localhost`, `*.localhost`, `127.0.0.0/8`, `[::1]`, any port) or the request's own origin (its host and port equal to `Host`, such as a page on the gateway's LAN address) passes, as does usemagpie.ai on `/v1/magpie/omarchy`. `null` and other schemes (`app://`, `file://`, `tauri://`, `vscode-webview://`, `chrome-extension://`) come from desktop apps, webviews and extensions that don't enforce CORS, and pass. A request with no Origin (every agent's) passes through with no CORS headers, as before. There is no `Host` check, so a page whose own name resolves to 127.0.0.1 (DNS rebinding) counts as same-origin. | [`gateway.go`](../../internal/gateway/gateway.go), [`cors.go`](../../internal/gateway/cors.go), [`codex_catalog.go`](../../internal/gateway/codex_catalog.go), [`internal/settings/origins.go`](../../internal/settings/origins.go) | +| HTTP surface | `Handler` routes `/v1/chat/completions`, `/v1/responses`, `/v1/messages` (each also without `/v1`), `/v1/models`, Gemini's `/v1beta/models/…`, the Codex backend path, `/v1/codex/models` (Codex's catalog shape for a Codex elsewhere with a gateway key, `codexCatalog`, kept to the key's models by `keyAllowed`; #1281), `/mcp/{name}` and the `/v1/magpie/*` status endpoints. A GET on `/v1/responses` (a client opening Responses over a WebSocket) is answered 426, a plain one 405: Responses is relayed over HTTP only. Every route is wrapped in `counted`, `corsGuard`, `callerGuard`, `withCaller` and `keyLimited`; `/v1/messages`, `/messages` and `/v1/messages/count_tokens` in `keepsSignIn` too. `corsGuard` (#1051) serves CORS only to the origins listed in Settings › Network › Web pages (`settings.CORSOrigins`, cleaned to `scheme://host[:port]` by `settings.CleanOrigins`, which refuses a wildcard). For a listed origin it answers the preflight (204) and adds `Access-Control-Allow-Origin`, and every other request from it needs an enabled gateway key (`access.Authenticate`), even on loopback, or gets a 401. It takes the key from the caller `lanGuard` recorded too, since on the running server `lanGuard` has already put magpie's own token in the key's place. Any other web page is refused 403 on every route (`foreignPage`): on loopback a call needs no key and the body's Content-Type isn't checked, so a page the user visits could otherwise send a "simple" `text/plain` POST and spend their subscriptions blind. Only an `http`/`https` Origin is judged, and one on this computer (`localhost`, `*.localhost`, `127.0.0.0/8`, `[::1]`, any port) or the request's own origin (its host and port equal to `Host`, such as a page on the gateway's LAN address) passes, as does usemagpie.ai on `/v1/magpie/omarchy`. `null` and other schemes (`app://`, `file://`, `tauri://`, `vscode-webview://`, `chrome-extension://`) come from desktop apps, webviews and extensions that don't enforce CORS, and pass. A request with no Origin (every agent's) passes through with no CORS headers, as before. A page whose own name resolves to 127.0.0.1 (DNS rebinding) would count as same-origin here, so `lanGuard` refuses it first (`rebound`, 403): a loopback request carrying a browser's marks (`Origin`, `Sec-Fetch-Site`/`Sec-Fetch-Mode`, a `Mozilla/` user agent) whose `Host` isn't this computer's (an IP, `localhost`, `*.localhost`, a container runtime's name for the host such as `host.docker.internal`, `MAGPIE_PUBLIC_URL`'s or `MAGPIE_ADDR`'s host, the machine's own name) needs an enabled gateway key. Agents send none of those marks and pass under any name. | [`gateway.go`](../../internal/gateway/gateway.go), [`cors.go`](../../internal/gateway/cors.go), [`codex_catalog.go`](../../internal/gateway/codex_catalog.go), [`internal/settings/origins.go`](../../internal/settings/origins.go) | | Model resolution | `serve` resolves the model the agent asked for: Claude tier stand-ins, a routing group (`provider.GroupFor`: its id, the model's id however a vendor spells it, else the one group whose name it is, unless a provider serves a model by that id), auto stand-ins, then `provider.Resolve`. A bare model id several providers serve (MOMO on Discord: `glm-5.3` under opencode-go and a6api) is never refused: with found groups on (the default) `GroupFor` takes it to the group magpie found for it (`auto-`), its members in the Providers order, routed and failed over as any group, a conversation staying with the member that answered it while its cache is worth it (affinity); with them off, `Resolve` takes the first provider in the Providers order that exposes it, with no fallback. magpie writes only `provider/model` ids into agents' configs (`magpieModels`), so a bare id is one the user wrote. Every reply says who answered in `X-Magpie-Provider` and `X-Magpie-Model` (`noteMember`), and the usage log and the Routing page keep it. A model id with `~` and no slash that resolves as nothing else is the one served id (a provider's model or a group) whose slashes it spells as `~` (`unflat`, `provider.Unflat`; MiniMax Code's spelling, #1387), taken back in `validateModel` before key limits, middleware and usage see it; two ids spelled alike are neither. An unknown model gets a 404 naming why: a switched-off provider, a disabled group or an empty group. Otherwise it names up to three ids nearest to what was asked (`provider.Closest`: words of a model's display name count before words of its id, and a provider's id or name only alongside them, so "Zen-Space-Bunny" is told `opencode-zen-free/space-bunny-free`, MOMO on Discord), then the ways a model can be named, then every id (`unknownModel`; chat and embeddings). | [`gateway.go`](../../internal/gateway/gateway.go), [`internal/provider/group.go`](../../internal/provider/group.go), [`internal/provider/flat.go`](../../internal/provider/flat.go) | | Candidates | `plan` lists one provider's candidates: one per key or account on, then each `Fallback` model, with resting ones last (`restLast`). `planGroup` and `planLevel` weigh a group's members together. A nested group plans by its own routing (#576). A member's own fallbacks are not the group's. | [`fallback.go`](../../internal/gateway/fallback.go) | | Order of keys and accounts | `Routing`: smart (""), `order`, `rotate`, `usage` (least used, with an `usageHalfLife` of 1h), `pace` or `weight` (`weightedFirst`). Smart, `usage` and `pace` weigh a subscription by its allowance (`allowances`), and a key by its own usage windows when magpie reads them (`keyAllowance`: a sub2api key given a 5-hour, day or 7-day limit, or a key bought as a plan, such as Kimi Code's or a GLM Coding Plan's); any other key has none. A lone key or account is weighed against nobody, but what it has left is still read into the trace (`weighRouted`), so the Routing page shows it as it does for several (#1016); all but used up, it keeps its conversation rather than being said to move (`affine`). A failure rests a candidate (`failure`, `failureOf`). | [`routing.go`](../../internal/gateway/routing.go), [`weighted.go`](../../internal/gateway/weighted.go), [`internal/provider/routing.go`](../../internal/provider/routing.go) | diff --git a/internal/gateway/cors_test.go b/internal/gateway/cors_test.go index 26e898d0..9fa0ea5d 100644 --- a/internal/gateway/cors_test.go +++ b/internal/gateway/cors_test.go @@ -149,6 +149,7 @@ func TestCORSKeyThroughTheServer(t *testing.T) { for _, key := range []string{secret, ""} { r := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(chatReq)) r.RemoteAddr = "127.0.0.1:50123" + r.Host = "127.0.0.1:3425" r.Header.Set("Origin", "http://localhost:3000") r.Header.Set("Content-Type", "application/json") r.Header.Set(SessionHeader, "browser-chat") @@ -271,7 +272,7 @@ func TestForeignPagesRefused(t *testing.T) { {"http://tauri.localhost", "127.0.0.1:3425", loop, ""}, // Tauri's webview on Windows {"https://app.example.com", "127.0.0.1:3425", loop, secret}, // listed, with its key {"http://192.168.1.5:3425", "192.168.1.5:3425", "192.168.1.9:50123", secret}, // the gateway's own origin, shared on the LAN - {"https://magpie.example.com", "magpie.example.com", loop, ""}, // its own origin behind a port-443 name + {"https://magpie.example.com", "magpie.example.com", loop, secret}, // its own origin behind a port-443 name, with a key (without one, a rebound page: TestReboundPageRefused) {"null", "127.0.0.1:3425", loop, ""}, // a sandboxed frame, a file:// page {"app://cherry", "127.0.0.1:3425", loop, ""}, // an Electron renderer {"file://", "127.0.0.1:3425", loop, ""}, diff --git a/internal/gateway/lan.go b/internal/gateway/lan.go index 172fd1b2..6880b77e 100644 --- a/internal/gateway/lan.go +++ b/internal/gateway/lan.go @@ -234,6 +234,11 @@ func (s *Server) Relisten() error { func lanGuard(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { remote := !local(r) + if !remote && rebound(r) { + log.Printf("refused %s %s: a web page reached loopback under the name %q", r.Method, r.URL.Path, r.Host) + writeError(w, provider.Chat, http.StatusForbidden, "magpie doesn't answer a browser that reached it as "+r.Host+" (a web site's name pointed at this computer): call it as localhost or 127.0.0.1, or send an enabled gateway key") + return + } if remote && !remoteKeyed() { if proxied(r) { log.Printf("refused %s %s through a proxy or tunnel: magpie isn't shared", r.Method, r.URL.Path) @@ -257,6 +262,59 @@ func lanGuard(next http.Handler) http.Handler { }) } +// rebound: a browser sent this request to loopback under a hostname that +// isn't this computer's — a web site whose DNS answered 127.0.0.1 after its +// page loaded (DNS rebinding). The page is then same-origin with the +// gateway: it sends no Origin on a GET, or one equal to the Host, so +// corsGuard lets it by, and on loopback a call needs no key, so the page +// could read the models and quotas and spend the user's subscriptions. +// Agents call magpie as localhost or an IP, and send none of a browser's +// marks; a hostname of this computer's own (MAGPIE_PUBLIC_URL's, +// MAGPIE_ADDR's, its own name, a container runtime's name for the host) +// isn't a web site's. An enabled gateway key, which such a page can't +// know, still lets a call in. +func rebound(r *http.Request) bool { + if !fromBrowser(r) || ownHost(r.Host) { + return false + } + return !slices.ContainsFunc(callerKeys(r), func(k string) bool { _, ok := access.Authenticate(k); return ok }) +} + +// fromBrowser: the request carries what a browser puts on every request +// and a page's script can't take off. +func fromBrowser(r *http.Request) bool { + return r.Header.Get("Origin") != "" || r.Header.Get("Sec-Fetch-Site") != "" || + r.Header.Get("Sec-Fetch-Mode") != "" || strings.HasPrefix(r.UserAgent(), "Mozilla/") +} + +// containerHostNames are the names container runtimes give the host. +var containerHostNames = []string{"host.docker.internal", "gateway.docker.internal", "host.containers.internal", "host.orb.internal", "docker.for.mac.localhost"} + +// ownHost: host (a Host header) names this computer — an IP, localhost, +// *.localhost, or one of the names above. +func ownHost(host string) bool { + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + host = strings.TrimSuffix(strings.ToLower(strings.Trim(host, "[]")), ".") + if host == "" || net.ParseIP(host) != nil || host == "localhost" || strings.HasSuffix(host, ".localhost") || slices.Contains(containerHostNames, host) { + return true + } + if strings.EqualFold(host, PublicHost()) { + return true + } + if h, _, err := net.SplitHostPort(Addr()); err == nil && strings.EqualFold(host, h) { + return true + } + if name, err := os.Hostname(); err == nil { + name = strings.TrimSuffix(strings.ToLower(name), ".local") + if name != "" && (host == name || host == name+".local") { + return true + } + } + return false +} + // callerGuard also covers embedded handlers used by the web app and tests. func callerGuard(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/gateway/lan_rebind_test.go b/internal/gateway/lan_rebind_test.go new file mode 100644 index 00000000..02bec2e5 --- /dev/null +++ b/internal/gateway/lan_rebind_test.go @@ -0,0 +1,70 @@ +package gateway + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// A web page whose site's DNS turns to 127.0.0.1 after it loads (DNS +// rebinding) calls the gateway as its own origin: a GET carries no Origin, +// a POST one equal to the Host, and on loopback no key is asked. Such a +// browser request under a name that isn't this computer's is refused; an +// agent, a browser on localhost or an IP, a container runtime's name for +// the host, and a call with an enabled gateway key go through as before. +func TestReboundPageRefused(t *testing.T) { + fresh(t) + t.Setenv("MAGPIE_ADDR", "") + t.Setenv("MAGPIE_PUBLIC_URL", "") + reached := false + h := lanGuard(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true })) + call := func(method, host string, hdr ...string) int { + reached = false + r := httptest.NewRequest(method, "/v1/models", nil) + r.RemoteAddr = "127.0.0.1:51234" + r.Host = host + for i := 0; i+1 < len(hdr); i += 2 { + r.Header.Set(hdr[i], hdr[i+1]) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code == http.StatusForbidden && !strings.Contains(w.Body.String(), "localhost or 127.0.0.1") { + t.Fatalf("%s %s: %q", method, host, w.Body.String()) + } + return w.Code + } + chrome := []string{"User-Agent", "Mozilla/5.0 (Macintosh) Chrome/140", "Sec-Fetch-Site", "same-origin", "Sec-Fetch-Mode", "cors"} + evil := "rebind.attacker.example:3425" + + if c := call("GET", evil, chrome...); c != http.StatusForbidden || reached { + t.Fatal("a rebound page's GET:", c) + } + if c := call("POST", evil, append([]string{"Origin", "http://" + evil}, chrome...)...); c != http.StatusForbidden || reached { + t.Fatal("a rebound page's POST:", c) + } + // Safari before Sec-Fetch: the user agent alone + if c := call("GET", evil, "User-Agent", "Mozilla/5.0 (Macintosh) Safari/605"); c != http.StatusForbidden || reached { + t.Fatal("an older browser:", c) + } + for _, host := range []string{"127.0.0.1:3425", "localhost:3425", "[::1]:3425", "wails.localhost", "host.docker.internal:3425", "LOCALHOST.:3425"} { + if c := call("GET", host, chrome...); c != 200 || !reached { + t.Fatal("a browser at", host, c) + } + } + // an agent sends no browser marks, whatever name it was given + if c := call("POST", evil, "Authorization", "Bearer magpie", "User-Agent", "claude-cli/2.1"); c != 200 || !reached { + t.Fatal("an agent:", c) + } + t.Setenv("MAGPIE_PUBLIC_URL", "https://magpie.example.net") + if c := call("GET", "magpie.example.net", chrome...); c != 200 || !reached { + t.Fatal("the public URL's host:", c) + } + _, secrets := newCaller(t, "Page") + if c := call("GET", evil, append([]string{"Authorization", "Bearer " + secrets[0]}, chrome...)...); c != 200 || !reached { + t.Fatal("with an enabled gateway key:", c) + } + if c := call("GET", evil, append([]string{"Authorization", "Bearer sk-magpie-wrong"}, chrome...)...); c != http.StatusForbidden || reached { + t.Fatal("with a wrong key:", c) + } +} diff --git a/internal/gateway/omarchy_test.go b/internal/gateway/omarchy_test.go index cd75cf23..db48456c 100644 --- a/internal/gateway/omarchy_test.go +++ b/internal/gateway/omarchy_test.go @@ -27,6 +27,7 @@ func TestOmarchyThemeForTheSite(t *testing.T) { call := func(method, from, origin string) *httptest.ResponseRecorder { r := httptest.NewRequest(method, "/v1/magpie/omarchy", nil) r.RemoteAddr = from + r.Host = "127.0.0.1:3425" if origin != "" { r.Header.Set("Origin", origin) }