feat(插件代理): 同源反向代理 —— VNC 不再暴露额外端口

问题:登录返回的 view_url 是 http://127.0.0.1:60100/vnc.html,WebUI 直接把它
嵌进 iframe。远程运维打开必白屏 —— 127.0.0.1 在客户端指向客户端自己,且
websockify/x11vnc 只绑回环,换服务器 IP 也连不上(实测 LAN IP 访问 000)。

★ 教训:只验证"服务端截图有画面"就宣布 VNC 可用是不充分的。四层链路里
①X 渲染 ②noVNC 客户端渲染 一直是好的,白屏真因在第④层「客户端如何拿到地址」。

内核新增通用「插件前缀代理」能力(不是为 channels 写的专用反向代理):
- plugin.http 支持 prefix = true:声明前缀路由,承接所有子路径
- handler 返回 { proxy = "http://127.0.0.1:PORT" },内核每次请求都问一遍
  ⇒ 会话停止/过期后插件不再给目标,旧地址自然失效(撤销点唯一)
- 鉴权与角色门禁复用现有链路,无旁路
- statusRecorder 增加 Unwrap(),让 Hijack 穿透访问日志中间件(否则 WebSocket 升级失败)
- 目标只允许带显式端口的**字面回环 IP**(用户拍板方案 A):拒绝 https、localhost
  主机名、无端口、带 userinfo/path/query;理由是主机名可能被 hosts/DNS 改写
- 拒绝跨站 Origin(WebSocket 带环境 Cookie,否则第三方网页可借管理员会话开隧道)
- 不向插件后端转发 Authorization/Cookie/api_key

channels 接入:
- 新增 channels/browser/proxy/:session(prefix=true)
- 会话代号 = seq-随机8位,防猜到活跃会话
- view_url 改为网关同源路径,noVNC 的 WebSocket 走同前缀 /websockify
- 会话过期即回收浏览器栈并返回 404

判据(每条咬真实外部效应):
- 静态资源转发
- 真实 HTTP 101 升级 + 双向数据回传(只断言静态转发不够 —— 升级是白屏真考点)
- 网关凭据不泄漏(在服务端断言请求头)
- 非法目标全拒(公网/localhost/https/无端口/带 path、query、userinfo)
- 跨站 Origin 403
- 未鉴权 401、过期会话 404、非子路径 404

回归:go test -tags luajit ./... 9 包全绿
This commit is contained in:
JianFeeeee
2026-10-15 14:57:13 +08:00
parent 850920155a
commit 7a8d97001c
9 changed files with 276 additions and 9 deletions

View File

