diff --git a/docs/subsystems/gateway-routing.md b/docs/subsystems/gateway-routing.md index 0c203b17..b84be269 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, `/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`, `callerGuard`, `withCaller` and `keyLimited`. | [`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, `/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`. `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. A request with any other Origin, or none, passes through with no CORS headers, as before. | [`gateway.go`](../../internal/gateway/gateway.go), [`cors.go`](../../internal/gateway/cors.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`), auto stand-ins, then `provider.Resolve`. An unknown model gets a 404 naming why: a switched-off provider, a disabled group or an empty group. | [`gateway.go`](../../internal/gateway/gateway.go), [`internal/provider/group.go`](../../internal/provider/group.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.go b/internal/gateway/cors.go new file mode 100644 index 00000000..b4982d85 --- /dev/null +++ b/internal/gateway/cors.go @@ -0,0 +1,78 @@ +package gateway + +import ( + "net/http" + "slices" + "strings" + + "github.com/yetone/magpie/internal/access" + "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/settings" +) + +// corsGuard lets the web pages the user listed in Settings › Gateway +// (settings.CORSOrigins, #1051) call the gateway from a browser: their +// preflight is answered, and their calls get the headers that let the page +// read the reply. Such a call carries an enabled gateway key — a page is +// not an agent the user started, and on loopback a call needs no key, so +// without one any script served at that origin (another dev server on +// the same port) could spend the user's subscriptions. A page not listed +// gets no CORS headers, as before, and a request with no Origin (every +// agent's) passes untouched. +func corsGuard(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin == "" || !corsAllowed(origin) { + next.ServeHTTP(w, r) + return + } + h := w.Header() + h.Set("Access-Control-Allow-Origin", origin) + h.Add("Vary", "Origin") + if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" { + h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + if asked := corsHeaders(r.Header.Get("Access-Control-Request-Headers")); asked != "" { + h.Set("Access-Control-Allow-Headers", asked) + } + // Chrome asks before a public page reaches this computer + if r.Header.Get("Access-Control-Request-Private-Network") == "true" { + h.Set("Access-Control-Allow-Private-Network", "true") + } + h.Set("Access-Control-Max-Age", "600") + w.WriteHeader(http.StatusNoContent) + return + } + if !slices.ContainsFunc(callerKeys(r), func(k string) bool { _, ok := access.Authenticate(k); return ok }) { + writeError(w, provider.Chat, http.StatusUnauthorized, "a web page calls magpie with a gateway key: create one in magpie's Gateway page and send it as Authorization: Bearer (or x-api-key)") + return + } + next.ServeHTTP(w, r) + }) +} + +// corsAllowed: origin is one the user listed. +func corsAllowed(origin string) bool { + saved := settings.Load().CORSOrigins + if len(saved) == 0 { + return false + } + // as saved from Settings, or as written into settings.json by hand + list, _ := settings.CleanOrigins(saved) + o, err := settings.CleanOrigins([]string{origin}) + return err == nil && len(o) == 1 && slices.Contains(list, o[0]) +} + +// corsHeaders is a preflight's Access-Control-Request-Headers as the +// answer allows them: the header names asked for, nothing else. +func corsHeaders(asked string) string { + var out []string + for _, f := range strings.Split(asked, ",") { + f = strings.TrimSpace(f) + if f != "" && !strings.ContainsFunc(f, func(c rune) bool { + return !(c == '-' || c == '_' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') + }) { + out = append(out, f) + } + } + return strings.Join(out, ", ") +} diff --git a/internal/gateway/cors_test.go b/internal/gateway/cors_test.go new file mode 100644 index 00000000..82f26ee0 --- /dev/null +++ b/internal/gateway/cors_test.go @@ -0,0 +1,119 @@ +package gateway + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/yetone/magpie/internal/access" + "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/settings" +) + +// A web page the user listed in Settings (#1051, SkyAerope: a preflight +// from http://localhost:3000 for /v1/models was answered 404 with no CORS +// headers) has its preflight answered and reads the reply, its calls with +// a gateway key; a page not listed gets no CORS headers, as before, nor +// does any page while none is listed; agents, which send no Origin, are +// untouched. +func TestCORSOrigins(t *testing.T) { + fresh(t) + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}`) + })) + defer up.Close() + if err := provider.Save(provider.Provider{ID: "plan", Name: "Plan", Key: "upstream-secret", Chat: up.URL + "/v1", Models: []string{"m1"}}); err != nil { + t.Fatal(err) + } + secret, err := access.Update("add-key", access.Change{Name: "Web app"}) + if err != nil { + t.Fatal(err) + } + h := New().Handler() + // from this computer, as a browser on it calls 127.0.0.1:3425 + do := func(method, path, origin, key string, hdr map[string]string) *httptest.ResponseRecorder { + t.Helper() + var body io.Reader + if method == "POST" { + body = strings.NewReader(chatReq) + } + r := httptest.NewRequest(method, path, body) + r.RemoteAddr = "127.0.0.1:50123" + if origin != "" { + r.Header.Set("Origin", origin) + } + if key != "" { + r.Header.Set("Authorization", "Bearer "+key) + } + for k, v := range hdr { + r.Header.Set(k, v) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w + } + preflight := map[string]string{"Access-Control-Request-Method": "GET", "Access-Control-Request-Headers": "authorization, content-type"} + + // none listed: the reporter's preflight, as before + if w := do("OPTIONS", "/v1/models", "http://localhost:3000", "", preflight); w.Header().Get("Access-Control-Allow-Origin") != "" || w.Code == http.StatusNoContent { + t.Fatalf("none listed: %d %v", w.Code, w.Header()) + } + + s := settings.Load() + if s.CORSOrigins, err = settings.CleanOrigins([]string{"http://LOCALHOST:3000/", "https://app.example.com:443"}); err != nil { + t.Fatal(err) + } + if err := settings.Save(s); err != nil { + t.Fatal(err) + } + + w := do("OPTIONS", "/v1/models", "http://localhost:3000", "", map[string]string{ + "Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": "authorization, content-type, x-app, bad name;", + "Access-Control-Request-Private-Network": "true"}) + if w.Code != http.StatusNoContent || w.Header().Get("Access-Control-Allow-Origin") != "http://localhost:3000" || + !strings.Contains(w.Header().Get("Access-Control-Allow-Methods"), "POST") || + w.Header().Get("Access-Control-Allow-Headers") != "authorization, content-type, x-app" || + w.Header().Get("Access-Control-Allow-Private-Network") != "true" || w.Header().Get("Vary") != "Origin" { + t.Fatalf("preflight: %d %v", w.Code, w.Header()) + } + // the call itself, with a gateway key: answered and readable + if w := do("GET", "/v1/models", "http://localhost:3000", secret, nil); w.Code != 200 || w.Header().Get("Access-Control-Allow-Origin") != "http://localhost:3000" { + t.Fatalf("models: %d %v", w.Code, w.Header()) + } + if w := do("POST", "/v1/chat/completions", "https://app.example.com", secret, nil); w.Code != 200 || w.Header().Get("Access-Control-Allow-Origin") != "https://app.example.com" || !strings.Contains(w.Body.String(), "ok") { + t.Fatalf("chat: %d %v %s", w.Code, w.Header(), w.Body) + } + // without one, or with a key that isn't a gateway key, it is refused — + // readably, so the page can say why + for _, key := range []string{"", "magpie", "sk-magpie-not-a-key"} { + w := do("POST", "/v1/chat/completions", "http://localhost:3000", key, nil) + if w.Code != http.StatusUnauthorized || w.Header().Get("Access-Control-Allow-Origin") != "http://localhost:3000" || !strings.Contains(w.Body.String(), "gateway key") { + t.Errorf("key %q: %d %v %s", key, w.Code, w.Header(), w.Body) + } + } + // a key turned off is no key + keys, _ := access.List() + access.Update("off-key", access.Change{Key: keys[len(keys)-1].ID}) + if w := do("GET", "/v1/models", "http://localhost:3000", secret, nil); w.Code != http.StatusUnauthorized { + t.Errorf("a key turned off: %d", w.Code) + } + access.Update("on-key", access.Change{Key: keys[len(keys)-1].ID}) + + // another page: no CORS headers, whatever it sends; a port or scheme + // apart is another origin + for _, o := range []string{"http://localhost:3001", "https://localhost:3000", "http://evil.example", "null"} { + if w := do("OPTIONS", "/v1/models", o, "", preflight); w.Header().Get("Access-Control-Allow-Origin") != "" || w.Code == http.StatusNoContent { + t.Errorf("%s preflight: %d %v", o, w.Code, w.Header()) + } + if w := do("GET", "/v1/models", o, secret, nil); w.Header().Get("Access-Control-Allow-Origin") != "" { + t.Errorf("%s: %v", o, w.Header()) + } + } + // an agent: no Origin, no key, as before + if w := do("POST", "/v1/chat/completions", "", "", nil); w.Code != 200 || w.Header().Get("Access-Control-Allow-Origin") != "" { + t.Fatalf("agent: %d %v", w.Code, w.Header()) + } +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index d84f179b..cb3691a5 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -527,7 +527,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { writeError(w, provider.Chat, http.StatusNotFound, "magpie serves /v1/chat/completions, /v1/responses, /v1/messages, /v1/systemone, /v1/images/generations, /v1/images/edits, /v1/videos, /v1/embeddings, /v1/rerank and /v1beta/models/*") }) - return s.counted(callerGuard(withCaller(keyLimited(mux)))) + return s.counted(corsGuard(callerGuard(withCaller(keyLimited(mux))))) } // responsesOverHTTP answers a GET on /v1/responses, which is how a client diff --git a/internal/gui/api.go b/internal/gui/api.go index 7ba61472..601521c6 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -994,6 +994,7 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { in.LAN, in.LANKey = cur.LAN, cur.LANKey in.LANKeyID = cur.LANKeyID in.Port = cur.Port // set on its own (port below), which moves the gateway + in.CORSOrigins = cur.CORSOrigins // set on its own (cors below) in.GitHubToken = cur.GitHubToken // set on its own (github-token below), never sent to the page in.RequestArchive = cur.RequestArchive // the Gateway page's, set on its own in.RequestArchiveMaxMB = cur.RequestArchiveMaxMB // in settings.json only @@ -1465,6 +1466,27 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { } writeJSON(rw, settingsState()) }) + // the web pages that may call the gateway from a browser (#1051), as + // their origins; none takes them all away + mux.HandleFunc("POST /api/settings/cors", func(rw http.ResponseWriter, r *http.Request) { + var in struct{ Origins []string } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + fail(rw, err) + return + } + origins, err := settings.CleanOrigins(in.Origins) + if err != nil { + fail(rw, err) + return + } + s := settings.Load() + s.CORSOrigins = origins + if err := settings.Save(s); err != nil { + fail(rw, err) + return + } + writeJSON(rw, settingsState()) + }) // the gateway's port (Magic_zero on Discord), set on its own: it moves // the gateway and every agent connected to it mux.HandleFunc("POST /api/settings/port", func(rw http.ResponseWriter, r *http.Request) { diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index e9f29fa0..0f150609 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -17626,6 +17626,7 @@ function renderSettings() { renderOTel(s, keep); renderPort(s); renderLAN(s); + renderCORS(s); renderSync(); const about = $("#about"); @@ -19026,6 +19027,60 @@ function renderPort(s) { box.replaceChildren(r); // swapped whole: the list is never laid out empty } +// renderCORS: the web pages whose scripts may call the gateway from a +// browser (#1051), by origin — a row to add one, and one row each to take +// it away. A page listed calls with a gateway key; none listed, no page +// can read what the gateway answers, as before. +let corsDraft = { value: "", err: "" }; +// corsSays is the gateway's word on an origin it didn't take, in the +// page's language +function corsSays(m) { + if (/wildcard/.test(m)) return t("Name each origin: a wildcard would let every web page use magpie"); + if (/is not an origin/.test(m)) return t("Write an origin as http://localhost:3000 or https://app.example.com"); + return t(m); +} +function renderCORS(s) { + const box = $("#corsList"); + box.replaceChildren(); + const origins = s.corsOrigins || []; + const d = corsDraft; + const set = (next, done) => writingPrefs(api("settings/cors", { origins: next })) + .then((ns) => { prefs = ns; d.err = ""; done?.(); renderSettings(); status(t("Saved"), "ok", 1500); }) + .catch((e) => { d.err = corsSays(e.message); status(d.err, "err"); renderSettings(); }); + const field = input(d.value, "http://localhost:3000"); + field.className = "words rule-match"; + field.setAttribute("aria-label", t("Web page origin")); + field.autocomplete = "off"; + field.spellcheck = false; + const add = el("button", "text", t("Add")); + add.onclick = () => { + d.value = field.value.trim(); + if (!d.value) return field.focus(); + set([...origins, d.value], () => { corsDraft = { value: "", err: "" }; }); + }; + field.oninput = () => { d.value = field.value; }; + field.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") add.onclick(); }; + const r = el("div", "row pref rule-row"); + const who = el("div", "who"); + who.append(el("div", "name", t("Web pages that may call magpie")), + el("div", "sub" + (d.err ? " err" : ""), d.err || t("Scripts on these origins can call the gateway from a browser, each request with a gateway key from Gateway. Any other page gets no CORS headers"))); + const val = el("div", "val rule-add"); + val.append(field, add); + r.append(who, val); + box.append(r); + for (const o of origins) { + const row = el("div", "row pref"); + const w = el("div", "who"); + w.append(el("div", "name", o)); + const x = el("button", "text", t("Remove")); + x.onclick = () => set(origins.filter((v) => v !== o)); + const v = el("div", "val"); + v.append(x); + row.append(w, v); + box.append(row); + } +} + // renderLAN: the gateway shared on the local network, for agents on other // machines. Gateway keys and connection examples live together in Gateway. let lanSelectedURL = "", lanProtocol = "openai"; diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index b464aab9..3726d565 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -1445,6 +1445,12 @@ const I18N = { "Edit as JSON": "以 JSON 编辑", "Collapse to one line": "折叠为单行", "Remove": "移除", + "Web pages": "网页", + "Web page origin": "网页来源", + "Web pages that may call magpie": "允许调用 magpie 的网页", + "Scripts on these origins can call the gateway from a browser, each request with a gateway key from Gateway. Any other page gets no CORS headers": "这些来源的网页脚本可以在浏览器中调用网关,每个请求需带上「网关」页的网关密钥。其他网页不会得到 CORS 响应头", + "Name each origin: a wildcard would let every web page use magpie": "请逐个填写来源:通配符会让任何网页都能使用 magpie", + "Write an origin as http://localhost:3000 or https://app.example.com": "来源格式如 http://localhost:3000 或 https://app.example.com", "{name} removed": "已移除 {name}", "Add": "添加", "{name} added": "已添加 {name}", @@ -4998,6 +5004,12 @@ const I18N = { "Edit as JSON": "JSON で編集", "Collapse to one line": "1行に折りたたむ", "Remove": "削除", + "Web pages": "Web ページ", + "Web page origin": "Web ページのオリジン", + "Web pages that may call magpie": "magpie を呼び出せる Web ページ", + "Scripts on these origins can call the gateway from a browser, each request with a gateway key from Gateway. Any other page gets no CORS headers": "これらのオリジンのスクリプトはブラウザからゲートウェイを呼び出せます。各リクエストには「ゲートウェイ」ページのゲートウェイキーが必要です。ほかのページには CORS ヘッダーを返しません", + "Name each origin: a wildcard would let every web page use magpie": "オリジンは一つずつ指定してください。ワイルドカードではどの Web ページでも magpie を使えてしまいます", + "Write an origin as http://localhost:3000 or https://app.example.com": "オリジンは http://localhost:3000 や https://app.example.com の形式で入力してください", "{name} removed": "{name} を削除しました", "Add": "追加", "{name} added": "{name} を追加しました", @@ -8563,6 +8575,12 @@ const I18N = { "Edit as JSON": "Als JSON bearbeiten", "Collapse to one line": "Auf eine Zeile reduzieren", "Remove": "Entfernen", + "Web pages": "Webseiten", + "Web page origin": "Herkunft der Webseite", + "Web pages that may call magpie": "Webseiten, die magpie aufrufen dürfen", + "Scripts on these origins can call the gateway from a browser, each request with a gateway key from Gateway. Any other page gets no CORS headers": "Skripte dieser Herkünfte können das Gateway aus dem Browser aufrufen, jede Anfrage mit einem Gateway-Schlüssel aus „Gateway“. Andere Seiten erhalten keine CORS-Header", + "Name each origin: a wildcard would let every web page use magpie": "Jede Herkunft einzeln angeben: Ein Platzhalter ließe jede Webseite magpie nutzen", + "Write an origin as http://localhost:3000 or https://app.example.com": "Eine Herkunft wird wie http://localhost:3000 oder https://app.example.com geschrieben", "{name} removed": "{name} entfernt", "Add": "Hinzufügen", "{name} added": "{name} hinzugefügt", diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index ee73c0d0..ad4419dc 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -437,6 +437,8 @@
Local network
+
Web pages
+
Images
diff --git a/internal/gui/cors_settings_test.go b/internal/gui/cors_settings_test.go new file mode 100644 index 00000000..f6a5f53c --- /dev/null +++ b/internal/gui/cors_settings_test.go @@ -0,0 +1,45 @@ +package gui + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/yetone/magpie/internal/settings" +) + +// The web pages that may call the gateway (#1051) are saved as origins, +// a wildcard or a URL that isn't one is refused with the list left as it +// was, and a save of the rest of the Settings page leaves them alone. +func TestCORSOriginsSetting(t *testing.T) { + h := t.TempDir() + t.Setenv("HOME", h) + t.Setenv("USERPROFILE", h) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(h, ".cache")) + srv := Handler(nil, nil) + post := func(path, body string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("POST", path, strings.NewReader(body))) + return rec + } + rec := post("/api/settings/cors", `{"origins":["http://LocalHost:3000/","https://app.example.com:443"]}`) + want := []string{"http://localhost:3000", "https://app.example.com"} + if rec.Code != http.StatusOK || !slices.Equal(settings.Load().CORSOrigins, want) || !strings.Contains(rec.Body.String(), `"corsOrigins":["http://localhost:3000","https://app.example.com"]`) { + t.Fatalf("%d %s, saved %v", rec.Code, rec.Body, settings.Load().CORSOrigins) + } + for _, bad := range []string{`["*"]`, `["http://localhost:3000","localhost:4000"]`, `["https://app.example.com/v1"]`} { + if rec := post("/api/settings/cors", `{"origins":`+bad+`}`); rec.Code == http.StatusOK || !slices.Equal(settings.Load().CORSOrigins, want) { + t.Errorf("%s: %d, saved %v", bad, rec.Code, settings.Load().CORSOrigins) + } + } + if rec := post("/api/settings", `{"theme":"dark","lang":"en"}`); rec.Code != http.StatusOK || !slices.Equal(settings.Load().CORSOrigins, want) { + t.Fatalf("the Settings page's save changed the origins: %d %v", rec.Code, settings.Load().CORSOrigins) + } + if rec := post("/api/settings/cors", `{"origins":[]}`); rec.Code != http.StatusOK || len(settings.Load().CORSOrigins) != 0 { + t.Fatalf("emptied: %d %v", rec.Code, settings.Load().CORSOrigins) + } +} diff --git a/internal/gui/settings_keep_test.go b/internal/gui/settings_keep_test.go index 586222fd..128a2e8e 100644 --- a/internal/gui/settings_keep_test.go +++ b/internal/gui/settings_keep_test.go @@ -131,6 +131,7 @@ func TestSettingsSaveKeepsWhatItDoesNotSend(t *testing.T) { LANKey: "sk-lan", LANKeyID: "lan-key-id", Port: 3591, + CORSOrigins: []string{"http://localhost:3000"}, GitHubToken: "ghp_kept", RequestArchive: true, RequestArchiveMaxMB: 64, diff --git a/internal/gui/tests/cors-origins.test.cjs b/internal/gui/tests/cors-origins.test.cjs new file mode 100644 index 00000000..08d36b9b --- /dev/null +++ b/internal/gui/tests/cors-origins.test.cjs @@ -0,0 +1,194 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// Web pages that may call magpie (#1051, SkyAerope: a page on +// http://localhost:3000 couldn't read the gateway's answers): Settings › +// Network lists them by origin. One is added from a field and its Add +// button (or Enter), saved as the gateway cleaned it, and taken away with +// its Remove; a wildcard or a URL that isn't an origin is refused, said in +// the page's language, and nothing is saved. At a 440px panel every row +// fits, and no click moves the page. English and Chinese, Chromium and +// WebKit; no backend, the API is faked here. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); + +function settingsPayload(over) { + return { + theme: "light", lang: "en", tray: "panel", quotaLeft: false, currency: "usd", + dock: false, dockWindow: false, proxy: "", redact: false, redactPersonal: false, redactWords: [], + codexWarmup: "", claudeWarmup: "", codexWarmAt: "", claudeWarmAt: "", workbuddyCheckin: false, noStats: false, + trayUsage: "", trayUsageEvery: 3, vision: "", imageGen: "", + version: "0.1.400", dir: "/config/magpie", gateway: "http://127.0.0.1:3425", + proxyNow: "none", proxySource: "none", login: false, + visionModels: [], imageGenModels: [], workbuddyCheckins: [], + lan: false, lanURLs: [], + fx: { rate: 7.2, at: new Date().toISOString(), stale: false }, + ...over, + }; +} + +// what the gateway does with a list: cleans it, or refuses it as +// internal/settings/origins.go does +function clean(list) { + const out = []; + for (const raw of list) { + if (raw.includes("*")) return { error: `"${raw}": name each origin; a wildcard would let every web page use magpie` }; + let u; + try { u = new URL(raw); } catch { u = null; } + if (!u || !/^https?:$/.test(u.protocol) || (u.pathname !== "/" && u.pathname !== "")) return { error: `"${raw}" is not an origin: write it as http://localhost:3000 or https://app.example.com` }; + if (!out.includes(u.origin)) out.push(u.origin); + } + return { origins: out }; +} + +function server(lang, posts) { + const s = settingsPayload({ lang, corsOrigins: [] }); + return async (route) => { + const url = new URL(route.request().url()); + const json = (data, status = 200) => route.fulfill({ json: data, status }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light"};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" }, fx: s.fx }); + if (url.pathname === "/api/settings") return json(s); + if (url.pathname === "/api/settings/cors") { + const body = route.request().postDataJSON(); + posts.push(body.origins); + const c = clean(body.origins); + if (c.error) return json({ error: c.error }, 400); + s.corsOrigins = c.origins; + return json(s); + } + if (url.pathname === "/api/plugins") return json({ plugins: [] }); + if (url.pathname === "/api/usage/quotas") return json([]); + if (url.pathname === "/api/groups") return json({ groups: [], models: [] }); + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + await route.fulfill({ body: await fs.readFile(file), contentType }); + }; +} + +const words = { + en: { + head: "Web pages", name: "Web pages that may call magpie", add: "Add", remove: "Remove", + sub: "Scripts on these origins can call the gateway from a browser, each request with a gateway key from Gateway. Any other page gets no CORS headers", + wildcard: "Name each origin: a wildcard would let every web page use magpie", + notOrigin: "Write an origin as http://localhost:3000 or https://app.example.com", + }, + zh: { + head: "网页", name: "允许调用 magpie 的网页", add: "添加", remove: "移除", + sub: null, wildcard: null, notOrigin: null, // read from i18n.js below + }, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + test(engine + ": web pages that may call magpie are listed by origin (#1051)", async (t) => { + assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit"); + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + + for (const lang of ["en", "zh"]) { + await t.test(lang, async () => { + const posts = []; + const page = await (await browser.newContext({ viewport: { width: 440, height: 520 }, reducedMotion: "reduce" })).newPage(); + page.setDefaultTimeout(5000); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", server(lang, posts)); + await page.goto("http://127.0.0.1:4999/?view=settings&tab=network"); + const box = page.locator("#corsList"); + await box.locator(".rule-row").waitFor(); + const tr = (s) => page.evaluate((s) => t(s), s); + const w = { ...words[lang] }; + for (const k of ["head", "name", "add", "remove", "sub", "wildcard", "notOrigin"]) { + const en = words.en[k]; + const got = await tr(en); + if (lang !== "en") assert.notEqual(got, en, `"${en}" has no ${lang} translation`); + if (w[k] === null) w[k] = got; + else assert.equal(got, w[k]); + } + const head = box.locator("xpath=preceding-sibling::div[contains(@class,'row-head')][1]"); + assert.equal((await head.textContent()).trim(), w.head); + const row = box.locator(".rule-row"); + assert.equal((await row.locator(".name").textContent()).trim(), w.name); + assert.equal((await row.locator(".sub").textContent()).trim(), w.sub); + + // every row fits the panel: nothing cut, nothing past the edge + const fits = async () => { + const bad = await box.evaluate((b) => { + const out = []; + const right = b.getBoundingClientRect().right + 0.5; + for (const e of b.querySelectorAll(".row, input, button, .name, .sub")) { + const r = e.getBoundingClientRect(); + if (r.right > right || r.width === 0) out.push(e.className + " " + r.right + ">" + right); + if (e.matches("button, .name") && e.scrollWidth > e.clientWidth + 1) out.push("cut: " + e.textContent); + } + if (document.documentElement.scrollWidth > innerWidth) out.push("page scrolls sideways"); + return out; + }); + assert.deepEqual(bad, []); + }; + await fits(); + const field = row.locator("input"); + const add = row.locator("button", { hasText: w.add }); + const view = page.locator("#view-settings"); + // where the view rests once the frame is drawn + const scroll = () => view.evaluate((v) => new Promise((ok) => requestAnimationFrame(() => requestAnimationFrame(() => ok(v.scrollTop))))); + // scrolled with the wheel, as a reader would (a script's scroll is + // put back), until the field is well inside the view + await view.hover(); + for (let i = 0; i < 40; i++) { + const r = await field.boundingBox(); + if (r && r.y > 60 && r.y + r.height < 460 && (await scroll()) > 0) break; + await page.mouse.wheel(0, 120); + await page.waitForTimeout(30); + } + assert((await scroll()) > 0, "the settings must be scrolled to the field"); + + // added from the field: saved as the gateway cleaned it + await field.fill("http://LocalHost:3000/"); + let before = await scroll(); + await add.click(); + await box.locator(".row:not(.rule-row) .name", { hasText: "http://localhost:3000" }).waitFor(); + assert.deepEqual(posts.at(-1), ["http://LocalHost:3000/"]); + assert.equal(await row.locator("input").inputValue(), "", "the field is left empty for the next one"); + assert.equal(await scroll(), before, "Add moved the page"); + // and with Enter + await row.locator("input").fill("https://app.example.com"); + await row.locator("input").press("Enter"); + await box.locator(".row:not(.rule-row) .name", { hasText: "https://app.example.com" }).waitFor(); + assert.deepEqual(posts.at(-1), ["http://localhost:3000", "https://app.example.com"]); + await fits(); + + // refused, in the page's language, and nothing saved + for (const [bad, says] of [["*", w.wildcard], ["localhost:4000/v1", w.notOrigin]]) { + await row.locator("input").fill(bad); + before = await scroll(); + await row.locator("button", { hasText: w.add }).click(); + await page.waitForFunction((s) => document.querySelector("#corsList .rule-row .sub.err")?.textContent.trim() === s, says); + assert.equal(await row.locator("input").inputValue(), bad, "what the user wrote stays, to be corrected"); + assert.equal(await box.locator(".row:not(.rule-row)").count(), 2); + assert.equal(await scroll(), before, "a refusal moved the page"); + await fits(); + } + + // taken away with its Remove + const gone = box.locator(".row:not(.rule-row)").filter({ hasText: "http://localhost:3000" }); + before = await scroll(); + await gone.locator("button", { hasText: w.remove }).click(); + await page.waitForFunction(() => document.querySelectorAll("#corsList .row:not(.rule-row)").length === 1); + assert.deepEqual(posts.at(-1), ["https://app.example.com"]); + assert.equal(await scroll(), before, "Remove moved the page"); + assert.equal((await row.locator(".sub").textContent()).trim(), w.sub, "a save clears the refusal"); + + const border = await box.locator(".row").first().evaluate((r) => getComputedStyle(r).borderLeftWidth); + assert(border === "0px" || border === "", "no left border accent: " + border); + assert.deepEqual(errors, []); + await page.context().close(); + }); + } + }); +} diff --git a/internal/settings/origins.go b/internal/settings/origins.go new file mode 100644 index 00000000..a7158091 --- /dev/null +++ b/internal/settings/origins.go @@ -0,0 +1,54 @@ +package settings + +import ( + "fmt" + "net" + "net/url" + "slices" + "strings" +) + +// CleanOrigins is the web origins a user typed for CORSOrigins, as a +// browser's Origin header spells them: scheme://host[:port], lower-cased, +// with no path and the scheme's default port left out. Only http and +// https are origins; "*" is refused, since it would let every web page the +// user opens spend their subscriptions. +func CleanOrigins(in []string) ([]string, error) { + var out []string + for _, raw := range in { + o, err := cleanOrigin(raw) + if err != nil { + return nil, err + } + if o != "" && !slices.Contains(out, o) { + out = append(out, o) + } + } + return out, nil +} + +func cleanOrigin(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + if strings.Contains(raw, "*") { + return "", fmt.Errorf("%q: name each origin; a wildcard would let every web page use magpie", raw) + } + u, err := url.Parse(strings.TrimSuffix(raw, "/")) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || + (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { + return "", fmt.Errorf("%q is not an origin: write it as http://localhost:3000 or https://app.example.com", raw) + } + host, port := strings.ToLower(u.Hostname()), u.Port() + if (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") { + port = "" + } + if strings.Contains(host, ":") { + host = "[" + host + "]" + } + if port != "" { + host = net.JoinHostPort(strings.Trim(host, "[]"), port) + } + return u.Scheme + "://" + host, nil +} diff --git a/internal/settings/origins_test.go b/internal/settings/origins_test.go new file mode 100644 index 00000000..7ade3a72 --- /dev/null +++ b/internal/settings/origins_test.go @@ -0,0 +1,24 @@ +package settings + +import ( + "strings" + "testing" +) + +// An origin is saved as the browser sends it, whichever way the user wrote +// it, and a wildcard or anything that is not an origin is refused (#1051). +func TestCleanOrigins(t *testing.T) { + got, err := CleanOrigins([]string{" http://LocalHost:3000/ ", "https://a.example:443", "http://[::1]:8080", "http://a.example:80", "", "http://localhost:3000"}) + if err != nil { + t.Fatal(err) + } + want := []string{"http://localhost:3000", "https://a.example", "http://[::1]:8080", "http://a.example"} + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Fatalf("%v, want %v", got, want) + } + for _, bad := range []string{"*", "https://*.example.com", "localhost:3000", "file:///x", "http://a.example/path", "http://u:p@a.example", "http://a.example?x=1", "null"} { + if _, err := CleanOrigins([]string{bad}); err == nil { + t.Errorf("%q taken", bad) + } + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index c292da63..42372708 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -85,6 +85,12 @@ type Settings struct { // named caller keys. LANKey is retained for older Magpie versions. LAN bool `json:"lan,omitempty"` LANKey string `json:"lanKey,omitempty"` + // CORSOrigins are the web pages (scheme://host[:port]) whose scripts + // may call the gateway from a browser (#1051): a preflight from one is + // answered, and its calls carry the CORS headers that let it read the + // reply, each with an enabled gateway key. None by default: a page + // gets no CORS headers, as before. + CORSOrigins []string `json:"corsOrigins,omitempty"` // Port is the gateway's port on this computer, 0 for DefaultPort. // MAGPIE_ADDR, where it is set, comes first (GatewayAddr). Port int `json:"port,omitempty"`