gateway, settings, gui: web pages the user lists in Settings › Network may call the gateway from a browser, each request with a gateway key (SkyAerope, #1051)
A page on http://localhost:3000 asking /v1/models got a 404 to its preflight and no Access-Control-Allow-Origin, so the browser hid every answer. Settings › Network › Web pages now takes origins (cleaned to scheme://host[:port]; "*" and anything that isn't an origin are refused, said in the page's language). For a listed origin corsGuard answers the preflight (204, the asked header names, Private Network Access) and adds Access-Control-Allow-Origin; every other request from it needs an enabled gateway key, even from loopback, or gets a readable 401. Default: none listed, and a request with any other Origin or none (every agent's) is unchanged. A save of the rest of the Settings page keeps the list. Verified: TestCORSOrigins (gateway, loopback requests: preflight, keyed calls, keyless/managed-token/turned-off key refused, unlisted origins incl. "null" and port/scheme variants get nothing, agents untouched) fails without corsGuard in Handler (preflight 404); TestCleanOrigins; TestCORSOriginsSetting fails without keeping CORSOrigins in POST /api/settings; TestSettingsSaveKeepsWhatItDoesNotSend. GUI cors-origins.test.cjs (en/zh, 440px, add by button and Enter, refusals, remove, no scroll, no left border) in Chromium and WebKit fails without renderCORS; gui-ja, gui-de, i18n-keys, gateway-port, lan-docker, redact-rule-row, settings-* pass in both. go test -tags nogui ./... green; -race -count=5 on the new tests; darwin, linux nogui, windows builds. click-scroll's two Requests-page subtests fail the same on origin/main.
This commit is contained in:
1 parent
76d82528e6
commit
b234507842
14 files changed
+620
-2
No files matched your search
@@ -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-<agent>` (`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) |
|
||||
|
||||
@@ -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 <key> (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, ", ")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -437,6 +437,8 @@
|
||||
<div class="list prefs" id="portList"></div>
|
||||
<div class="row-head"><span class="label" data-t>Local network</span></div>
|
||||
<div class="list prefs" id="lanList"></div>
|
||||
<div class="row-head"><span class="label" data-t>Web pages</span></div>
|
||||
<div class="list prefs" id="corsList"></div>
|
||||
</section>
|
||||
<section class="set-page" id="setPage-models" role="tabpanel" aria-labelledby="setTab-models">
|
||||
<div class="row-head"><span class="label" data-t>Images</span></div>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
Reference in new issue
Block a user