diff --git a/internal/core/channels_vnc_test.go b/internal/core/channels_vnc_test.go index 3f9e20e..f0b21b4 100644 --- a/internal/core/channels_vnc_test.go +++ b/internal/core/channels_vnc_test.go @@ -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 { diff --git a/internal/gateway/plugin_http_routes.go b/internal/gateway/plugin_http_routes.go index 4b43848..d4114cf 100644 --- a/internal/gateway/plugin_http_routes.go +++ b/internal/gateway/plugin_http_routes.go @@ -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) } diff --git a/internal/gateway/plugin_proxy.go b/internal/gateway/plugin_proxy.go new file mode 100644 index 0000000..93e2eff --- /dev/null +++ b/internal/gateway/plugin_proxy.go @@ -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) +} diff --git a/internal/gateway/plugin_proxy_security_test.go b/internal/gateway/plugin_proxy_security_test.go new file mode 100644 index 0000000..bfc2cc2 --- /dev/null +++ b/internal/gateway/plugin_proxy_security_test.go @@ -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) + } + } +} diff --git a/internal/gateway/plugin_proxy_test.go b/internal/gateway/plugin_proxy_test.go new file mode 100644 index 0000000..640aea9 --- /dev/null +++ b/internal/gateway/plugin_proxy_test.go @@ -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) + } +} diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 0dcc5f6..e5d7a93 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -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 diff --git a/internal/lua/plugin_http_handler.go b/internal/lua/plugin_http_handler.go index 859a115..bc69f32 100644 --- a/internal/lua/plugin_http_handler.go +++ b/internal/lua/plugin_http_handler.go @@ -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 } diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index 55cc278..a987f74 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -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 } diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index e73f8b9..1beba79 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -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