@ -53,8 +53,8 @@ func TestChannelsVncStackEndToEnd(t *testing.T) {
t.Fatalf("浏览器栈未能启动: %s", e)
}
view, _ := m["view_url"].(string)
if !strings.Contains(view, "vnc.html") {
t.Fatalf("应给出 noVNC 内嵌地址, 实得 %q", view)
if !strings.HasPrefix(view, "/api/plugins/http/channels/browser/proxy/") || !strings.Contains(view, "vnc.html") {
t.Fatalf("应给出同源 noVNC 内嵌地址,不能让远程客户端连服务器回环端口, 实得 %q", view)
}
sess := m["session"].(map[string]interface{})
if sess["cdp_port"] == nil {

View File

@ -55,6 +55,15 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
for i := range matches {
h := matches[i]
p, ok := matchHandlerPath(h.Path, rest)
if h.Prefix {
pattern := strings.Trim(h.Path, "/")
parts := strings.Split(strings.Trim(rest, "/"), "/")
n := len(strings.Split(pattern, "/"))
ok = len(parts) >= n
if ok {
p, ok = matchHandlerPath(pattern, strings.Join(parts[:n], "/"))
}
}
if !ok {
continue
}
@ -116,6 +125,16 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
return
}
if resp.Proxy != "" {
if !chosen.Prefix {
writeError(w, http.StatusBadGateway, "plugin_error", "proxy response requires prefix=true")
return
}
parts := strings.Split(strings.Trim(rest, "/"), "/")
n := len(strings.Split(strings.Trim(chosen.Path, "/"), "/"))
g.servePluginProxy(w, r, resp.Proxy, pluginHTTPPrefix+strings.Join(parts[:n], "/"))
return
}
lua.WriteHandlerResponse(w, resp)
}

View File

@ -0,0 +1,54 @@
package gateway
import (
"net"
"net/http"
"net/http/httputil"
"net/url"
"strings"
)
// servePluginProxy forwards a plugin-owned prefix to an explicit loopback
// origin. No destination or path may be supplied by the client. Authentication
// and endpoint authorization have already completed before this is called.
func (g *Gateway) servePluginProxy(w http.ResponseWriter, r *http.Request, target, prefix string) {
u, err := url.Parse(target)
if err != nil || u.Scheme != "http" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
writeError(w, http.StatusBadGateway, "plugin_proxy", "proxy must name a loopback HTTP origin")
return
}
ip := net.ParseIP(u.Hostname())
if ip == nil || !ip.IsLoopback() || u.Port() == "" {
writeError(w, http.StatusBadGateway, "plugin_proxy", "proxy requires a literal loopback address and port")
return
}
// WebSocket requests are browser-initiated and carry ambient cookies. Do not
// allow a foreign website to use an administrator's session to open a tunnel.
if origin := r.Header.Get("Origin"); origin != "" {
o, e := url.Parse(origin)
if e != nil || !strings.EqualFold(o.Host, r.Host) {
writeError(w, http.StatusForbidden, "forbidden", "cross-origin proxy request refused")
return
}
}
proxy := httputil.NewSingleHostReverseProxy(u)
director := proxy.Director
proxy.Director = func(out *http.Request) {
director(out)
out.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
if out.URL.Path == "" {
out.URL.Path = "/"
}
out.URL.RawPath = ""
out.Host = u.Host
out.Header.Del("Authorization")
out.Header.Del("Cookie")
q := out.URL.Query()
q.Del("api_key")
out.URL.RawQuery = q.Encode()
}
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
writeError(w, http.StatusBadGateway, "plugin_proxy", "plugin service unavailable")
}
proxy.ServeHTTP(w, r)
}

View File

@ -0,0 +1,66 @@
package gateway
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestPluginProxyRejectsUnsafeTargetsAndOrigin(t *testing.T) {
g, _ := gatewayWithSpy(t)
for _, target := range []string{"http://example.com:80", "http://localhost:80", "https://127.0.0.1:80", "http://127.0.0.1", "http://u:p@127.0.0.1:80", "http://127.0.0.1:80/path", "http://127.0.0.1:80/?x=1"} {
rr := httptest.NewRecorder()
g.servePluginProxy(rr, httptest.NewRequest("GET", "http://gateway/api/plugins/http/relay/s/live.js", nil), target, "/api/plugins/http/relay/s")
if rr.Code != 502 {
t.Errorf("unsafe target %q accepted: %d", target, rr.Code)
}
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "http://gateway/api/plugins/http/relay/s/websockify", nil)
req.Header.Set("Origin", "https://evil.example")
g.servePluginProxy(rr, req, "http://127.0.0.1:12345", "/api/plugins/http/relay/s")
if rr.Code != 403 {
t.Fatalf("foreign origin accepted: %d", rr.Code)
}
}
func TestPluginPrefixProxyAuthorizationAndRevocation(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("denied request reached backend") }))
defer backend.Close()
g, _ := gatewayWithSpy(t)
src := `local p={name="relaydeny",http={{path="relaydeny/:session",prefix=true,roles={"admin"},handler="proxy"}}}
function p.proxy(req) return {status=404,body="expired session"} end
return p`
if err := g.core.Plugins().LoadSource("relaydeny", src); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(g.Handler())
defer server.Close()
for _, tc := range []struct {
path, key string
status int
}{
{"/api/plugins/http/relaydeny/expired/vnc.html", "", 401},
{"/api/plugins/http/relaydeny/expired/vnc.html", "sk-test", 404},
{"/api/plugins/http/relaydeny-not-a-child/expired/vnc.html", "sk-test", 404},
} {
req, _ := http.NewRequest("GET", server.URL+tc.path, nil)
if tc.key != "" {
req.Header.Set("Authorization", "Bearer "+tc.key)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != tc.status {
t.Fatalf("%s got %d %s", tc.path, resp.StatusCode, b)
}
if tc.key != "" && strings.Contains(tc.path, "/expired/") && strings.Contains(tc.path, "/relaydeny/") && string(b) != "expired session" {
t.Fatalf("revocation response=%s", b)
}
}
}

