From f891275027ba7375c873ce975dec59e1095c404d Mon Sep 17 00:00:00 2001 From: chris lee Date: Fri, 9 Oct 2026 10:24:24 -0400 Subject: [PATCH 1/3] Add bounded fixed-upstream CDP proxy foundation for desktop guests --- docs/macos-desktop.md | 45 ++++++++++ lib/desktop/cdp.go | 158 ++++++++++++++++++++++++++++++++ lib/desktop/cdp_test.go | 195 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 398 insertions(+) create mode 100644 docs/macos-desktop.md create mode 100644 lib/desktop/cdp.go create mode 100644 lib/desktop/cdp_test.go diff --git a/docs/macos-desktop.md b/docs/macos-desktop.md new file mode 100644 index 000000000..9e7a94615 --- /dev/null +++ b/docs/macos-desktop.md @@ -0,0 +1,45 @@ +# macOS desktop and managed browser (in development) + +The macOS runtime does not need a host viewer to keep its VMM running. A desktop +session inside the guest, desktop capture/input permission, and browser readiness +are separate capabilities: none follows from VMM `Running` or root-agent readiness. +The experimental spike's host Cocoa viewer is not a supported viewer API. + +This branch introduces a transport-independent CDP proxy in `lib/desktop`. It is +not yet wired into the instance API and does not enable browser control on an +existing image. No desktop agent or TCC permissions are installed automatically. + +## CDP transport boundary + +`NewCDPProxy` supports GET discovery (`/json`, `/json/list`, `/json/version`, +`/json/protocol`) and browser/page debugger WebSockets. Its caller supplies a +fixed transport, strips the instance route prefix, and supplies a trusted, +instance-scoped public `ws`/`wss` base. Requests cannot select the upstream host, +port, path outside that allowlist, query, or arbitrary HTTP body. + +The proxy forwards only discovery/WebSocket handshake headers, removing API +credentials, cookies, origin, and arbitrary application/forwarding headers. +It rejects redirects and invalid/oversized discovery responses. Advertised +WebSocket URLs must point to the fixed browser loopback endpoint and are rewritten +to the instance-scoped public base. Unproxied DevTools frontend links and guest +cookies are removed. Hosting a DevTools frontend is not included. + +This component **does not authorize callers**. Before exposing it, API integration +must enforce instance-write authorization, resolved instance ownership, declared +desktop/browser capability, a running eligible guest, origin admission, and a +selected non-root desktop agent/session. Never derive the public base from an +untrusted Host or forwarding header. CDP grants full browser authority; the HTTP +path allowlist is not a CDP-command sandbox or a limit on websites visited. + +Browser launch/status are separate management operations, not arbitrary proxied +paths. Worker-specific debugger paths and `/json/new`/close/activate endpoints +remain unsupported; browser-level CDP clients may manage targets over WebSockets. + +## Validation status + +Synthetic tests exercise discovery rewriting, credential isolation, request +admission, fixed-upstream behavior, malicious/oversized responses, redirects, and +an actual proxied WebSocket round trip. They do not prove live guest provisioning, +vsock routing, authenticated API integration, session selection, reconnect after a +guest/browser crash, capture/input/TCC, viewer attachment, or viewer-detach behavior. +Those remain gates before this desktop/browser feature is ready for use. diff --git a/lib/desktop/cdp.go b/lib/desktop/cdp.go new file mode 100644 index 000000000..2eba5d1cf --- /dev/null +++ b/lib/desktop/cdp.go @@ -0,0 +1,158 @@ +// Package desktop contains transport-independent desktop guest interfaces. +package desktop + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httputil" + "net/url" + "strings" +) + +const maxDiscoveryBytes = 2 << 20 + +// NewCDPProxy forwards discovery and WebSocket traffic to a fixed browser endpoint. +// The caller owns instance authorization, origin checks, guest capability admission, +// and the transport (normally a connection to the selected desktop agent over vsock). +// publicBase must be a trusted, instance-scoped ws/wss URL, not a request Host header. +// Requests must have the instance route prefix removed before reaching this handler. +func NewCDPProxy(transport http.RoundTripper, publicBase string) (http.Handler, error) { + base, err := url.Parse(publicBase) + if err != nil || base == nil || (base.Scheme != "ws" && base.Scheme != "wss") || base.Host == "" || base.User != nil || base.RawQuery != "" || base.Fragment != "" || base.RawPath != "" || strings.HasSuffix(base.Path, "/") { + return nil, fmt.Errorf("invalid public CDP base") + } + if transport == nil { + return nil, fmt.Errorf("CDP transport required") + } + proxy := &httputil.ReverseProxy{ + Transport: transport, + Rewrite: func(p *httputil.ProxyRequest) { + p.Out.URL.Scheme = "http" + p.Out.URL.Host = "127.0.0.1:9222" + p.Out.Host = "127.0.0.1:9222" + p.Out.URL.RawQuery = "" + p.Out.URL.User = nil + p.Out.URL.Fragment = "" + p.Out.URL.Opaque = "" + // Do not forward API credentials or arbitrary application headers to the guest. + headers := make(http.Header) + for _, key := range []string{"Accept", "Connection", "Upgrade", "Sec-WebSocket-Key", "Sec-WebSocket-Version", "Sec-WebSocket-Protocol", "Sec-WebSocket-Extensions"} { + for _, value := range p.Out.Header.Values(key) { + headers.Add(key, value) + } + } + p.Out.Header = headers + }, + ModifyResponse: func(r *http.Response) error { + r.Header.Del("Set-Cookie") + if r.StatusCode >= 300 && r.StatusCode < 400 { + return fmt.Errorf("CDP redirects unsupported") + } + if r.StatusCode == http.StatusOK && discoveryPath(r.Request.URL.Path) { + return rewriteDiscovery(r, base.String()) + } + return nil + }, + ErrorHandler: func(w http.ResponseWriter, _ *http.Request, _ error) { + http.Error(w, "browser unavailable or invalid discovery response", http.StatusBadGateway) + }, + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.ContentLength != 0 || len(r.TransferEncoding) != 0 || r.URL.RawQuery != "" || r.URL.RawPath != "" || !(discoveryPath(r.URL.Path) || debuggerPath(r.URL.Path)) { + http.Error(w, "unsupported CDP request", http.StatusBadRequest) + return + } + if debuggerPath(r.URL.Path) && !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + http.Error(w, "WebSocket upgrade required", http.StatusBadRequest) + return + } + proxy.ServeHTTP(w, r) + }), nil +} + +func discoveryPath(path string) bool { + switch path { + case "/json", "/json/list", "/json/version", "/json/protocol": + return true + } + return false +} + +func debuggerPath(path string) bool { + parts := strings.Split(path, "/") + if len(parts) != 4 || parts[0] != "" || parts[1] != "devtools" || (parts[2] != "browser" && parts[2] != "page") || parts[3] == "" { + return false + } + for _, c := range parts[3] { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') { + return false + } + } + return true +} + +func rewriteDiscovery(r *http.Response, base string) error { + defer r.Body.Close() + data, err := io.ReadAll(io.LimitReader(r.Body, maxDiscoveryBytes+1)) + if err != nil { + return err + } + if len(data) > maxDiscoveryBytes { + return fmt.Errorf("CDP discovery too large") + } + var value any + if err := json.Unmarshal(data, &value); err != nil { + return err + } + rewrite := func(v any) error { + obj, ok := v.(map[string]any) + if !ok { + return fmt.Errorf("invalid CDP discovery object") + } + // These frontend links can embed an unproxied debugger address. Clients use the + // rewritten webSocketDebuggerUrl; hosting a DevTools frontend is separate. + delete(obj, "devtoolsFrontendUrl") + delete(obj, "devtoolsFrontendUrlCompat") + raw, exists := obj["webSocketDebuggerUrl"] + if !exists { + return nil + } + address, ok := raw.(string) + if !ok { + return fmt.Errorf("invalid debugger URL") + } + u, err := url.Parse(address) + if err != nil || u.Scheme != "ws" || u.Host != "127.0.0.1:9222" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" || !debuggerPath(u.Path) { + return fmt.Errorf("unexpected debugger URL") + } + obj["webSocketDebuggerUrl"] = base + u.Path + return nil + } + switch v := value.(type) { + case []any: + for _, obj := range v { + if err := rewrite(obj); err != nil { + return err + } + } + default: + if err := rewrite(v); err != nil { + return err + } + } + data, err = json.Marshal(value) + if err != nil { + return err + } + r.Body = io.NopCloser(bytes.NewReader(data)) + r.ContentLength = int64(len(data)) + r.Header.Del("Content-Length") + r.Header.Del("Content-Encoding") + r.Header.Del("ETag") + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Cache-Control", "no-store") + return nil +} diff --git a/lib/desktop/cdp_test.go b/lib/desktop/cdp_test.go new file mode 100644 index 000000000..5e8efc936 --- /dev/null +++ b/lib/desktop/cdp_test.go @@ -0,0 +1,195 @@ +package desktop + +import ( + "context" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func TestCDPDiscoveryAndHeaderIsolation(t *testing.T) { + for _, payload := range []string{ + `{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/devtools/browser/abc-123","devtoolsFrontendUrl":"unsafe"}`, + `[{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/devtools/page/ABC123"}]`, + } { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Host != "127.0.0.1:9222" || r.URL.Path != "/json/version" { + t.Errorf("unexpected upstream: %s %s", r.Host, r.URL) + } + for _, key := range []string{"Authorization", "Cookie", "Origin", "Proxy-Authorization", "X-API-Key", "X-Forwarded-Host", "X-Forwarded-For"} { + if r.Header.Get(key) != "" { + t.Errorf("credential/header forwarded: %s", key) + } + } + w.Header().Set("Set-Cookie", "guest-secret=yes") + _, _ = io.WriteString(w, payload) + })) + transport := testTransport(t, upstream) + handler, err := NewCDPProxy(transport, "wss://api.example/instances/id/cdp") + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("GET", "http://private:secret@attacker.example/json/version", nil) + for _, key := range []string{"Authorization", "Cookie", "Origin", "Proxy-Authorization", "X-API-Key", "X-Forwarded-Host", "X-Forwarded-For"} { + req.Header.Set(key, "secret") + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != 200 || !strings.Contains(w.Body.String(), "wss://api.example/instances/id/cdp/devtools/") { + t.Fatalf("discovery: %d %s", w.Code, w.Body) + } + if strings.Contains(w.Body.String(), "unsafe") || w.Header().Get("Set-Cookie") != "" || w.Header().Get("Cache-Control") != "no-store" { + t.Fatal("unsafe discovery response") + } + upstream.Close() + } +} + +func testTransport(t *testing.T, upstream *httptest.Server) *http.Transport { + t.Helper() + tr := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "tcp", upstream.Listener.Addr().String()) + }} + t.Cleanup(tr.CloseIdleConnections) + return tr +} + +func TestCDPRejectsUnsupportedRequestsBeforeDial(t *testing.T) { + tr := &http.Transport{DialContext: func(context.Context, string, string) (net.Conn, error) { + t.Fatal("unsupported request reached upstream") + return nil, nil + }} + h, err := NewCDPProxy(tr, "ws://api.example/instances/id/cdp") + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct{ method, path string }{ + {"POST", "/json/version"}, {"PUT", "/json/new"}, {"GET", "/json/new"}, {"GET", "/json/close/id"}, {"GET", "/status"}, {"GET", "/json/version?url=http://evil"}, {"GET", "/json%2fversion"}, {"GET", "/devtools/browser/../id"}, {"GET", "/devtools/browser/id"}, + } { + t.Run(tc.method+tc.path, func(t *testing.T) { + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(tc.method, "http://api.example"+tc.path, nil)) + if w.Code != 400 { + t.Fatalf("status %d", w.Code) + } + }) + } +} + +func TestCDPRejectsUntrustedDiscovery(t *testing.T) { + for _, payload := range []string{ + `{"webSocketDebuggerUrl":"ws://evil.example/devtools/browser/id"}`, + `{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/devtools/browser/id?token=secret"}`, + `{"webSocketDebuggerUrl":"ws://user@127.0.0.1:9222/devtools/browser/id"}`, + `{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/devtools/browser/id#fragment"}`, + `{"webSocketDebuggerUrl":42}`, `null`, `[42]`, `not json`, strings.Repeat(" ", maxDiscoveryBytes+1), + } { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, payload) })) + h, err := NewCDPProxy(testTransport(t, upstream), "ws://api.example/instances/id/cdp") + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest("GET", "http://api.example/json/version", nil)) + if w.Code != 502 { + t.Fatalf("invalid discovery accepted: %d", w.Code) + } + upstream.Close() + } +} + +func TestCDPRejectsRedirect(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "http://evil.example", 302) })) + defer upstream.Close() + h, err := NewCDPProxy(testTransport(t, upstream), "ws://api.example/instances/id/cdp") + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest("GET", "http://api.example/json/version", nil)) + if w.Code != 502 { + t.Fatalf("redirect status %d", w.Code) + } +} + +func TestCDPWebSocketRoundTrip(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/devtools/browser/abc-123" || r.Header.Get("Authorization") != "" { + t.Error("invalid WebSocket upstream request") + } + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + t.Error(err) + return + } + defer conn.Close() + kind, data, err := conn.ReadMessage() + if err == nil { + _ = conn.WriteMessage(kind, data) + } + })) + defer upstream.Close() + h, err := NewCDPProxy(testTransport(t, upstream), "ws://api.example/instances/id/cdp") + if err != nil { + t.Fatal(err) + } + proxy := httptest.NewServer(h) + defer proxy.Close() + headers := http.Header{"Authorization": []string{"Bearer private"}} + conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(proxy.URL, "http")+"/devtools/browser/abc-123", headers) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + if err := conn.WriteMessage(websocket.TextMessage, []byte(`{"id":1,"method":"Browser.getVersion"}`)); err != nil { + t.Fatal(err) + } + _, data, err := conn.ReadMessage() + if err != nil || string(data) != `{"id":1,"method":"Browser.getVersion"}` { + t.Fatalf("WebSocket round trip failed: %s %v", data, err) + } +} + +func TestCDPRejectsRequestBody(t *testing.T) { + tr := &http.Transport{DialContext: func(context.Context, string, string) (net.Conn, error) { + t.Fatal("request body reached upstream") + return nil, nil + }} + h, err := NewCDPProxy(tr, "ws://api.example/instances/id/cdp") + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest("GET", "http://api.example/json/version", strings.NewReader("private body"))) + if w.Code != 400 { + t.Fatalf("status %d", w.Code) + } +} + +func TestCDPConfigurationAndTargetPaths(t *testing.T) { + for _, base := range []string{"http://api.example/cdp", "ws://user@api.example/cdp", "ws://api.example/cdp?token=secret", "ws://api.example/cdp#fragment", "ws://api.example/cdp/", "ws:///cdp"} { + if _, err := NewCDPProxy(http.DefaultTransport, base); err == nil { + t.Fatalf("accepted base %s", base) + } + } + if _, err := NewCDPProxy(nil, "ws://api.example/cdp"); err == nil { + t.Fatal("nil transport accepted") + } + for _, path := range []string{"/devtools/browser/abc-123", "/devtools/page/ABC123"} { + if !debuggerPath(path) { + t.Fatalf("valid path rejected: %s", path) + } + } + for _, path := range []string{"/devtools/browser/", "/devtools/browser/..", "/devtools/page/id/extra", "/devtools/worker/id", "/devtools/page/id.token"} { + if debuggerPath(path) { + t.Fatalf("unsafe path accepted: %s", path) + } + } +} From 70404afec42718a89f9f4052e8215b6897af9a79 Mon Sep 17 00:00:00 2001 From: chris lee Date: Fri, 9 Oct 2026 11:11:37 -0400 Subject: [PATCH 2/3] Add macOS desktop-role admission and managed Chrome APIs --- cmd/api/api/api.go | 3 + cmd/api/api/cdp.go | 130 ++++++++++++++ cmd/api/api/cdp_test.go | 149 +++++++++++++++ cmd/api/api/cdp_websocket_test.go | 90 ++++++++++ cmd/api/config/config.go | 21 ++- cmd/api/config/desktop_test.go | 23 +++ cmd/api/main.go | 14 ++ docs/examples/macos-desktop-agent.plist | 16 ++ docs/macos-desktop.md | 167 +++++++++++++---- lib/desktop/backend_darwin.go | 219 +++++++++++++++++++++++ lib/desktop/backend_darwin_test.go | 68 +++++++ lib/desktop/cdp.go | 24 ++- lib/desktop/cdp_test.go | 20 ++- lib/desktop/origin.go | 24 +++ lib/desktop/service.go | 179 ++++++++++++++++++ lib/desktop/service_test.go | 209 +++++++++++++++++++++ lib/images/types.go | 3 + lib/scopes/routes_test.go | 6 +- lib/scopes/scopes.go | 6 +- lib/system/guest_agent/desktop_darwin.go | 36 ++++ lib/system/guest_agent/desktop_other.go | 7 + lib/system/guest_agent/main.go | 13 ++ 22 files changed, 1372 insertions(+), 55 deletions(-) create mode 100644 cmd/api/api/cdp.go create mode 100644 cmd/api/api/cdp_test.go create mode 100644 cmd/api/api/cdp_websocket_test.go create mode 100644 cmd/api/config/desktop_test.go create mode 100644 docs/examples/macos-desktop-agent.plist create mode 100644 lib/desktop/backend_darwin.go create mode 100644 lib/desktop/backend_darwin_test.go create mode 100644 lib/desktop/origin.go create mode 100644 lib/desktop/service.go create mode 100644 lib/desktop/service_test.go create mode 100644 lib/system/guest_agent/desktop_darwin.go create mode 100644 lib/system/guest_agent/desktop_other.go diff --git a/cmd/api/api/api.go b/cmd/api/api/api.go index 8a2c72db0..c52b861a7 100644 --- a/cmd/api/api/api.go +++ b/cmd/api/api/api.go @@ -16,10 +16,13 @@ import ( "github.com/kernel/hypeman/lib/resources" "github.com/kernel/hypeman/lib/vm_metrics" "github.com/kernel/hypeman/lib/volumes" + "sync" ) // ApiService implements the oapi.StrictServerInterface type ApiService struct { + desktopSlotsOnce sync.Once + desktopSlots chan struct{} // Bounds active desktop requests/upgraded sessions across instances. Config *config.Config ImageManager images.Manager InstanceManager instances.Manager diff --git a/cmd/api/api/cdp.go b/cmd/api/api/cdp.go new file mode 100644 index 000000000..44e70bc74 --- /dev/null +++ b/cmd/api/api/cdp.go @@ -0,0 +1,130 @@ +package api + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/kernel/hypeman/lib/desktop" + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/instances" + mw "github.com/kernel/hypeman/lib/middleware" +) + +// CDPHandler requires instance-write authentication and resolution in the router. +func (s *ApiService) CDPHandler(w http.ResponseWriter, r *http.Request) { + s.serveDesktop(w, r, newDesktopTransport) +} + +func newDesktopTransport(inst *instances.Instance) (*http.Transport, error) { + dialer, err := hypervisor.NewVsockDialer(hypervisor.Type(inst.HypervisorType), inst.VsockSocket, inst.VsockCID) + if err != nil { + return nil, err + } + return &http.Transport{DisableKeepAlives: true, ResponseHeaderTimeout: 20 * time.Second, DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return dialer.DialVsock(ctx, int(desktop.AgentPort)) + }}, nil +} + +func (s *ApiService) serveDesktop(w http.ResponseWriter, r *http.Request, transportFor func(*instances.Instance) (*http.Transport, error)) { + inst := mw.GetResolvedInstance[instances.Instance](r.Context()) + if inst == nil { + http.Error(w, "instance not resolved", 500) + return + } + if inst.MacOS == nil || hypervisor.Type(inst.HypervisorType) != hypervisor.TypeVZ || inst.MacOS.DesktopAgentUID == 0 || inst.SkipGuestAgent { + http.Error(w, "instance does not declare an enabled macOS desktop agent", 501) + return + } + if inst.State != instances.StateRunning { + http.Error(w, "instance must be running", 409) + return + } + if s.Config == nil || s.Config.MacOSDesktopOrigin == "" { + http.Error(w, "desktop API origin not configured", 503) + return + } + origin, err := desktop.ParseOrigin(s.Config.MacOSDesktopOrigin) + if err != nil { + http.Error(w, "invalid desktop API origin", 503) + return + } + if origins := r.Header.Values("Origin"); len(origins) > 1 || len(origins) == 1 && !desktop.SameOrigin(origins[0], origin) { + http.Error(w, "cross-origin desktop access rejected", 403) + return + } + selector := chi.URLParam(r, "id") + if selector == "" { + selector = inst.Id + } + prefix := "/instances/" + selector + "/cdp" + if !strings.HasPrefix(r.URL.Path, prefix+"/") || r.URL.RawPath != "" || r.URL.RawQuery != "" || r.URL.ForceQuery || r.ContentLength != 0 || len(r.TransferEncoding) != 0 { + http.Error(w, "invalid desktop request", 400) + return + } + path := strings.TrimPrefix(r.URL.Path, prefix) + status := path == "/status" && r.Method == http.MethodGet + start := path == "/start" && r.Method == http.MethodPost + request := r.Clone(r.Context()) + request.URL.Path = path + if !status && !start { + if err := desktop.ValidateCDPRequest(request); err != nil { + http.Error(w, "unsupported CDP request", 400) + return + } + } + s.desktopSlotsOnce.Do(func() { s.desktopSlots = make(chan struct{}, desktop.MaxSessions) }) + select { + case s.desktopSlots <- struct{}{}: + defer func() { <-s.desktopSlots }() + default: + http.Error(w, "desktop session limit reached", http.StatusTooManyRequests) + return + } + transport, err := transportFor(inst) + if err != nil { + http.Error(w, "desktop transport unavailable", 503) + return + } + defer transport.CloseIdleConnections() + timeout := 3 * time.Second + if start { + timeout = 20 * time.Second + } + ctx, cancel := context.WithTimeout(r.Context(), timeout) + state, err := desktop.Probe(ctx, transport, inst.MacOS.DesktopAgentUID, start) + cancel() + if err != nil { + http.Error(w, "selected desktop agent unavailable or incompatible", 503) + return + } + if status || start { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(struct { + desktop.Status + DesktopReady bool `json:"desktop_ready"` + }{state, state.SessionReady()}) + return + } + if !state.BrowserReady { + http.Error(w, "selected desktop session or managed browser not ready", 409) + return + } + scheme := "ws" + if origin.Scheme == "https" { + scheme = "wss" + } + base := scheme + "://" + origin.Host + "/instances/" + url.PathEscape(inst.Id) + "/cdp" + proxy, err := desktop.NewCDPProxy(transport, base) + if err != nil { + http.Error(w, "invalid instance CDP configuration", 503) + return + } + proxy.ServeHTTP(w, request) +} diff --git a/cmd/api/api/cdp_test.go b/cmd/api/api/cdp_test.go new file mode 100644 index 000000000..214a65941 --- /dev/null +++ b/cmd/api/api/cdp_test.go @@ -0,0 +1,149 @@ +package api + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/golang-jwt/jwt/v5" + "github.com/kernel/hypeman/cmd/api/config" + "github.com/kernel/hypeman/lib/desktop" + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/instances" + mw "github.com/kernel/hypeman/lib/middleware" + "github.com/kernel/hypeman/lib/scopes" + "github.com/stretchr/testify/require" +) + +type desktopInstances struct { + instances.Manager + inst *instances.Instance + lookups atomic.Int32 +} + +func (m *desktopInstances) GetInstance(_ context.Context, name string) (*instances.Instance, error) { + m.lookups.Add(1) + if name != "test" && name != "alias" { + return nil, instances.ErrNotFound + } + return m.inst, nil +} +func desktopInstance() *instances.Instance { + return &instances.Instance{StoredMetadata: instances.StoredMetadata{Id: "test", HypervisorType: "vz", MacOS: &images.MacOSImage{DesktopAgentUID: 501}}, State: instances.StateRunning} +} +func desktopToken(t *testing.T, permission string) string { + t.Helper() + s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": "test", "exp": time.Now().Add(time.Hour).Unix(), "permissions": []string{permission}}).SignedString([]byte("synthetic-test-secret")) + require.NoError(t, err) + return s +} +func desktopRouter(s *ApiService, factory func(*instances.Instance) (*http.Transport, error)) http.Handler { + r := chi.NewRouter() + sub := r.With(mw.JwtAuth("synthetic-test-secret"), scopes.RequireScope(scopes.InstanceWrite), mw.ResolveResource(s.NewResolvers(), ResolverErrorResponder)) + h := func(w http.ResponseWriter, r *http.Request) { s.serveDesktop(w, r, factory) } + sub.Get("/instances/{id}/cdp/*", h) + sub.Post("/instances/{id}/cdp/start", h) + return r +} + +func TestDesktopAdmissionBeforeDial(t *testing.T) { + for _, tc := range []struct { + name string + change func(*instances.Instance, *config.Config, *http.Request) + code int + }{ + {"missing token", func(_ *instances.Instance, _ *config.Config, r *http.Request) { r.Header.Del("Authorization") }, 401}, + {"read-only", func(_ *instances.Instance, _ *config.Config, r *http.Request) { + r.Header.Set("Authorization", "Bearer "+desktopToken(t, string(scopes.InstanceRead))) + }, 403}, + {"undeclared", func(i *instances.Instance, _ *config.Config, _ *http.Request) { i.MacOS.DesktopAgentUID = 0 }, 501}, + {"disabled", func(i *instances.Instance, _ *config.Config, _ *http.Request) { i.SkipGuestAgent = true }, 501}, + {"stopped", func(i *instances.Instance, _ *config.Config, _ *http.Request) { i.State = instances.StateStopped }, 409}, + {"unknown instance", func(_ *instances.Instance, _ *config.Config, r *http.Request) { + r.URL.Path = "/instances/other/cdp/json/version" + }, 404}, + {"no origin config", func(_ *instances.Instance, c *config.Config, _ *http.Request) { c.MacOSDesktopOrigin = "" }, 503}, + {"wrong origin", func(_ *instances.Instance, _ *config.Config, r *http.Request) { + r.Header.Set("Origin", "https://evil.example") + }, 403}, + {"multiple origins", func(_ *instances.Instance, _ *config.Config, r *http.Request) { + r.Header.Add("Origin", "https://api.example") + r.Header.Add("Origin", "https://api.example") + }, 403}, + {"unsafe discovery", func(_ *instances.Instance, _ *config.Config, r *http.Request) { + r.URL.Path = "/instances/test/cdp/json/new" + }, 400}, + {"query", func(_ *instances.Instance, _ *config.Config, r *http.Request) { r.URL.RawQuery = "token=secret" }, 400}, + } { + t.Run(tc.name, func(t *testing.T) { + i := desktopInstance() + c := &config.Config{MacOSDesktopOrigin: "https://api.example"} + m := &desktopInstances{inst: i} + s := &ApiService{Config: c, InstanceManager: m} + r := httptest.NewRequest("GET", "http://spoof.example/instances/test/cdp/json/version", nil) + r.Header.Set("Authorization", "Bearer "+desktopToken(t, string(scopes.InstanceWrite))) + tc.change(i, c, r) + w := httptest.NewRecorder() + desktopRouter(s, func(*instances.Instance) (*http.Transport, error) { t.Fatal("dial before admission"); return nil, nil }).ServeHTTP(w, r) + require.Equal(t, tc.code, w.Code, w.Body.String()) + if tc.code == 401 || tc.name == "read-only" { + require.Zero(t, m.lookups.Load()) + } + }) + } +} + +func TestDesktopHandshakeAndDiscovery(t *testing.T) { + for _, tc := range []struct { + name string + uid uint32 + ready bool + code int + }{{"ready", 501, true, 200}, {"wrong desktop user", 502, true, 503}, {"root desktop user", 0, true, 503}, {"browser not ready", 501, false, 409}} { + t.Run(tc.name, func(t *testing.T) { + var calls atomic.Int32 + guest := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + require.Empty(t, r.Header.Get("Authorization")) + require.Empty(t, r.Header.Get("Cookie")) + require.Empty(t, r.Header.Get("Forwarded")) + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/status" { + _ = json.NewEncoder(w).Encode(desktop.Status{Version: 1, OS: "darwin", Architecture: "arm64", UID: tc.uid, ConsoleUID: tc.uid, GUISession: true, BrowserManaged: tc.ready, BrowserReady: tc.ready}) + return + } + require.Equal(t, "/json/version", r.URL.Path) + _, _ = w.Write([]byte(`{"Browser":"Chrome/test","webSocketDebuggerUrl":"ws://127.0.0.1:9222/devtools/browser/test-id"}`)) + })) + defer guest.Close() + m := &desktopInstances{inst: desktopInstance()} + s := &ApiService{Config: &config.Config{MacOSDesktopOrigin: "https://api.example"}, InstanceManager: m} + factory := func(*instances.Instance) (*http.Transport, error) { + return &http.Transport{DisableKeepAlives: true, DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "tcp", strings.TrimPrefix(guest.URL, "http://")) + }}, nil + } + r := httptest.NewRequest("GET", "http://spoof.example/instances/alias/cdp/json/version", nil) + r.Header.Set("Authorization", "Bearer "+desktopToken(t, string(scopes.InstanceWrite))) + r.Header.Set("Cookie", "secret=value") + r.Header.Set("Forwarded", "host=evil.example") + r.Header.Set("Origin", "https://api.example") + w := httptest.NewRecorder() + desktopRouter(s, factory).ServeHTTP(w, r) + require.Equal(t, tc.code, w.Code, w.Body.String()) + if tc.code == 200 { + require.Contains(t, w.Body.String(), "wss://api.example/instances/test/cdp/devtools/browser/test-id") + require.Equal(t, int32(2), calls.Load()) + } else { + require.Equal(t, int32(1), calls.Load()) + } + }) + } +} diff --git a/cmd/api/api/cdp_websocket_test.go b/cmd/api/api/cdp_websocket_test.go new file mode 100644 index 000000000..d0cdcd3cc --- /dev/null +++ b/cmd/api/api/cdp_websocket_test.go @@ -0,0 +1,90 @@ +package api + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/kernel/hypeman/cmd/api/config" + "github.com/kernel/hypeman/lib/desktop" + "github.com/kernel/hypeman/lib/instances" + "github.com/kernel/hypeman/lib/scopes" + "github.com/stretchr/testify/require" +) + +func TestDesktopWebsocketReconnect(t *testing.T) { + guest := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" || r.Header.Get("Origin") != "" { + t.Error("guest received API authority") + http.Error(w, "unexpected headers", 400) + return + } + if r.URL.Path == "/status" { + _ = json.NewEncoder(w).Encode(desktop.Status{Version: 1, OS: "darwin", Architecture: "arm64", UID: 501, ConsoleUID: 501, GUISession: true, BrowserManaged: true, BrowserReady: true}) + return + } + if r.URL.Path != "/devtools/browser/test-id" { + t.Error("unexpected browser route") + http.Error(w, "bad route", 400) + return + } + upgrader := websocket.Upgrader{} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Error(err) + return + } + defer conn.Close() + kind, data, err := conn.ReadMessage() + if err != nil { + t.Error(err) + return + } + if err := conn.WriteMessage(kind, data); err != nil { + t.Error(err) + } + })) + defer guest.Close() + s := &ApiService{Config: &config.Config{MacOSDesktopOrigin: "https://api.example"}, InstanceManager: &desktopInstances{inst: desktopInstance()}} + factory := func(*instances.Instance) (*http.Transport, error) { + return &http.Transport{DisableKeepAlives: true, DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "tcp", strings.TrimPrefix(guest.URL, "http://")) + }}, nil + } + api := httptest.NewServer(desktopRouter(s, factory)) + defer api.Close() + for i := 0; i < 2; i++ { + headers := http.Header{"Authorization": {"Bearer " + desktopToken(t, string(scopes.InstanceWrite))}, "Cookie": {"secret=value"}, "Origin": {"https://api.example"}} + c, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(api.URL, "http")+"/instances/test/cdp/devtools/browser/test-id", headers) + require.NoError(t, err) + require.NoError(t, c.WriteMessage(websocket.TextMessage, []byte(`{"id":1,"method":"Browser.getVersion"}`))) + _, data, err := c.ReadMessage() + require.NoError(t, err) + require.Contains(t, string(data), "Browser.getVersion") + require.NoError(t, c.Close()) + require.Eventually(t, func() bool { return len(s.desktopSlots) == 0 }, 2*time.Second, 10*time.Millisecond) + } +} + +func TestDesktopActiveSessionLimit(t *testing.T) { + s := &ApiService{Config: &config.Config{MacOSDesktopOrigin: "https://api.example"}, InstanceManager: &desktopInstances{inst: desktopInstance()}} + s.desktopSlotsOnce.Do(func() { s.desktopSlots = make(chan struct{}, desktop.MaxSessions) }) + for i := 0; i < desktop.MaxSessions; i++ { + s.desktopSlots <- struct{}{} + } + r := httptest.NewRequest("GET", "http://api.example/instances/test/cdp/status", nil) + r.Header.Set("Authorization", "Bearer "+desktopToken(t, string(scopes.InstanceWrite))) + w := httptest.NewRecorder() + desktopRouter(s, func(*instances.Instance) (*http.Transport, error) { + t.Fatal("dial above session limit") + return nil, nil + }).ServeHTTP(w, r) + require.Equal(t, http.StatusTooManyRequests, w.Code) + require.Len(t, s.desktopSlots, desktop.MaxSessions) +} diff --git a/cmd/api/config/config.go b/cmd/api/config/config.go index fe1fc74ee..8663065e9 100644 --- a/cmd/api/config/config.go +++ b/cmd/api/config/config.go @@ -10,6 +10,7 @@ import ( "time" "github.com/c2h5oh/datasize" + "github.com/kernel/hypeman/lib/desktop" "github.com/kernel/hypeman/lib/snapshot" "github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/providers/env" @@ -275,13 +276,14 @@ type GPUConfig struct { // Config is the top-level Hypeman server configuration. type Config struct { - Port string `koanf:"port"` - ListenAddress string `koanf:"listen_address"` // Empty preserves listening on all interfaces. - DataDir string `koanf:"data_dir"` - JwtSecret string `koanf:"jwt_secret"` - Env string `koanf:"env"` - Version string `koanf:"version"` - MacOSOnly bool `koanf:"macos_only"` // Experimental: omit Linux boot downloads and reject Linux creates. + Port string `koanf:"port"` + ListenAddress string `koanf:"listen_address"` // Empty preserves listening on all interfaces. + DataDir string `koanf:"data_dir"` + JwtSecret string `koanf:"jwt_secret"` + Env string `koanf:"env"` + Version string `koanf:"version"` + MacOSDesktopOrigin string `koanf:"macos_desktop_origin"` // Trusted external API origin; empty disables desktop/CDP routes. + MacOSOnly bool `koanf:"macos_only"` // Experimental: omit Linux boot downloads and reject Linux creates. Network NetworkConfig `koanf:"network"` Caddy CaddyConfig `koanf:"caddy"` @@ -590,6 +592,11 @@ func expandHomePath(path string) string { // Validate checks configuration values for correctness. // Returns an error if any configuration value is invalid. func (c *Config) Validate() error { + if c.MacOSDesktopOrigin != "" { + if _, err := desktop.ParseOrigin(c.MacOSDesktopOrigin); err != nil { + return fmt.Errorf("macos_desktop_origin: %w", err) + } + } if c.MacOSOnly && (runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" || c.Hypervisor.Default != "vz") { return fmt.Errorf("macos_only requires vz on Apple silicon") } diff --git a/cmd/api/config/desktop_test.go b/cmd/api/config/desktop_test.go new file mode 100644 index 000000000..8fdca024b --- /dev/null +++ b/cmd/api/config/desktop_test.go @@ -0,0 +1,23 @@ +package config + +import ( + "strings" + "testing" +) + +func TestDesktopOriginConfig(t *testing.T) { + for _, origin := range []string{"", "https://api.example", "http://127.0.0.1:4974/"} { + cfg := defaultConfig() + cfg.MacOSDesktopOrigin = origin + if err := cfg.Validate(); err != nil && strings.Contains(err.Error(), "macos_desktop_origin") { + t.Fatalf("valid origin rejected: %v", err) + } + } + for _, origin := range []string{"ws://api.example", "https://user:pass@api.example", "https://api.example/path", "https://api.example?token=x", "https://api.example#fragment"} { + cfg := defaultConfig() + cfg.MacOSDesktopOrigin = origin + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "macos_desktop_origin") { + t.Fatalf("invalid origin accepted: %s (%v)", origin, err) + } + } +} diff --git a/cmd/api/main.go b/cmd/api/main.go index 72bf7f87c..b63b46897 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -459,6 +459,20 @@ func run() error { mw.ResolveResource(app.ApiService.NewResolvers(), api.ResolverErrorResponder), ).Get("/instances/{id}/exec", app.ApiService.ExecHandler) + // Desktop/browser routes grant browser control, including discovery and status. + desktopRoutes := r.With( + middleware.RequestID, + middleware.RealIP, + middleware.Recoverer, + mw.InjectLogger(logger), + mw.AccessLogger(accessLogger), + mw.JwtAuth(app.Config.JwtSecret), + scopes.RequireScope(scopes.InstanceWrite), + mw.ResolveResource(app.ApiService.NewResolvers(), api.ResolverErrorResponder), + ) + desktopRoutes.Get("/instances/{id}/cdp/*", app.ApiService.CDPHandler) + desktopRoutes.Post("/instances/{id}/cdp/start", app.ApiService.CDPHandler) + // Custom cp endpoint (outside OpenAPI spec, uses WebSocket) r.With( middleware.RequestID, diff --git a/docs/examples/macos-desktop-agent.plist b/docs/examples/macos-desktop-agent.plist new file mode 100644 index 000000000..8c9db81cb --- /dev/null +++ b/docs/examples/macos-desktop-agent.plist @@ -0,0 +1,16 @@ + + + + + Labelsh.hypeman.desktop-agent + ProgramArguments + + /Library/Application Support/Hypeman/guest-agent + --roledesktop + + LimitLoadToSessionTypeAqua + RunAtLoad + KeepAlive + ThrottleInterval30 + + diff --git a/docs/macos-desktop.md b/docs/macos-desktop.md index 9e7a94615..f2523a127 100644 --- a/docs/macos-desktop.md +++ b/docs/macos-desktop.md @@ -1,45 +1,138 @@ -# macOS desktop and managed browser (in development) +# macOS desktop and managed browser (draft) -The macOS runtime does not need a host viewer to keep its VMM running. A desktop -session inside the guest, desktop capture/input permission, and browser readiness -are separate capabilities: none follows from VMM `Running` or root-agent readiness. -The experimental spike's host Cocoa viewer is not a supported viewer API. +The macOS runtime does not need a host viewer to keep its VMM running. VMM +`Running`, system GuestService readiness, selected desktop-session readiness, +managed browser readiness, and capture/input permission are separate. The spike's +host Cocoa viewer is not a supported viewer API. -This branch introduces a transport-independent CDP proxy in `lib/desktop`. It is -not yet wired into the instance API and does not enable browser control on an -existing image. No desktop agent or TCC permissions are installed automatically. +This branch adds authenticated instance CDP routes, a bounded fixed-upstream proxy, +and a versioned desktop-role service backed by a non-root Darwin Chrome launcher. +It **does not install a desktop LaunchAgent automatically**. Existing images and +the spike's old GUI agent do not satisfy the new handshake. No guest or host +TCC permissions are installed automatically. + +## Explicit admission + +An administrator must configure `macos_desktop_origin` to the public API origin, +for example `https://hypeman.example`. Empty disables the routes. Configuration +rejects credentials, paths (other than `/`), fragments, queries, and non-HTTP schemes. +Discovery never trusts incoming Host or forwarding headers. + +The machine-image metadata must declare `desktop_agent_uid` as the provisioned +non-root user's UID. Zero disables desktop operations. This capability is separate +from `guest_agent` (system GuestService on 2222), and `SkipGuestAgent` disables both. +The desktop role uses fixed vsock port 2223, not a client-selected endpoint. Its +native Darwin listener admits host-CID connections only. A declaration is a +capability claim, not a credential or proof of provisioning. + +All routes require JWT `instance:write`, including discovery and status, and use +the same instance resolution/authorization model as exec. Read-only tokens are +rejected before resolution or guest connection. This does not add multi-tenant +ownership semantics beyond the existing API's scoped-token model. + +Only running macOS/VZ guests with an enabled desktop capability are admitted. +Present Origin headers must exactly match the configured scheme/authority; +multiple, null, cross-origin, and malformed origins are rejected. Native clients +may omit Origin but must still authenticate; cookies/query tokens are not accepted +as authentication. Encoded selectors/paths are not supported on these routes; use +the canonical instance ID (ordinary names/aliases resolve normally). + +## Management and readiness + +- `GET /instances/{id}/cdp/status`: live desktop handshake plus derived + `desktop_ready`; this is not a persisted boot marker or VMM/system-agent health. +- `POST /instances/{id}/cdp/start`: explicit fixed-policy browser launch. No body, + query, command, arguments, browser binary, profile, or user selection is accepted. +- `GET /instances/{id}/cdp/json[/list|/version|/protocol]`: CDP discovery. +- `GET /instances/{id}/cdp/devtools/{browser|page}/{id}`: debugger WebSocket. + +The protocol-v1 handshake must report Darwin/arm64, the declared non-root UID, +the active console UID and GUI-session availability. Desktop readiness requires +matching console/agent UIDs and a GUI session. Browser readiness additionally +requires the backend to establish ownership of the managed browser; discovering +an open debugging port is insufficient. Inconsistent or incompatible handshakes +are rejected. This does not claim an unlocked screen or TCC permission. + +`lib/desktop.NewService` defines the guest role's status, launch and CDP boundary. +`DarwinBackend` checks `/dev/console` ownership and `launchctl managername` (`Aqua`), +uses the fixed `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` +binary and private `~/Library/Application Support/Hypeman/Chrome` profile, refuses +occupied unmanaged debugging ports, serializes launches and observes child exit. +Profile directories must belong to the selected user, have no symlink components, +and use private permissions for the Hypeman subtree. Chrome inherits only HOME +and a fixed PATH, not arbitrary agent environment/credentials. It is headful. + +Readiness requires `lsof` to identify the fixed IPv4 loopback listener on the +agent's own launched Chrome PID, followed by bounded validated Chrome discovery. +The browser is not killed when the launch request, CDP socket or viewer closes. +After a browser crash, an explicit start can launch a replacement. Agent restart +adoption of a surviving Chrome is deliberately unsupported: the new agent refuses +to attach to that unmanaged listener rather than guessing process ownership. No system shutdown, arbitrary exec, file access or OS input is +available through this role. The system GuestService remains separate. + +Host probes are bounded (3 seconds for status, 20 for explicit launch, at most +8KiB of JSON). The guest service bounds status/launch work and serializes launches. +Both sides limit active CDP sessions to 16; host admission also counts management +requests. Connections release slots on disconnect, without a guest shutdown call. +Real process ownership, session detection, startup and recovery remain live +validation gates; these OS interactions have not been exercised by this PR's tests. ## CDP transport boundary -`NewCDPProxy` supports GET discovery (`/json`, `/json/list`, `/json/version`, -`/json/protocol`) and browser/page debugger WebSockets. Its caller supplies a -fixed transport, strips the instance route prefix, and supplies a trusted, -instance-scoped public `ws`/`wss` base. Requests cannot select the upstream host, -port, path outside that allowlist, query, or arbitrary HTTP body. - -The proxy forwards only discovery/WebSocket handshake headers, removing API -credentials, cookies, origin, and arbitrary application/forwarding headers. -It rejects redirects and invalid/oversized discovery responses. Advertised -WebSocket URLs must point to the fixed browser loopback endpoint and are rewritten -to the instance-scoped public base. Unproxied DevTools frontend links and guest -cookies are removed. Hosting a DevTools frontend is not included. - -This component **does not authorize callers**. Before exposing it, API integration -must enforce instance-write authorization, resolved instance ownership, declared -desktop/browser capability, a running eligible guest, origin admission, and a -selected non-root desktop agent/session. Never derive the public base from an -untrusted Host or forwarding header. CDP grants full browser authority; the HTTP -path allowlist is not a CDP-command sandbox or a limit on websites visited. - -Browser launch/status are separate management operations, not arbitrary proxied -paths. Worker-specific debugger paths and `/json/new`/close/activate endpoints -remain unsupported; browser-level CDP clients may manage targets over WebSockets. +`NewCDPProxy` supports GET discovery and browser/page debugger WebSockets. Its +caller supplies a fixed transport, strips the instance route prefix, and supplies +a trusted instance-scoped `ws`/`wss` base. Clients cannot select the upstream host, +port, other HTTP paths, query or body. Advertised debugger URLs must point to the +fixed browser loopback endpoint (`127.0.0.1:9222`), then are rewritten to the +canonical instance ID even when accessed by an alias. + +Only discovery/WebSocket handshake headers are forwarded. API credentials, +cookies, origin, URL userinfo, and application/forwarding headers are stripped. +Redirects, invalid/oversized discovery (2MiB), guest cookies, and unproxied DevTools +frontend links are rejected/removed. Hosting a frontend is not included. + +CDP grants full browser authority; the HTTP path allowlist is not a command +sandbox or a limit on websites visited. Worker-specific debugger paths and +`/json/new`/close/activate remain unsupported; browser-level CDP can manage targets. +There is no transparent browser auto-launch on discovery or WebSocket connection. + +## Provisioning (isolated guest only) + +Build `./lib/system/guest_agent` for Darwin/arm64 with CGO enabled. Provision the +binary at `/Library/Application Support/Hypeman/guest-agent` in the image and +Chrome at the fixed application path above. Installation in this protected path +requires separately authorized guest provisioning, not a host-root installer. + +The example `docs/examples/macos-desktop-agent.plist` belongs in the selected +user's `~/Library/LaunchAgents/`, not `/Library/LaunchDaemons/`. Bootstrap it in +`gui/` as that user after login; it runs the same binary with `--role desktop`. +Do not configure UserName=root or launch it in the system domain. Default/no-role +invocation remains the existing system GuestService on 2222. Desktop role is +unsupported off Darwin or without native CGO vsock support, and rejects root or +set-ID execution. Only mark `desktop_agent_uid` on a stopped matching image after +provisioning and handshake validation. System-agent readiness must not be used as +proof that the desktop role is provisioned. + +No install/bootstrap command has been run by these tests. This example does not +provide automatic login, credential storage, TCC grants, rekeying or fleet rollout. ## Validation status -Synthetic tests exercise discovery rewriting, credential isolation, request -admission, fixed-upstream behavior, malicious/oversized responses, redirects, and -an actual proxied WebSocket round trip. They do not prove live guest provisioning, -vsock routing, authenticated API integration, session selection, reconnect after a -guest/browser crash, capture/input/TCC, viewer attachment, or viewer-detach behavior. -Those remain gates before this desktop/browser feature is ready for use. +Synthetic tests cover role/version/UID validation, readiness separation, fixed +launch admission, bounded probes, trusted-origin validation, real JWT/write-scope +middleware and instance resolution, unsupported/stopped/disabled admission before +dial, alias-safe discovery rewriting, credential isolation, malicious/oversized +responses, redirects, active-session caps, actual WebSocket round trips, reconnect +and slot release. Darwin-specific tests inspect fixed launch arguments/environment, +listener-ownership parsing and private-profile/symlink policy without launching +Chrome. Focused tests run with the race detector. + +OS desktop capture/input, automatic TCC grants and a supported viewer API are not +implemented in this checkpoint; they remain in this draft's display scope. + +Not yet proven: live Darwin backend operation, LaunchAgent provisioning/console selection, +native desktop listener/API vsock routing, browser process ownership and crash +recovery, agent restart recovery, capture/input/TCC, viewer attachment/detachment. +The new routes are disabled by default and this feature remains a draft until +those gates pass in an explicitly authorized isolated guest. No live deployment +or guest provisioning is implied by these local tests. diff --git a/lib/desktop/backend_darwin.go b/lib/desktop/backend_darwin.go new file mode 100644 index 000000000..fc3813764 --- /dev/null +++ b/lib/desktop/backend_darwin.go @@ -0,0 +1,219 @@ +//go:build darwin + +package desktop + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "os/user" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +const chromeBinary = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + +// DarwinBackend runs in the selected user's Aqua LaunchAgent, never as root. +// It only controls a Chrome process it started; restart adoption is intentionally +// unsupported rather than attaching to an unrelated loopback debugging server. +type DarwinBackend struct { + uid uint32 + home string + mu sync.Mutex + process *exec.Cmd + client *http.Client +} + +func NewDarwinBackend() (*DarwinBackend, error) { + if os.Getuid() == 0 || os.Geteuid() != os.Getuid() || os.Getegid() != os.Getgid() || runtime.GOARCH != "arm64" { + return nil, fmt.Errorf("desktop role requires a non-root Darwin/arm64 user") + } + account, err := user.Current() + if err != nil { + return nil, err + } + if !filepath.IsAbs(account.HomeDir) { + return nil, fmt.Errorf("desktop user's home must be absolute") + } + return &DarwinBackend{uid: uint32(os.Getuid()), home: account.HomeDir, client: &http.Client{Timeout: 2 * time.Second, Transport: &http.Transport{Proxy: nil, DisableKeepAlives: true}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil +} + +func (b *DarwinBackend) Status(ctx context.Context) (Status, error) { + s := Status{Version: ProtocolVersion, OS: "darwin", Architecture: runtime.GOARCH, UID: b.uid} + console, err := os.Stat("/dev/console") + if err != nil { + return s, err + } + stat, ok := console.Sys().(*syscall.Stat_t) + if !ok { + return s, fmt.Errorf("console ownership unavailable") + } + s.ConsoleUID = stat.Uid + gui, err := exec.CommandContext(ctx, "/bin/launchctl", "managername").Output() + s.GUISession = err == nil && strings.TrimSpace(string(gui)) == "Aqua" + b.mu.Lock() + process := b.process + b.mu.Unlock() + if process == nil || !s.SessionReady() { + return s, nil + } + // Starting a process and observing any open port is not proof of ownership. + // lsof is restricted to our own launched PID and the fixed loopback listener. + owners, err := exec.CommandContext(ctx, "/usr/sbin/lsof", "-nP", "-a", "-p", strconv.Itoa(process.Process.Pid), "-iTCP:9222", "-sTCP:LISTEN", "-Fn").Output() + if err != nil || !ownsBrowserListener(string(owners)) { + return s, nil + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:9222/json/version", nil) + if err != nil { + return s, err + } + response, err := b.client.Do(request) + if err != nil { + return s, nil + } + defer response.Body.Close() + data, err := io.ReadAll(io.LimitReader(response.Body, maxStatusBytes+1)) + if err != nil || len(data) > maxStatusBytes || response.StatusCode != 200 { + return s, nil + } + var version struct { + Browser string `json:"Browser"` + Debugger string `json:"webSocketDebuggerUrl"` + } + if json.Unmarshal(data, &version) != nil || !strings.HasPrefix(version.Browser, "Chrome/") || len(version.Browser) > 256 { + return s, nil + } + address, err := url.Parse(version.Debugger) + if err != nil || address.Scheme != "ws" || address.Host != "127.0.0.1:9222" || address.User != nil || address.RawQuery != "" || address.ForceQuery || address.Fragment != "" || address.RawPath != "" || !strings.HasPrefix(address.Path, "/devtools/browser/") || !debuggerPath(address.Path) { + return s, nil + } + b.mu.Lock() + stillOwned := b.process == process + b.mu.Unlock() + if stillOwned { + s.BrowserManaged = true + s.BrowserReady = true + s.Browser = version.Browser + } + return s, nil +} + +func ownsBrowserListener(output string) bool { + for _, line := range strings.Split(output, "\n") { + if line == "n127.0.0.1:9222" { + return true + } + } + return false +} + +func (b *DarwinBackend) profilePath() string { + return filepath.Join(b.home, "Library", "Application Support", "Hypeman", "Chrome") +} + +func (b *DarwinBackend) prepareProfile() error { + // Refuse symlinks along the selected home-relative profile path. Existing + // ordinary Library directories need not have private mode; our subtree must. + current := b.home + for _, part := range []string{"Library", "Application Support", "Hypeman", "Chrome"} { + current = filepath.Join(current, part) + if err := os.Mkdir(current, 0700); err != nil && !os.IsExist(err) { + return err + } + info, err := os.Lstat(current) + if err != nil { + return err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !info.IsDir() || !ok || stat.Uid != b.uid { + return fmt.Errorf("profile path is not a user-owned directory") + } + if (part == "Hypeman" || part == "Chrome") && info.Mode().Perm()&0077 != 0 { + return fmt.Errorf("managed profile directory must be private") + } + } + return nil +} + +func (b *DarwinBackend) chromeCommand() *exec.Cmd { + command := exec.Command(chromeBinary, "--user-data-dir="+b.profilePath(), "--remote-debugging-address=127.0.0.1", "--remote-debugging-port=9222", "--no-first-run", "--no-default-browser-check") + // Do not pass agent/service credentials through an inherited environment. + command.Env = []string{"HOME=" + b.home, "PATH=/usr/bin:/bin:/usr/sbin:/sbin"} + return command +} + +func (b *DarwinBackend) StartBrowser(ctx context.Context) (Status, error) { + s, err := b.Status(ctx) + if err != nil { + return s, err + } + if !s.SessionReady() { + return s, fmt.Errorf("selected Aqua session is not active") + } + if s.BrowserReady { + return s, nil + } + b.mu.Lock() + if b.process == nil { + if err := ctx.Err(); err != nil { + b.mu.Unlock() + return s, err + } + listener, err := net.Listen("tcp", "127.0.0.1:9222") + if err != nil { + b.mu.Unlock() + return s, fmt.Errorf("refusing occupied unmanaged browser port") + } + _ = listener.Close() + if err := b.prepareProfile(); err != nil { + b.mu.Unlock() + return s, err + } + command := b.chromeCommand() + // The browser lifetime is not tied to this HTTP request, viewer or socket. + if err := command.Start(); err != nil { + b.mu.Unlock() + return s, err + } + b.process = command + go func() { + _ = command.Wait() + b.mu.Lock() + if b.process == command { + b.process = nil + } + b.mu.Unlock() + }() + } + b.mu.Unlock() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + s, err = b.Status(ctx) + if err != nil || s.BrowserReady { + return s, err + } + b.mu.Lock() + exited := b.process == nil + b.mu.Unlock() + if exited { + return s, fmt.Errorf("managed Chrome exited before readiness") + } + select { + case <-ctx.Done(): + return s, ctx.Err() + case <-ticker.C: + } + } +} diff --git a/lib/desktop/backend_darwin_test.go b/lib/desktop/backend_darwin_test.go new file mode 100644 index 000000000..9e7d7ba39 --- /dev/null +++ b/lib/desktop/backend_darwin_test.go @@ -0,0 +1,68 @@ +//go:build darwin + +package desktop + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestDarwinBrowserLaunchCommand(t *testing.T) { + t.Setenv("AWS_SECRET_ACCESS_KEY", "synthetic-secret-not-to-inherit") + b := &DarwinBackend{home: "/Users/test", uid: 501} + cmd := b.chromeCommand() + expected := []string{chromeBinary, "--user-data-dir=/Users/test/Library/Application Support/Hypeman/Chrome", "--remote-debugging-address=127.0.0.1", "--remote-debugging-port=9222", "--no-first-run", "--no-default-browser-check"} + if !reflect.DeepEqual(cmd.Args, expected) { + t.Fatal("unexpected Chrome command") + } + if !reflect.DeepEqual(cmd.Env, []string{"HOME=/Users/test", "PATH=/usr/bin:/bin:/usr/sbin:/sbin"}) { + t.Fatal("unexpected browser environment") + } + for _, arg := range cmd.Args { + if strings.Contains(arg, "headless") { + t.Fatal("headless browser configured") + } + } +} + +func TestDarwinBrowserListenerOwnership(t *testing.T) { + if !ownsBrowserListener("p123\nf22\nn127.0.0.1:9222\n") { + t.Fatal("owned listener rejected") + } + for _, output := range []string{"", "p123\nn*:9222\n", "p123\nn127.0.0.1:9223\n", "p123\nn127.0.0.1:9222evil\n"} { + if ownsBrowserListener(output) { + t.Fatal("unproven listener accepted") + } + } +} + +func TestDarwinPrivateProfile(t *testing.T) { + for _, kind := range []string{"private", "symlink", "shared"} { + t.Run(kind, func(t *testing.T) { + b := &DarwinBackend{uid: uint32(os.Getuid()), home: t.TempDir()} + if err := b.prepareProfile(); err != nil { + t.Fatal(err) + } + if kind == "symlink" { + if err := os.Remove(b.profilePath()); err != nil { + t.Fatal(err) + } + if err := os.Symlink(t.TempDir(), b.profilePath()); err != nil { + t.Fatal(err) + } + } + if kind == "shared" { + if err := os.Chmod(filepath.Dir(b.profilePath()), 0755); err != nil { + t.Fatal(err) + } + } + err := b.prepareProfile() + if (err == nil) != (kind == "private") { + t.Fatalf("profile policy: %v", err) + } + }) + } +} diff --git a/lib/desktop/cdp.go b/lib/desktop/cdp.go index 2eba5d1cf..2a64587c0 100644 --- a/lib/desktop/cdp.go +++ b/lib/desktop/cdp.go @@ -21,7 +21,7 @@ const maxDiscoveryBytes = 2 << 20 // Requests must have the instance route prefix removed before reaching this handler. func NewCDPProxy(transport http.RoundTripper, publicBase string) (http.Handler, error) { base, err := url.Parse(publicBase) - if err != nil || base == nil || (base.Scheme != "ws" && base.Scheme != "wss") || base.Host == "" || base.User != nil || base.RawQuery != "" || base.Fragment != "" || base.RawPath != "" || strings.HasSuffix(base.Path, "/") { + if err != nil || base == nil || (base.Scheme != "ws" && base.Scheme != "wss") || base.Host == "" || base.User != nil || base.RawQuery != "" || base.ForceQuery || base.Fragment != "" || base.RawPath != "" || strings.HasSuffix(base.Path, "/") { return nil, fmt.Errorf("invalid public CDP base") } if transport == nil { @@ -48,6 +48,9 @@ func NewCDPProxy(transport http.RoundTripper, publicBase string) (http.Handler, }, ModifyResponse: func(r *http.Response) error { r.Header.Del("Set-Cookie") + if r.StatusCode == http.StatusSwitchingProtocols && !debuggerPath(r.Request.URL.Path) { + return fmt.Errorf("unexpected discovery upgrade") + } if r.StatusCode >= 300 && r.StatusCode < 400 { return fmt.Errorf("CDP redirects unsupported") } @@ -61,18 +64,25 @@ func NewCDPProxy(transport http.RoundTripper, publicBase string) (http.Handler, }, } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet || r.ContentLength != 0 || len(r.TransferEncoding) != 0 || r.URL.RawQuery != "" || r.URL.RawPath != "" || !(discoveryPath(r.URL.Path) || debuggerPath(r.URL.Path)) { - http.Error(w, "unsupported CDP request", http.StatusBadRequest) - return - } - if debuggerPath(r.URL.Path) && !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { - http.Error(w, "WebSocket upgrade required", http.StatusBadRequest) + if err := ValidateCDPRequest(r); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } proxy.ServeHTTP(w, r) }), nil } +// ValidateCDPRequest allows API admission to run before dialing any guest service. +func ValidateCDPRequest(r *http.Request) error { + if r.Method != http.MethodGet || r.ContentLength != 0 || len(r.TransferEncoding) != 0 || r.URL.RawQuery != "" || r.URL.ForceQuery || r.URL.RawPath != "" || !(discoveryPath(r.URL.Path) || debuggerPath(r.URL.Path)) { + return fmt.Errorf("unsupported CDP request") + } + if debuggerPath(r.URL.Path) && !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + return fmt.Errorf("WebSocket upgrade required") + } + return nil +} + func discoveryPath(path string) bool { switch path { case "/json", "/json/list", "/json/version", "/json/protocol": diff --git a/lib/desktop/cdp_test.go b/lib/desktop/cdp_test.go index 5e8efc936..ecebef177 100644 --- a/lib/desktop/cdp_test.go +++ b/lib/desktop/cdp_test.go @@ -118,6 +118,24 @@ func TestCDPRejectsRedirect(t *testing.T) { } } +func TestCDPRejectsDiscoveryUpgrade(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Connection", "Upgrade") + w.Header().Set("Upgrade", "websocket") + w.WriteHeader(http.StatusSwitchingProtocols) + })) + defer upstream.Close() + h, err := NewCDPProxy(testTransport(t, upstream), "ws://api.example/instances/id/cdp") + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest("GET", "http://api.example/json/version", nil)) + if w.Code != 502 { + t.Fatalf("unexpected discovery upgrade: %d", w.Code) + } +} + func TestCDPWebSocketRoundTrip(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/devtools/browser/abc-123" || r.Header.Get("Authorization") != "" { @@ -174,7 +192,7 @@ func TestCDPRejectsRequestBody(t *testing.T) { } func TestCDPConfigurationAndTargetPaths(t *testing.T) { - for _, base := range []string{"http://api.example/cdp", "ws://user@api.example/cdp", "ws://api.example/cdp?token=secret", "ws://api.example/cdp#fragment", "ws://api.example/cdp/", "ws:///cdp"} { + for _, base := range []string{"http://api.example/cdp", "ws://user@api.example/cdp", "ws://api.example/cdp?token=secret", "ws://api.example/cdp?", "ws://api.example/cdp#fragment", "ws://api.example/cdp/", "ws:///cdp"} { if _, err := NewCDPProxy(http.DefaultTransport, base); err == nil { t.Fatalf("accepted base %s", base) } diff --git a/lib/desktop/origin.go b/lib/desktop/origin.go new file mode 100644 index 000000000..50d017918 --- /dev/null +++ b/lib/desktop/origin.go @@ -0,0 +1,24 @@ +package desktop + +import ( + "fmt" + "net/url" + "strings" +) + +// ParseOrigin validates an administrator-provided API origin. Forwarded headers +// and request Host headers are deliberately not involved in URL generation. +func ParseOrigin(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Hostname() == "" || u.User != nil || u.Opaque != "" || (u.Path != "" && u.Path != "/") || u.RawPath != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" { + return nil, fmt.Errorf("desktop API origin must be an http(s) origin without credentials, query or path") + } + u.Path = "" + u.Host = strings.ToLower(u.Host) + return u, nil +} + +func SameOrigin(raw string, expected *url.URL) bool { + u, err := url.Parse(raw) + return err == nil && u.Scheme == expected.Scheme && strings.EqualFold(u.Host, expected.Host) && u.User == nil && u.Path == "" && u.RawQuery == "" && !u.ForceQuery && u.Fragment == "" && u.Opaque == "" +} diff --git a/lib/desktop/service.go b/lib/desktop/service.go new file mode 100644 index 000000000..9ac155a13 --- /dev/null +++ b/lib/desktop/service.go @@ -0,0 +1,179 @@ +package desktop + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" +) + +const ( + ProtocolVersion = 1 + MaxSessions = 16 + AgentPort uint32 = 2223 + maxStatusBytes = 8 << 10 +) + +// Status reports the selected user session, not VMM or root-service readiness. +// BrowserManaged means the desktop agent established ownership of this browser. +// Merely finding an open loopback debugging port must not set BrowserManaged. +type Status struct { + Version int `json:"version"` + OS string `json:"os"` + Architecture string `json:"architecture"` + UID uint32 `json:"uid"` + ConsoleUID uint32 `json:"console_uid"` + GUISession bool `json:"gui_session"` + BrowserManaged bool `json:"browser_managed"` + BrowserReady bool `json:"browser_ready"` + Browser string `json:"browser,omitempty"` +} + +func (s Status) ValidateRole(uid uint32) error { + if s.Version != ProtocolVersion || s.OS != "darwin" || s.Architecture != "arm64" || uid == 0 || s.UID != uid { + return fmt.Errorf("incompatible desktop agent or user") + } + if s.BrowserReady && (!s.BrowserManaged || !s.SessionReady()) { + return fmt.Errorf("inconsistent browser readiness") + } + return nil +} + +func (s Status) SessionReady() bool { return s.UID != 0 && s.ConsoleUID == s.UID && s.GUISession } + +// Backend must implement fixed browser provisioning, not client-selected commands, +// paths, arguments or profiles. System shutdown and arbitrary exec are not here. +type Backend interface { + Status(context.Context) (Status, error) + StartBrowser(context.Context) (Status, error) +} + +// NewService is the guest desktop role's HTTP interface. Its listener must admit +// host-CID connections only; it must never be published on a guest TCP interface. +// uid selects the provisioned non-root user and does not come from the request. +func NewService(backend Backend, uid uint32, cdp http.Handler) (http.Handler, error) { + if backend == nil || uid == 0 || cdp == nil { + return nil, fmt.Errorf("desktop backend, non-root user and CDP handler required") + } + launching := make(chan struct{}, 1) + sessions := make(chan struct{}, MaxSessions) + mux := http.NewServeMux() + respond := func(w http.ResponseWriter, s Status) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(s) + } + mux.HandleFunc("GET /status", func(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery != "" || r.URL.ForceQuery || r.ContentLength != 0 || len(r.TransferEncoding) != 0 { + http.Error(w, "invalid status request", 400) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + s, err := backend.Status(ctx) + if err != nil || s.ValidateRole(uid) != nil { + http.Error(w, "desktop status unavailable", 503) + return + } + respond(w, s) + }) + mux.HandleFunc("POST /browser/start", func(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery != "" || r.URL.ForceQuery || r.ContentLength != 0 || len(r.TransferEncoding) != 0 { + http.Error(w, "browser launch arguments unsupported", 400) + return + } + select { + case launching <- struct{}{}: + defer func() { <-launching }() + default: + http.Error(w, "browser launch in progress", 409) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + s, err := backend.Status(ctx) + if err != nil || s.ValidateRole(uid) != nil { + http.Error(w, "desktop status unavailable", 503) + return + } + if !s.SessionReady() { + http.Error(w, "selected desktop session not ready", 409) + return + } + if !s.BrowserReady { + s, err = backend.StartBrowser(ctx) + } + if err != nil || s.ValidateRole(uid) != nil || !s.BrowserReady { + http.Error(w, "managed browser not ready", 503) + return + } + respond(w, s) + }) + mux.Handle("GET /", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := ValidateCDPRequest(r); err != nil { + http.Error(w, err.Error(), 400) + return + } + select { + case sessions <- struct{}{}: + defer func() { <-sessions }() + default: + http.Error(w, "desktop session limit reached", 429) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + s, err := backend.Status(ctx) + cancel() + if err != nil || s.ValidateRole(uid) != nil || !s.BrowserReady { + http.Error(w, "managed browser not ready", 409) + return + } + cdp.ServeHTTP(w, r) + })) + return mux, nil +} + +// Probe validates a bounded desktop role handshake over the supplied transport. +// The request carries no API headers or caller-selected endpoint. +func Probe(ctx context.Context, transport http.RoundTripper, uid uint32, start bool) (Status, error) { + if transport == nil { + return Status{}, errors.New("desktop transport required") + } + method, path := http.MethodGet, "/status" + if start { + method, path = http.MethodPost, "/browser/start" + } + request, err := http.NewRequestWithContext(ctx, method, "http://desktop"+path, nil) + if err != nil { + return Status{}, err + } + response, err := transport.RoundTrip(request) + if err != nil { + return Status{}, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return Status{}, fmt.Errorf("desktop agent returned %d", response.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(response.Body, maxStatusBytes+1)) + if err != nil { + return Status{}, err + } + if len(data) > maxStatusBytes { + return Status{}, errors.New("desktop status too large") + } + var s Status + if err = json.Unmarshal(data, &s); err != nil { + return Status{}, err + } + if err = s.ValidateRole(uid); err != nil { + return Status{}, err + } + if start && !s.BrowserReady { + return Status{}, errors.New("browser launch did not establish readiness") + } + return s, nil +} diff --git a/lib/desktop/service_test.go b/lib/desktop/service_test.go new file mode 100644 index 000000000..3109cdce9 --- /dev/null +++ b/lib/desktop/service_test.go @@ -0,0 +1,209 @@ +package desktop + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +type fixtureBackend struct { + state Status + starts atomic.Int32 +} + +func (b *fixtureBackend) Status(context.Context) (Status, error) { return b.state, nil } +func (b *fixtureBackend) StartBrowser(context.Context) (Status, error) { + b.starts.Add(1) + s := b.state + s.BrowserManaged = true + s.BrowserReady = true + s.Browser = "Chrome/test" + return s, nil +} +func readyStatus() Status { + return Status{Version: ProtocolVersion, OS: "darwin", Architecture: "arm64", UID: 501, ConsoleUID: 501, GUISession: true} +} + +func TestDesktopRoleReadiness(t *testing.T) { + for _, tc := range []struct { + name string + change func(*Status) + valid, ready bool + }{ + {"ready", func(*Status) {}, true, true}, + {"logged out", func(s *Status) { s.ConsoleUID = 0 }, true, false}, + {"no GUI", func(s *Status) { s.GUISession = false }, true, false}, + {"wrong user", func(s *Status) { s.UID = 502 }, false, false}, + {"root", func(s *Status) { s.UID = 0 }, false, false}, + {"old version", func(s *Status) { s.Version = 0 }, false, false}, + {"unmanaged browser", func(s *Status) { s.BrowserReady = true }, false, true}, + {"browser without session", func(s *Status) { s.BrowserManaged = true; s.BrowserReady = true; s.GUISession = false }, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + s := readyStatus() + tc.change(&s) + if (s.ValidateRole(501) == nil) != tc.valid { + t.Fatal("role validation") + } + if tc.valid && s.SessionReady() != tc.ready { + t.Fatal("session readiness") + } + }) + } +} + +func TestDesktopServiceLaunchPolicy(t *testing.T) { + for _, tc := range []struct { + name, path, method, body string + loggedIn bool + code, starts int + }{ + {"status", "/status", "GET", "", true, 200, 0}, + {"launch", "/browser/start", "POST", "", true, 200, 1}, + {"logout", "/browser/start", "POST", "", false, 409, 0}, + {"arguments", "/browser/start", "POST", "{\"args\":[\"--dangerous\"]}", true, 400, 0}, + {"query", "/browser/start?profile=other", "POST", "", true, 400, 0}, + {"shutdown unavailable", "/shutdown", "POST", "", true, 405, 0}, + {"CDP before browser ready", "/json/version", "GET", "", true, 409, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + b := &fixtureBackend{state: readyStatus()} + b.state.GUISession = tc.loggedIn + h, err := NewService(b, 501, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("unexpected CDP") })) + if err != nil { + t.Fatal(err) + } + r := httptest.NewRequest(tc.method, "http://guest"+tc.path, strings.NewReader(tc.body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != tc.code || int(b.starts.Load()) != tc.starts { + t.Fatalf("status=%d starts=%d", w.Code, b.starts.Load()) + } + }) + } +} + +func TestDesktopProbeBoundsAndIdentity(t *testing.T) { + for _, tc := range []struct { + name string + payload string + valid bool + }{ + {"valid", statusJSON(readyStatus()), true}, + {"wrong UID", strings.Replace(statusJSON(readyStatus()), `"uid":501`, `"uid":502`, 1), false}, + {"empty", "{}", false}, {"oversized", strings.Repeat(" ", maxStatusBytes+1), false}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "" || r.URL.Path != "/status" { + t.Error("unexpected probe") + } + _, _ = w.Write([]byte(tc.payload)) + })) + defer server.Close() + _, err := Probe(context.Background(), testTransport(t, server), 501, false) + if (err == nil) != tc.valid { + t.Fatalf("probe: %v", err) + } + }) + } +} +func statusJSON(s Status) string { b, _ := json.Marshal(s); return string(b) } + +type blockedLaunchBackend struct { + fixtureBackend + entered, release chan struct{} +} + +func (b *blockedLaunchBackend) StartBrowser(ctx context.Context) (Status, error) { + close(b.entered) + select { + case <-b.release: + return b.fixtureBackend.StartBrowser(ctx) + case <-ctx.Done(): + return Status{}, ctx.Err() + } +} +func TestDesktopSerializesLaunches(t *testing.T) { + b := &blockedLaunchBackend{fixtureBackend: fixtureBackend{state: readyStatus()}, entered: make(chan struct{}), release: make(chan struct{})} + h, err := NewService(b, 501, http.NotFoundHandler()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + first := httptest.NewRecorder() + done := make(chan struct{}) + go func() { + defer close(done) + h.ServeHTTP(first, httptest.NewRequest("POST", "http://guest/browser/start", nil).WithContext(ctx)) + }() + select { + case <-b.entered: + case <-ctx.Done(): + t.Fatal("first launch did not start") + } + second := httptest.NewRecorder() + h.ServeHTTP(second, httptest.NewRequest("POST", "http://guest/browser/start", nil)) + if second.Code != 409 { + t.Errorf("concurrent launch status: %d", second.Code) + } + close(b.release) + select { + case <-done: + case <-ctx.Done(): + t.Fatal("launch did not finish") + } + if first.Code != 200 || b.starts.Load() != 1 { + t.Fatalf("first launch status=%d starts=%d", first.Code, b.starts.Load()) + } +} + +func TestDesktopProbeCancellation(t *testing.T) { + entered := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(entered); <-r.Context().Done() })) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + done := make(chan error, 1) + go func() { _, err := Probe(ctx, testTransport(t, server), 501, false); done <- err }() + select { + case <-entered: + case <-ctx.Done(): + t.Fatal("probe did not start") + } + cancel() + select { + case err := <-done: + if err == nil { + t.Fatal("canceled probe succeeded") + } + case <-time.After(2 * time.Second): + t.Fatal("probe did not cancel") + } +} + +func TestDesktopOrigin(t *testing.T) { + u, err := ParseOrigin("https://API.example:443/") + if err != nil { + t.Fatal(err) + } + if !SameOrigin("https://api.example:443", u) { + t.Fatal("same origin rejected") + } + for _, bad := range []string{"null", "https://evil.example", "http://api.example:443", "https://api.example:443/path", "https://user@api.example:443", "https://api.example:443?", "https://api.example:443#x"} { + if SameOrigin(bad, u) { + t.Fatalf("accepted origin %s", bad) + } + } + for _, bad := range []string{"ws://api.example", "https://user@api.example", "https://api.example/path", "https://api.example?", "https://api.example#x"} { + if _, err := ParseOrigin(bad); err == nil { + t.Fatalf("accepted config %s", bad) + } + } +} diff --git a/lib/images/types.go b/lib/images/types.go index f49437ef9..8093600e6 100644 --- a/lib/images/types.go +++ b/lib/images/types.go @@ -39,6 +39,9 @@ type MacOSImage struct { // GuestAgent declares a provisioned system GuestService on vsock 2222. // Readiness is probed separately; old templates remain unmanaged. GuestAgent bool `json:"guest_agent,omitempty"` + // DesktopAgentUID declares the non-root desktop role provisioned on vsock2223. + // Zero keeps desktop/browser operations disabled; readiness is a live handshake. + DesktopAgentUID uint32 `json:"desktop_agent_uid,omitempty"` } // Validate checks the platform fields every macOS bundle must carry, whether it diff --git a/lib/scopes/routes_test.go b/lib/scopes/routes_test.go index f78bf3f89..40ccf4129 100644 --- a/lib/scopes/routes_test.go +++ b/lib/scopes/routes_test.go @@ -20,9 +20,11 @@ func TestAllRoutesHaveScopes(t *testing.T) { r := chi.NewRouter() noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) - // WebSocket endpoints registered outside OpenAPI (same as cmd/api/main.go) + // Custom endpoints registered outside OpenAPI (same as cmd/api/main.go) r.Get("/instances/{id}/exec", noop) r.Get("/instances/{id}/cp", noop) + r.Get("/instances/{id}/cdp/*", noop) + r.Post("/instances/{id}/cdp/start", noop) // Public/unauthenticated endpoints r.Get("/spec.yaml", noop) @@ -88,6 +90,8 @@ func TestRouteScopesHaveNoStaleEntries(t *testing.T) { // Mirror production routes r.Get("/instances/{id}/exec", noop) r.Get("/instances/{id}/cp", noop) + r.Get("/instances/{id}/cdp/*", noop) + r.Post("/instances/{id}/cdp/start", noop) r.Get("/spec.yaml", noop) r.Get("/spec.json", noop) r.Get("/swagger", noop) diff --git a/lib/scopes/scopes.go b/lib/scopes/scopes.go index 9d078c1fb..0ac71e4f0 100644 --- a/lib/scopes/scopes.go +++ b/lib/scopes/scopes.go @@ -194,8 +194,10 @@ var PublicRoutes = map[string]bool{ // middleware directly (not via the Middleware() scope checker). These are // outside the OpenAPI router group (e.g. WebSocket endpoints). var DirectScopeRoutes = map[string]Scope{ - "GET /instances/{id}/exec": InstanceWrite, - "GET /instances/{id}/cp": InstanceWrite, + "GET /instances/{id}/exec": InstanceWrite, + "GET /instances/{id}/cp": InstanceWrite, + "GET /instances/{id}/cdp/*": InstanceWrite, + "POST /instances/{id}/cdp/start": InstanceWrite, } // RouteScopes maps "METHOD /path-pattern" to the required scope. diff --git a/lib/system/guest_agent/desktop_darwin.go b/lib/system/guest_agent/desktop_darwin.go new file mode 100644 index 000000000..eb09b217c --- /dev/null +++ b/lib/system/guest_agent/desktop_darwin.go @@ -0,0 +1,36 @@ +//go:build darwin + +package main + +import ( + "net/http" + "os" + "time" + + "github.com/kernel/hypeman/lib/desktop" +) + +func runDesktopAgent() error { + backend, err := desktop.NewDarwinBackend() + if err != nil { + return err + } + transport := &http.Transport{Proxy: nil, DisableKeepAlives: true, ResponseHeaderTimeout: 3 * time.Second} + defer transport.CloseIdleConnections() + proxy, err := desktop.NewCDPProxy(transport, "ws://127.0.0.1:9222") + if err != nil { + return err + } + service, err := desktop.NewService(backend, uint32(os.Getuid()), proxy) + if err != nil { + return err + } + // Reuse the native host-CID-only listener, not a guest TCP listener or root RPC. + listener, err := listenVsock(desktop.AgentPort) + if err != nil { + return err + } + defer listener.Close() + server := &http.Server{Handler: service, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 32 << 10} + return server.Serve(listener) +} diff --git a/lib/system/guest_agent/desktop_other.go b/lib/system/guest_agent/desktop_other.go new file mode 100644 index 000000000..010735f74 --- /dev/null +++ b/lib/system/guest_agent/desktop_other.go @@ -0,0 +1,7 @@ +//go:build !darwin + +package main + +import "fmt" + +func runDesktopAgent() error { return fmt.Errorf("desktop HTTP role is only supported on Darwin") } diff --git a/lib/system/guest_agent/main.go b/lib/system/guest_agent/main.go index 5f8159fc6..b08644b09 100644 --- a/lib/system/guest_agent/main.go +++ b/lib/system/guest_agent/main.go @@ -1,6 +1,7 @@ package main import ( + "flag" "fmt" "log" "net" @@ -27,6 +28,18 @@ type guestServer struct { } func main() { + role := flag.String("role", "system", "agent role: system or desktop (Darwin Aqua user only)") + flag.Parse() + switch *role { + case "desktop": + if err := runDesktopAgent(); err != nil { + log.Fatalf("[desktop-agent] %v", err) + } + return + case "system": + default: + log.Fatalf("unsupported agent role %q", *role) + } // Listen on vsock port 2222 with retries var l net.Listener var err error From e852ee67c7ca7ebf9ae0d7b58ee372f43d779c1e Mon Sep 17 00:00:00 2001 From: chris lee Date: Fri, 9 Oct 2026 17:45:01 -0400 Subject: [PATCH 3/3] Rewrite CDP discovery only at the authenticated host boundary --- cmd/api/api/cdp.go | 2 +- docs/macos-desktop.md | 6 ++ lib/desktop/cdp.go | 25 ++++++- lib/desktop/cdp_forwarder_test.go | 83 ++++++++++++++++++++++++ lib/desktop/cdp_test.go | 8 ++- lib/desktop/service.go | 4 +- lib/system/guest_agent/desktop_darwin.go | 2 +- 7 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 lib/desktop/cdp_forwarder_test.go diff --git a/cmd/api/api/cdp.go b/cmd/api/api/cdp.go index 44e70bc74..51f7f1994 100644 --- a/cmd/api/api/cdp.go +++ b/cmd/api/api/cdp.go @@ -63,7 +63,7 @@ func (s *ApiService) serveDesktop(w http.ResponseWriter, r *http.Request, transp selector = inst.Id } prefix := "/instances/" + selector + "/cdp" - if !strings.HasPrefix(r.URL.Path, prefix+"/") || r.URL.RawPath != "" || r.URL.RawQuery != "" || r.URL.ForceQuery || r.ContentLength != 0 || len(r.TransferEncoding) != 0 { + if !strings.HasPrefix(r.URL.Path, prefix+"/") || desktop.ValidateBodylessRequest(r) != nil { http.Error(w, "invalid desktop request", 400) return } diff --git a/docs/macos-desktop.md b/docs/macos-desktop.md index f2523a127..1fa03b24f 100644 --- a/docs/macos-desktop.md +++ b/docs/macos-desktop.md @@ -79,6 +79,12 @@ validation gates; these OS interactions have not been exercised by this PR's tes ## CDP transport boundary +The host `NewCDPProxy` is the only discovery-validation/public-URL rewriting +boundary. The host-only guest listener uses `NewCDPForwarder` to carry Chrome's +fixed-upstream discovery unchanged. Both retain body/path/query/encoding admission, +header isolation and no-redirect policy; guest session/browser ownership is still +checked before forwarding. The guest forwarder is not a public authenticated proxy. + `NewCDPProxy` supports GET discovery and browser/page debugger WebSockets. Its caller supplies a fixed transport, strips the instance route prefix, and supplies a trusted instance-scoped `ws`/`wss` base. Clients cannot select the upstream host, diff --git a/lib/desktop/cdp.go b/lib/desktop/cdp.go index 2a64587c0..ebab77bd6 100644 --- a/lib/desktop/cdp.go +++ b/lib/desktop/cdp.go @@ -24,6 +24,16 @@ func NewCDPProxy(transport http.RoundTripper, publicBase string) (http.Handler, if err != nil || base == nil || (base.Scheme != "ws" && base.Scheme != "wss") || base.Host == "" || base.User != nil || base.RawQuery != "" || base.ForceQuery || base.Fragment != "" || base.RawPath != "" || strings.HasSuffix(base.Path, "/") { return nil, fmt.Errorf("invalid public CDP base") } + return newBrowserProxy(transport, base) +} + +// NewCDPForwarder serves the guest's fixed loopback browser without interpreting +// discovery. The authenticated host proxy owns public URL validation/rewriting. +func NewCDPForwarder(transport http.RoundTripper) (http.Handler, error) { + return newBrowserProxy(transport, nil) +} + +func newBrowserProxy(transport http.RoundTripper, publicBase *url.URL) (http.Handler, error) { if transport == nil { return nil, fmt.Errorf("CDP transport required") } @@ -54,8 +64,8 @@ func NewCDPProxy(transport http.RoundTripper, publicBase string) (http.Handler, if r.StatusCode >= 300 && r.StatusCode < 400 { return fmt.Errorf("CDP redirects unsupported") } - if r.StatusCode == http.StatusOK && discoveryPath(r.Request.URL.Path) { - return rewriteDiscovery(r, base.String()) + if publicBase != nil && r.StatusCode == http.StatusOK && discoveryPath(r.Request.URL.Path) { + return rewriteDiscovery(r, publicBase.String()) } return nil }, @@ -74,7 +84,7 @@ func NewCDPProxy(transport http.RoundTripper, publicBase string) (http.Handler, // ValidateCDPRequest allows API admission to run before dialing any guest service. func ValidateCDPRequest(r *http.Request) error { - if r.Method != http.MethodGet || r.ContentLength != 0 || len(r.TransferEncoding) != 0 || r.URL.RawQuery != "" || r.URL.ForceQuery || r.URL.RawPath != "" || !(discoveryPath(r.URL.Path) || debuggerPath(r.URL.Path)) { + if ValidateBodylessRequest(r) != nil || r.Method != http.MethodGet || !(discoveryPath(r.URL.Path) || debuggerPath(r.URL.Path)) { return fmt.Errorf("unsupported CDP request") } if debuggerPath(r.URL.Path) && !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { @@ -83,6 +93,15 @@ func ValidateCDPRequest(r *http.Request) error { return nil } +// ValidateBodylessRequest rejects alternate URL encodings, query parameters and +// request bodies for both desktop control and CDP routes. +func ValidateBodylessRequest(r *http.Request) error { + if r.ContentLength != 0 || len(r.TransferEncoding) != 0 || r.URL.RawQuery != "" || r.URL.ForceQuery || r.URL.RawPath != "" { + return fmt.Errorf("invalid desktop request") + } + return nil +} + func discoveryPath(path string) bool { switch path { case "/json", "/json/list", "/json/version", "/json/protocol": diff --git a/lib/desktop/cdp_forwarder_test.go b/lib/desktop/cdp_forwarder_test.go new file mode 100644 index 000000000..24bc471f1 --- /dev/null +++ b/lib/desktop/cdp_forwarder_test.go @@ -0,0 +1,83 @@ +package desktop + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCDPForwarderLeavesDiscoveryToHostBoundary(t *testing.T) { + for _, tc := range []struct { + name, payload string + status int + }{ + {"valid", ` {"webSocketDebuggerUrl":"ws://127.0.0.1:9222/devtools/browser/id","devtoolsFrontendUrl":"unsafe"} `, 200}, + {"foreign-host", `{"webSocketDebuggerUrl":"ws://evil.example/devtools/browser/id"}`, 502}, + {"bad-json", `not json`, 502}, + {"oversized", strings.Repeat(" ", maxDiscoveryBytes+1), 502}, + } { + t.Run(tc.name, func(t *testing.T) { + browser := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for _, key := range []string{"Authorization", "Cookie", "Origin", "X-API-Key", "X-Forwarded-For"} { + if r.Header.Get(key) != "" { + t.Errorf("forwarded %s", key) + } + } + w.Header().Set("Set-Cookie", "private=yes") + io.WriteString(w, tc.payload) + })) + defer browser.Close() + forwarder, err := NewCDPForwarder(testTransport(t, browser)) + if err != nil { + t.Fatal(err) + } + // The guest forwards bytes, not a second JSON transform. + direct := httptest.NewRecorder() + forwarder.ServeHTTP(direct, httptest.NewRequest("GET", "http://guest/json/version", nil)) + if direct.Code != 200 || direct.Body.String() != tc.payload || direct.Header().Get("Set-Cookie") != "" { + t.Fatal("guest modified discovery or leaked cookies") + } + guest := httptest.NewServer(forwarder) + defer guest.Close() + host, err := NewCDPProxy(testTransport(t, guest), "wss://api.example/instances/id/cdp") + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest("GET", "http://api.example/json/version", nil) + request.Header.Set("Authorization", "private") + request.Header.Set("Cookie", "private=yes") + response := httptest.NewRecorder() + host.ServeHTTP(response, request) + if response.Code != tc.status { + t.Fatalf("host status=%d want %d", response.Code, tc.status) + } + if tc.status == 200 && (!strings.Contains(response.Body.String(), "wss://api.example/instances/id/cdp/devtools/browser/id") || strings.Contains(response.Body.String(), "unsafe")) { + t.Fatal("host failed discovery transformation") + } + }) + } +} + +func TestCDPForwarderRejectsRequestsAndRedirects(t *testing.T) { + browser := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", "http://evil.example") + w.WriteHeader(302) + })) + defer browser.Close() + handler, err := NewCDPForwarder(testTransport(t, browser)) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + method, path string + status int + }{{"GET", "/json/new", 400}, {"POST", "/json/version", 400}, {"GET", "/json/version?url=evil", 400}, {"GET", "/json/version", 502}} { + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(tc.method, "http://guest"+tc.path, nil)) + if response.Code != tc.status { + t.Fatalf("%s %s=%d", tc.method, tc.path, response.Code) + } + } +} diff --git a/lib/desktop/cdp_test.go b/lib/desktop/cdp_test.go index ecebef177..b97e0ff1e 100644 --- a/lib/desktop/cdp_test.go +++ b/lib/desktop/cdp_test.go @@ -153,7 +153,13 @@ func TestCDPWebSocketRoundTrip(t *testing.T) { } })) defer upstream.Close() - h, err := NewCDPProxy(testTransport(t, upstream), "ws://api.example/instances/id/cdp") + forwarder, err := NewCDPForwarder(testTransport(t, upstream)) + if err != nil { + t.Fatal(err) + } + guest := httptest.NewServer(forwarder) + defer guest.Close() + h, err := NewCDPProxy(testTransport(t, guest), "ws://api.example/instances/id/cdp") if err != nil { t.Fatal(err) } diff --git a/lib/desktop/service.go b/lib/desktop/service.go index 9ac155a13..f710eafce 100644 --- a/lib/desktop/service.go +++ b/lib/desktop/service.go @@ -67,7 +67,7 @@ func NewService(backend Backend, uid uint32, cdp http.Handler) (http.Handler, er _ = json.NewEncoder(w).Encode(s) } mux.HandleFunc("GET /status", func(w http.ResponseWriter, r *http.Request) { - if r.URL.RawQuery != "" || r.URL.ForceQuery || r.ContentLength != 0 || len(r.TransferEncoding) != 0 { + if ValidateBodylessRequest(r) != nil { http.Error(w, "invalid status request", 400) return } @@ -81,7 +81,7 @@ func NewService(backend Backend, uid uint32, cdp http.Handler) (http.Handler, er respond(w, s) }) mux.HandleFunc("POST /browser/start", func(w http.ResponseWriter, r *http.Request) { - if r.URL.RawQuery != "" || r.URL.ForceQuery || r.ContentLength != 0 || len(r.TransferEncoding) != 0 { + if ValidateBodylessRequest(r) != nil { http.Error(w, "browser launch arguments unsupported", 400) return } diff --git a/lib/system/guest_agent/desktop_darwin.go b/lib/system/guest_agent/desktop_darwin.go index eb09b217c..1905d5023 100644 --- a/lib/system/guest_agent/desktop_darwin.go +++ b/lib/system/guest_agent/desktop_darwin.go @@ -17,7 +17,7 @@ func runDesktopAgent() error { } transport := &http.Transport{Proxy: nil, DisableKeepAlives: true, ResponseHeaderTimeout: 3 * time.Second} defer transport.CloseIdleConnections() - proxy, err := desktop.NewCDPProxy(transport, "ws://127.0.0.1:9222") + proxy, err := desktop.NewCDPForwarder(transport) if err != nil { return err }