feat(config,gateway,web): 路径式安全入口 ADMIN_PATH + 数据目录 platform 哨兵(裁决 1.6/16.4.7/12.4.2 + 1.5/12.4.3 核对)
- 1.6 + 16.4.7:安全入口=路径式 `/<ADMIN_PATH>`(env `ADMIN_PATH`,默认 `admin`;`xxx/admin` 亦可,
非法值回退 `/admin`)。命中前缀 → 送注入了 `<base href="/">` + `window.__CYRENE_ADMIN_ENTRY__` 的面板入口页
(深链直达);**形如面板入口但前缀不符 → 404**(不回落用户端聊天壳);`/api/*`、`/ws`、webhook 先放行
⇒ **API 不藏**。前端路由改**路径式**(`router.ts` 只读注入,无硬编兜底;移除 hash 入口)
- 1.5 / 12.4.3 核对:暴露面默认 `0.0.0.0` 与裁决一致,无需改代码(仅补注释)
- 12.4.2:`DATA_DIR` 支持 `platform` 哨兵(大小写不敏感)→ 平台标准目录;默认 `./data` 与显式空值行为不变
(显式空值保持原兜底,避免正在运行实例突然换库)
- 复核:build/vet 绿 + config/gateway 包测试绿 + linux/darwin 交叉编译绿;新增 7 条用例,
并用真实构建产物(`webui.Assets()`)跑通「浏览器直开 /admin 能启动面板」(`<base>` 必须在资源引用之前)
- 变异 2 条(关掉错前缀 404 / 前端默认前缀兜底)已还原
- ⚠️ 运行时序:前端已生效而后端 hatch 未生效期间**面板打不开** ⇒ 需紧接一次「build 到位 + restart」
This commit is contained in:
1 parent
9253fef92a
commit
a398dc9b92
7 files changed
+481
-33
No files matched your search
@@ -0,0 +1,137 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
// adminEntryTestIndex 是最小的 index.html:注入点 `<head>` 与
|
||||
// 站点根锚点都由 handler 追加,因此这里只保证两者都存在。
|
||||
const adminEntryTestIndex = `<html><head><meta charset="utf-8"></head><body><div id="root"></div></body></html>`
|
||||
|
||||
// adminEntryEnv 构造一个挂了前端、并按 adminPath 配置安全入口的网关。
|
||||
func adminEntryEnv(t *testing.T, adminPath string) *httptest.Server {
|
||||
t.Helper()
|
||||
cfg := cfgDefault()
|
||||
cfg.Server.AdminPath = adminPath
|
||||
front := fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{Data: []byte(adminEntryTestIndex)},
|
||||
"assets/app.js": &fstest.MapFile{Data: []byte("var x = 1;")},
|
||||
}
|
||||
_, ts, _ := buildEnv(t, cfg, Deps{Frontend: front})
|
||||
return ts
|
||||
}
|
||||
|
||||
// TestAdminEntry_DefaultPrefix 守住默认前缀下的安全入口(裁决 1.6/16.4.7):
|
||||
// `/admin` 与深链直达面板入口页(带注入),不再回落用户端聊天壳;用户端
|
||||
// 路径与真实静态资源不受影响。
|
||||
func TestAdminEntry_DefaultPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts := adminEntryEnv(t, "")
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/admin", nil, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /admin status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(string(body), `window.__CYRENE_ADMIN_ENTRY__={"prefix":"/admin"}`) {
|
||||
t.Fatalf("GET /admin missing injected entry info: %s", body)
|
||||
}
|
||||
if !strings.Contains(string(body), `<base href="/">`) {
|
||||
t.Fatalf("GET /admin missing root base href: %s", body)
|
||||
}
|
||||
if got := resp.Header.Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("GET /admin Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
|
||||
resp, body = doReq(t, http.MethodGet, ts.URL+"/admin/personas", nil, "")
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"prefix":"/admin"`) {
|
||||
t.Fatalf("GET /admin/personas = %d body=%s, want the injected admin entry", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// 真实静态资源仍按文件送出(不注入)。
|
||||
resp, body = doReq(t, http.MethodGet, ts.URL+"/assets/app.js", nil, "")
|
||||
if resp.StatusCode != http.StatusOK || string(body) != "var x = 1;" {
|
||||
t.Fatalf("GET /assets/app.js = %d body=%q, want the raw asset", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// 用户端回落路径不带入口信息(用户端 bundle 与页面都不该出现面板前缀)。
|
||||
resp, body = doReq(t, http.MethodGet, ts.URL+"/chat/somewhere", nil, "")
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "root") {
|
||||
t.Fatalf("GET /chat/somewhere = %d body=%s, want the user shell", resp.StatusCode, body)
|
||||
}
|
||||
if strings.Contains(string(body), "__CYRENE_ADMIN_ENTRY__") {
|
||||
t.Fatalf("user shell leaked the admin entry info: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminEntry_CustomPrefix 守住自定义前缀(`host/xxx/admin` 形态):正确
|
||||
// 前缀直达,猜错前缀(含默认 /admin)一律 404,绝不回落用户端聊天壳。
|
||||
func TestAdminEntry_CustomPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts := adminEntryEnv(t, "xxx/admin")
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/xxx/admin", nil, "")
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"prefix":"/xxx/admin"`) {
|
||||
t.Fatalf("GET /xxx/admin = %d body=%s, want the injected custom prefix", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
for _, p := range []string{"/admin", "/admin/personas", "/other/admin", "/admin/"} {
|
||||
resp, body = doReq(t, http.MethodGet, ts.URL+p, nil, "")
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("GET %s = %d body=%s, want 404 (wrong prefix must not fall back)", p, resp.StatusCode, body)
|
||||
}
|
||||
if strings.Contains(string(body), "__CYRENE_ADMIN_ENTRY__") {
|
||||
t.Fatalf("GET %s leaked the admin entry info", p)
|
||||
}
|
||||
}
|
||||
|
||||
// 用户端路径依旧正常。
|
||||
resp, body = doReq(t, http.MethodGet, ts.URL+"/chat", nil, "")
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "root") {
|
||||
t.Fatalf("GET /chat = %d body=%s, want the user shell", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminEntry_APIRouteUntouched 守住「API 不藏」:前缀即便被配成含 api 段
|
||||
// 的值,也不得截住 REST 路由(design/07 §0.1)。
|
||||
func TestAdminEntry_APIRouteUntouched(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts := adminEntryEnv(t, "api/v1/admin")
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/health", nil, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /api/v1/health = %d body=%s, want the health payload", resp.StatusCode, body)
|
||||
}
|
||||
if strings.Contains(string(body), "__CYRENE_ADMIN_ENTRY__") {
|
||||
t.Fatalf("API route was served the admin entry page: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminEntry_PrefixOverAssetDir 守住前缀撞上真实资源目录的退化:把前缀
|
||||
// 配成 assets 时,真实资源必须仍由文件服务器送出,不能被面板入口页顶掉。
|
||||
func TestAdminEntry_PrefixOverAssetDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts := adminEntryEnv(t, "assets")
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/assets/app.js", nil, "")
|
||||
if resp.StatusCode != http.StatusOK || string(body) != "var x = 1;" {
|
||||
t.Fatalf("GET /assets/app.js = %d body=%q, want the real asset (not the shell)", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminEntryHTML_NoHead 守住注入的两个回退点:没有 `<head>` 时注入到
|
||||
// 文档最前(仍先于任何资源引用),且 `<base>` 与入口信息一并出现。
|
||||
func TestAdminEntryHTML_NoHead(t *testing.T) {
|
||||
t.Parallel()
|
||||
out := adminEntryHTML([]byte("<html><body>x</body></html>"), "/admin")
|
||||
s := string(out)
|
||||
if !strings.HasPrefix(s, `<base href="/">`) {
|
||||
t.Fatalf("injection without <head> = %q, want it prepended", s)
|
||||
}
|
||||
if !strings.Contains(s, `"prefix":"/admin"`) {
|
||||
t.Fatalf("injection without <head> lost the entry info: %s", s)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// 本文件拥有 HTTP 表面:路由表(REST /api/v1、实时
|
||||
@@ -252,15 +258,115 @@ func (s *Server) apiKeyTable() auth.KeyTable {
|
||||
|
||||
// withMiddleware 把已路由的 handler 包进外层,最外层在前:
|
||||
// 上传能力 URL 防护、SPA 回退(当配置了前端时)、
|
||||
// panic recover、CORS 与请求日志。
|
||||
// 安全入口(管理面板前缀)、panic recover、CORS 与请求日志。
|
||||
func (s *Server) withMiddleware(mux http.Handler) http.Handler {
|
||||
var h http.Handler = mux
|
||||
h = guardUploadPaths(h)
|
||||
if s.frontend != nil {
|
||||
h = s.spa(h)
|
||||
// 安全入口(裁决 1.6 / 16.4.7,design/07 §0.2):管理面板前端按 .env 的
|
||||
// ADMIN_PATH 挂载(默认 /admin),猜错的前缀 404 而不回落用户端聊天壳。
|
||||
h = s.adminEntryHatch(h, s.cfg.Server.AdminPathPrefix())
|
||||
}
|
||||
h = s.recover(h)
|
||||
h = s.cors(h)
|
||||
h = s.logging(h)
|
||||
return h
|
||||
}
|
||||
|
||||
// --- 安全入口(裁决 1.6 / 16.4.7,design/07 §0.2) ---------------------------
|
||||
|
||||
// adminEntryGlobal 是注入面板入口页的全局变量名:SPA 据此判定「本页由安全
|
||||
// 入口送达」并取得面板前缀。它是前缀抵达前端的唯一通道,因此该串不硬编进
|
||||
// 用户端 bundle(见 frontend/web/src/admin/router.ts)。
|
||||
const adminEntryGlobal = "__CYRENE_ADMIN_ENTRY__"
|
||||
|
||||
// adminEntryHatch 把管理面板挂到配置的 ADMIN_PATH 前缀下:
|
||||
// - 前缀自身与其下的深链(/<前缀>/<section>)送出注入了入口信息的
|
||||
// index.html,使真实路径直达面板而不回落用户端聊天壳;
|
||||
// - 形如面板入口但前缀不对的探测(例如前缀被改后仍被访问的 /admin)
|
||||
// 直接 404,既不回落聊天壳也不泄露正确前缀;
|
||||
// - 其余路径原样交给常规 SPA / API 处理。
|
||||
func (s *Server) adminEntryHatch(next http.Handler, prefix string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := r.URL.Path
|
||||
if isAPIRoute(p) || s.isWebhookRoute(p) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case p == prefix || strings.HasPrefix(p, prefix+"/"):
|
||||
if s.adminEntryRealFile(p) || !s.serveAdminEntry(w, prefix) {
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
case isAdminShapedPath(p):
|
||||
http.NotFound(w, r)
|
||||
default:
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// adminEntryRealFile 报告 path 是否命中 dist 里的真实静态文件:前缀若恰好
|
||||
// 撞上资源目录/文件名(例如把 ADMIN_PATH 配成 assets),文件服务器照常送
|
||||
// 文件,绝不让面板入口页顶掉 SPA 的资源。
|
||||
func (s *Server) adminEntryRealFile(p string) bool {
|
||||
rel := strings.TrimPrefix(path.Clean(p), "/")
|
||||
if rel == "" {
|
||||
return false
|
||||
}
|
||||
info, err := fs.Stat(s.frontend, rel)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// serveAdminEntry 送出管理面板入口页。返回 false 表示 index.html 读不出来
|
||||
// (例如外置 dist 尚未构建),交由常规 SPA 处理。
|
||||
func (s *Server) serveAdminEntry(w http.ResponseWriter, prefix string) bool {
|
||||
raw, err := fs.ReadFile(s.frontend, "index.html")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
// 入口页按请求注入(前缀 / 站点根锚点),不能缓存。
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(adminEntryHTML(raw, prefix))
|
||||
return true
|
||||
}
|
||||
|
||||
// adminEntryHTML 在 `<head>` 之后注入入口信息与 `<base href="/">`:
|
||||
// - 入口信息是内联经典脚本,必先于 Vite 的 deferred module 执行,前端启动
|
||||
// 时即可读到面板前缀;
|
||||
// - `<base href="/">` 把相对资源锚回站点根:面板入口可能是多段路径
|
||||
// (/xxx/admin)或深链(/admin/personas),否则 Vite 产出的 `./assets/...`
|
||||
// 会解析到错误目录(单二进制始终挂在站点根,design/13 §0)。
|
||||
//
|
||||
// 找不到 `<head>` 时注入到文档最前,同样先于任何资源引用。
|
||||
func adminEntryHTML(indexHTML []byte, prefix string) []byte {
|
||||
payload, err := json.Marshal(map[string]string{"prefix": prefix})
|
||||
if err != nil {
|
||||
// 字符串映射不会让 json.Marshal 失败;真出错时只保住 <base>。
|
||||
payload = []byte(`{}`)
|
||||
}
|
||||
snippet := []byte(`<base href="/"><script>window.` + adminEntryGlobal + "=" + string(payload) + `;</script>`)
|
||||
out := make([]byte, 0, len(indexHTML)+len(snippet))
|
||||
if i := bytes.Index(indexHTML, []byte("<head>")); i >= 0 {
|
||||
at := i + len("<head>")
|
||||
out = append(out, indexHTML[:at]...)
|
||||
out = append(out, snippet...)
|
||||
return append(out, indexHTML[at:]...)
|
||||
}
|
||||
out = append(out, snippet...)
|
||||
return append(out, indexHTML...)
|
||||
}
|
||||
|
||||
// isAdminShapedPath 报告 path 是否形如一次管理面板入口尝试(路径里出现
|
||||
// `admin` 段,如 /admin、/admin/x、/foo/admin):前缀被自定义后,这类猜测
|
||||
// 一律 404,避免回落用户端聊天壳把探测结果变成「面板不存在」以外的信息。
|
||||
func isAdminShapedPath(p string) bool {
|
||||
for _, seg := range strings.Split(p, "/") {
|
||||
if seg == config.DefaultAdminPath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in new issue
Block a user