View File

@ -0,0 +1,82 @@
package gateway
import (
"bufio"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Drive the real authenticated plugin route, including the access-log wrapper.
// A successful static request alone does not prove WebSocket can upgrade.
func TestPluginPrefixProxyHTTPAndUpgrade(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" || r.URL.Query().Get("api_key") != "" {
t.Error("gateway credentials leaked to plugin backend")
}
if r.URL.Path == "/websockify" {
c, b, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Error(err)
return
}
defer c.Close()
fmt.Fprint(b, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
b.Flush()
io.Copy(c, c)
return
}
fmt.Fprint(w, r.URL.Path+"?"+r.URL.RawQuery)
}))
defer backend.Close()
g, _ := gatewayWithSpy(t)
src := fmt.Sprintf(`local p={name="relay",http={{path="relay/:session",prefix=true,method="GET",roles={"admin"},handler="proxy"}}}
function p.proxy(req)
if req.params.session ~= "live" then return {status=404,body="expired"} end
return {proxy=%q}
end
return p`, backend.URL)
if err := g.core.Plugins().LoadSource("relay", src); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(g.Handler())
defer server.Close()
req, _ := http.NewRequest("GET", server.URL+"/api/plugins/http/relay/live/app/main.js?x=1&api_key=sk-test", nil)
req.Header.Set("Authorization", "Bearer sk-test")
req.AddCookie(&http.Cookie{Name: "gw_key", Value: "sk-test"})
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 || string(body) != "/app/main.js?x=1" {
t.Fatalf("HTTP proxy: %d %s", resp.StatusCode, body)
}
c, err := net.Dial("tcp", strings.TrimPrefix(server.URL, "http://"))
if err != nil {
t.Fatal(err)
}
defer c.Close()
fmt.Fprintf(c, "GET /api/plugins/http/relay/live/websockify HTTP/1.1\r\nHost: %s\r\nAuthorization: Bearer sk-test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", strings.TrimPrefix(server.URL, "http://"))
b := bufio.NewReader(c)
reply, err := http.ReadResponse(b, nil)
if err != nil {
t.Fatal(err)
}
if reply.StatusCode != 101 {
t.Fatalf("upgrade failed: %s", reply.Status)
}
c.Write([]byte("ping"))
got := make([]byte, 4)
if _, err = io.ReadFull(b, got); err != nil {
t.Fatal(err)
}
if string(got) != "ping" {
t.Fatalf("tunnel payload=%q", got)
}
}

View File

