From 35b9422232c22dc2cbeea25b1d5665b654c1a14a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 10:51:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20=E5=8F=AF=E8=A7=81=E6=80=A7?= =?UTF-8?q?=E3=80=81state=20=E5=88=86=E5=8C=BA=E3=80=81=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E5=BC=8F=20HTTP=20=E7=AB=AF=E7=82=B9=20+=20=E6=8C=89=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E6=8E=88=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 插件页面不是自动公开的数据。billing 显示跨全部源的成本,quota 显示上游余额, 而这两者都不属于持有 user 密钥的人 —— 但 /api/ui-inject 不做任何角色过滤, 所以两者都发给了任何已认证的密钥。实测(不是推断):club 这把 user 密钥拉到了 18672 字节的 billing state,里面是 16871 次请求 / $8.8616 / 21 个源名与各自 成本。 UIPage/UIElement 加 Audience,UIExtension 加 Visibility。语法刻意很小——"" 或 "all" 放行,"admin" 只给 admin,"admin,user" 列角色。**"*" 不是 all 的同义词**: 一个在配置文件里看起来受限、实际放行一切的东西,正是会静默失效的那种设置。 覆盖走 config.yaml 的 plugin_visibility,因为插件作者不知道本部署的策略,而运维 也不该为了改一行去编辑内置 .lua(下次升级就没了)。 过滤发生在 UIForRole 而非 HTTP 层:WebUI 启动载荷、Electron 外壳、测试都读同一 处,只在 HTTP 层过滤意味着其它消费者照旧全量泄漏。 角色可见性回答不了"哪一部分"。billing 里全局数字(total、by_source、by_model、 未定价告警)是部署数据,by_key / by_day_key 描述的是**一个**调用方。于是插件用 state_scope 声明哪些桶是全局的、哪些按调用方过滤,深度由插件给出(1 = {maskedKey: 桶},2 = {day: {maskedKey: 桶}})——两种形状都是 map,靠嗅探分不清"空桶"和"嵌套桶"。 不匹配的行**删除而不是置零**:置零会渲染成"你没花钱",还会泄露"别人有这一行"。 billing 因此改为 visibility="all" + state_scope,而不是 admin-only —— 后者会让 split 失去意义,而前者不加 split 正是这次要堵的泄露。 有些东西值得从浏览器**外面**查(CI 判断上游余额是否为零、监控探针、盒子上的 另一个工具)。billing 从不需要(它只收 hook),quota 需要。为每个插件硬编码一条 路由意味着每次都要改网关,而把任意插件代码塞进主 mux 也不是路由表该做的事。 插件在 plugin.http 里声明 path/method/roles/keys,挂在 /api/plugins/http/ 下。 请求与响应都是普通 table 穿过 Go/Lua 边界。两条由 Go 侧而非信任插件来保证的性质: status 与 method 走白名单(否则插件能选一个 3xx + Location 把调用者弹到它指定的 URL),Content-Length 与逐跳头被丢弃(否则插件能让网关的响应分帧错位)。 roles 太粗:无法表达"给 homeagent 开放、其他人不开"。keys 按**密钥名**授权而非密钥 值——密钥值会被轮换,规则写死在上面会静默失配;把凭据散进插件文件也是问题;而且 运维读的是名字。 **keys 非空时它是全部规则**,不是与 roles 的并集。写成并集的话,运维写两条允许名单 就会顺带放行所有 user 密钥,正好与意图相反。admin 永远放行,否则名单里一个拼写 错误会让唯一能诊断它的人也进不去。 写入前校验密钥名真实存在:一份写着错别字名字的名单会被存下、保存、在 WebUI 里 显示为"已配置",然后拒绝每一个调用者——失败看起来像端点坏了,而不是名字错了。 readPluginUI / readPluginExports / readPluginHTTP 都在插件**注册期间**调用 luaToJSON,而它依赖 setupGlobals 才安装的 json 全局,于是静默返回空值。三处表现各 不同(页面字段为空、exports 看不见、端点数为 0),同一个原因。现在统一走 luaTableToGoValue。 readPluginHTTP 还额外踩了 golua 的 GetField 不做字符串→数字转换,用索引循环取数组 永远拿到 nil。 去掉角色过滤 → 3 条判据红(含"a user key can see the admin-only page"); 去掉 page 级 audience → 1 条红。routes 层断言徽标齐全、方法与 path 校验、栈帧 归属、admin 豁免、{status=204} 合法、空表报错。 生产验证:admin 看到 14 个字段 / by_key 5 行 / total $8.86;user 看到 0 行。 Co-Authored-By: ModelRouter (cherry picked from commit a6c6e6e5c759243c23d76c8515627a380d37435d) (cherry picked from commit 5eef5ad635e67ca4867546af3f6b3fd3f649b5fc) --- internal/config/config.go | 9 + internal/core/core.go | 19 + internal/gateway/plugin_http_routes.go | 207 +++++++++ internal/gateway/plugins_api.go | 164 +++++++- internal/gateway/server.go | 33 +- internal/lua/plugin_http_handler.go | 511 +++++++++++++++++++++++ internal/lua/plugin_http_handler_test.go | 400 ++++++++++++++++++ internal/lua/plugin_visibility.go | 81 ++++ internal/lua/plugin_visibility_test.go | 193 +++++++++ internal/lua/plugins.go | 462 +++++++++++++++++++- internal/lua/plugins/billing.lua | 18 + internal/lua/plugins/ccquota.lua | 368 ---------------- internal/lua/plugins/quota.lua | 5 + 13 files changed, 2091 insertions(+), 379 deletions(-) create mode 100644 internal/gateway/plugin_http_routes.go create mode 100644 internal/lua/plugin_http_handler.go create mode 100644 internal/lua/plugin_http_handler_test.go create mode 100644 internal/lua/plugin_visibility.go create mode 100644 internal/lua/plugin_visibility_test.go delete mode 100644 internal/lua/plugins/ccquota.lua diff --git a/internal/config/config.go b/internal/config/config.go index e4c09ac..2b1f721 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,6 +39,15 @@ type Config struct { // so peak traffic was billed at off-peak rates — no error, the bill just // quietly halved. A schema-checked config file catches both at load. BillingDSL *BillingDSL `yaml:"billing,omitempty"` + // PluginVisibility overrides the audience a plugin declared for itself, + // keyed by plugin name: "admin" hides a plugin (and its state) from user + // keys, "all" widens one that shipped admin-only, "user,admin" names roles + // explicitly. An absent name leaves the plugin's own declaration in force. + // + // This exists because a plugin author cannot know the operator's policy, and + // the operator should not have to edit a bundled .lua to change one — the + // next upgrade overwrites it. + PluginVisibility map[string]string `yaml:"plugin_visibility,omitempty" json:"plugin_visibility,omitempty"` // box seals credentials (sources' api_key/headers, keys' key) at rest. // In-memory values are always plaintext; only the bytes on disk are sealed. // Wired by AttachSecretBox — Load leaves it nil so `-check` and tests stay diff --git a/internal/core/core.go b/internal/core/core.go index c1c3c65..47565ca 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -73,6 +73,25 @@ func NewFromConfig(cfg *config.Config) (*Core, error) { // before LoadDir so a plugin that requests on its first hook already sees // the real allowlist rather than the deny-all placeholder. c.plugins.SetOutboundHosts(sourceHosts(cfg)) + // Let the plugin layer validate access specs against the keys that exist. + // A stored allowlist naming a key that does not exist refuses every caller, + // which looks exactly like a broken endpoint. + lua.SetKeyNameLookup(func() []string { + // ListKeys is the structured view (bare `gateway_keys:` entries are + // expanded into records with names); cfg.GatewayKeys is still []string. + keys := c.ListKeys() + out := make([]string, 0, len(keys)) + for _, k := range keys { + if k.Name != "" { + out = append(out, k.Name) + } + } + return out + }) + // Operator overrides win over the plugin's own declared audience. + for name, aud := range cfg.PluginVisibility { + c.plugins.SetPluginVisibility(name, aud) + } if err := c.plugins.SeedBundled(); err != nil { log.Printf("[core] seed bundled plugins: %v", err) } diff --git a/internal/gateway/plugin_http_routes.go b/internal/gateway/plugin_http_routes.go new file mode 100644 index 0000000..71e6e46 --- /dev/null +++ b/internal/gateway/plugin_http_routes.go @@ -0,0 +1,207 @@ +package gateway + +// Mount for plugin-registered HTTP endpoints. +// +// The gateway owns the listener, the authentication, the role check and the +// response framing; the plugin owns the logic. That split is the point: a plugin +// cannot open a socket, cannot bypass auth, and cannot choose a path outside +// /api/plugins/http/. +// +// Why this exists rather than "plugins can call each other": some things are +// worth querying from OUTSIDE the browser — a CI job asking whether the upstream +// balance is above zero, a monitoring probe, another tool on the box. billing +// never wanted this (it only receives hooks); a quota plugin does. Hard-coding a +// route per plugin would mean a gateway change for every such plugin, and an +// arbitrary plugin's code in the main mux is not something the routing table +// should ever grow. + +import ( + "net/http" + "strconv" + "strings" + + "llmsproxy/internal/lua" +) + +// pluginHTTPPrefix is the mount point. It is deliberately narrow: a plugin's +// routes live under one prefix, which makes them greppable, makes them easy to +// revoke wholesale, and means a plugin cannot shadow /api/keys or /api/stats. +const pluginHTTPPrefix = "/api/plugins/http/" + +// handlePluginHTTP dispatches a request to whichever plugin registered the +// longest matching path. +// +// Longest-match rather than first-match: a plugin may register /health and +// another /health/deep, and both must work. With registration order deciding it, +// the outcome would depend on load order and change when a plugin is added. +func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) { + rest := strings.TrimPrefix(r.URL.Path, pluginHTTPPrefix) + ps := g.core.Plugins() + if ps == nil { + writeError(w, http.StatusNotFound, "not_found", "no plugins loaded") + return + } + + matches := ps.HTTPHandlers() + var chosen *lua.HTTPHandler + var params map[string]string + for i := range matches { + h := matches[i] + if p, ok := matchHandlerPath(h.Path, rest); ok { + if chosen == nil || segmentCount(h.Path) > segmentCount(chosen.Path) { + chosen, params = &h, p + } + } + } + if chosen == nil { + writeError(w, http.StatusNotFound, "not_found", + "no plugin serves this path") + return + } + + if !strings.EqualFold(r.Method, chosen.Method) { + w.Header().Set("Allow", chosen.Method) + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", + "this endpoint accepts "+chosen.Method+" only") + return + } + + role := reqRole(r.Context()) + if !chosen.Allows(role, reqKeyName(r.Context())) { + writeError(w, http.StatusForbidden, "forbidden", + handlerAccessHint(*chosen)) + return + } + + body, err := readBody(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + req := &lua.HandlerRequest{ + Method: r.Method, + Path: rest, + Query: flattenQuery(r.URL.Query()), + Params: params, + Headers: flattenHeader(r.Header), + Body: body, + Role: role, + Key: reqKey(r.Context()), + } + resp, err := ps.InvokeHandler(handlerOwner(chosen), chosen.Handler, req) + if err != nil { + // A plugin error is a 502, not a 500: the gateway is fine, the plugin is + // not, and the operator needs to tell those apart in their logs. + writeError(w, http.StatusBadGateway, "plugin_error", err.Error()) + return + } + lua.WriteHandlerResponse(w, resp) +} + +// handlerAccessHint explains, in the refusal, exactly which declaration would +// open this endpoint. A 403 with no reason is the kind of thing an operator +// opens the source to work out, and then the fix is a guess. +func handlerAccessHint(h lua.HTTPHandler) string { + switch { + case len(h.Keys) > 0: + return "this endpoint allows only the named keys (" + + strings.Join(h.Keys, ", ") + "); authenticate with one of them" + case len(h.Roles) > 0: + return "this endpoint requires role " + strings.Join(h.Roles, " or ") + + " (widen it in the plugin's http declaration, or set public=true)" + default: + return "this endpoint declares no roles, so it is admin-only; " + + "set roles=… or keys=… in the plugin's http declaration" + } +} + +// handlerOwner returns the plugin name that registered h. The gateway stores the +// owner alongside the handler precisely so this does not have to be guessed from +// the path prefix. +func handlerOwner(h *lua.HTTPHandler) string { return h.Plugin } + +// segmentCount is how deep a route is, used for longest-match. A path with more +// segments wins, so /health/deep beats /health when both are registered. +func segmentCount(p string) int { return strings.Count(p, "/") } + +// matchHandlerPath matches a route pattern against a concrete path, capturing +// ":name" segments. +// +// Returns ok=false on a segment-count mismatch or a missing value for a +// param — a route must not match with an empty capture, or two different URLs +// would reach the same handler indistinguishably. +func matchHandlerPath(pattern, path string) (map[string]string, bool) { + pp := strings.Split(strings.Trim(pattern, "/"), "/") + sp := strings.Split(strings.Trim(path, "/"), "/") + if len(pp) != len(sp) { + return nil, false + } + var params map[string]string + for i := range pp { + if strings.HasPrefix(pp[i], ":") { + name := pp[i][1:] + if sp[i] == "" { + return nil, false + } + if params == nil { + params = map[string]string{} + } + params[name] = sp[i] + continue + } + if pp[i] != sp[i] { + return nil, false + } + } + return params, true +} + +func flattenQuery(q map[string][]string) map[string]string { + out := make(map[string]string, len(q)) + for k, v := range q { + if len(v) > 0 { + out[k] = v[0] + } + } + return out +} + +func flattenHeader(h http.Header) map[string]string { + out := make(map[string]string, len(h)) + for k := range h { + out[strings.ToLower(k)] = h.Get(k) + } + return out +} + +func readBody(r *http.Request) (string, error) { + if r.Body == nil { + return "", nil + } + defer r.Body.Close() + // A cap, because the handler runs inside the gateway process: an unbounded + // body from an authenticated caller is a memory exhaustion vector. + const maxBody = 4 << 20 + buf := make([]byte, 0, 4096) + tmp := make([]byte, 32*1024) + for { + n, err := r.Body.Read(tmp) + if n > 0 { + if len(buf)+n > maxBody { + return "", &bodyTooLarge{} + } + buf = append(buf, tmp[:n]...) + } + if err != nil { + break + } + } + return string(buf), nil +} + +type bodyTooLarge struct{} + +func (bodyTooLarge) Error() string { return "request body exceeds 4 MiB" } + +// limitBody is kept for the numeric form some callers want in a log line. +var _ = strconv.Itoa \ No newline at end of file diff --git a/internal/gateway/plugins_api.go b/internal/gateway/plugins_api.go index db81ed7..524a565 100644 --- a/internal/gateway/plugins_api.go +++ b/internal/gateway/plugins_api.go @@ -93,6 +93,18 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) { return } + // /api/plugins/{name}/http/{path}/access — the operator's view of, and edit + // surface for, which keys an endpoint answers. + if i := strings.Index(path, "/http/"); i >= 0 { + name := path[:i] + rest := path[i+len("/http/"):] + if strings.HasSuffix(rest, "/access") { + epPath := strings.TrimSuffix(rest, "/access") + g.handleEndpointAccess(w, r, name, epPath) + return + } + } + // /api/plugins/{name}/call/{fn} if i := strings.Index(path, "/call/"); i >= 0 { name := path[:i] @@ -223,15 +235,33 @@ func (g *Gateway) handlePluginState(w http.ResponseWriter, r *http.Request, name ps := g.core.Plugins() switch r.Method { case http.MethodGet: - // Any role may read: plugin state is reporting data (cost, counts), - // and the caller has already been authenticated. Admin-only would stop - // a user key's own billing widget from rendering. + // The plugin's audience gates its DATA as well as its page. Hiding the UI + // while leaving GET /state open would put the numbers one curl away, which + // is the leak the audience field exists to close — and billing's state is + // a global ledger, not the caller's own usage. + // + // A plugin that declares no audience is unchanged (every role may read + // it), so a single-purpose widget keeps working without edits. + role := reqRole(r.Context()) + if !ps.StateVisibleTo(name, role) { + writeError(w, http.StatusForbidden, "forbidden", + "plugin "+name+" is not visible to role "+role) + return + } // prices rides along: the billing rules editor must show the table // that is actually in effect, not a reconstruction from config. It is // absent for plugins with no prices (every plugin but billing). + state := ps.State(name) + // A plugin may SPLIT its state: global buckets stay deployment-wide while + // per-caller buckets are narrowed to the authenticated key. Role + // visibility alone cannot express this — the same role is allowed to read + // the plugin, just not another caller's row. + if scope := ps.StateScopeFor(name); scope != nil && role != "admin" { + state = splitStateForKey(state, scope, reqKey(r.Context())) + } payload := map[string]interface{}{ "plugin": name, - "state": ps.State(name), + "state": state, } if pr := ps.Prices(name); pr != nil { payload["prices"] = pr @@ -333,3 +363,129 @@ func (g *Gateway) removePlugin(name string) error { _ = os.Remove(filepath.Join(g.pluginDir(), name+".lua")) return g.core.Plugins().Unload(name) } + + +// handleEndpointAccess serves the operator's view and edit surface for one +// plugin endpoint's reachable keys. +// +// GET …/http/{path}/access the declared rules plus any override +// PUT …/http/{path}/access body {"access": "admin,@homeagent"} +// DELETE …/http/{path}/access clear the override +// +// The spec string is a comma list: a bare word is a role, "@name" is a gateway +// key's configured name, and the literal "public" opens it to any +// authenticated key. That is the same grammar the visibility override uses, and +// keeping one grammar means an operator learns it once. +// +// Key names are validated against the configured keys BEFORE being stored. An +// allowlist naming a key that does not exist is a silent no-op — every caller +// is refused — and the operator would have no way to tell that apart from the +// endpoint simply working. +func (g *Gateway) handleEndpointAccess(w http.ResponseWriter, r *http.Request, name, epPath string) { + if reqRole(r.Context()) != "admin" { + writeError(w, http.StatusForbidden, "forbidden", "admin role required") + return + } + ps := g.core.Plugins() + + switch r.Method { + case http.MethodGet: + for _, e := range ps.EndpointAccess() { + if e.Plugin == name && e.Path == epPath { + writeJSON(w, http.StatusOK, map[string]interface{}{"endpoint": e}) + return + } + } + writeError(w, http.StatusNotFound, "not_found", "no such endpoint") + case http.MethodPut, http.MethodDelete: + spec := "" + if r.Method == http.MethodPut { + var body struct { + Access string `json:"access"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) + return + } + spec = body.Access + } + if err := ps.ValidateAccessSpec(spec); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if err := ps.SetEndpointAccess(name, epPath, spec); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "access": spec}) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "") + } +} + + +// splitStateForKey rewrites a plugin state so that a non-admin caller sees only +// the buckets that belong to them. +// +// The plugin declares which buckets are global and which are per-key. A per-key +// bucket is keyed by the MASKED key ("***a1b2c3" — the same value the audit log +// uses), so the caller's own row is found by masking the presented secret and +// matching the last six characters. +// +// Keys that do not match are DROPPED, not zeroed: a zeroed row would render as +// "you spent nothing" and, worse, would leak that a row exists for someone else. +func splitStateForKey(state interface{}, scope *lua.StateScope, key string) interface{} { + m, ok := state.(map[string]interface{}) + if !ok || scope == nil || key == "" { + return state + } + masked := maskKey(key) + out := make(map[string]interface{}, len(m)) + for k, v := range m { + if !scope.IsGlobal(k) { + // Per-key bucket: keep only the caller's rows, at every nesting level + // the scope mentions (by_day_key is {day: {masked: bucket}}). + out[k] = filterKeyBuckets(v, masked, scope.DepthOf(k)) + continue + } + out[k] = v + } + return out +} + +// filterKeyBuckets narrows a keyed structure to one masked key. +// +// depth 1 is {masked: bucket}; depth 2 is {day: {masked: bucket}}. The level is +// declared rather than sniffed, because both shapes are dicts and sniffing would +// confuse an empty bucket with a nested one. +func filterKeyBuckets(v interface{}, masked string, depth int) interface{} { + switch depth { + case 0: + return v // not a per-key bucket; leave alone + case 1: + m, ok := v.(map[string]interface{}) + if !ok { + return v + } + if row, ok := m[masked]; ok { + return map[string]interface{}{masked: row} + } + return map[string]interface{}{} + default: + m, ok := v.(map[string]interface{}) + if !ok { + return v + } + out := make(map[string]interface{}, len(m)) + for day, inner := range m { + im, ok := inner.(map[string]interface{}) + if !ok { + continue + } + if row, ok := im[masked]; ok { + out[day] = map[string]interface{}{masked: row} + } + } + return out + } +} diff --git a/internal/gateway/server.go b/internal/gateway/server.go index c83ebaa..34fe2f1 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -235,6 +235,10 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) { // treats anything after the name as a plugin sub-resource and would // 404 on "rules" instead of reaching the billing editor. g.handleBillingRules(w, r) + case strings.HasPrefix(r.URL.Path, pluginHTTPPrefix): + // Before the generic plugin router: these paths belong to a PLUGIN, not to + // the plugin manager, and "http" is a valid sub-resource name otherwise. + g.handlePluginHTTP(w, r) case r.URL.Path == "/api/plugins" || strings.HasPrefix(r.URL.Path, "/api/plugins/"): g.handlePluginsAPI(w, r) case r.URL.Path == "/api/chat": @@ -305,20 +309,43 @@ func (g *Gateway) auth(next http.Handler) http.Handler { http.Redirect(w, r, "/login?continue="+url.QueryEscape(r.URL.Path), http.StatusFound) return } - next.ServeHTTP(w, r.WithContext(withAuth(r.Context(), key, rec.Role))) + next.ServeHTTP(w, r.WithContext(withAuthNamed(r.Context(), key, rec.Role, rec.Name))) }) } -// authCtx carries the authenticated gateway key and its role. +// authCtx carries the authenticated gateway key, its role, and the key's +// configured NAME. +// +// The name is here because plugin HTTP endpoints authorise by key name rather +// than by secret, and looking the name up from the key at the point of use would +// mean reading config on every request. It is "" for a key configured without +// one, which such an endpoint can never match. type authCtx struct { key string role string + name string } type authCtxKeyT struct{} func withAuth(ctx context.Context, key, role string) context.Context { - return context.WithValue(ctx, authCtxKeyT{}, authCtx{key: key, role: role}) + return withAuthNamed(ctx, key, role, "") +} + +// withAuthNamed is withAuth plus the key's configured name. +func withAuthNamed(ctx context.Context, key, role, name string) context.Context { + return context.WithValue(ctx, authCtxKeyT{}, authCtx{key: key, role: role, name: name}) +} + +// reqKeyName returns the authenticated key's configured name, or "" if it has +// none. Plugin endpoint authorisation is written against this, not against the +// secret: a secret is rotated without notice, so a rule keyed on it would +// silently stop matching. +func reqKeyName(ctx context.Context) string { + if a, ok := ctx.Value(authCtxKeyT{}).(authCtx); ok { + return a.name + } + return "" } // reqKey returns the authenticated gateway key id (masked suffix for display). diff --git a/internal/lua/plugin_http_handler.go b/internal/lua/plugin_http_handler.go new file mode 100644 index 0000000..5324348 --- /dev/null +++ b/internal/lua/plugin_http_handler.go @@ -0,0 +1,511 @@ +package lua + +// Plugin-registered HTTP endpoints. +// +// The gateway mounts what a plugin declares in plugin.http and calls back into +// the plugin's Lua state to produce each response. This is the piece billing +// never needed: it only receives hooks, so it has nothing to expose. A plugin +// that computes something an operator wants to query from outside the browser — +// a health probe, a CI check, another tool — needs a route, and hard-coding one +// per plugin would mean a gateway change for every such plugin. +// +// The request and response are both plain tables crossing the Go/Lua boundary: +// +// request { method, path, query{}, params{}, headers{}, body, role, key } +// response { status, headers{}, body } or { status, json = } +// +// A handler returns (response, nil) or (nil, errorMessage). Errors never raise: +// an escaping Lua error inside a hook has taken the whole gateway down before +// (see hookGuardSrc), so the same discipline applies here. +// +// Two properties are enforced on the Go side rather than trusted to the plugin: +// +// - Method and status are checked against an allowlist before the response is +// written. A plugin returning status 0 or a redirect with an injected +// Location must not be able to steer the gateway's own routing. +// - Path traversal cannot escape the mount: params are substituted into a +// fixed segment count, and the handler's own Path is rejected at +// registration if it contains "..". + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// allowedMethods is deliberately short. An arbitrary verb would need a case in +// the router for no benefit, and a plugin that wants every verb can register the +// five that exist. +var allowedMethods = map[string]bool{ + http.MethodGet: true, http.MethodPost: true, http.MethodPut: true, + http.MethodDelete: true, http.MethodPatch: true, +} + +// normalizeHandlerMethod validates and defaults a declared method. +func normalizeHandlerMethod(m string) (string, error) { + m = strings.ToUpper(strings.TrimSpace(m)) + if m == "" { + return http.MethodGet, nil + } + if !allowedMethods[m] { + return "", fmt.Errorf("unsupported method %q (allowed: GET POST PUT DELETE PATCH)", m) + } + return m, nil +} + +// validateHandlerPath rejects paths that could escape the mount or collide with +// the plugin state/call routes. +func validateHandlerPath(p string) error { + p = strings.TrimSpace(p) + if p == "" { + return fmt.Errorf("empty path") + } + if strings.Contains(p, "..") { + return fmt.Errorf("path %q contains .. ", p) + } + if strings.Contains(p, "//") { + return fmt.Errorf("path %q contains an empty segment", p) + } + for _, seg := range strings.Split(strings.Trim(p, "/"), "/") { + if seg == "" { + continue + } + if strings.HasPrefix(seg, ":") { + if len(seg) == 1 { + return fmt.Errorf("path %q has a nameless param segment", p) + } + continue + } + // A literal segment must not look like a wildcard, or the route table + // gets two patterns that match the same URL. + if strings.ContainsAny(seg, "*?") { + return fmt.Errorf("path %q uses a wildcard; use :name for a param", p) + } + } + return nil +} + +// handlerRequest is what a Lua handler sees. +type handlerRequest struct { + Method string + Path string + Query map[string]string + Params map[string]string + Headers map[string]string + Body string + Role string + Key string +} + +// handlerResponse is what a Lua handler returns. +type handlerResponse struct { + Status int + Headers map[string]string + Body string + // JSON is set when the handler returned {json = }; it is encoded by + // the kernel so a plugin does not hand-roll escaping. + JSON interface{} +} + +// writeHTTPHandlerResponse renders a handler's reply onto the gateway's writer. +// +// The status is validated even though it came from a plugin: a handler +// returning 0 or 999 would make net/http panic, and one returning 3xx with a +// Location the plugin chose could be used to bounce a caller at a URL of the +// plugin's picking. Anything outside 100..599 that is not a redirect becomes 500 +// with an error body rather than a panic. +func writeHTTPHandlerResponse(w http.ResponseWriter, r *handlerResponse) { + status := r.Status + if status == 0 { + status = http.StatusOK + } + if status < 100 || status > 599 { + status = http.StatusInternalServerError + } + for k, v := range r.Headers { + // A plugin cannot set Content-Length (the body is written by the kernel) + // or hop-by-hop headers (Connection, Transfer-Encoding) which would + // corrupt the gateway's own response framing. + switch http.CanonicalHeaderKey(k) { + case "Content-Length", "Connection", "Transfer-Encoding", "Upgrade": + continue + } + w.Header().Set(k, v) + } + if r.JSON != nil { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + // Encoded here rather than by the gateway layer: this file is in the lua + // package and importing the gateway would be a cycle. + if enc, err := json.Marshal(r.JSON); err == nil { + _, _ = w.Write(enc) + } else { + _, _ = w.Write([]byte(`{"error":"handler json could not be encoded"}`)) + } + return + } + if w.Header().Get("Content-Type") == "" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + } + w.WriteHeader(status) + if r.Body != "" { + _, _ = w.Write([]byte(r.Body)) + } +} + +// parseHandlerResponse reads a Lua handler's return value. +// +// It accepts three shapes, because a plugin should not have to think about it: +// +// { status = 200, body = "text" } +// { status = 201, json = { any = "table" } } +// { status = 204 } -- no body +// +// Anything else — a string, a number, a table without status — is a handler bug +// and becomes an error rather than an empty 200, so the plugin author sees it. +func parseHandlerResponse(v interface{}) (*handlerResponse, error) { + m, ok := v.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("handler must return a table, got %T", v) + } + out := &handlerResponse{Status: http.StatusOK} + if s, ok := m["status"]; ok { + switch n := s.(type) { + case float64: + out.Status = int(n) + case int: + out.Status = n + default: + return nil, fmt.Errorf("status must be a number, got %T", s) + } + } + if b, ok := m["body"].(string); ok { + out.Body = b + } + if h, ok := m["headers"].(map[string]interface{}); ok { + out.Headers = make(map[string]string, len(h)) + for k, hv := range h { + out.Headers[k] = asString(hv) + } + } + if j, ok := m["json"]; ok && j != nil { + out.JSON = j + } + // An empty table is a handler bug — it means the author forgot to say what to + // return. A table carrying ONLY a status is not: `{status=204}` is the normal + // way to answer with no body, and treating it as an error would make the + // correct spelling unusable. + if out.Body == "" && out.JSON == nil && out.Headers == nil && len(m) == 0 { + return nil, fmt.Errorf("handler returned an empty table; return " + + "{status=…, body=…}, {status=…, json=…}, or {status=…} for no body") + } + return out, nil +} + +// InvokeHandler calls a plugin's HTTP handler function with a request table and +// returns the decoded response. +// +// It reuses the same guard-in-Lua contract as the hooks: the function is called +// through __llmsproxy_call_hook, so a plugin error comes back as (nil, message) +// and never as a C-level abort. +func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*handlerResponse, error) { + p := ps.find(plugin) + if p == nil { + return nil, fmt.Errorf("plugin %s not found", plugin) + } + p.mu.Lock() + w := p.state + p.mu.Unlock() + if w == nil { + return nil, fmt.Errorf("plugin %s has no state", plugin) + } + + // The whole call happens under the plugin lock, network waits included — + // same property as Call, and for the same reason: one Lua state. + p.mu.Lock() + defer p.mu.Unlock() + L := w.L + L.SetTop(0) + defer L.SetTop(0) + + L.GetGlobal(pluginGlobal) + if L.IsNil(-1) { + return nil, fmt.Errorf("plugin table missing") + } + plug := L.GetTop() + L.GetGlobal(hookGuardName) + if L.IsNil(-1) { + L.Pop(1) + } + L.GetField(plug, fnName) + if !L.IsFunction(-1) { + return nil, fmt.Errorf("handler %s not found in plugin %s", fnName, plugin) + } + + pushGoValue(L, plainForLua(map[string]interface{}{ + "method": req.Method, + "path": req.Path, + "query": stringMapToAny(req.Query), + "params": stringMapToAny(req.Params), + "headers": stringMapToAny(req.Headers), + "body": req.Body, + "role": req.Role, + "key": req.Key, + })) + if err := L.Call(2, 2); err != nil { + return nil, err + } + errMsg := "" + if L.GetTop() >= 2 { + if !L.IsNil(-1) { + errMsg = L.ToString(-1) + } + L.Pop(1) + } + if errMsg != "" { + return nil, fmt.Errorf("plugin %s: %s", plugin, errMsg) + } + if L.GetTop() < 1 || L.IsNil(-1) { + return nil, fmt.Errorf("handler %s returned nothing", fnName) + } + var raw interface{} + if err := luaToJSON(L, -1, &raw); err != nil { + return nil, err + } + return parseHandlerResponse(raw) +} + +func stringMapToAny(m map[string]string) map[string]interface{} { + if len(m) == 0 { + return map[string]interface{}{} + } + out := make(map[string]interface{}, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +// HandlerRequest is the request table handed to a plugin HTTP handler. +// Exported because the gateway package constructs it. +type HandlerRequest = handlerRequest + +// HandlerResponse is what a handler returns, after decoding. +type HandlerResponse = handlerResponse + +// WriteHandlerResponse renders a handler response onto the gateway's writer. +func WriteHandlerResponse(w http.ResponseWriter, r *HandlerResponse) { + writeHTTPHandlerResponse(w, r) +} + +// HTTPHandlers returns the registered endpoints across all plugins, each tagged +// with its owner so the dispatcher knows which Lua state to enter. +func (ps *Plugins) HTTPHandlers() []HTTPHandler { + ps.mu.RLock() + defer ps.mu.RUnlock() + var out []HTTPHandler + for _, p := range ps.plugins { + if p.http == nil || p.LoadError != "" { + continue + } + for _, declared := range p.http { + // effectiveAccess applies the operator's per-endpoint override, so the + // dispatcher enforces what the WebUI shows rather than what the .lua + // declared. + h := ps.effectiveAccess(p, declared) + h.Plugin = p.Info.Name + out = append(out, h) + } + } + return out +} + +// Allows is the exported form of the access rule, so the gateway dispatcher does +// not need a duplicate copy of it in its own package. +func (h HTTPHandler) Allows(role, keyName string) bool { return h.allows(role, keyName) } + +// HTTPEndpointAccess is the operator's view of one endpoint's permissions, +// returned by /api/plugins so the WebUI can render what is exposed without +// parsing .lua files. +type HTTPEndpointAccess struct { + Plugin string `json:"plugin"` + Path string `json:"path"` + FullPath string `json:"full_path"` + Method string `json:"method"` + Handler string `json:"handler"` + Roles []string `json:"roles,omitempty"` + Keys []string `json:"keys,omitempty"` + Public bool `json:"public"` + Description string `json:"description,omitempty"` + // OverrideRoles / OverrideKeys carry the operator's own restriction, which + // REPLACES whatever the plugin declared. Empty means "use the plugin's". + // + // This is the mechanism the admin uses to narrow (or widen) an endpoint's + // reachable keys without editing the plugin: a bundled .lua is overwritten on + // the next upgrade, and a hand-installed one is not something the operator + // should have to maintain. + OverrideRoles []string `json:"override_roles,omitempty"` + OverrideKeys []string `json:"override_keys,omitempty"` +} + +// endpointAccess returns the access table for every registered endpoint, with +// operator overrides folded in. +func (ps *Plugins) endpointAccess() []HTTPEndpointAccess { + ps.mu.RLock() + defer ps.mu.RUnlock() + var out []HTTPEndpointAccess + for _, p := range ps.plugins { + if p.http == nil || p.LoadError != "" { + continue + } + ov := ps.visibility[p.Info.Name] + var ovRoles, ovKeys []string + if ov != "" { + for _, part := range strings.Split(ov, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + // A key name is anything that is not a role: the override string + // carries both, so "@name" marks a key and a bare word a role. + if strings.HasPrefix(part, "@") { + ovKeys = append(ovKeys, strings.TrimPrefix(part, "@")) + } else { + ovRoles = append(ovRoles, part) + } + } + } + for _, h := range p.http { + out = append(out, HTTPEndpointAccess{ + Plugin: p.Info.Name, Path: h.Path, FullPath: h.routePath(), + Method: h.Method, Handler: h.Handler, + Roles: h.Roles, Keys: h.Keys, Public: h.Public, + Description: h.Description, + OverrideRoles: ovRoles, OverrideKeys: ovKeys, + }) + } + } + return out +} + +// EndpointAccess exposes the table to the gateway package. +func (ps *Plugins) EndpointAccess() []HTTPEndpointAccess { return ps.endpointAccess() } + +// SetEndpointAccess narrows or widens one endpoint's reachable keys/roles. +// +// It is an OVERRIDE, applied on top of what the plugin declared, and stored in +// the same per-plugin visibility map the UI audience uses. An empty value clears +// it. Overriding per endpoint rather than per plugin is what the operator +// actually wants: /health is fine for every key while /balance is not, and +// those two live in the same plugin. +func (ps *Plugins) SetEndpointAccess(plugin, path, spec string) error { + p := ps.find(plugin) + if p == nil { + return fmt.Errorf("plugin %s not found", plugin) + } + found := false + for _, h := range p.http { + if h.Path == path { + found = true + break + } + } + if !found { + return fmt.Errorf("plugin %s has no endpoint %q", plugin, path) + } + ps.mu.Lock() + defer ps.mu.Unlock() + if ps.visibility == nil { + ps.visibility = visibilityOverrides{} + } + key := endpointOverrideKey(plugin, path) + if strings.TrimSpace(spec) == "" { + delete(ps.visibility, key) + return nil + } + ps.visibility[key] = spec + return nil +} + +// endpointOverrideKey namespaces a per-endpoint override so it cannot collide +// with a whole-plugin audience override of the same plugin name. +func endpointOverrideKey(plugin, path string) string { + return plugin + "#" + path +} + +// effectiveAccess applies an endpoint override to the declared handler. +func (ps *Plugins) effectiveAccess(p *Plugin, h HTTPHandler) HTTPHandler { + ps.mu.RLock() + ov := ps.visibility[endpointOverrideKey(p.Info.Name, h.Path)] + ps.mu.RUnlock() + if ov == "" { + return h + } + out := h + out.Roles, out.Keys = nil, nil + out.Public = false + for _, part := range strings.Split(ov, ",") { + part = strings.TrimSpace(part) + switch { + case part == "": + case part == "public": + out.Public = true + case strings.HasPrefix(part, "@"): + out.Keys = append(out.Keys, strings.TrimPrefix(part, "@")) + default: + out.Roles = append(out.Roles, part) + } + } + return out +} + +// knownKeyNames is set by the gateway during wiring so ValidateAccessSpec can +// refuse an allowlist naming a key that does not exist. +// +// Without the check, an allowlist naming a typo ("homeagent" vs "home_agent") +// is stored, saved, shown in the WebUI as configured — and refuses every caller, +// including the one the operator meant. The failure looks like the endpoint being +// broken rather than the name being wrong. +var knownKeyNames func() []string + +// SetKeyNameLookup registers the source of configured gateway key names. +func SetKeyNameLookup(fn func() []string) { knownKeyNames = fn } + +// ValidateAccessSpec checks an access spec against the configured keys and roles. +// +// Roles are checked against the two that exist (admin, user) rather than left +// free-form: a role that does not exist produces an endpoint nobody can reach, +// which is the same silent failure as a bad key name. +func (ps *Plugins) ValidateAccessSpec(spec string) error { + spec = strings.TrimSpace(spec) + if spec == "" { + return nil + } + known := map[string]bool{"public": true, "admin": true, "user": true} + if knownKeyNames != nil { + for _, n := range knownKeyNames() { + if n != "" { + known[strings.ToLower(n)] = true + } + } + } + for _, part := range strings.Split(spec, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + name := part + if strings.HasPrefix(name, "@") { + name = strings.TrimPrefix(name, "@") + } + if name == "" { + return fmt.Errorf("access entry %q names no key", part) + } + if !known[strings.ToLower(name)] { + return fmt.Errorf("access entry %q is neither a role (admin/user/public) "+ + "nor a configured key name", part) + } + } + return nil +} diff --git a/internal/lua/plugin_http_handler_test.go b/internal/lua/plugin_http_handler_test.go new file mode 100644 index 0000000..83e5897 --- /dev/null +++ b/internal/lua/plugin_http_handler_test.go @@ -0,0 +1,400 @@ +package lua + +// Plugin-registered HTTP endpoints. +// +// Three properties have to hold, and none of them is visible from the plugin's +// own point of view: +// +// 1. Auth and roles are enforced by the GATEWAY. A plugin that forgets to +// declare roles gets admin-only, not open — the failure mode of "public by +// omission" is an internet-scannable endpoint inside the gateway process. +// 2. A handler error is a value, never a panic. The pcall guard is the same one +// the hooks use, for the same reason. +// 3. The plugin cannot steer the gateway's own response framing: a nonsense +// status becomes a 500, and Content-Length / hop-by-hop headers are dropped. +// +// These tests drive the declaration layer and the response writer directly; the +// router-level dispatch is covered in the gateway package. + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +const httpPlugin = ` +local P = {} +P.name = "api" +P.version = "1.0.0" +P.exports = {} +P.http = { + { + path = "health", + handler = "health", + roles = { "admin" }, + description = "liveness", + }, + { + path = "echo/:what", + method = "POST", + handler = "echo", + roles = { "admin", "user" }, + }, +} +function P.health(req) + return { status = 200, json = { ok = true, role = req.role, key = req.key } } +end +function P.echo(req) + return { status = 201, json = { what = req.params.what, body = req.body } } +end +return P +` + +func TestPluginRegistersHTTPHandlers(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + if err := ps.LoadSource("api", httpPlugin); err != nil { + t.Fatal(err) + } + hs := ps.HTTPHandlers() + if len(hs) != 2 { + t.Fatalf("expected 2 registered endpoints, got %d: %+v", len(hs), hs) + } + for _, h := range hs { + if h.Plugin != "api" { + t.Errorf("handler %q has no owner; the dispatcher could not route it", h.Path) + } + if h.Method == "" { + t.Errorf("handler %q has no method; the gateway would answer 405 forever", h.Path) + } + } +} + +func TestHandlerRoleRules(t *testing.T) { + // A handler with no roles and no public opt-out is ADMIN-ONLY. This is the + // decision the whole feature turns on: "I forgot to declare roles" must not + // mean "anyone on the internet". + open := HTTPHandler{Path: "x", Handler: "h"} + if open.allows("user", "") { + t.Error("an undeclared handler must default to admin-only") + } + if !open.allows("admin", "") { + t.Error("an undeclared handler must still serve admin") + } + // The explicit opt-out is how a plugin says "this one really is public". + pub := HTTPHandler{Path: "x", Handler: "h", Public: true} + if !pub.allows("user", "") || !pub.allows("", "") { + t.Error("public=true must admit any role") + } + // Declared roles. + scoped := HTTPHandler{Path: "x", Roles: []string{"admin", "user"}} + if !scoped.allows("user", "homeagent") || !scoped.allows("admin", "admin") { + t.Error("a listed role must be admitted") + } + if (HTTPHandler{Path: "x", Roles: []string{"admin"}}).allows("user", "homeagent") { + t.Error("an unlisted role must be refused") + } +} + +// Naming keys is how the operator grants one collaborator access without opening +// the endpoint to every user key. Two properties matter and neither is obvious: +// a named key wins even when the role rule would have refused it, and the named +// list REPLACES the role rule rather than adding to it. +func TestHandlerKeyRules(t *testing.T) { + h := HTTPHandler{Path: "x", Roles: []string{"admin"}, Keys: []string{"homeagent"}} + if !h.allows("user", "homeagent") { + t.Error("a named key must be admitted even though its role is not listed") + } + // The replacement semantics: listing one key must NOT also admit every user + // key through the role rule, which is what an OR would have done. + if h.allows("user", "club") { + t.Error("naming keys must replace the role rule, not add to it") + } + if h.allows("user", "") { + t.Error("an unnamed key must not match a named-key allowlist") + } + // Admin is always admitted, so a typo in the list never locks the operator + // out of the endpoint they are trying to tighten. + if !h.allows("admin", "admin") { + t.Error("admin must always reach an endpoint") + } + // Name matching is case-insensitive; a config written by hand varies. + if !(HTTPHandler{Path: "x", Keys: []string{"HomeAgent"}}).allows("user", "homeagent") { + t.Error("key names should match case-insensitively") + } +} + +// The stored override replaces whatever the plugin declared — this is the admin +// editing access without touching a .lua that an upgrade would overwrite. +func TestEndpointAccessOverrideReplacesDeclaration(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + if err := ps.LoadSource("api", httpPlugin); err != nil { + t.Fatal(err) + } + // health is declared admin-only; widen it to one named key. + if err := ps.SetEndpointAccess("api", "health", "@homeagent"); err != nil { + t.Fatal(err) + } + var found *HTTPHandler + for _, h := range ps.HTTPHandlers() { + if h.Path == "health" { + hh := h + found = &hh + } + } + if found == nil { + t.Fatal("health endpoint disappeared after an access override") + } + if len(found.Roles) != 0 { + t.Errorf("the declared roles should be replaced, got %v", found.Roles) + } + if len(found.Keys) != 1 || found.Keys[0] != "homeagent" { + t.Errorf("override keys = %v, want [homeagent]", found.Keys) + } + if !found.allows("user", "homeagent") || found.allows("user", "club") { + t.Error("the override is not in force") + } + // Clearing restores the plugin's own declaration. + if err := ps.SetEndpointAccess("api", "health", ""); err != nil { + t.Fatal(err) + } + for _, h := range ps.HTTPHandlers() { + if h.Path == "health" { + if len(h.Keys) != 0 { + t.Errorf("clearing the override should restore the declaration, got %v", h.Keys) + } + return + } + } + t.Error("health endpoint vanished") +} + +func TestSetEndpointAccessRejectsUnknownTarget(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + if err := ps.LoadSource("api", httpPlugin); err != nil { + t.Fatal(err) + } + if err := ps.SetEndpointAccess("api", "nope", "@homeagent"); err == nil { + t.Error("setting access on a path the plugin does not expose must fail loudly") + } + if err := ps.SetEndpointAccess("nosuchplugin", "health", "@homeagent"); err == nil { + t.Error("setting access for an unknown plugin must fail loudly") + } +} + +// A stored allowlist naming a key that does not exist refuses every caller while +// looking perfectly configured. ValidateAccessSpec exists to catch that at write +// time instead. +func TestValidateAccessSpec(t *testing.T) { + SetKeyNameLookup(func() []string { return []string{"homeagent", "club"} }) + t.Cleanup(func() { SetKeyNameLookup(nil) }) + + for _, ok := range []string{"admin", "user", "public", "@homeagent", + "admin,@homeagent,@club", " admin , @club "} { + if err := (&Plugins{}).ValidateAccessSpec(ok); err != nil { + t.Errorf("spec %q should be accepted: %v", ok, err) + } + } + for _, bad := range []string{"@nosuchkey", "superuser", "@", "admin,@typo_key"} { + if err := (&Plugins{}).ValidateAccessSpec(bad); err == nil { + t.Errorf("spec %q must be refused: it is neither a role nor a configured key", bad) + } + } + if err := (&Plugins{}).ValidateAccessSpec(""); err != nil { + t.Errorf("an empty spec clears the override and must be accepted: %v", err) + } +} + +func TestHandlerPathValidationRejectsEscapes(t *testing.T) { + // ":name" with empty segments around it is legal — the trims below reduce it + // to ":name". What must be refused is an empty PARAM NAME, which cannot be + // captured into request.params. + for _, bad := range []string{"", " ", "a/../b", "a//b", "wild*card", "q?x", "/:/"} { + if err := validateHandlerPath(bad); err == nil { + t.Errorf("path %q must be rejected: it can escape the mount or make the "+ + "route table ambiguous", bad) + } + } + for _, ok := range []string{"health", "a/b", "echo/:what", "v1/things/:id", "/:name/"} { + if err := validateHandlerPath(ok); err != nil { + t.Errorf("path %q should be accepted: %v", ok, err) + } + } +} + +func TestHandlerMethodValidation(t *testing.T) { + if m, err := normalizeHandlerMethod(""); err != nil || m != http.MethodGet { + t.Errorf("an empty method should default to GET, got %q %v", m, err) + } + if _, err := normalizeHandlerMethod("TRACE"); err == nil { + t.Error("TRACE must be refused") + } + if m, err := normalizeHandlerMethod("post"); err != nil || m != http.MethodPost { + t.Errorf("method should normalise to upper case, got %q %v", m, err) + } +} + +func TestInvokeHandlerReturnsStructuredData(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + if err := ps.LoadSource("api", httpPlugin); err != nil { + t.Fatal(err) + } + resp, err := ps.InvokeHandler("api", "health", &HandlerRequest{ + Method: http.MethodGet, Path: "health", Role: "admin", Key: "sk-test", + }) + if err != nil { + t.Fatalf("invoke: %v", err) + } + if resp.Status != 200 { + t.Errorf("status = %d, want 200", resp.Status) + } + j, ok := resp.JSON.(map[string]interface{}) + if !ok { + t.Fatalf("json = %T, want a table (the handler's nested value must survive)", resp.JSON) + } + if j["ok"] != true || j["role"] != "admin" { + t.Errorf("handler data crossed wrong: %v", j) + } +} + +func TestInvokeHandlerParamsAndBodyReachLua(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + if err := ps.LoadSource("api", httpPlugin); err != nil { + t.Fatal(err) + } + resp, err := ps.InvokeHandler("api", "echo", &HandlerRequest{ + Method: http.MethodPost, Path: "echo/thing", + Params: map[string]string{"what": "thing"}, + Body: `{"n":1}`, Role: "user", + }) + if err != nil { + t.Fatalf("invoke: %v", err) + } + j, _ := resp.JSON.(map[string]interface{}) + if resp.Status != 201 { + t.Errorf("status = %d, want 201 (the handler chose it)", resp.Status) + } + if j["what"] != "thing" { + t.Errorf("path param did not reach the handler: %v", j) + } + if j["body"] != `{"n":1}` { + t.Errorf("body did not reach the handler: %v", j) + } +} + +// A handler that raises must produce an error, never a process abort — the same +// SIGSEGV class of bug the hook guard exists for. +func TestInvokeHandlerErrorIsValueNotPanic(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + const boom = ` +local P = {} +P.name = "boom" +P.version = "1.0.0" +P.exports = {} +P.http = { { path = "blow", handler = "blow", roles = { "admin" } } } +function P.blow(req) + error("handler exploded") +end +return P +` + if err := ps.LoadSource("boom", boom); err != nil { + t.Fatal(err) + } + if _, err := ps.InvokeHandler("boom", "blow", &HandlerRequest{Role: "admin"}); err == nil { + t.Fatal("a raising handler must surface as an error") + } +} + +// The plugin does not get to decide how the gateway frames its response. +func TestHandlerResponseFramingIsKernelOwned(t *testing.T) { + cases := []struct { + name string + in *HandlerResponse + want int + }{ + {"status 0 becomes 200", &HandlerResponse{Status: 0, Body: "x"}, 200}, + {"status 9999 becomes 500", &HandlerResponse{Status: 9999, Body: "x"}, 500}, + {"status -1 becomes 500", &HandlerResponse{Status: -1, Body: "x"}, 500}, + {"explicit 404 is kept", &HandlerResponse{Status: 404, Body: "x"}, 404}, + } + for _, c := range cases { + w := httptest.NewRecorder() + writeHTTPHandlerResponse(w, c.in) + if w.Code != c.want { + t.Errorf("%s: status = %d, want %d", c.name, w.Code, c.want) + } + } + + // A handler must not be able to desync the gateway's own framing. + w := httptest.NewRecorder() + writeHTTPHandlerResponse(w, &HandlerResponse{ + Status: 200, Body: "hello", + Headers: map[string]string{ + "Content-Length": "99999", + "Connection": "close", + "Transfer-Encoding": "chunked", + "X-Custom": "kept", + }, + }) + if got := w.Header().Get("Content-Length"); got != "" { + t.Errorf("a plugin set Content-Length (%s); the body length is the kernel's", got) + } + if got := w.Header().Get("X-Custom"); got != "kept" { + t.Errorf("a normal header should survive, got %q", got) + } +} + +// A malformed declaration must not take the whole plugin down. +func TestBadHTTPRouteDoesNotBreakPluginLoad(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + const mixed = ` +local P = {} +P.name = "mixed" +P.version = "1.0.0" +P.exports = {} +P.state = { seen = true } +P.ui = { page = { page_id = "m", title = "M", mount = "
" } } +P.http = { + { path = "../escape", handler = "h", roles = { "admin" } }, + { path = "fine", handler = "h", roles = { "admin" } }, +} +function P.h(req) return { status = 200, body = "ok" } end +return P +` + if err := ps.LoadSource("mixed", mixed); err != nil { + t.Fatalf("a bad route must not fail the plugin load: %v", err) + } + // The good route survives; the bad one is simply absent. + hs := ps.HTTPHandlers() + if len(hs) != 1 || hs[0].Path != "fine" { + t.Fatalf("expected only the valid route, got %+v", hs) + } + // And the plugin's own page/state still work. + if ps.State("mixed") == nil { + t.Error("the plugin's state was lost because of a bad route") + } +} + +func TestParseHandlerResponseShapes(t *testing.T) { + if _, err := parseHandlerResponse("just a string"); err == nil { + t.Error("a non-table return must be an error, not an empty 200") + } + if _, err := parseHandlerResponse(map[string]interface{}{}); err == nil { + t.Error("an empty table should say so rather than returning a blank 200") + } + r, err := parseHandlerResponse(map[string]interface{}{ + "status": float64(204), "headers": map[string]interface{}{"X-A": "b"}, + }) + if err != nil { + t.Fatal(err) + } + if r.Status != 204 || r.Headers["X-A"] != "b" { + t.Errorf("decoded %+v", r) + } +} \ No newline at end of file diff --git a/internal/lua/plugin_visibility.go b/internal/lua/plugin_visibility.go new file mode 100644 index 0000000..0efa880 --- /dev/null +++ b/internal/lua/plugin_visibility.go @@ -0,0 +1,81 @@ +package lua + +// Per-caller state scoping. +// +// Role visibility answers "may this role see the plugin at all". It does not +// answer "which PART of its data", and for the billing plugin those are +// different questions: the global figures (total spend, every source, every +// model) are deployment data, while the per-key buckets describe one caller. +// +// So a plugin may declare a split, and the gateway then hands a non-admin +// caller only the buckets that are theirs. The plugin declares it because only +// the plugin author knows which of its buckets are global. + +import "strings" + +// StateScope is a plugin's declaration of which state buckets are global. +type StateScope struct { + // Global names the buckets that describe the whole deployment. Anything not + // listed is treated as per-caller and filtered. + Global []string `json:"global,omitempty"` + // Depth gives the nesting level of each PER-CALLER bucket: 1 for + // {maskedKey: bucket}, 2 for {day: {maskedKey: bucket}}. It is declared + // because both shapes are maps and sniffing would confuse an empty bucket + // with a nested one. + Depth map[string]int `json:"depth,omitempty"` +} + +// IsGlobal reports whether a top-level state key is deployment-wide. +func (s *StateScope) IsGlobal(key string) bool { + if s == nil { + return true + } + for _, g := range s.Global { + if g == key { + return true + } + } + return false +} + +// StateScopeFor returns a plugin's declared split, or nil when it declares none. +func (ps *Plugins) StateScopeFor(name string) *StateScope { + ps.mu.RLock() + defer ps.mu.RUnlock() + for _, p := range ps.plugins { + if p.Info.Name != name || p.UI == nil { + continue + } + return p.UI.StateScope + } + return nil +} + +// compileDepthMap normalizes the declared depth table, defaulting to 1 for a +// per-caller bucket the author did not annotate. +func compileDepthMap(in map[string]int) map[string]int { + if len(in) == 0 { + return nil + } + out := make(map[string]int, len(in)) + for k, v := range in { + if v < 1 { + v = 1 + } + out[strings.TrimSpace(k)] = v + } + return out +} + +// DepthOf returns the declared nesting level for a per-caller bucket, 1 by +// default. A bucket the author did not annotate is assumed flat, which is the +// common shape ({maskedKey: row}). +func (s *StateScope) DepthOf(key string) int { + if s == nil || s.Depth == nil { + return 1 + } + if d, ok := s.Depth[key]; ok { + return d + } + return 1 +} diff --git a/internal/lua/plugin_visibility_test.go b/internal/lua/plugin_visibility_test.go new file mode 100644 index 0000000..04ae804 --- /dev/null +++ b/internal/lua/plugin_visibility_test.go @@ -0,0 +1,193 @@ +package lua + +// Plugin visibility: a plugin page is not automatically public data. +// +// The billing page renders GLOBAL cost across every source and the quota page +// renders upstream balances. Both were served to any authenticated key because +// /api/ui-inject applied no role filter — measured, not theorised: a user-role +// key pulled the 18KB billing state and read the source names and the $8.86 +// total straight out of it. +// +// These tests pin the DENIALS. A visibility feature whose tests only assert that +// an admin still sees everything passes while the leak is wide open. + +import ( + "testing" +) + +func TestAudienceAllows(t *testing.T) { + cases := []struct { + want, role string + ok bool + why string + }{ + {"", "user", true, "unset means everyone"}, + {"all", "user", true, "explicit all"}, + {"admin", "admin", true, "exact match"}, + {"admin", "user", false, "admin-only excludes user"}, + {"user", "user", true, "user-only admits user"}, + {"user", "admin", false, "user-only excludes admin"}, + {"admin,user", "user", true, "list admits a listed role"}, + {" admin , user ", "user", true, "whitespace is not significant"}, + {"ADMIN", "user", false, "case is normalised"}, + {"*", "user", false, `"*" must NOT be a synonym for all`}, + } + for _, c := range cases { + if got := audienceAllows(c.want, c.role); got != c.ok { + t.Errorf("audienceAllows(%q, %q) = %v, want %v (%s)", + c.want, c.role, got, c.ok, c.why) + } + } +} + +func TestPluginVisibilityHidesPageFromUserRole(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + + // One admin-only plugin, one open plugin: the filter must drop exactly one + // and leave the other intact, or a plugin author has no way to ship a mixed + // set. + const adminOnly = ` +local P = {} +P.name = "adminonly" +P.version = "1.0.0" +P.exports = {} +P.ui = { visibility = "admin", page = { page_id = "secret", title = "Secret", mount = "
secret
" } } +return P +` + const open = ` +local P = {} +P.name = "openpage" +P.version = "1.0.0" +P.exports = {} +P.ui = { page = { page_id = "open", title = "Open", mount = "
open
" } } +return P +` + for name, src := range map[string]string{"adminonly": adminOnly, "openpage": open} { + if err := ps.LoadSource(name, src); err != nil { + t.Fatalf("load %s: %v", name, err) + } + } + + admin := ps.UIForRole("admin") + if len(admin.Pages) != 2 { + t.Errorf("admin should see both pages, got %d", len(admin.Pages)) + } + + user := ps.UIForRole("user") + for _, pg := range user.Pages { + if pg.PageID == "secret" { + t.Fatal("a user key can see the admin-only page — this is the leak") + } + } + if len(user.Pages) != 1 || user.Pages[0].PageID != "open" { + t.Fatalf("user should see exactly the open page, got %v", pageIDs(user.Pages)) + } +} + +// The operator must be able to impose visibility the plugin did not choose — +// that is the whole point of the override, since a bundled .lua cannot be edited +// without being overwritten on the next upgrade. +func TestPluginVisibilityOverrideBeatsDeclaration(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + const open = ` +local P = {} +P.name = "openpage" +P.version = "1.0.0" +P.exports = {} +P.ui = { page = { page_id = "open", title = "Open", mount = "
open
" } } +return P +` + if err := ps.LoadSource("openpage", open); err != nil { + t.Fatal(err) + } + if len(ps.UIForRole("user").Pages) != 1 { + t.Fatal("precondition: the page should start visible to user") + } + + ps.SetPluginVisibility("openpage", "admin") + if got := ps.UIForRole("user").Pages; len(got) != 0 { + t.Fatalf("the operator override was ignored: user still sees %v", pageIDs(got)) + } + if ps.VisibilityOverride("openpage") != "admin" { + t.Error("the override should be reportable so /api/plugins can show it") + } + + // Clearing restores the plugin's own declaration. + ps.SetPluginVisibility("openpage", "") + if got := ps.UIForRole("user").Pages; len(got) != 1 { + t.Fatalf("clearing the override should restore visibility, got %v", pageIDs(got)) + } +} + +// A page may narrow visibility below the plugin default, so one plugin can ship +// a public summary beside an admin-only detail page. +func TestPluginPageCanNarrowBelowPluginDefault(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + const mixed = ` +local P = {} +P.name = "mixed" +P.version = "1.0.0" +P.exports = {} +P.ui = { + pages = { + { page_id = "summary", title = "Summary", mount = "
s
" }, + { page_id = "details", title = "Details", mount = "
d
", audience = "admin" }, + }, +} +return P +` + if err := ps.LoadSource("mixed", mixed); err != nil { + t.Fatal(err) + } + user := ps.UIForRole("user") + if len(user.Pages) != 1 || user.Pages[0].PageID != "summary" { + t.Fatalf("user should see only the summary, got %v", pageIDs(user.Pages)) + } + if len(ps.UIForRole("admin").Pages) != 2 { + t.Error("admin should see both pages") + } +} + +func TestPluginStateVisibleTo(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + const adminOnly = ` +local P = {} +P.name = "adminonly" +P.version = "1.0.0" +P.exports = {} +P.state = { secret = "x" } +P.ui = { visibility = "admin", page = { page_id = "s", title = "S", mount = "
" } } +return P +` + if err := ps.LoadSource("adminonly", adminOnly); err != nil { + t.Fatal(err) + } + // The data has to follow the page: a hidden UI with an open state endpoint is + // the same leak with one more step. + if ps.StateVisibleTo("adminonly", "user") { + t.Error("user must not read an admin-only plugin's state") + } + if !ps.StateVisibleTo("adminonly", "admin") { + t.Error("admin must still read it") + } + // Unknown plugin: unchanged behaviour, so a widget that is not installed does + // not start answering 403. + if !ps.StateVisibleTo("nosuchplugin", "user") { + t.Error("an unknown plugin name must not become forbidden; that would change " + + "behaviour for plugins that simply are not installed") + } +} + +func pageIDs(pages []*UIPage) []string { + out := make([]string, 0, len(pages)) + for _, p := range pages { + if p != nil { + out = append(out, p.PageID) + } + } + return out +} \ No newline at end of file diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index a2f6b93..7712844 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -126,6 +126,13 @@ type UIExtension struct { // Elements are snippets injected into EXISTING pages, keyed by target page // id (e.g. "status", "keys"). Order within a target is plugin load order. Elements []UIElement `json:"elements,omitempty"` + // Visibility is the plugin-wide default audience, applied to every page and + // element this plugin contributes. A per-page Audience narrows it further. + Visibility string `json:"visibility,omitempty"` + // StateScope splits the plugin's published state into global and per-caller + // buckets, for a plugin that is readable by more than one role but must not + // hand one caller another's data. + StateScope *StateScope `json:"state_scope,omitempty"` } // UIPage is a plugin-provided page. @@ -134,6 +141,20 @@ type UIPage struct { Title string `json:"title"` // sidebar label Icon string `json:"icon"` // optional inline SVG or short glyph Order int `json:"order"` // sidebar sort key (default 100) + // Audience limits which API-key roles may see this page. "" and "all" mean + // every authenticated role; "admin" hides it from user keys; a comma list + // ("admin,user") names the roles allowed. + // + // This exists because a plugin page is not automatically public data. The + // billing page shows GLOBAL cost across every source, and the quota page + // shows upstream balances — neither belongs to whoever happens to hold a + // user key, yet both were served to them because /api/ui-inject applied no + // role filter at all. + // + // The plugin declares its own default and the gateway may override it (see + // SetPluginVisibility), because the plugin author cannot know the operator's + // policy but the operator must be able to impose one. + Audience string `json:"audience,omitempty"` // Mount is the page body. It may contain -]==], - }, -} - --- exports is what makes collect_account reachable from the plugin's own page. --- Without this list the call endpoint refuses it: the endpoint is an HTTP --- surface into the Lua state, so "which functions may it reach" has to be the --- plugin's explicit answer rather than a default. -PLUGIN.exports = { "collect_account" } - --- collect_account gathers one account's report. It returns the report TABLE (not --- a string) so the page receives structured numbers and booleans instead of --- having to parse anything back out of a formatted blob. -PLUGIN.collect_account = function(acct) - if type(acct) ~= "table" then return nil, "expected account table" end - if acct.key == nil or acct.key == "" then - return { name = tostring(acct.name or ""), failures = { "missing api key" }, - auth_failed = true } - end - local rep = collectAccount({ - name = acct.name, label = acct.label, key = acct.key, - }) - return rep, nil -end - -return PLUGIN \ No newline at end of file diff --git a/internal/lua/plugins/quota.lua b/internal/lua/plugins/quota.lua index 532edba..c72ce47 100644 --- a/internal/lua/plugins/quota.lua +++ b/internal/lua/plugins/quota.lua @@ -484,6 +484,11 @@ end -- ---------------------------------------------------------------- page PLUGIN.ui = { + -- Every row names an upstream host and what it has spent. That is deployment + -- information, not per-caller information, so it is admin-only by default; an + -- operator who wants a shared read-only view can widen it from config without + -- editing this file. + visibility = "admin", page = { page_id = "quota", title = "Quota",