mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-27 04:43:00 +00:00
feat: WebUI 改为依赖 /api/v1,UI 与 agent 共用一套 API 契约
- sources / sort / keys 三个页面的数据源从 /api/sources 切到 /api/v1/sources
(写操作仍走 /api/sources:v1 是只读门面,不做变更)
- 编辑弹窗改用 /api/v1/sources/{name}?reveal=credentials(admin-only)取明文 key。
这是必须的:表单要整体回传源,若不回填 key,改个端口就会把 key 清空。
- 遮蔽视图仍是默认,只有显式 reveal 才返回明文
端到端验证(真浏览器 + 临时实例,非仅 API 测试):
- sources/sort/keys 三页实际发出 GET /api/v1/sources,0 console error
- editSource('demo') → reveal=credentials,#s-key 与 #s-url 正确回填
- 写入往返:改 base_url /v1→/v2 后重开,key 仍在(未被清空)
- 落盘 api_key 明文残留 0、密文 1
测试:+1(reveal 必须 admin,否则任意 user key 可读全部凭据)
变异验证:reveal 去掉 admin 校验 → 403 断言变红
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -68,6 +68,19 @@ func (g *Gateway) apiV1Routes(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
for _, s := range g.core.Sources() {
|
||||
if s.Name == name {
|
||||
// ?reveal=credentials is admin-only and is what the Web UI's
|
||||
// edit dialog uses: a form that round-trips a source must be
|
||||
// able to show the current key, otherwise saving an unrelated
|
||||
// field would blank it. Everything else stays masked.
|
||||
if r.URL.Query().Get("reveal") == "credentials" {
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden",
|
||||
"admin role required to reveal credentials")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"source": s})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"source": maskSource(s)})
|
||||
return
|
||||
}
|
||||
@ -176,6 +189,9 @@ func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) {
|
||||
"path_escape": "URL-encode source and key names; {name} is a single path segment",
|
||||
"idempotency": "POST /api/sources and PUT /api/sources/{name} both upsert by name",
|
||||
"config_truth": "all configuration lives in config.yaml; API writes are persisted immediately",
|
||||
"credentials": "credentials are masked by default. GET /api/v1/sources/{name}?reveal=credentials " +
|
||||
"returns them in the clear and is admin-only — the Web UI edit dialog uses it, because a form " +
|
||||
"that round-trips a source must show the current key or saving another field would blank it.",
|
||||
},
|
||||
}
|
||||
writeJSON(w, http.StatusOK, index)
|
||||
|
||||
@ -271,3 +271,29 @@ func serveViaHandler(t *testing.T, g *Gateway, req *http.Request) *httptest.Resp
|
||||
g.Handler().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestAPIV1RevealRequiresAdmin: the only way to read a key in the clear is the
|
||||
// explicit admin-only reveal. Without the role check this endpoint would hand
|
||||
// every source credential to any valid (even user-scoped) key.
|
||||
func TestAPIV1RevealRequiresAdmin(t *testing.T) {
|
||||
g, _, user := v1Gateway(t)
|
||||
rec := serveViaHandler(t, g, newAuthedRequest(t, http.MethodGet, "/api/v1/sources/up?reveal=credentials", user))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("user reveal = %d, want 403: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "sk-up-secret") {
|
||||
t.Error("a forbidden reveal still leaked the key")
|
||||
}
|
||||
rec = doReq(t, g, http.MethodGet, "/api/v1/sources/up?reveal=credentials", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("admin reveal = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "sk-up-secret") {
|
||||
t.Error("admin reveal should return the key in the clear (the edit form needs it)")
|
||||
}
|
||||
// The masked default view must still hide it on the same path.
|
||||
rec = doReq(t, g, http.MethodGet, "/api/v1/sources/up", "")
|
||||
if strings.Contains(rec.Body.String(), "sk-up-secret") {
|
||||
t.Error("the default view leaked the key")
|
||||
}
|
||||
}
|
||||
|
||||
@ -2772,7 +2772,10 @@
|
||||
|
||||
/* ---------- sources tab ---------- */
|
||||
async function renderSources() {
|
||||
const j = await api("/api/sources");
|
||||
// Read through /api/v1: the UI is a client of the same public API an
|
||||
// agent uses, so there is one contract rather than a private path.
|
||||
// The list view needs no credentials, so the masked view is enough.
|
||||
const j = await api("/api/v1/sources");
|
||||
const rows = j.sources
|
||||
.map(
|
||||
(
|
||||
@ -2794,13 +2797,23 @@
|
||||
function editSource(name) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.id = "modal-wrap";
|
||||
Promise.all([api("/api/sources"), api("/api/status")])
|
||||
.then(([src, st]) => {
|
||||
const s = src.sources.find((x) => x.name === name) || {
|
||||
name: name,
|
||||
models: [{ id: "", priority: 0, kind: "chat" }],
|
||||
};
|
||||
const cur = s.adapter || "openai";
|
||||
// The edit dialog round-trips the whole source, so it must load the
|
||||
// real api_key — otherwise saving an unrelated field would blank
|
||||
// it. That is what ?reveal=credentials is for (admin-only).
|
||||
const srcPath = name
|
||||
? "/api/v1/sources/" + encodeURIComponent(name) + "?reveal=credentials"
|
||||
: "/api/v1/sources";
|
||||
Promise.all([api(srcPath), api("/api/status")])
|
||||
.then(([src, st]) => {
|
||||
// Editing: the single-source shape. Creating: an empty form.
|
||||
const s = name
|
||||
? src.source
|
||||
: { name: "", models: [{ id: "", priority: 0, kind: "chat" }] };
|
||||
if (!s) {
|
||||
toast(t("toastSave"));
|
||||
return;
|
||||
}
|
||||
const cur = s.adapter || "openai";
|
||||
const apps = ["", ...(st.adapters || []).map((a) => a.name)];
|
||||
if (cur && !apps.includes(cur)) apps.push(cur);
|
||||
const adSel =
|
||||
@ -3215,7 +3228,7 @@
|
||||
const sortLanes = { chat: [], image: [] };
|
||||
let sortStateMap = new Map();
|
||||
async function renderSort() {
|
||||
const j = await api("/api/sources");
|
||||
const j = await api("/api/v1/sources");
|
||||
let autoR = [];
|
||||
let autoImg = [];
|
||||
try {
|
||||
@ -4155,7 +4168,7 @@
|
||||
async function loadModelPairs() {
|
||||
let srcs = [];
|
||||
try {
|
||||
srcs = (await api("/api/sources")).sources || [];
|
||||
srcs = (await api("/api/v1/sources")).sources || [];
|
||||
} catch (e) {}
|
||||
const pairs = [];
|
||||
(srcs || []).forEach((s) =>
|
||||
|
||||
Reference in New Issue
Block a user