@ -187,6 +187,10 @@ type statusRecorder struct {
code int
}
// Unwrap preserves optional writer capabilities (notably Hijack for WebSocket)
// through the access-log middleware via http.ResponseController.
func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }
func (s *statusRecorder) WriteHeader(code int) {
if s.code == 0 {
s.code = code

View File

@ -100,6 +100,8 @@ type handlerRequest struct {
// handlerResponse is what a Lua handler returns.
type handlerResponse struct {
// Proxy is a loopback HTTP origin. The gateway owns streaming and upgrades.
Proxy string
Status int
Headers map[string]string
Body string
@ -180,6 +182,9 @@ func parseHandlerResponse(v interface{}) (*handlerResponse, error) {
return nil, fmt.Errorf("status must be a number, got %T", s)
}
}
if target, ok := m["proxy"].(string); ok {
out.Proxy = target
}
if b, ok := m["body"].(string); ok {
out.Body = b
}
@ -196,9 +201,9 @@ func parseHandlerResponse(v interface{}) (*handlerResponse, error) {
// 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 {
if out.Body == "" && out.JSON == nil && out.Headers == nil && out.Proxy == "" && len(m) == 0 {
return nil, fmt.Errorf("handler returned an empty table; return " +
"{status=…, body=…}, {status=…, json=…}, or {status=…} for no body")
"{status=…, body=…}, {status=…, json=…}, {status=…, proxy=…}, or {status=…} for no body")
}
return out, nil
}

View File

@ -2359,6 +2359,8 @@ type HTTPHandler struct {
// Path is the route suffix under /api/plugins/http/. It may contain ":name"
// segments, captured into request.params.
Path string `json:"path"`
// Prefix claims this path and its descendants for a streaming proxy handler.
Prefix bool `json:"prefix,omitempty"`
// Method is the HTTP method; "" or GET. Only GET/POST/PUT/DELETE/PATCH are
// accepted — an arbitrary verb would need a switch in the router for no gain.
Method string `json:"method,omitempty"`
@ -2483,7 +2485,7 @@ func readPluginHTTP(L *golua.State) []HTTPHandler {
continue
}
ctx.strictKeys(h, "http[]", "path", "method", "handler", "description",
"roles", "keys", "public")
"roles", "keys", "public", "prefix")
dh := HTTPHandler{
Path: decString(h, "path"),
Method: decString(h, "method"),
@ -2504,6 +2506,9 @@ func readPluginHTTP(L *golua.State) []HTTPHandler {
}
dh.Handler = name
}
if p, ok := h["prefix"].(bool); ok {
dh.Prefix = p
}
if p, ok := h["public"].(bool); ok {
dh.Public = p
}

View File

@ -4269,11 +4269,22 @@ local function spawnBrowser(start_url)
return nil, "浏览器 CDP 无应答(:" .. tostring(cdpPort) .. "):" .. tostring(why)
end
b.pids = pids
b.session = { display = disp, vnc = vport, ws = wport, cdp = cdpPort, pass = pass, seq = seq }
local host = (plugin.state.settings and plugin.state.settings.vnc_host) or "127.0.0.1"
-- 同源代理不再需要 vnc_host;后端仍只监听回环。
local sess = tostring(seq) .. "-" .. pass:sub(1, 8)
b.session = { display = disp, vnc = vport, ws = wport, cdp = cdpPort, pass = pass, seq = seq, sess = sess,
expires_at = (os.time and os.time() or 0) + 900 }
-- 代理入口:把 noVNC 视图路径经网关回传,而不是让客户端去连 VNC 端口。
-- 客户端可能来自另一台机器(127.0.0.1 指向客户端自己)且 websockify 只绑回环,
-- 所以只能由网关同端口转发;插件只提供回环目标与绝对路径,不处理传输。
local base = "/api/plugins/http/channels/browser/proxy/" .. sess
local proxyPath = base .. "/vnc.html?autoconnect=1&resize=scale&password=" .. pass
.. "&path=" .. urlencode(base:sub(2) .. "/websockify")
return {
view_url = ("http://%s:%d/vnc.html?autoconnect=1&host=%s&port=%d&password=%s"):format(
host, wport, host, wport, pass),
view_url = proxyPath,
proxy_path = proxyPath,
proxy_url = base .. "/vnc.html",
session_id = sess,
vnc_target = ("http://127.0.0.1:%d"):format(wport),
password = pass,
cdp_port = cdpPort,
display = disp,
@ -6251,6 +6262,9 @@ plugin.http = {
roles = { "admin" }, description = "浏览器栈依赖预检(缺什么、装哪个包)" },
{ path = "channels/browser/status", method = "GET", handler = "browser_status",
roles = { "admin" }, description = "服务端浏览器会话状态" },
{ path = "channels/browser/proxy/:session", method = "GET", prefix = true,
handler = "browser_proxy", roles = { "admin" },
description = "把会话的 noVNC 静态资源与 WebSocket 经网关同端口转发(不暴露 VNC 端口)" },
{ path = "channels/browser/collect", method = "POST", handler = "collect_credentials",
roles = { "admin" }, description = "从服务端浏览器取凭据(CDP)" },
{ path = "channels/browser/stop", method = "POST", handler = "stop_browser",
@ -7485,6 +7499,24 @@ function plugin.browser_status(req)
return { status = 200, json = { ok = true, running = s ~= nil, session = s } }
end
-- browser_proxy:前缀代理的每次请求入口。
-- 只回答「当前会话的回环目标」;回收后 session 为空,地址即失效(404),
-- 未运行的会话 id 也不会被转发(防目录穿越与跨会话探测)。
function plugin.browser_proxy(req)
sweepSessions()
local b = browserStack()
local s = b.session
local want = req and req.params and req.params.session or nil
if s and s.expires_at and s.expires_at <= os.time() then
reapBrowser()
s = nil
end
if not s or not s.ws or not s.sess or s.sess ~= want then
return { status = 404, json = { error = "没有这个浏览器会话" } }
end
return { proxy = ("http://127.0.0.1:%d"):format(s.ws) }
end
function plugin.inject_platforms(req)
local args = bodyOf(req)
if args.platforms == nil then args = req end