diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index e507d9efcd..b96d305e66 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -3,7 +3,6 @@ Scoped guidance for `apps/backend/`. Repo-wide rules (commit format, code-quality limits, etc.) live in the root `AGENTS.md`. For plugin work, start at the [canonical plugin authoring guide](../../docs/public/plugins-authoring.md), follow choose recipe → edit `manifest.yaml` → implement → validate → package → smoke test, and treat `pkg/pluginsdk`, `proto/kandev/plugin/v1/plugin.proto`, `internal/plugins/manifest`, and `internal/plugins/pkgtar` as authoritative; the fixture is test support, and plugins must not access databases or `internal/...` packages. ## Package Structure - ```text apps/backend/ ├── cmd/ @@ -180,6 +179,7 @@ Standalone agentctl is launched in its own process group so terminal Ctrl+C is h - **Dispatched WS actions have a gateway backstop.** `Client.authorizeAction` (`internal/gateway/websocket/dispatch_scope.go`) checks any payload carrying `task_id` or `session_id` against the client's identity *before* dispatch, so a newly added action is scoped by default rather than only when its author remembers. Handler-level scoping is still required (defense in depth, and it covers non-WS callers) — do not delete a service-layer check because the backstop exists. The backstop reads `task_id`, `session_id` and `task_environment_id`, plus `id` on **top-level `task.` actions only** (`task.state`, `task.move`, `task.get`, … — `task.` and exactly one more segment). Keep using those names for task-scoped actions, because an action that invents a new name for the same kind of resource silently opts out. (`user_shell.stop` was exactly that: it keys off `task_environment_id` with `task_id` optional, so a `task_id`-only backstop missed it. `task.state` and `task.move` were the second instance: they name the task `id`, so the backstop parsed no refs and let one user mutate any other user's task — hence the `id` rule.) Deeper `task.*` namespaces are deliberately excluded: in `task.plan.revision.get` and `task.review.finding.update` the `id` names a revision or a finding, so those actions must carry `task_id` when they are task-scoped. Also avoid permissive types (`any`, `json.RawMessage`) for those fields: a payload that fails to unmarshal into the ref struct is treated as naming nothing. - **WS**: clients carry their identity; dispatched actions and subscriptions are scoped; workspace-carrying events route via `Hub.BroadcastToWorkspace`. A new `hub.Broadcast` (global) call site needs a `//ws:global` justification comment. - **Task overview vs. session detail:** Keep cross-task state in the bounded, persisted `TaskStatusSummary` projection and `task.status_summary.updated`; keep unbounded transcript and execution data session-scoped. The [bounded task-status spec](../../docs/specs/platform/bounded-task-status-delivery.md) and [accepted ADR](../../docs/decisions/2026-08-01-separate-task-summary-session-stream-traffic.md) define the shared contract. +- **Port-proxy subtree capability:** with auth enforced, `/port-proxy/:sessionId/:port/*` needs a credential on *every* request, but browser subresource fetches cannot always carry one (the `` fetch never sends cookies; sandboxed iframes and `?token=`-opened previews drop them elsewhere). `PortProxyHandler` mints a short-lived (15 min, sliding), path-scoped, HMAC-signed capability after the document authenticates and propagates it as a cookie scoped to the proxy subtree plus a `kandev_cap` query parameter appended to rewritten asset URLs. `requireConnectionAuth` (`internal/gateway/websocket/access.go`) accepts either form only for the exact session:port it was minted for and restores the issuing identity, so `CheckSessionAccess` still gates on the real owner. Synthetic identities (auth disabled) skip minting — proxied bodies stay byte-identical to the pre-auth behavior. - **Self-authenticating webhooks** (automation, office channels, plugin webhooks) and `/health`, `/api/v1/features`, `/api/v1/app-state` stay public — the allowlist lives in `auth/httpmw/middleware.go` with a pinning test. ## Execution Flow diff --git a/apps/backend/internal/backendapp/auth.go b/apps/backend/internal/backendapp/auth.go index 9208002b45..95911ef52e 100644 --- a/apps/backend/internal/backendapp/auth.go +++ b/apps/backend/internal/backendapp/auth.go @@ -81,6 +81,13 @@ func gatewayAuthPolicy(authSvc *auth.Service, taskSvc *taskservice.Service, task return gateways.AuthPolicy{ Enforced: func() bool { return authSvc.Mode() != auth.ModeDisabled }, ResolveToken: authSvc.ResolveBearer, + // A restored subtree-capability identity must pass the same live + // account gate as cookie/PAT auth: IdentityForUser fails when the + // account is missing or no longer active. + ActiveUser: func(ctx context.Context, userID string) bool { + _, ok := authSvc.IdentityForUser(ctx, userID) + return ok + }, Subscriptions: gateways.SubscriptionAccessPolicy{ Task: taskSvc.AuthorizeTaskAccess, Session: taskSvc.AuthorizeSessionAccess, diff --git a/apps/backend/internal/gateway/websocket/access.go b/apps/backend/internal/gateway/websocket/access.go index ab8b9eb381..8e736e2515 100644 --- a/apps/backend/internal/gateway/websocket/access.go +++ b/apps/backend/internal/gateway/websocket/access.go @@ -34,6 +34,12 @@ type AuthPolicy struct { // ResolveToken authenticates a ?token= query credential for // programmatic WS clients that cannot send cookies or headers. ResolveToken func(ctx context.Context, token string) (authn.Identity, bool) + // ActiveUser reports whether a user account is still active. It is + // consulted after capability authentication: the subtree capability + // restores the signed identity without a live credential, so without this + // check a user disabled mid-preview would keep the sliding-mint capability + // alive indefinitely. Nil skips the check (zero-policy compatibility). + ActiveUser func(ctx context.Context, userID string) bool // Subscriptions gates task/session topic subscriptions. Subscriptions SubscriptionAccessPolicy // WorkspaceOwner powers BroadcastToWorkspace owner routing. @@ -55,7 +61,12 @@ func (g *Gateway) SetAuthPolicy(policy AuthPolicy) { // requireConnectionAuth guards WS upgrades and proxy routes. The global HTTP // auth middleware has already resolved cookie/bearer credentials into the gin // context; this closes the gap for unauthenticated attempts (JSON 401 before -// any upgrade) and accepts ?token= for headerless clients. +// any upgrade) and accepts ?token= for headerless clients. Port-proxy +// requests additionally accept the short-lived subtree capability minted after +// the preview document authenticated (see PortProxyHandler): carried as a +// path-scoped cookie for ordinary subresource fetches, or as a query parameter +// appended to rewritten asset URLs for fetches that never send cookies (the +// browser's fetch, sandboxed iframes). func (g *Gateway) requireConnectionAuth() gin.HandlerFunc { return func(c *gin.Context) { policy := g.authPolicy @@ -67,9 +78,27 @@ func (g *Gateway) requireConnectionAuth() gin.HandlerFunc { c.Next() return } + if g.resolvePortProxyCapability(c) { + // A preview reloaded with ?token= after the capability cookie + // was installed authenticates via the capability, but the PAT is + // still a gateway credential: it must be stripped from the + // forwarded query (never reach agentctl or the app), while a + // cookie-authenticated app's own token parameter is preserved. + if token := c.Query("token"); token != "" && policy.ResolveToken != nil { + if _, ok := policy.ResolveToken(c.Request.Context(), token); ok { + c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), proxyTokenConsumedKey{}, true)) + } + } + c.Next() + return + } if token := c.Query("token"); token != "" && policy.ResolveToken != nil { if identity, ok := policy.ResolveToken(c.Request.Context(), token); ok { authn.SetOnGin(c, identity) + // Remember that the gateway consumed ?token= so the port proxy + // can strip it from the forwarded query; a cookie-authenticated + // app's own token parameter must be preserved. + c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), proxyTokenConsumedKey{}, true)) c.Next() return } @@ -78,6 +107,54 @@ func (g *Gateway) requireConnectionAuth() gin.HandlerFunc { } } +// resolvePortProxyCapability authenticates a /port-proxy/ request via the +// short-lived subtree capability minted after the preview document +// authenticated. It accepts the query-parameter form (appended to rewritten +// asset URLs for fetches that never send cookies, like the browser's manifest +// fetch) and the path-scoped cookie form (ordinary subresource fetches). The +// credential only validates against the exact session:port subtree it was +// minted for; on success the issuing identity is restored so downstream +// session-ownership checks still run as the real user. +func (g *Gateway) resolvePortProxyCapability(c *gin.Context) bool { + sessionID, port, ok := portProxyTarget(c) + if !ok || g.PortProxyHandler == nil { + return false + } + // Accept ANY value of the capability parameter that validates: a + // credential-less request may carry multiple kandev_cap values (the app's + // own or an encoded duplicate), and the first one must not be able to + // shadow a valid one. A restored identity whose account is no longer + // active is rejected (same active-user gate the cookie/PAT paths run). + active := func(identity authn.Identity) bool { + if g.authPolicy.ActiveUser == nil { + return true + } + return g.authPolicy.ActiveUser(c.Request.Context(), identity.UserID) + } + if values := c.Request.URL.Query()[proxyCapabilityQueryParam]; len(values) > 0 { + for _, raw := range values { + if identity, valid := g.PortProxyHandler.validateCapability(raw, sessionID, port); valid && active(identity) { + authn.SetOnGin(c, identity) + return true + } + } + } + // Accept ANY capability cookie that validates: duplicate kandev_port_proxy + // cookies (a stale or app-created value on a more-specific path is sent + // before the gateway's subtree cookie) must not let the first one shadow a + // valid value, matching the duplicate-safe query-parameter handling. + for _, cookie := range c.Request.Cookies() { + if cookie.Name != proxyCapabilityCookieName || cookie.Value == "" { + continue + } + if identity, valid := g.PortProxyHandler.validateCapability(cookie.Value, sessionID, port); valid && active(identity) { + authn.SetOnGin(c, identity) + return true + } + } + return false +} + // clientMayReceive reports whether a client may receive workspace-scoped // traffic for the given owner. Synthetic identities (auth disabled) see // everything; unowned workspaces ("" owner) are visible to all. diff --git a/apps/backend/internal/gateway/websocket/access_test.go b/apps/backend/internal/gateway/websocket/access_test.go index cb805eea17..3b29799b88 100644 --- a/apps/backend/internal/gateway/websocket/access_test.go +++ b/apps/backend/internal/gateway/websocket/access_test.go @@ -210,6 +210,229 @@ func assertAuthRequiredBody(t *testing.T, rec *httptest.ResponseRecorder) { } } +// TestRequireConnectionAuthAcceptsPortProxyCapability covers the subtree +// capability credential minted after the preview document authenticates: it is +// accepted as a query parameter (appended to rewritten asset URLs, used by +// credential-less fetches like the manifest) and as a path-scoped cookie +// (cookie-sending subresource fetches), and only for the exact session:port +// subtree it was minted for. +func TestRequireConnectionAuthAcceptsPortProxyCapability(t *testing.T) { + gin.SetMode(gin.TestMode) + gateway := newGatewayForTest(t) + gateway.SetPortProxy(nil) // constructs the handler; capability logic needs no manager + gateway.SetAuthPolicy(AuthPolicy{ + Enforced: func() bool { return true }, + ResolveToken: func(context.Context, string) (authn.Identity, bool) { return authn.Identity{}, false }, + }) + identity := authn.Identity{UserID: "owner-1", Role: authn.RoleAdmin} + capability := gateway.PortProxyHandler.issueCapability("sess-cap", 5173, identity) + + type outcome struct { + status int + identity authn.Identity + hasID bool + } + run := func(path string, cookie *http.Cookie) outcome { + t.Helper() + router := gin.New() + var got outcome + router.Any("/port-proxy/:sessionId/:port/*path", gateway.requireConnectionAuth(), func(c *gin.Context) { + got.identity, got.hasID = authn.FromGin(c) + c.Status(http.StatusOK) + }) + router.Any("/vscode/:sessionId/*path", gateway.requireConnectionAuth(), func(c *gin.Context) { + got.identity, got.hasID = authn.FromGin(c) + c.Status(http.StatusOK) + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + if cookie != nil { + req.AddCookie(cookie) + } + router.ServeHTTP(rec, req) + got.status = rec.Code + return got + } + + // Query parameter form: accepted, identity restored. + if got := run("/port-proxy/sess-cap/5173/manifest.webmanifest?"+proxyCapabilityQueryParam+"="+capability, nil); got.status != http.StatusOK { + t.Fatalf("capability query status = %d, want %d", got.status, http.StatusOK) + } else if !got.hasID || got.identity.UserID != "owner-1" { + t.Fatalf("capability query identity = %+v (has=%v), want owner-1", got.identity, got.hasID) + } + + // Cookie form: accepted. + cookie := &http.Cookie{Name: proxyCapabilityCookieName, Value: capability, Path: "/port-proxy/sess-cap/5173"} + if got := run("/port-proxy/sess-cap/5173/src/main.tsx", cookie); got.status != http.StatusOK { + t.Fatalf("capability cookie status = %d, want %d", got.status, http.StatusOK) + } + + // The same capability on a different subtree is rejected. + if got := run("/port-proxy/other-session/5173/x?"+proxyCapabilityQueryParam+"="+capability, nil); got.status != http.StatusUnauthorized { + t.Fatalf("capability on wrong session status = %d, want %d", got.status, http.StatusUnauthorized) + } + if got := run("/port-proxy/sess-cap/5174/x?"+proxyCapabilityQueryParam+"="+capability, nil); got.status != http.StatusUnauthorized { + t.Fatalf("capability on wrong port status = %d, want %d", got.status, http.StatusUnauthorized) + } + + // A capability on a non-port-proxy route is never consulted. + if got := run("/vscode/sess-cap/x?"+proxyCapabilityQueryParam+"="+capability, nil); got.status != http.StatusUnauthorized { + t.Fatalf("capability on vscode route status = %d, want %d", got.status, http.StatusUnauthorized) + } + + // No credential at all is still rejected. + if got := run("/port-proxy/sess-cap/5173/x", nil); got.status != http.StatusUnauthorized { + t.Fatalf("no credential status = %d, want %d", got.status, http.StatusUnauthorized) + } +} + +// A request may carry multiple kandev_cap values (the app's own or encoded +// duplicates); a valid one must win even when an invalid value comes first. +func TestRequireConnectionAuthAcceptsAnyValidCapabilityAmongDuplicates(t *testing.T) { + gin.SetMode(gin.TestMode) + gateway := newGatewayForTest(t) + gateway.SetPortProxy(nil) + gateway.SetAuthPolicy(AuthPolicy{ + Enforced: func() bool { return true }, + ResolveToken: func(context.Context, string) (authn.Identity, bool) { return authn.Identity{}, false }, + }) + capability := gateway.PortProxyHandler.issueCapability("sess-cap", 5173, + authn.Identity{UserID: "owner-1", Role: authn.RoleAdmin}) + + router := gin.New() + var got struct { + identity authn.Identity + hasID bool + } + router.Any("/port-proxy/:sessionId/:port/*path", gateway.requireConnectionAuth(), func(c *gin.Context) { + got.identity, got.hasID = authn.FromGin(c) + c.Status(http.StatusOK) + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, + "/port-proxy/sess-cap/5173/x?"+proxyCapabilityQueryParam+"=garbage&"+proxyCapabilityQueryParam+"="+capability, nil) + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d (valid duplicate must win)", rec.Code, http.StatusOK) + } + if !got.hasID || got.identity.UserID != "owner-1" { + t.Fatalf("identity = %+v (has=%v), want owner-1", got.identity, got.hasID) + } +} + +// A restored capability identity must pass the same live-account gate as +// cookie/PAT auth: a user disabled mid-preview cannot keep the sliding-mint +// capability alive. +func TestRequireConnectionAuthCapabilityRequiresActiveUser(t *testing.T) { + gin.SetMode(gin.TestMode) + gateway := newGatewayForTest(t) + gateway.SetPortProxy(nil) + identity := authn.Identity{UserID: "owner-1", Role: authn.RoleAdmin} + capability := gateway.PortProxyHandler.issueCapability("sess-cap", 5173, identity) + + run := func(active func(context.Context, string) bool) int { + gateway.SetAuthPolicy(AuthPolicy{ + Enforced: func() bool { return true }, + ResolveToken: func(context.Context, string) (authn.Identity, bool) { return authn.Identity{}, false }, + ActiveUser: active, + }) + router := gin.New() + router.Any("/port-proxy/:sessionId/:port/*path", gateway.requireConnectionAuth(), func(c *gin.Context) { + c.Status(http.StatusOK) + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/port-proxy/sess-cap/5173/x?"+proxyCapabilityQueryParam+"="+capability, nil) + router.ServeHTTP(rec, req) + return rec.Code + } + + if got := run(func(context.Context, string) bool { return true }); got != http.StatusOK { + t.Fatalf("active user status = %d, want %d", got, http.StatusOK) + } + if got := run(func(context.Context, string) bool { return false }); got != http.StatusUnauthorized { + t.Fatalf("disabled user status = %d, want %d (capability must not outlive the account)", got, http.StatusUnauthorized) + } + // Nil ActiveUser preserves zero-policy behavior. + if got := run(nil); got != http.StatusOK { + t.Fatalf("nil ActiveUser status = %d, want %d", got, http.StatusOK) + } +} + +// Duplicate kandev_port_proxy cookies: a stale value sent first (more +// specific path) must not shadow a valid subtree cookie — any cookie value +// that validates authorizes the request. +func TestRequireConnectionAuthAcceptsAnyValidCapabilityCookie(t *testing.T) { + gin.SetMode(gin.TestMode) + gateway := newGatewayForTest(t) + gateway.SetPortProxy(nil) + gateway.SetAuthPolicy(AuthPolicy{ + Enforced: func() bool { return true }, + ResolveToken: func(context.Context, string) (authn.Identity, bool) { return authn.Identity{}, false }, + }) + capability := gateway.PortProxyHandler.issueCapability("sess-cap", 5173, + authn.Identity{UserID: "owner-1", Role: authn.RoleAdmin}) + + router := gin.New() + var got struct { + identity authn.Identity + hasID bool + } + router.Any("/port-proxy/:sessionId/:port/*path", gateway.requireConnectionAuth(), func(c *gin.Context) { + got.identity, got.hasID = authn.FromGin(c) + c.Status(http.StatusOK) + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/port-proxy/sess-cap/5173/x", nil) + req.AddCookie(&http.Cookie{Name: proxyCapabilityCookieName, Value: "stale-not-valid", Path: "/port-proxy/sess-cap/5173"}) + req.AddCookie(&http.Cookie{Name: proxyCapabilityCookieName, Value: capability, Path: "/port-proxy/sess-cap/5173"}) + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d (stale cookie must not shadow the valid one)", rec.Code, http.StatusOK) + } + if !got.hasID || got.identity.UserID != "owner-1" { + t.Fatalf("identity = %+v (has=%v), want owner-1", got.identity, got.hasID) + } +} + +// TestRequireConnectionAuthIgnoresCapabilityWhenHandlerUnwired pins that the +// capability gate is inert when the port proxy is not wired (SetupRoutes +// registers no port-proxy routes then, and requireConnectionAuth must not +// reach into a nil handler). +func TestRequireConnectionAuthIgnoresCapabilityWhenHandlerUnwired(t *testing.T) { + gin.SetMode(gin.TestMode) + gateway := NewGateway(newNopTestLogger(t)) + gateway.SetAuthPolicy(AuthPolicy{ + Enforced: func() bool { return true }, + ResolveToken: func(context.Context, string) (authn.Identity, bool) { return authn.Identity{}, false }, + }) + + router := gin.New() + var called bool + router.GET("/ws", gateway.requireConnectionAuth(), func(c *gin.Context) { + called = true + c.Status(http.StatusOK) + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/ws?token=anything", nil) + req.AddCookie(&http.Cookie{Name: proxyCapabilityCookieName, Value: "whatever"}) + router.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } + if called { + t.Fatal("downstream handler ran without a valid credential") + } +} + +func newNopTestLogger(t *testing.T) *logger.Logger { + t.Helper() + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("logger: %v", err) + } + return log +} + func newAccessTestHub(t *testing.T) *Hub { t.Helper() log, err := logger.NewFromZap(zap.NewNop()) diff --git a/apps/backend/internal/gateway/websocket/port_proxy.go b/apps/backend/internal/gateway/websocket/port_proxy.go index 1242c0009f..82410daacb 100644 --- a/apps/backend/internal/gateway/websocket/port_proxy.go +++ b/apps/backend/internal/gateway/websocket/port_proxy.go @@ -1,21 +1,95 @@ package websocket import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" "fmt" "net/http" "net/http/httputil" "net/url" + "path" "strconv" "strings" "sync" + "time" "github.com/gin-gonic/gin" "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/common/logger" ) +// Port-proxy capability credential. +// +// With authentication enforced, every request to /port-proxy/:sessionId/:port/* +// requires a credential (session cookie, PAT, or ?token=). The preview document +// carries one, but the browser's subresource fetches do not always: fetches are made with credentials omitted, so they never send +// the session cookie, and headerless contexts (sandboxed iframes, ?token= URLs) +// drop it from other requests too. A document that authenticated would 200 +// while every asset 401s — the preview appears broken. +// +// The handler therefore mints a short-lived, path-scoped capability after the +// document authenticates and propagates it two ways: as a cookie scoped to the +// proxy subtree (covers cookie-sending subresource fetches) and appended to the +// rewritten asset URLs in proxied HTML/CSS (covers the credential-less manifest +// fetch and cookie-less contexts). requireConnectionAuth accepts either form +// for the matching /port-proxy/ subtree and reconstructs the identity, so +// CheckSessionAccess still gates on the real owner. +const ( + // proxyCapabilityCookieName is the cookie carrying the subtree capability. + proxyCapabilityCookieName = "kandev_port_proxy" + // proxyCapabilityQueryParam is the query parameter appended to rewritten + // asset URLs by rewriteProxyResponse. + proxyCapabilityQueryParam = "kandev_cap" + // proxyCapabilityTTL is how long a minted capability stays valid. Sliding: + // every proxied response mints a fresh one. Previews opened with a durable + // session cookie re-authenticate indefinitely; a preview that relies + // solely on the capability (no cookie storage) is deliberately short-lived + // — after an idle proxyCapabilityTTL the capability expires and the + // subtree requires a fresh credential. + proxyCapabilityTTL = 15 * time.Minute +) + +// proxyCapabilityContextKey carries the capability for the current request from +// the handler into the cached proxy's ModifyResponse, which appends it to +// rewritten asset URLs. +type proxyCapabilityContextKey struct{} + +// proxyCSSBaseContextKey carries the PUBLIC directory (proxy prefix + app +// path directory, trailing slash) that a standalone CSS response resolves +// relative references against. The request path is rewritten to the +// agentctl form before the proxy runs, so the public path must be captured +// at the handler. +type proxyCSSBaseContextKey struct{} + +// publicCSSBase returns the public directory (trailing slash) that a +// standalone CSS file at the given public path resolves relative references +// against: proxyPrefix + the directory of the app-relative path. +func publicCSSBase(proxyPrefix, remaining string) string { + if remaining == "" { + return "" + } + dir := path.Dir(remaining) + if dir == "." || dir == "/" { + return proxyPrefix + "/" + } + return proxyPrefix + "/" + strings.TrimPrefix(dir, "/") + "/" +} + +// proxyTokenConsumedKey marks that requireConnectionAuth authenticated this +// request via ?token=, so the port proxy strips the token from the +// forwarded query. When absent, a ?token= parameter belongs to the app and is +// forwarded untouched. +type proxyTokenConsumedKey struct{} + // portProxyEntry caches a reverse proxy and its target for a session:port pair. type portProxyEntry struct { proxy *httputil.ReverseProxy @@ -28,16 +102,29 @@ type PortProxyHandler struct { lifecycleMgr *lifecycle.Manager logger *logger.Logger + // capabilitySecret signs the subtree capability cookie/query credentials. + // Per-process random: capabilities are short-lived, so a backend restart + // simply invalidates them. + capabilitySecret [32]byte + mu sync.Mutex proxies map[string]*portProxyEntry // key: "sessionId:port" } // NewPortProxyHandler creates a new port proxy handler. func NewPortProxyHandler(lifecycleMgr *lifecycle.Manager, log *logger.Logger) *PortProxyHandler { + secret := [32]byte{} + if _, err := rand.Read(secret[:]); err != nil { + // crypto/rand never fails on supported platforms; a deterministic + // fallback would make capabilities forgeable, so fail loudly instead + // of serving an auth bypass. + panic(fmt.Sprintf("port proxy: generate capability secret: %v", err)) + } return &PortProxyHandler{ - lifecycleMgr: lifecycleMgr, - logger: log.WithFields(zap.String("component", "port-proxy")), - proxies: make(map[string]*portProxyEntry), + lifecycleMgr: lifecycleMgr, + logger: log.WithFields(zap.String("component", "port-proxy")), + capabilitySecret: secret, + proxies: make(map[string]*portProxyEntry), } } @@ -63,6 +150,22 @@ func (h *PortProxyHandler) HandlePortProxy(c *gin.Context) { return } + // After the request authenticated (session cookie, PAT, ?token=, or a + // previously issued subtree capability), mint a fresh short-lived + // capability for this session:port subtree. It authorizes the browser's + // subresource fetches — which cannot always carry the session credential — + // for the next proxyCapabilityTTL, sliding with every proxied response. + // Synthetic identities (auth disabled) skip it: with no auth anywhere the + // subtree needs no credential, and the proxied bodies stay byte-identical + // to the pre-auth behavior. + capability := "" + if identity, ok := authn.FromGin(c); ok && !identity.Synthetic { + capability = h.issueCapability(sessionID, port, identity) + h.setCapabilityCookie(c, sessionID, port, capability) + ctx := context.WithValue(c.Request.Context(), proxyCapabilityContextKey{}, capability) + c.Request = c.Request.WithContext(ctx) + } + proxy, err := h.resolveProxy(c, sessionID, port) if err != nil { return // error already written to response @@ -76,9 +179,26 @@ func (h *PortProxyHandler) HandlePortProxy(c *gin.Context) { if remaining == "" { remaining = "/" } + + // The runtime shim is served by the gateway itself (same-origin, so an + // app Content-Security-Policy that allows 'self' scripts lets it load), + // not by the proxied app. + if remaining == "/"+runtimeShimPath { + h.serveRuntimeShim(c, prefix, capability) + return + } + c.Request.URL.Path = "/api/v1/port-proxy/" + portStr + remaining c.Request.URL.RawPath = "" + // The request path is rewritten to the agentctl form above, so the + // public path is unrecoverable at ModifyResponse time. Stash the PUBLIC + // stylesheet directory for standalone CSS rewriting: relative CSS + // references resolve against the stylesheet file's public directory. + if dir := publicCSSBase(prefix, remaining); dir != "" { + c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), proxyCSSBaseContextKey{}, dir)) + } + h.logger.Debug("port proxy forwarding", zap.String("session_id", sessionID), zap.Int("port", port), @@ -151,6 +271,40 @@ func (h *PortProxyHandler) createProxy(cacheKey string, target *url.URL, authTok r.SetURL(target) r.Out.URL.Path = r.In.URL.Path r.Out.URL.RawPath = "" + // Never forward the browser's Accept-Encoding: Go's Transport adds + // its own and transparently decompresses, so ModifyResponse (and the + // HTML/CSS rewriter) always sees identity bytes. Forwarding gzip/br + // would deliver compressed bytes to the rewriter, which then strips + // Content-Encoding while keeping the compressed body. + r.Out.Header.Del("Accept-Encoding") + // The subtree capability is consumed by requireConnectionAuth at the + // gateway; strip it (in any encoding) before forwarding so it never + // lands in agentctl or application logs, redirects, or analytics. Only + // the reserved pairs are removed — every other parameter keeps its + // original bytes and order. The ?token= PAT is stripped only when the + // gateway actually consumed it for authentication; a cookie-authenticated + // app's own token parameter passes through. + if consumed, _ := r.In.Context().Value(proxyTokenConsumedKey{}).(bool); consumed { + r.Out.URL.RawQuery = stripReservedProxyParams(r.Out.URL.RawQuery) + } else { + r.Out.URL.RawQuery = stripCapabilityParam(r.Out.URL.RawQuery) + } + // The browser may send a rewritten asset URL (which embeds the + // capability, possibly percent-encoded) as the Referer of a later + // request; sanitize every Referer. Capability-free Referers are left + // byte-identical; malformed ones are dropped rather than forwarded + // with their query intact. + if ref := r.Out.Header.Get("Referer"); ref != "" { + if parsed, err := url.Parse(ref); err == nil { + cleaned := stripReservedProxyParams(parsed.RawQuery) + if cleaned != parsed.RawQuery { + parsed.RawQuery = cleaned + r.Out.Header.Set("Referer", parsed.String()) + } + } else { + r.Out.Header.Del("Referer") + } + } // Inject agentctl auth token if authToken != "" { r.Out.Header.Set("Authorization", "Bearer "+authToken) @@ -161,11 +315,35 @@ func (h *PortProxyHandler) createProxy(cacheKey string, target *url.URL, authTok } proxy.ModifyResponse = func(resp *http.Response) error { + filterReservedCookies(resp.Header) + capability, _ := resp.Request.Context().Value(proxyCapabilityContextKey{}).(string) + if capability != "" { + // Every response in an authenticated proxy subtree carries the + // per-user capability cookie, and rewritten bodies embed the + // capability: a shared intermediary must not retain or replay them + // for another user, and the browser must not leak the embedded + // capability to external origins through the Referer header. This + // applies to every response class (JS/JSON/redirects/upgrades + // included), not just the HTML/CSS bodies the rewriter touches. + resp.Header.Set("Cache-Control", "private, no-store") + resp.Header.Set("Referrer-Policy", "no-referrer") + } + // Redirect Location headers are root-relative to the proxied app; the + // browser resolves them against the gateway origin, so they must be + // re-anchored on the proxy subtree (navigation: no capability). + if loc := resp.Header.Get("Location"); loc != "" { + resp.Header.Set("Location", rewriteURLReference(loc, proxyPrefix, "")) + } if resp.StatusCode == http.StatusSwitchingProtocols { resp.Header.Set("Connection", "Upgrade") return nil } - return rewriteProxyResponse(resp, proxyPrefix) + if resp.Request.Method == http.MethodHead { + // HEAD responses carry no body; rewriting would synthesize a + // misleading Content-Length for the would-be GET body. + return nil + } + return rewriteProxyResponse(resp, proxyPrefix, capability) } proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { @@ -200,3 +378,218 @@ func (h *PortProxyHandler) InvalidateSession(sessionID string) { } } } + +// capabilityPayload is the signed, tamper-evident body of a subtree capability. +// The identity travels inside the payload so requireConnectionAuth can restore +// it and CheckSessionAccess still runs as the real owner. +type capabilityPayload struct { + UserID string `json:"u"` + Role string `json:"r"` + SessionID string `json:"s"` + Port int `json:"p"` + ExpiresAt int64 `json:"e"` // unix seconds +} + +// issueCapability mints a signed capability for the session:port subtree with +// the default TTL. +func (h *PortProxyHandler) issueCapability(sessionID string, port int, identity authn.Identity) string { + return h.issueCapabilityWithTTL(sessionID, port, identity, proxyCapabilityTTL) +} + +// issueCapabilityWithTTL mints a signed capability with an explicit TTL (test +// seam for expiry coverage). +func (h *PortProxyHandler) issueCapabilityWithTTL(sessionID string, port int, identity authn.Identity, ttl time.Duration) string { + payload := capabilityPayload{ + UserID: identity.UserID, + Role: string(identity.Role), + SessionID: sessionID, + Port: port, + ExpiresAt: time.Now().Add(ttl).Unix(), + } + raw, err := json.Marshal(payload) + if err != nil { + // The payload is a fixed shape; marshal cannot fail. + return "" + } + encoded := base64.RawURLEncoding.EncodeToString(raw) + mac := h.signCapability(raw) + return encoded + "." + mac +} + +// validateCapability verifies the signature, expiry, and session:port target of +// a capability and returns the identity it was issued to. +func (h *PortProxyHandler) validateCapability(raw, sessionID string, port int) (authn.Identity, bool) { + dot := strings.IndexByte(raw, '.') + if dot <= 0 || dot == len(raw)-1 { + return authn.Identity{}, false + } + encoded, mac := raw[:dot], raw[dot+1:] + payload, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return authn.Identity{}, false + } + if !hmac.Equal([]byte(h.signCapability(payload)), []byte(mac)) { + return authn.Identity{}, false + } + var parsed capabilityPayload + if err := json.Unmarshal(payload, &parsed); err != nil { + return authn.Identity{}, false + } + if parsed.SessionID != sessionID || parsed.Port != port { + return authn.Identity{}, false + } + // Standard expiry semantics: the capability is valid strictly before + // ExpiresAt; at the exact boundary (now == ExpiresAt) it is expired. + if time.Now().Unix() >= parsed.ExpiresAt { + return authn.Identity{}, false + } + return authn.Identity{UserID: parsed.UserID, Role: authn.Role(parsed.Role)}, true +} + +// signCapability computes the HMAC-SHA256 of the capability payload. +func (h *PortProxyHandler) signCapability(payload []byte) string { + mac := hmac.New(sha256.New, h.capabilitySecret[:]) + _, _ = mac.Write(payload) + return hex.EncodeToString(mac.Sum(nil)) +} + +// cookieName extracts the cookie name from a Set-Cookie header value (the +// token before the first '='), trimming leading/trailing whitespace. An +// empty or malformed value yields "". +func cookieName(setCookie string) string { + name, _, _ := strings.Cut(setCookie, "=") + return strings.TrimSpace(name) +} + +// filterReservedCookies removes every upstream Set-Cookie whose name is the +// reserved capability cookie: a proxied app must never control +// kandev_port_proxy, since an app value with the same or a narrower Path +// could override the gateway's capability and 401 the authenticated subtree. +func filterReservedCookies(h http.Header) { + cookies := h["Set-Cookie"] + if len(cookies) == 0 { + return + } + kept := cookies[:0] + for _, c := range cookies { + if cookieName(c) == proxyCapabilityCookieName { + continue + } + kept = append(kept, c) + } + if len(kept) == 0 { + h.Del("Set-Cookie") + return + } + h["Set-Cookie"] = kept +} + +// setCapabilityCookie sets the subtree-scoped capability cookie on the response +// so the browser's cookie-sending subresource fetches stay authorized. The +// cookie's Path is exactly the proxy subtree, so it is never presented to (or +// accepted for) any other path. +func (h *PortProxyHandler) setCapabilityCookie(c *gin.Context, sessionID string, port int, capability string) { + if capability == "" { + return + } + path := "/port-proxy/" + sessionID + "/" + strconv.Itoa(port) + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(proxyCapabilityCookieName, capability, int(proxyCapabilityTTL.Seconds()), path, "", requestIsTLS(c), true) +} + +// requestIsTLS reports whether the request arrived over TLS, directly or via a +// reverse proxy setting X-Forwarded-Proto. Mirrors the auth cookie helper so +// the Secure flag tracks the session cookie. +func requestIsTLS(c *gin.Context) bool { + if c.Request.TLS != nil { + return true + } + return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") +} + +// proxyTokenQueryParam is the ?token= query credential requireConnectionAuth +// accepts on proxy routes. Like the capability, it is consumed at the gateway +// and must never be forwarded to the proxied app. +const proxyTokenQueryParam = "token" + +// stripCapabilityParam removes the capability query parameter from a raw query +// string. See stripReservedProxyParams. +func stripCapabilityParam(rawQuery string) string { + return stripQueryParam(rawQuery, proxyCapabilityQueryParam) +} + +// stripQueryParam removes the named query parameter from a raw query string, +// matching the decoded key so percent-encoded spellings are caught too. +func stripQueryParam(rawQuery, name string) string { + if rawQuery == "" { + return rawQuery + } + parts := strings.Split(rawQuery, "&") + out := make([]string, 0, len(parts)) + for _, part := range parts { + key, _, _ := strings.Cut(part, "=") + if decoded, err := url.QueryUnescape(key); err == nil { + key = decoded + } + if key != name { + out = append(out, part) + } + } + return strings.Join(out, "&") +} + +// stripReservedProxyParams removes every reserved gateway credential pair +// (kandev_cap, token) from a raw query string, matching the decoded key so +// percent-encoded spellings (%6bandev_cap=) are caught too. Every other +// parameter keeps its original bytes and order — nothing is re-encoded. +// Returns the input unchanged when no reserved pair is present. +// +// The grammar is "&"-separated pairs, which is the only form the gateway ever +// issues (the rewriter and runtime shim append with "?"/"&") and the only form +// the gateway's own query parser accepts as a credential; semicolon- or +// space-separated spellings cannot carry a gateway-issued capability, so they +// are out of scope here. +func stripReservedProxyParams(rawQuery string) string { + if rawQuery == "" { + return rawQuery + } + parts := strings.Split(rawQuery, "&") + out := make([]string, 0, len(parts)) + for _, part := range parts { + key, _, _ := strings.Cut(part, "=") + if decoded, err := url.QueryUnescape(key); err == nil { + key = decoded + } + if key != proxyCapabilityQueryParam && key != proxyTokenQueryParam { + out = append(out, part) + } + } + return strings.Join(out, "&") +} + +// portProxyTarget extracts the session:port subtree from a request handled by +// the /port-proxy/:sessionId/:port(/...) routes. +func portProxyTarget(c *gin.Context) (sessionID string, port int, ok bool) { + sessionID = c.Param("sessionId") + portStr := c.Param("port") + if sessionID == "" || portStr == "" { + return "", 0, false + } + port, err := strconv.Atoi(portStr) + if err != nil || port < 1024 || port > 65535 { + return "", 0, false + } + return sessionID, port, true +} + +// serveRuntimeShim answers the reserved per-subtree runtime shim path with the +// shim JavaScript, prefix and capability baked in. Served same-origin by the +// gateway so an app Content-Security-Policy allowing 'self' scripts does not +// block the proxy's runtime fixes; never cached (per-user body). +func (h *PortProxyHandler) serveRuntimeShim(c *gin.Context, prefix, capability string) { + js := runtimeShim(prefix, capability) + c.Header("Content-Type", "application/javascript; charset=utf-8") + c.Header("Cache-Control", "private, no-store") + c.Header("Referrer-Policy", "no-referrer") + c.Data(http.StatusOK, "application/javascript; charset=utf-8", []byte(js)) +} diff --git a/apps/backend/internal/gateway/websocket/port_proxy_auth_test.go b/apps/backend/internal/gateway/websocket/port_proxy_auth_test.go new file mode 100644 index 0000000000..300e56aaac --- /dev/null +++ b/apps/backend/internal/gateway/websocket/port_proxy_auth_test.go @@ -0,0 +1,680 @@ +package websocket + +import ( + "bytes" + "compress/gzip" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "regexp" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/auth" + "github.com/kandev/kandev/internal/auth/authn" + authhttpmw "github.com/kandev/kandev/internal/auth/httpmw" + "github.com/kandev/kandev/internal/auth/store" + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/logger" + userstore "github.com/kandev/kandev/internal/user/store" +) + +// newProxyAuthService builds an auth service with authentication enabled and an +// admin user whose session-cookie token is returned. Mirrors the httpmw test +// harness so the full production chain (httpmw.Middleware → requireConnectionAuth +// → HandlePortProxy) is exercised against a real credential store. +func newProxyAuthService(t *testing.T) (*auth.Service, string) { + t.Helper() + conn, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + users, cleanup, err := userstore.Provide(conn, conn) + if err != nil { + t.Fatalf("user store: %v", err) + } + t.Cleanup(func() { _ = cleanup() }) + authStore, err := store.New(conn, conn) + if err != nil { + t.Fatalf("auth store: %v", err) + } + cfg := &config.Config{} + cfg.Features.Auth = true + cfg.Auth.SessionTTLHours = 720 + svc, err := auth.NewService(context.Background(), auth.Deps{ + Cfg: cfg, Store: authStore, Users: users, + }) + if err != nil { + t.Fatalf("auth service: %v", err) + } + _, token, err := svc.Setup(context.Background(), "admin@x.dev", "adminpass123", "Admin", "", "") + if err != nil { + t.Fatalf("setup admin: %v", err) + } + return svc, token +} + +// newAuthEnforcingFakeAgentctl stands in for the agentctl inside an executor: it +// rejects requests without the injected Bearer token (like the real agentctl's +// bearerTokenAuth) and forwards /api/v1/port-proxy//* to the fake app. +// It records every forwarded request so tests can assert the gateway stripped +// the capability from the query before it reaches the app. +func newAuthEnforcingFakeAgentctl(t *testing.T, appURL, expectedToken string) (*httptest.Server, *fakeAgentctlRecordings) { + t.Helper() + target, err := url.Parse(appURL) + if err != nil { + t.Fatalf("parse app URL: %v", err) + } + var mu sync.Mutex + var forwarded []string // "path?query" as received by the fake agentctl + var referers []string + record := func(rawQuery string, path, referer string) { + mu.Lock() + defer mu.Unlock() + if rawQuery != "" { + forwarded = append(forwarded, path+"?"+rawQuery) + } else { + forwarded = append(forwarded, path) + } + referers = append(referers, referer) + } + proxy := &httputil.ReverseProxy{} + proxy.Rewrite = func(r *httputil.ProxyRequest) { + record(r.Out.URL.RawQuery, r.Out.URL.Path, r.Out.Header.Get("Referer")) + r.SetURL(target) + // /api/v1/port-proxy// → / + rest, ok := strings.CutPrefix(r.Out.URL.Path, "/api/v1/port-proxy/") + if ok { + if _, path, found := strings.Cut(rest, "/"); found { + r.Out.URL.Path = "/" + path + } else { + r.Out.URL.Path = "/" + } + } + r.Out.URL.RawPath = "" + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if expectedToken != "" && r.Header.Get("Authorization") != "Bearer "+expectedToken { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"error":"missing or invalid Authorization header"}`) + return + } + proxy.ServeHTTP(w, r) + })) + srv.Config.SetKeepAlivesEnabled(false) + t.Cleanup(srv.Close) + recordings := &fakeAgentctlRecordings{forwarded: &forwarded, referers: &referers, mu: &mu} + return srv, recordings +} + +// fakeAgentctlRecordings captures what the fake agentctl received, with a +// race-safe snapshot for assertions. +type fakeAgentctlRecordings struct { + mu *sync.Mutex + forwarded *[]string // "path?query" as received by the fake agentctl + referers *[]string +} + +func (r *fakeAgentctlRecordings) snapshot() (forwarded, referers []string) { + r.mu.Lock() + defer r.mu.Unlock() + forwarded = append([]string(nil), (*r.forwarded)...) + referers = append([]string(nil), (*r.referers)...) + return forwarded, referers +} + +// newFakeProxyApp serves a Vite-style single page: an HTML document referencing +// a manifest and a module script, plus the manifest itself. The HTML mirrors +// the index.html shape that triggers the bug: the manifest is fetched by +// the browser with credentials omitted, so it never carries the session cookie. +func newFakeProxyApp(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/manifest.webmanifest", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/manifest+json") + _, _ = io.WriteString(w, `{"name":"proxied-app"}`) + }) + mux.HandleFunc("/src/main.tsx", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/javascript") + _, _ = io.WriteString(w, `console.log("main");`) + }) + mux.HandleFunc("/redirect", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "/landing") + w.WriteHeader(http.StatusFound) + }) + mux.HandleFunc("/landing", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, `landedok`) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, ``+ + ``+ + ``+ + `proxied app`) + }) + srv := httptest.NewServer(mux) + srv.Config.SetKeepAlivesEnabled(false) + t.Cleanup(srv.Close) + return srv +} + +// proxyAuthGatewayResponse is one response from the gateway under test. +type proxyAuthGatewayResponse struct { + status int + body string + headers http.Header +} + +// proxyAuthGateway issues requests against the full production chain: CORS, +// auth middleware, gateway routes (requireConnectionAuth + port proxy), a +// token-enforcing fake agentctl, and the fake app. +func proxyAuthGateway(t *testing.T, handler http.Handler) func(method, path string, headers map[string]string) proxyAuthGatewayResponse { + t.Helper() + srv := httptest.NewUnstartedServer(handler) + srv.Config.SetKeepAlivesEnabled(false) + srv.Start() + t.Cleanup(srv.Close) + + transport := &http.Transport{DisableKeepAlives: true} + t.Cleanup(transport.CloseIdleConnections) + client := &http.Client{ + Transport: transport, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + return func(method, path string, headers map[string]string) proxyAuthGatewayResponse { + t.Helper() + req, err := http.NewRequest(method, srv.URL+path, nil) + if err != nil { + t.Fatalf("build request %s: %v", path, err) + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := client.Do(req) + if err != nil { + t.Fatalf("request %s: %v", path, err) + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body for %s: %v", path, err) + } + return proxyAuthGatewayResponse{status: resp.StatusCode, body: string(body), headers: resp.Header} + } +} + +var manifestLinkPattern = regexp.MustCompile(`href="([^"]*manifest\.webmanifest[^"]*)"`) + +// TestPortProxyServesCredentiallessSubresourcesAfterDocumentAuth pins the +// regression from the field report: with auth enforced, the preview document +// loads (it carries the session cookie) but the browser's manifest fetch — +// which never sends cookies — 401s on every subresource request. Once the +// document authenticates, the proxy must mint a short-lived, path-scoped +// capability and propagate it (cookie + rewritten asset URLs) so the whole +// proxy subtree stays authorized without requiring per-request credentials. +func TestPortProxyServesCredentiallessSubresourcesAfterDocumentAuth(t *testing.T) { + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("logger: %v", err) + } + svc, sessionToken := newProxyAuthService(t) + app := newFakeProxyApp(t) + agentctl, recordings := newAuthEnforcingFakeAgentctl(t, app.URL, "agentctl-token") + + manager := newProxyTestManager(t, log) + // Wire the production ownership boundary: the session-ownership check runs + // with whatever identity requireConnectionAuth restored (session cookie or + // capability), so a capability minted for user A must not unlock user B's + // session. Records the identities the check actually saw. + var checkerMu sync.Mutex + var seenOwners []string + manager.SetSessionAccessChecker(func(ctx context.Context, sessionID string) error { + identity, ok := authn.IdentityFromContext(ctx) + checkerMu.Lock() + defer checkerMu.Unlock() + if !ok { + seenOwners = append(seenOwners, "") + return errors.New("no identity") + } + seenOwners = append(seenOwners, identity.UserID) + if identity.UserID != "default-user" { + return errors.New("foreign session") + } + return nil + }) + addExecutionForURL(t, manager, "sess-auth", agentctl.URL, "agentctl-token", log) + + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(authhttpmw.Middleware(svc)) + gateway := NewGateway(log) + gateway.SetAuthPolicy(AuthPolicy{ + Enforced: func() bool { return svc.Mode() != auth.ModeDisabled }, + ResolveToken: svc.ResolveBearer, + }) + gateway.SetPortProxy(manager) + gateway.SetupRoutes(router) + + request := proxyAuthGateway(t, router) + cookieHeader := map[string]string{"Cookie": svc.CookieName() + "=" + sessionToken} + + // 1. The document request authenticates with the session cookie. + doc := request(http.MethodGet, "/port-proxy/sess-auth/5173/", cookieHeader) + if doc.status != http.StatusOK { + t.Fatalf("document status = %d, want %d (body=%s)", doc.status, http.StatusOK, doc.body) + } + if !strings.Contains(doc.body, `/port-proxy/sess-auth/5173/manifest.webmanifest`) { + t.Fatalf("document HTML does not reference the proxied manifest:\n%s", doc.body) + } + capSetCookie := doc.headers.Values("Set-Cookie") + var capabilityCookie string + for _, sc := range capSetCookie { + if strings.HasPrefix(sc, proxyCapabilityCookieName+"=") { + capabilityCookie = sc + } + } + if capabilityCookie == "" { + t.Fatal("document response does not set the path-scoped capability cookie") + } + if !strings.Contains(capabilityCookie, "Path=/port-proxy/sess-auth/5173") { + t.Fatalf("capability cookie %q not scoped to the proxy subtree", capabilityCookie) + } + // Capability-bearing responses are uncacheable and referrer-safe. + if got := doc.headers.Get("Cache-Control"); got != "private, no-store" { + t.Fatalf("document Cache-Control = %q, want %q", got, "private, no-store") + } + if got := doc.headers.Get("Referrer-Policy"); got != "no-referrer" { + t.Fatalf("document Referrer-Policy = %q, want %q", got, "no-referrer") + } + + // 2. The rewritten manifest link carries the capability query parameter, so + // the browser's credential-less manifest fetch authenticates without cookies. + match := manifestLinkPattern.FindStringSubmatch(doc.body) + if len(match) != 2 { + t.Fatalf("no manifest link in rewritten HTML:\n%s", doc.body) + } + manifestURL := match[1] + if !strings.Contains(manifestURL, proxyCapabilityQueryParam+"=") { + t.Fatalf("manifest link %q missing the capability query parameter", manifestURL) + } + manifestReq := request(http.MethodGet, manifestURL, nil) // no cookie, like Chrome's manifest fetch + if manifestReq.status != http.StatusOK { + t.Fatalf("manifest (capability query, no cookie) status = %d, want %d (body=%s)", + manifestReq.status, http.StatusOK, manifestReq.body) + } + + // 3. The capability never reaches the proxied app: the gateway strips the + // reserved parameter from the forwarded query (any encoding) and from any + // Referer header that would embed a rewritten asset URL. + forwarded, _ := recordings.snapshot() + for _, seen := range forwarded { + if strings.Contains(seen, proxyCapabilityQueryParam) { + t.Fatalf("capability leaked to agentctl in forwarded request %q", seen) + } + } + // A later request whose Referer embeds a rewritten asset URL (which carries + // the capability) must be forwarded with the capability stripped. The + // request itself authenticates with the capability, like a browser + // subresource would. + _, capQuery, _ := strings.Cut(manifestURL, proxyCapabilityQueryParam+"=") + warmURL := "/port-proxy/sess-auth/5173/@react-refresh?" + proxyCapabilityQueryParam + "=" + capQuery + warm := request(http.MethodGet, warmURL, + map[string]string{"Referer": "http://gateway.test" + manifestURL}) + if warm.status != http.StatusOK { + t.Fatalf("warm-up request status = %d, want %d", warm.status, http.StatusOK) + } + _, referers := recordings.snapshot() + if len(referers) > 0 { + last := referers[len(referers)-1] + if strings.Contains(last, proxyCapabilityQueryParam) { + t.Fatalf("capability leaked to agentctl in Referer %q", last) + } + if !strings.Contains(last, "manifest.webmanifest") { + t.Fatalf("expected the manifest Referer to survive sanitization, got %q", last) + } + } + + // A Referer whose capability key is percent-encoded must be sanitized too — + // the sanitizer cannot rely on the literal spelling. + encodedRef := "http://gateway.test/port-proxy/sess-auth/5173/x?%6bandev_cap=stale&a=1" + _ = request(http.MethodGet, "/port-proxy/sess-auth/5173/@vite/client?"+proxyCapabilityQueryParam+"="+capQuery, + map[string]string{"Referer": encodedRef}) + _, referers = recordings.snapshot() + if len(referers) > 0 { + last := referers[len(referers)-1] + if strings.Contains(last, "kandev_cap") || strings.Contains(last, "%6bandev_cap") { + t.Fatalf("encoded capability leaked to agentctl in Referer %q", last) + } + if !strings.Contains(last, "a=1") { + t.Fatalf("encoded-Referer sanitization dropped unrelated params, got %q", last) + } + } + + // 3b. A reload carrying BOTH the capability and ?token= authenticates + // via the capability, but the valid PAT is still a gateway credential and + // must be stripped from the forwarded query (never reach the app), while + // the capability itself is also stripped. + // A real gateway PAT (svc.ResolveBearer only accepts stored API tokens, + // not the Setup bootstrap token). + _, pat, patErr := svc.MintToken(context.Background(), userstore.DefaultUserID, "ci", 0) + if patErr != nil { + t.Fatalf("mint PAT: %v", patErr) + } + tokenReload := "/port-proxy/sess-auth/5173/@vite/client?" + proxyCapabilityQueryParam + "=" + capQuery + "&token=" + pat + tok := request(http.MethodGet, tokenReload, nil) + if tok.status != http.StatusOK { + t.Fatalf("capability+token reload status = %d, want %d", tok.status, http.StatusOK) + } + forwarded, _ = recordings.snapshot() + if len(forwarded) > 0 { + last := forwarded[len(forwarded)-1] + if strings.Contains(last, "token=") || strings.Contains(last, proxyCapabilityQueryParam) { + t.Fatalf("gateway PAT/capability leaked to agentctl in forwarded request %q", last) + } + } + + // 3c. Duplicate kandev_port_proxy cookies (a stale value first, then the + // valid subtree cookie) must not let the stale one shadow the valid one: + // any cookie value that validates authorizes the request. + staleCap := "kandev_port_proxy=stale-not-valid; Path=/port-proxy/sess-auth/5173" + dup := request(http.MethodGet, "/port-proxy/sess-auth/5173/@vite/client?"+proxyCapabilityQueryParam+"="+capQuery, + map[string]string{"Cookie": staleCap + "; " + capabilityCookie}) + if dup.status != http.StatusOK { + t.Fatalf("duplicate-cookie request status = %d, want %d (stale cookie must not shadow the valid one)", dup.status, http.StatusOK) + } + + // 4. Ownership restoration: every proxied request ran the session-ownership + // check as the real owner (default-user), never as a synthetic or empty + // identity. + checkerMu.Lock() + owners := append([]string(nil), seenOwners...) + checkerMu.Unlock() + if len(owners) == 0 { + t.Fatal("session-ownership checker never ran") + } + for _, owner := range owners { + if owner != "default-user" { + t.Fatalf("ownership check ran as %q, want default-user", owner) + } + } + + // 5. A capability minted for another user is rejected at the ownership + // boundary: requireConnectionAuth restores the foreign identity and the + // checker denies it. + intruder := gateway.PortProxyHandler.issueCapability("sess-auth", 5173, + authn.Identity{UserID: "intruder", Role: authn.RoleAdmin}) + denied := request(http.MethodGet, + "/port-proxy/sess-auth/5173/x?"+proxyCapabilityQueryParam+"="+intruder, nil) + if denied.status != http.StatusNotFound { + t.Fatalf("intruder capability status = %d, want %d (ownership denial)", denied.status, http.StatusNotFound) + } + checkerMu.Lock() + lastOwner := seenOwners[len(seenOwners)-1] + checkerMu.Unlock() + if lastOwner != "intruder" { + t.Fatalf("intruder request ran ownership check as %q, want intruder", lastOwner) + } + + // 6. The same manifest without any credential still 401s — the capability + // is not a blanket bypass. + noCred := request(http.MethodGet, "/port-proxy/sess-auth/5173/manifest.webmanifest", nil) + if noCred.status != http.StatusUnauthorized { + t.Fatalf("manifest (no credential) status = %d, want %d", noCred.status, http.StatusUnauthorized) + } + + // 7. A module script carrying only the capability cookie (Path-scoped, no + // session cookie) also passes — covers cookie-sending subresource fetches + // in contexts that lack the session cookie. + capValue := capabilityCookie + if idx := strings.Index(capValue, ";"); idx >= 0 { + capValue = capValue[:idx] + } + _, capToken, _ := strings.Cut(capValue, "=") + script := request(http.MethodGet, "/port-proxy/sess-auth/5173/src/main.tsx", + map[string]string{"Cookie": proxyCapabilityCookieName + "=" + capToken}) + if script.status != http.StatusOK { + t.Fatalf("module script (capability cookie, no session) status = %d, want %d (body=%s)", + script.status, http.StatusOK, script.body) + } + + // 8. The runtime shim is served by the gateway at its reserved path, with + // the prefix and capability baked in, so an app CSP allowing 'self' scripts + // does not block the proxy's runtime fixes. + shim := request(http.MethodGet, "/port-proxy/sess-auth/5173/__kandev_runtime_shim.js", cookieHeader) + if shim.status != http.StatusOK { + t.Fatalf("runtime shim status = %d, want %d", shim.status, http.StatusOK) + } + if ct := shim.headers.Get("Content-Type"); !strings.Contains(ct, "application/javascript") { + t.Fatalf("runtime shim Content-Type = %q, want application/javascript", ct) + } + if !strings.Contains(shim.body, `var P="/port-proxy/sess-auth/5173";`) { + t.Fatalf("runtime shim missing the baked-in prefix:\n%.300s", shim.body) + } + if shim.headers.Get("Cache-Control") != "private, no-store" { + t.Fatalf("runtime shim Cache-Control = %q, want private, no-store", shim.headers.Get("Cache-Control")) + } + // The shim body is also the exact JS served for the injected ` + `var OBS=['href','src','action','formaction','cite','data','poster','background','manifest','srcset','imagesrcset','ping','rel','style','content','http-equiv'];` + + `var MO=window.MutationObserver;if(MO&&document.documentElement){try{new MO(function(rs){for(var i=0;i tag: an app's Content-Security-Policy (script-src 'self') blocks inline +// scripts but allows a same-origin script. +func runtimeShim(prefix, capability string) string { + return fmt.Sprintf(runtimeShimTemplate, prefix, shimCapabilityJS(capability)) } -// urlInCSSPattern matches `url(...)` invocations in CSS where the argument is -// a path-absolute URL we want to rewrite. The argument can be optionally -// wrapped in single or double quotes and may be surrounded by whitespace. -// -// url(/foo) -// url('/foo') -// url("/foo") -// -// Network-relative (`//host/foo`) and absolute (`http://...`) are left alone. -var urlInCSSPattern = regexp.MustCompile(`url\(\s*(['"]?)(/[^/'"][^'")]*)`) +// runtimeShimPath is the reserved per-subtree path the port proxy serves the +// runtime shim JavaScript from. +const runtimeShimPath = "__kandev_runtime_shim.js" -// importInCSSPattern matches `@import "/foo";` style root-absolute imports. -var importInCSSPattern = regexp.MustCompile(`@import\s+(['"])(/[^/'"][^'"]*)['"]`) +// runtimeShimTag is the ` +} + +// validCSPNonce accepts only the CSP nonce grammar: base64/base64url +// characters with optional padding. A malformed nonce-shaped token from an +// app-controlled policy must never be interpolated into the injected markup. +func validCSPNonce(nonce string) bool { + if nonce == "" || len(nonce) > 256 { + return false + } + seenContent := false + padding := 0 + for i := 0; i < len(nonce); i++ { + if !cspNonceChar(nonce[i]) { + return false + } + if nonce[i] == '=' { + padding++ + if padding > 2 { + return false + } + } else { + if padding > 0 { + return false // padding only at the very end (ab=c invalid) + } + seenContent = true + } + } + // An all-padding token ("=", "==") is not a valid nonce value. + return seenContent +} + +// cspNonceChar reports whether a byte is valid in a CSP nonce (base64 / +// base64url alphabet). +func cspNonceChar(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || + c == '+' || c == '/' || c == '-' || c == '_' || c == '=' +} + +// cspScriptSrcElem is the CSP3 directive that governs script elements; when +// present it wins over script-src for the injected shim. +const cspScriptSrcElem = "script-src-elem" + +// scriptNoncesFromCSP returns the valid script nonces a single +// Content-Security-Policy value requires for script elements: from the +// script-src-elem directive when present (per CSP3 it governs script +// elements), else from script-src. When script-src-elem EXISTS it is the +// governing directive for script elements, so script-src nonces are NEVER +// used — even when the elem directive carries no nonce tokens (a nonce from +// script-src is not authorized by the governing directive). Invalid +// nonce-shaped tokens are skipped so a later valid one is used; multiple +// nonces in one directive are all candidates and the caller intersects +// across policies. +func scriptNoncesFromCSP(policy string) []string { + var elem, src []string + hasElem := false + for _, directive := range strings.Split(policy, ";") { + fields := strings.Fields(strings.TrimSpace(directive)) + if len(fields) == 0 { + continue + } + name := strings.ToLower(fields[0]) + if name != "script-src" && name != cspScriptSrcElem { + continue + } + if name == cspScriptSrcElem { + hasElem = true + } + for _, source := range fields[1:] { + nonce, ok := strings.CutPrefix(source, "'nonce-") + if !ok { + continue + } + nonce, ok = strings.CutSuffix(nonce, "'") + if ok && validCSPNonce(nonce) { + if name == cspScriptSrcElem { + elem = append(elem, nonce) + } else { + src = append(src, nonce) + } + } + } + } + if hasElem { + return elem + } + return src +} + +// cspScriptNonceSet returns the nonce set a single CSP policy constrains the +// injected script to, or nil when the policy does not constrain the choice: +// no script nonce in its governing directive means a nonce cannot help (the +// shim must match the policy's other sources), and 'self'/'*' already allow +// the same-origin shim regardless of any nonce attribute. 'strict-dynamic' +// inverts that: it disables 'self' for script elements (CSP3), so a nonce is +// REQUIRED even when 'self' is present. All source expressions are scanned +// before deciding, so 'strict-dynamic' later in the same directive is not +// masked by an earlier 'self'. +func cspScriptNonceSet(policy string) []string { + nonces := scriptNoncesFromCSP(policy) + if len(nonces) == 0 { + return nil + } + // The governing directive for script elements is script-src-elem when + // present, else script-src (CSP3 fallback); a nonce in the non-governing + // directive does not apply to the shim. + for _, name := range []string{cspScriptSrcElem, "script-src"} { + for _, directive := range strings.Split(policy, ";") { + fields := strings.Fields(strings.TrimSpace(directive)) + if len(fields) == 0 || !strings.EqualFold(fields[0], name) { + continue + } + hasStrictDynamic := false + hasSelfOrStar := false + for _, source := range fields[1:] { + switch source { + case "'strict-dynamic'": + hasStrictDynamic = true + case "'self'", "*": + hasSelfOrStar = true + } + } + if hasStrictDynamic { + return nonces // ignores 'self' for scripts; nonce required + } + if hasSelfOrStar { + return nil // shim allowed without a nonce + } + return nonces + } + } + return nonces +} + +// responseScriptNonce returns a script nonce for the injected shim tag that +// is valid under every ENFORCING Content-Security-Policy for the response — +// all CSP headers plus all CSP meta tags, since meta policies append to +// header policies — or "" when no single nonce satisfies all of them. With +// two enforcing policies using different nonces, a nonce valid under only +// one would still leave the injected shim blocked by the other. When no +// policy constrains the choice, the first valid nonce found is still +// attached: it is harmless (a nonce attribute never disables 'self'/host +// matching in other policies) and preserves nonce-friendly app setups. Meta +// tags are scanned with the HTML tokenizer: attributes are decoded +// (entity-encoded policies work), keys match exactly (data-content decoys do +// not), any attribute order is accepted, and every meta is examined. +func responseScriptNonce(resp *http.Response, body []byte) string { + var constraining [][]string + fallback := "" + collect := func(policy string) { + if set := cspScriptNonceSet(policy); set != nil { + constraining = append(constraining, set) + return + } + if fallback == "" { + if ns := scriptNoncesFromCSP(policy); len(ns) > 0 { + fallback = ns[0] + } + } + } + for _, policy := range resp.Header.Values("Content-Security-Policy") { + collect(policy) + } + scanMetaCSPPolicies(body, collect) + if len(constraining) == 0 { + return fallback + } + return intersectNonceSets(constraining) +} + +// scanMetaCSPPolicies walks an HTML body and invokes collect for every +// Content-Security-Policy meta tag (attributes decoded, any order, exact +// http-equiv key; every meta examined so a nonce-free policy cannot hide a +// later nonce-bearing one). +func scanMetaCSPPolicies(body []byte, collect func(string)) { + tok := html.NewTokenizer(bytes.NewReader(body)) + for { + tt := tok.Next() + if tt == html.ErrorToken { + return + } + if tt != html.StartTagToken && tt != html.SelfClosingTagToken { + continue + } + name, hasAttr := tok.TagName() + if !bytes.Equal(name, []byte("meta")) || !hasAttr { + continue + } + var equiv, content string + for { + key, val, more := tok.TagAttr() + switch { + case bytes.EqualFold(key, []byte("http-equiv")): + equiv = string(val) + case bytes.EqualFold(key, []byte("content")): + content = string(val) + } + if !more { + break + } + } + if strings.EqualFold(strings.TrimSpace(equiv), "content-security-policy") { + collect(content) + } + } +} + +// intersectNonceSets returns the first candidate present in every +// constraining set, or "" when the intersection is empty. +func intersectNonceSets(constraining [][]string) string { + candidates := constraining[0] + for _, set := range constraining[1:] { + kept := candidates[:0] + for _, c := range candidates { + for _, s := range set { + if c == s { + kept = append(kept, c) + break + } + } + } + candidates = kept + if len(candidates) == 0 { + return "" + } + } + return candidates[0] +} + +// shimCapabilityJS returns the JavaScript spliced into the shim's path +// rewriter so runtime-rewritten URLs carry the subtree capability. It appends +// the capability as a query parameter: any URL fragment is split off first so +// the query lands before it and existing query strings are preserved. The +// append is idempotent for the EXACT issued capability: a URL whose query has +// a kandev_cap parameter whose DECODED key matches and whose value equals the +// issued capability is not touched (this stops the MutationObserver from +// looping on its own rewrites), while an app's own different kandev_cap value +// still gets the issued capability appended — the gateway accepts any valid +// value among duplicates. A substring like `/foo/kandev_cap=x` in the path or +// `?note=kandev_cap=x` in another parameter does NOT suppress the append. +// Empty when no capability is minted, so the auth-disabled output carries no +// capability bytes (see TestRuntimeShim_NoCapabilityBytes). +func shimCapabilityJS(capability string) string { + if capability == "" { + return "" + } + param := proxyCapabilityQueryParam + "=" + return fmt.Sprintf(`var K=%q;`+ + `function hcp(u){var qi=u.indexOf('?');if(qi===-1)return false;var ps=u.slice(qi+1).split('&');for(var i=0;i/" (no trailing slash). It is prepended to // matched URLs that start with a single "/" — see `rewriteAbsolutePath`. -func rewriteProxyResponse(resp *http.Response, proxyPrefix string) error { +// +// `capability`, when non-empty, is appended to every rewritten URL as +// `kandev_cap=`. It is the short-lived subtree credential minted +// after the preview document authenticated; embedding it in the asset URLs +// keeps the browser's subresource fetches authorized even when they cannot +// carry cookies (the fetch, sandboxed iframes). +func rewriteProxyResponse(resp *http.Response, proxyPrefix, capability string) error { + // 1xx/204/205/206/304 responses are left untouched: 1xx/204/205/304 carry + // no body (304's Content-Length describes the selected representation), + // and 206's body is a byte RANGE whose Content-Range describes it — + // rewriting would desynchronize the length/range metadata. + if resp.StatusCode < 200 || resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusResetContent || resp.StatusCode == http.StatusPartialContent || resp.StatusCode == http.StatusNotModified { + return nil + } + // If the upstream response is still content-encoded here, Go's Transport + // did not (or could not) decode it (e.g. a misbehaving upstream ignoring + // Accept-Encoding and sending brotli/deflate/zstd): rewriting the + // compressed bytes and deleting Content-Encoding would corrupt the + // document. Leave such responses untouched. + if enc := resp.Header.Get("Content-Encoding"); enc != "" { + return nil + } ct := strings.ToLower(resp.Header.Get("Content-Type")) - var rewrite func([]byte, string) []byte + // The public directory the response was served from (stashed at the + // handler before the request path was rewritten to the agentctl form): + // HTML and CSS resolve their relative references against it, so a + // document below the proxy root (e.g. .../pages/index.html with + // ../assets) stays in-subtree and keeps its capability. + basePath := proxyPrefix + "/" + if resp.Request != nil { + if v, ok := resp.Request.Context().Value(proxyCSSBaseContextKey{}).(string); ok && v != "" { + basePath = v + } + } + var rewrite func([]byte, string, string, string) []byte switch { case strings.Contains(ct, "text/html"): - rewrite = rewriteHTMLURLs + base := basePath + rewrite = func(body []byte, prefix, capability, nonce string) []byte { + return rewriteHTMLURLsAtDepthWithBase(body, prefix, capability, nonce, 0, base) + } case strings.Contains(ct, "text/css"): - rewrite = rewriteCSSURLs + base := basePath + rewrite = func(body []byte, prefix, capability, _ string) []byte { + return rewriteCSSURLsAt(body, prefix, capability, base) + } default: return nil } @@ -134,7 +546,8 @@ func rewriteProxyResponse(resp *http.Response, proxyPrefix string) error { return closeErr } - modified := rewrite(body, proxyPrefix) + nonce := responseScriptNonce(resp, body) + modified := rewrite(body, proxyPrefix, capability, nonce) resp.Body = io.NopCloser(bytes.NewReader(modified)) resp.Header.Del("Content-Encoding") resp.Header.Set("Content-Length", strconv.Itoa(len(modified))) @@ -142,18 +555,48 @@ func rewriteProxyResponse(resp *http.Response, proxyPrefix string) error { return nil } +// withCapability appends the capability query parameter to a rewritten URL, +// using "&" when the URL already carries a query string. A URL fragment, when +// present, stays last — the capability must precede it or the browser would +// treat it as part of the fragment and never send it. +func withCapability(rewritten, capability string) string { + if capability == "" { + return rewritten + } + path, fragment, hasFragment := strings.Cut(rewritten, "#") + sep := "?" + if strings.ContainsRune(path, '?') { + sep = "&" + } + path += sep + proxyCapabilityQueryParam + "=" + capability + if hasFragment { + path += "#" + fragment + } + return path +} + // rewriteAbsolutePath turns a path-absolute URL ("/foo") into a proxied path // ("/foo"). Returns the input unchanged for non-rewritable cases: // empty strings, network-relative URLs (`//host`), schemes (`http:`, `data:`, // `mailto:`, etc.), or relative paths (`foo`, `./foo`, `../foo`). -func rewriteAbsolutePath(rawURL, prefix string) string { +// Dot segments in the path are normalized: a root path whose normalized form +// escapes the subtree (/../x) is returned unchanged rather than emitting a +// capability on a non-proxy path. +func rewriteAbsolutePath(rawURL, prefix, capability string) string { if len(rawURL) < 1 || rawURL[0] != '/' { return rawURL } if len(rawURL) >= 2 && rawURL[1] == '/' { return rawURL // network-relative } - return prefix + rawURL + if strings.Contains(rawURL, "/.") { + resolved := removeDotSegments(prefix + rawURL) + if !inSubtreePath(resolved, prefix) { + return rawURL + } + return withCapability(resolved, capability) + } + return withCapability(prefix+rawURL, capability) } // htmlRewriteState tracks per-document position during a single @@ -163,16 +606,61 @@ func rewriteAbsolutePath(rawURL, prefix string) string { type htmlRewriteState struct { out *bytes.Buffer prefix string + capability string shim string + nonce string + depth int inStyle bool inScript bool shimInjected bool + // basePath is the effective document base DIRECTORY (root-absolute, + // resolved against the subtree). "" means the effective base resolves + // OUTSIDE the proxy subtree (scheme-bearing, network-relative, or + // dot-segment-escaping base): every path-absolute and relative reference + // in the document resolves against an external origin, so embedding the + // capability in them would leak the bearer; rewriting is suppressed. + basePath string + baseSet bool +} + +// applyBase records the document's effective (the FIRST base +// element wins per the HTML spec). The href is left for the normal token +// rewrite pass (navigation: prefix-only, no capability); only the RESOLVED +// directory of the nav-rewritten value is captured here. A scheme-bearing, +// network-relative, or dot-segment-escaping base makes every later +// path/relative reference resolve outside the subtree (basePath = ""). +func (s *htmlRewriteState) applyBase(token *html.Token) { + if s.baseSet || token.Data != "base" { + return + } + for _, attr := range token.Attr { + if strings.EqualFold(attr.Key, "href") { + s.baseSet = true + rewritten := rewriteURLReference(attr.Val, s.prefix, "") + norm := normalizeURLForClassification(rewritten) + if hasURLScheme(norm) || strings.HasPrefix(norm, "//") { + s.basePath = "" + return + } + if norm == "" || norm[0] == '#' { + return + } + resolved := resolvePathReference(norm, s.basePath) + if !inSubtreePath(resolved, s.prefix) { + s.basePath = "" + return + } + s.basePath = resolved + return + } + } } // onStartTag emits the (URL-rewritten) start tag and updates raw-text // element tracking. Also injects the runtime shim immediately after ``. func (s *htmlRewriteState) onStartTag(token html.Token) { - rewriteTokenURLs(&token, s.prefix) + s.applyBase(&token) + rewriteTokenURLs(&token, s.prefix, s.capability, s.nonce, s.depth, s.basePath) s.out.WriteString(token.String()) if !s.shimInjected && token.Data == headTagName { s.out.WriteString(s.shim) @@ -204,7 +692,14 @@ func (s *htmlRewriteState) onEndTag(token html.Token) { func (s *htmlRewriteState) onTextToken(token html.Token) { switch { case s.inStyle: - s.out.WriteString(rewriteCSSFragment(token.Data, s.prefix)) + if s.basePath == "" { + // Under an external , url() references inside the style + // body resolve against the external origin: rewriting them would + // leak the capability (same policy as inline style attributes). + s.out.WriteString(token.Data) + return + } + s.out.WriteString(rewriteCSSFragment(token.Data, s.prefix, s.capability, s.basePath)) case s.inScript: s.out.WriteString(token.Data) default: @@ -220,11 +715,23 @@ func (s *htmlRewriteState) onTextToken(token html.Token) { // // Falls back to returning the input unchanged if tokenization fails midway, so // a malformed page never blocks the response. -func rewriteHTMLURLs(body []byte, prefix string) []byte { +func rewriteHTMLURLs(body []byte, prefix, capability, nonce string) []byte { + return rewriteHTMLURLsAtDepth(body, prefix, capability, nonce, 0) +} + +// maxSRCDocDepth bounds recursive srcdoc rewriting so a deeply nested inline +// document cannot exhaust the stack or balloon memory. +const maxSRCDocDepth = 5 + +func rewriteHTMLURLsAtDepth(body []byte, prefix, capability, nonce string, depth int) []byte { + return rewriteHTMLURLsAtDepthWithBase(body, prefix, capability, nonce, depth, prefix+"/") +} + +func rewriteHTMLURLsAtDepthWithBase(body []byte, prefix, capability, nonce string, depth int, basePath string) []byte { tok := html.NewTokenizer(bytes.NewReader(body)) var out bytes.Buffer out.Grow(len(body) + 256 + len(runtimeShimTemplate)) - s := &htmlRewriteState{out: &out, prefix: prefix, shim: runtimeShim(prefix)} + s := &htmlRewriteState{out: &out, prefix: prefix, capability: capability, shim: runtimeShimTag(prefix, capability, nonce), nonce: nonce, depth: depth, basePath: basePath} for { tt := tok.Next() if tt == html.ErrorToken { @@ -238,7 +745,8 @@ func rewriteHTMLURLs(body []byte, prefix string) []byte { case html.StartTagToken: s.onStartTag(token) case html.SelfClosingTagToken: - rewriteTokenURLs(&token, prefix) + s.applyBase(&token) + rewriteTokenURLs(&token, prefix, capability, nonce, depth, s.basePath) out.WriteString(token.String()) case html.EndTagToken: s.onEndTag(token) @@ -251,28 +759,506 @@ func rewriteHTMLURLs(body []byte, prefix string) []byte { } // rewriteTokenURLs walks a single token's attributes and rewrites any URL- -// shaped attribute value in place. -func rewriteTokenURLs(token *html.Token, prefix string) { +// shaped attribute value in place. Navigation attributes — anchor/area/base +// href, form action, button/input formaction — get the proxy prefix but NOT +// the capability: clicking them navigates the top-level frame, so a bearer in +// the URL would land in the address bar, history, bookmarks, and cross-origin +// Referers (the subtree cookie authorizes the navigation instead). Subresource +// attributes (script/img/stylesheet/manifest/iframe src-href, srcset, +// imagesrcset, …) get the capability so cookie-less fetches stay authorized. +func rewriteTokenURLs(token *html.Token, prefix, capability, nonce string, depth int, basePath string) { if token.Type != html.StartTagToken && token.Type != html.SelfClosingTagToken { return } + // Metadata links (rel=canonical, alternate, license, author, …) are not + // fetched; a capability in their href would leak through the emitted + // canonical URL. rel values that DO fetch (stylesheet, icon, manifest, + // preload, modulepreload, …) keep the capability. + metaLink := token.Data == "link" && !isFetchingLinkRel(relValue(token)) for i, attr := range token.Attr { key := strings.ToLower(attr.Key) - switch { - case rewritableURLAttrs[key]: - token.Attr[i].Val = rewriteAbsolutePath(attr.Val, prefix) - case key == "srcset": - token.Attr[i].Val = rewriteSrcSet(attr.Val, prefix) - case key == "style": - token.Attr[i].Val = rewriteCSSFragment(attr.Val, prefix) + token.Attr[i].Val = rewriteTokenAttr(token, key, attr.Val, prefix, capability, nonce, depth, metaLink, basePath) + } +} + +// rewriteTokenAttr rewrites one URL-shaped attribute value of a token, +// applying the navigation/subresource capability distinction. +func rewriteTokenAttr(token *html.Token, key, val, prefix, capability, nonce string, depth int, metaLink bool, basePath string) string { + // Inline srcdoc documents are dispatched FIRST: the child document can + // declare its own , which resets the inherited external-base + // policy, and a srcdoc value (starting with '<') would otherwise be + // classified as a relative reference and suppressed unchanged. + if key == "srcdoc" { + return rewriteSrcdocValue(val, prefix, capability, nonce, depth, basePath) + } + // Under an external , every path-absolute and relative + // reference in the document resolves OUTSIDE the proxy subtree: adding + // the proxy prefix or the capability would either change the app's + // intended resolution or leak the bearer to the external origin, so such + // values are left exactly as authored. Scheme-bearing and network-relative + // references were never rewritten anyway; only the inline style content + // needs an explicit guard (its leading text is not a URL). + if basePath == "" && suppressedByExternalBase(val) { + // External base: leave every path/relative reference as authored. + return val + } + return rewriteTokenAttrKey(token, key, val, prefix, capability, nonce, depth, metaLink, basePath) +} + +// rewriteTokenAttrKey dispatches one attribute value to its rewriter. +func rewriteTokenAttrKey(token *html.Token, key, val, prefix, capability, nonce string, depth int, metaLink bool, basePath string) string { + switch { + case isNavigationAttr(token, key, metaLink): + // Navigation references — anchor/area/base href, form action, + // button/input formaction — and cite (copyable metadata, never + // fetched): prefix-only, no capability. + return rewriteURLReference(val, prefix, "") + case key == attrContent && token.Data == "meta" && isMetaRefresh(token): + return rewriteMetaRefresh(val, prefix) + case rewritableURLAttrs[key]: + return rewriteURLReferenceBase(val, prefix, capability, basePath) + case key == "srcset" || key == "imagesrcset": + return rewriteSrcSet(val, prefix, capability, basePath) + case key == "ping": + // a[ping] is a whitespace-separated URL list the browser POSTs to on + // activation (hyperlink auditing): each candidate is a capability- + // bearing subresource request. + return rewritePing(val, prefix, capability, basePath) + case key == "style": + return rewriteStyleValue(val, prefix, capability, basePath) + } + return val +} + +// rewritePing rewrites every whitespace-separated URL in an a[ping] list. +func rewritePing(val, prefix, capability, basePath string) string { + fields := strings.Fields(val) + for i, f := range fields { + fields[i] = rewriteURLReferenceBase(f, prefix, capability, basePath) + } + return strings.Join(fields, " ") +} + +// rewriteStyleValue rewrites inline style url() references, unless the +// document has an external base (the url() references would resolve against +// it and leak the capability). +func rewriteStyleValue(val, prefix, capability, basePath string) string { + if basePath == "" { + return val + } + return rewriteCSSFragment(val, prefix, capability, basePath) +} + +// suppressedByExternalBase reports whether a reference resolves against an +// external (i.e. it is path-absolute or relative, not scheme-bearing, +// network-relative, or fragment-only) and must be left exactly as authored. +func suppressedByExternalBase(val string) bool { + norm := normalizeURLForClassification(val) + return norm != "" && norm[0] != '#' && !hasURLScheme(norm) && !strings.HasPrefix(norm, "//") +} + +// rewriteSrcdocValue rewrites an inline child document under the same base +// policy as its parent, bounded by maxSRCDocDepth. +func rewriteSrcdocValue(val, prefix, capability, nonce string, depth int, basePath string) string { + if depth < maxSRCDocDepth { + return string(rewriteHTMLURLsAtDepthWithBase([]byte(val), prefix, capability, nonce, depth+1, basePath)) + } + return val +} + +// isNavigationAttr reports whether an attribute is a navigation reference +// (anchor/area/base href, form action, button/input formaction) or cite +// (copyable metadata the browser never fetches): prefix-only, no capability. +func isNavigationAttr(token *html.Token, key string, metaLink bool) bool { + return key == "cite" || key == "href" && (isNavHref(token.Data) || metaLink) || + key == "action" && token.Data == "form" || + key == "formaction" && (token.Data == "button" || token.Data == "input") +} + +// isNavHref reports whether an element's href is a navigation reference. +func isNavHref(tag string) bool { + return tag == "a" || tag == "area" || tag == "base" +} + +// relValue extracts the normalized rel attribute value of a token. +func relValue(token *html.Token) string { + for _, attr := range token.Attr { + if strings.EqualFold(attr.Key, "rel") { + return strings.ToLower(strings.TrimSpace(attr.Val)) } } + return "" +} + +// attrContent is the HTML "content" attribute key (meta refresh targets). +const attrContent = "content" + +// fetchingLinkRels are link rel tokens whose href the browser fetches; a rel +// value with ANY fetching token is a subresource. Metadata-only rels (no +// fetching token) must not carry the capability. +var fetchingLinkRels = map[string]bool{ + "stylesheet": true, "icon": true, "shortcut": true, "apple-touch-icon": true, + "manifest": true, "preload": true, "modulepreload": true, "prefetch": true, + "dns-prefetch": true, "preconnect": true, +} + +// isFetchingLinkRel reports whether a link rel value (a whitespace-separated +// token list, per the HTML spec) contains any fetching token. +func isFetchingLinkRel(rel string) bool { + for _, token := range strings.Fields(rel) { + if fetchingLinkRels[strings.ToLower(token)] { + return true + } + } + return false +} + +// isMetaRefresh reports whether a meta token is an http-equiv=refresh. +func isMetaRefresh(token *html.Token) bool { + for _, attr := range token.Attr { + if strings.EqualFold(attr.Key, "http-equiv") && strings.EqualFold(strings.TrimSpace(attr.Val), "refresh") { + return true + } + } + return false +} + +// rewriteMetaRefresh prefixes the root-absolute navigation target inside a +// meta refresh content value (no capability — it is a navigation). The "url" +// token is recognized only at a field boundary — the start of the value or +// right after any ";" separator, ASCII case-insensitive, ignoring surrounding +// whitespace — so unrelated text like `noturl=/evil` is never rewritten and a +// multi-field value like `5;foo=bar; url=/next` is handled. Matching scans +// the ORIGINAL bytes with a length-preserving ASCII fold: strings.ToLower can +// change UTF-8 byte length for some Unicode characters (U+212A KELVIN folds +// 3 bytes to 1), which would corrupt byte offsets into the value. The target +// is parsed exactly once, respecting an optional quote; any text after it is +// preserved untouched. +func rewriteMetaRefresh(content, prefix string) string { + idx := -1 + from := 0 + for from <= len(content) { + semi := strings.IndexByte(content[from:], ';') + end := len(content) + if semi >= 0 { + end = from + semi + } + field := content[from:end] + k := 0 + for k < len(field) && isCSSSpace(field[k]) { + k++ + } + if asciiFoldHasPrefix(field[k:], "url=") { + idx = from + k + break + } + if semi < 0 { + break + } + from = end + 1 + } + if idx < 0 { + return content + } + rest := content[idx+4:] + trimmed := strings.TrimLeft(rest, " \t\n\r\f") + offset := len(rest) - len(trimmed) + target, tail, quote := parseRefreshTarget(trimmed) + return content[:idx+4] + rest[:offset] + quote + rewriteURLReference(target, prefix, "") + tail +} + +// asciiFoldHasPrefixAt reports whether value[i:] starts with prefix under +// ASCII case folding, comparing at most len(prefix) bytes (bounded: it never +// scans or allocates the rest of the string). +func asciiFoldHasPrefixAt(value string, i int, prefix string) bool { + if i+len(prefix) > len(value) { + return false + } + return asciiFoldHasPrefix(value[i:i+len(prefix)], prefix) +} + +// asciiFoldHasPrefix reports whether s starts with prefix under ASCII +// case folding (A-Z only). Length-preserving: unlike strings.ToLower it never +// changes byte lengths, so offsets into s remain valid. +func asciiFoldHasPrefix(s, prefix string) bool { + if len(s) < len(prefix) { + return false + } + for i := 0; i < len(prefix); i++ { + a, b := s[i], prefix[i] + if a >= 'A' && a <= 'Z' { + a += 'a' - 'A' + } + if b >= 'A' && b <= 'Z' { + b += 'a' - 'A' + } + if a != b { + return false + } + } + return true +} + +// parseRefreshTarget splits a meta refresh target off the text after `url=`, +// respecting an optional quote. The tail keeps the closing quote and any +// remaining text (including nested url= tokens) untouched. +func parseRefreshTarget(trimmed string) (target, tail, quote string) { + if trimmed == "" { + return "", "", "" + } + if trimmed[0] != '\'' && trimmed[0] != '"' { + // All five ASCII whitespace bytes terminate an unquoted target, + // matching the runtime mref() and CSS whitespace semantics. + if end := strings.IndexAny(trimmed, "; \t\n\r\f"); end >= 0 { + return trimmed[:end], trimmed[end:], "" + } + return trimmed, "", "" + } + quote = trimmed[:1] + if end := strings.IndexByte(trimmed[1:], quote[0]); end >= 0 { + return trimmed[1 : 1+end], trimmed[1+end:], quote + } + return trimmed[1:], "", quote +} + +// rewriteURLReference rewrites a URL-shaped attribute value. Path-absolute +// URLs get the proxy prefix plus the capability. Same-origin relative URLs +// keep their own resolution — the document already lives in the proxy subtree, +// so the browser lands inside it — but still get the capability appended so +// cookie-less fetches (a relative ) stay authorized. +// Classification runs against a WHATWG-normalized copy (ASCII tabs/newlines +// removed anywhere, leading C0/space trimmed — the browser does the same when +// parsing): a scheme-bearing or network-relative reference hidden behind +// whitespace or embedded controls is left untouched rather than leaking the +// capability to an external origin. Empty, fragment-only, and scheme-bearing +// values are never modified. +func rewriteURLReference(rawURL, prefix, capability string) string { + // The default base is the proxied document directory; callers that know + // the effective pass it via rewriteURLReferenceBase. + return rewriteURLReferenceBase(rawURL, prefix, capability, prefix+"/") +} + +// rewriteURLReferenceBase rewrites a URL-shaped reference against an +// explicit document base directory. Relative capability-bearing references +// are resolved against basePath (RFC 3986 merge + dot-segment removal): a +// reference whose resolved path escapes the proxy subtree (e.g. ../x) is +// returned unchanged rather than carrying the capability to a non-proxy +// endpoint. +func rewriteURLReferenceBase(rawURL, prefix, capability, basePath string) string { + normalized := normalizeURLForClassification(rawURL) + if capability == "" { + // Navigation/metadata rewriting never carries the capability: strip + // any previously issued one (a link reclassified from fetching to + // metadata, or an app URL carrying a stale cap) so the bearer cannot + // land in the address bar, history, or a cross-origin Referer. The + // gateway also strips reserved pairs on forward; this keeps them out + // of the DOM in the first place. Scheme-bearing references are + // preserved verbatim EXCEPT http:/https: absolute references, which + // ARE gateway HTTP queries and must have a stale capability removed + // (the runtime rn() does the same for same-origin absolutes); + // data:, mailto:, and other scheme payloads keep their '?'/'#' as + // payload, and network-relative references are left untouched. + strip := normalized != "" && normalized[0] != '#' && !strings.HasPrefix(normalized, "//") + if strip && hasURLScheme(normalized) { + strip = httpScheme(normalized) + } + if strip { + rawURL = stripCapability(rawURL) + normalized = normalizeURLForClassification(rawURL) + } + } + if normalized == "" || normalized[0] == '#' || hasURLScheme(normalized) || strings.HasPrefix(normalized, "//") { + return rawURL + } + if normalized[0] == '/' { + return rewriteAbsolutePath(normalized, prefix, capability) + } + if capability == "" { + return rawURL + } + // Relative capability-bearing emission uses the WHATWG-normalized form + // (controls removed, backslashes slashed, leading/trailing C0/space + // trimmed) exactly like the runtime nz(): a trailing space in the raw + // value would otherwise survive into `x ?kandev_cap=…` and be + // percent-encoded into the pathname by the browser. Only append when the + // reference resolves inside the proxy subtree (.. segments can escape). + if !resolveInSubtree(normalized, basePath, prefix) { + return rawURL + } + return withCapability(normalized, capability) +} + +// resolveInSubtree reports whether a relative reference resolves to a path +// boundary-inside the proxy prefix when merged with the document base. Only +// the PATH portion is resolved: a trailing ?query/#fragment attached to a .. +// segment (../?q) must not hide the escape. +func resolveInSubtree(ref, basePath, prefix string) bool { + if i := strings.IndexAny(ref, "?#"); i >= 0 { + ref = ref[:i] + } + return inSubtreePath(resolvePathReference(ref, basePath), prefix) +} + +// inSubtreePath reports whether a root-absolute path is boundary-inside the +// proxy prefix (path == prefix or path starts with prefix + "/"). +func inSubtreePath(path, prefix string) bool { + return path == prefix || strings.HasPrefix(path, prefix+"/") +} + +// resolvePathReference merges a relative reference with a base path and +// removes dot segments per RFC 3986 section 5.3/5.2.4. +func resolvePathReference(ref, basePath string) string { + if strings.HasPrefix(ref, "/") { + return removeDotSegments(ref) + } + base := basePath + if !strings.HasSuffix(base, "/") { + base += "/" + } + return removeDotSegments(base + ref) +} + +// removeDotSegments removes "." and ".." path segments without escaping +// above the root (RFC 3986 5.2.4). A trailing slash is preserved when the +// input is directory-shaped (ends in "/", "/.", or "/.."), so the result +// stays usable as a base directory. +func removeDotSegments(p string) string { + trailing := strings.HasSuffix(p, "/") || strings.HasSuffix(p, "/.") || strings.HasSuffix(p, "/..") + segments := strings.Split(p, "/") + out := make([]string, 0, len(segments)) + for _, seg := range segments { + switch seg { + case ".": + // skip + case "..": + if len(out) > 1 || len(out) == 1 && out[0] != "" { + out = out[:len(out)-1] + } + default: + out = append(out, seg) + } + } + joined := strings.Join(out, "/") + if trailing && !strings.HasSuffix(joined, "/") { + joined += "/" + } + return joined +} + +// stripCapability removes every query parameter whose DECODED key equals the +// reserved proxy capability key (so percent-encoded key spellings like +// %6bandev_cap are stripped too), preserving all other parameters. The +// fragment is split off FIRST: a '?' inside a fragment (#state?x=1) is +// fragment payload, not a query, and a '#' inside a data URL is payload too. +// A URL with no query string is returned unchanged. +func stripCapability(raw string) string { + path, fragment, hasFragment := strings.Cut(raw, "#") + if hasFragment { + path = stripCapabilityQuery(path) + return path + "#" + fragment + } + return stripCapabilityQuery(raw) +} + +func stripCapabilityQuery(raw string) string { + path, query, hasQuery := strings.Cut(raw, "?") + if !hasQuery { + return raw + } + parts := strings.Split(query, "&") + kept := parts[:0] + for _, p := range parts { + key := p + if e := strings.IndexByte(p, '='); e >= 0 { + key = p[:e] + } + decoded, err := url.QueryUnescape(key) + if err != nil { + decoded = key + } + if decoded == proxyCapabilityQueryParam { + continue + } + kept = append(kept, p) + } + if len(kept) == 0 { + // The original explicitly supplied a query; keep the marker so the + // reference still resolves as an empty query rather than inheriting + // the current document's query. + return path + "?" + } + return path + "?" + strings.Join(kept, "&") +} + +// normalizeURLForClassification returns a copy of a URL reference with the +// WHATWG parser's forgiving adjustments applied: ASCII tabs and newlines are +// removed anywhere in the string, BACKSLASHES are converted to slashes (the +// browser treats '\' as '/' in special-URL paths, so '\foo' resolves to +// /foo while '\/evil' becomes the network-relative //evil), and leading AND +// trailing C0/space characters are trimmed (the parser strips both for +// special URLs). Only the result is used for classification/emission; the +// original bytes are preserved when the reference is returned unchanged. +func normalizeURLForClassification(raw string) string { + noControls := strings.Map(func(r rune) rune { + switch r { + case '\t', '\n', '\r': + return -1 + case '\\': + return '/' + } + return r + }, raw) + return strings.TrimFunc(noControls, func(r rune) bool { return r <= ' ' }) +} + +// hasURLScheme reports whether a URL reference starts with a scheme per +// RFC 3986 — ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) followed by ":" +// (http:, data:, javascript:, mailto:, …). A colon in a position where a +// scheme could not start (a digit or a non-scheme character first, or after a +// path/query/fragment delimiter) makes the reference relative instead. +func hasURLScheme(raw string) bool { + for i := 0; i < len(raw); i++ { + c := raw[i] + switch { + case c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z': + continue + case c >= '0' && c <= '9' || c == '+' || c == '-' || c == '.': + if i == 0 { + return false // schemes start with an ASCII letter + } + continue + case c == ':': + return i > 0 + default: + return false + } + } + return false +} + +// httpScheme reports whether a scheme-bearing reference uses the http: or +// https: scheme (the only schemes whose '?' is a gateway HTTP query). +func httpScheme(raw string) bool { + for i := 0; i < len(raw); i++ { + if raw[i] == ':' { + return strings.EqualFold(raw[:i], "http") || strings.EqualFold(raw[:i], "https") + } + if !isSchemeChar(raw[i]) { + return false + } + } + return false +} + +// isSchemeChar reports whether c is valid inside a URL scheme (per RFC 3986). +func isSchemeChar(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '+' || c == '-' || c == '.' } // rewriteSrcSet rewrites each candidate URL in a `srcset` attribute. The // value format is `url [descriptor], url [descriptor], …` per the HTML spec. -func rewriteSrcSet(value, prefix string) string { - parts := strings.Split(value, ",") +func rewriteSrcSet(value, prefix, capability, basePath string) string { + parts := splitSrcSetParts(value) for i, part := range parts { trimmed := strings.TrimSpace(part) if trimmed == "" { @@ -283,35 +1269,55 @@ func rewriteSrcSet(value, prefix string) string { if len(fields) == 0 { continue } - fields[0] = rewriteAbsolutePath(fields[0], prefix) + fields[0] = rewriteURLReferenceBase(fields[0], prefix, capability, basePath) parts[i] = strings.Join(fields, " ") } return strings.Join(parts, ", ") } -// rewriteCSSURLs rewrites url(/...) and @import "/..." occurrences inside a -// standalone CSS document. -func rewriteCSSURLs(body []byte, prefix string) []byte { - return []byte(rewriteCSSFragment(string(body), prefix)) -} - -// rewriteCSSFragment rewrites CSS URL references inside an arbitrary string -// (either a full CSS file or the contents of an inline style attribute). -func rewriteCSSFragment(css, prefix string) string { - css = urlInCSSPattern.ReplaceAllStringFunc(css, func(match string) string { - sub := urlInCSSPattern.FindStringSubmatch(match) - // sub: full match, quote, url - if len(sub) != 3 { - return match +// splitSrcSetParts splits a srcset value into its candidate strings. The +// spec separates candidates on commas, but a comma INSIDE a data: URL (e.g. +// data:image/svg+xml,) belongs to the URL, not to the candidate list: +// a candidate that started with data: and has not yet hit whitespace is +// still inside its URL, so its commas are preserved. Descriptor text (after +// the first whitespace) is ordinary candidate space where commas separate. +// The scan is single-pass: whitespace-only and data:-prefix state is tracked +// incrementally, never by rescanning the accumulated candidate. +func splitSrcSetParts(value string) []string { + var parts []string + var cur strings.Builder + inData := false + curEmpty := true // cur contains only whitespace so far + for i := 0; i < len(value); i++ { + c := value[i] + if c == ',' { + if inData { + cur.WriteByte(c) + continue + } + parts = append(parts, cur.String()) + cur.Reset() + curEmpty = true + inData = false + continue } - return "url(" + sub[1] + rewriteAbsolutePath(sub[2], prefix) - }) - css = importInCSSPattern.ReplaceAllStringFunc(css, func(match string) string { - sub := importInCSSPattern.FindStringSubmatch(match) - if len(sub) != 3 { - return match + // The data: prefix is tested only at the FIRST non-whitespace byte of + // a candidate (curEmpty is true then), with a bounded five-byte + // ASCII-fold compare — never a whole-suffix scan or allocation, so + // N candidates cost O(N), not O(N^2). + if !inData && curEmpty && !isCSSSpace(c) && asciiFoldHasPrefixAt(value, i, "data:") { + inData = true } - return "@import " + sub[1] + rewriteAbsolutePath(sub[2], prefix) + sub[1] - }) - return css + if inData && isCSSSpace(c) { + inData = false + } + if !isCSSSpace(c) { + curEmpty = false + } + cur.WriteByte(c) + } + if !curEmpty { + parts = append(parts, cur.String()) + } + return parts } diff --git a/apps/backend/internal/gateway/websocket/proxy_rewriter_css.go b/apps/backend/internal/gateway/websocket/proxy_rewriter_css.go new file mode 100644 index 0000000000..291c5fab1a --- /dev/null +++ b/apps/backend/internal/gateway/websocket/proxy_rewriter_css.go @@ -0,0 +1,442 @@ +package websocket + +import ( + "strings" +) + +// This file holds the CSS URL rewriter. It lives apart from +// proxy_rewriter.go so the latter stays under the revive file-length limit. + +// rewriteCSSURLsAt rewrites url(/...) and @import "/..." occurrences inside a +// standalone CSS document, resolving relative references against the given +// base directory (the stylesheet file's public directory). +func rewriteCSSURLsAt(body []byte, prefix, capability, basePath string) []byte { + return []byte(rewriteCSSFragment(string(body), prefix, capability, basePath)) +} + +// cssScanner rewrites CSS URL references inside an arbitrary string (either +// a full CSS file or the contents of an inline style attribute) with a small +// tokenizer so only REAL url() tokens and @import strings are rewritten. +// String literals and comments are copied untouched — a CSS string like +// content:'url(/x)' is data, not a URL reference — and url(var(--x)) is left +// alone because var() cannot be a URL token. Unquoted url() tokens run to +// whitespace or ')', which keeps data: URLs intact (their ';' and ',' are +// URL content, e.g. url(data:image/png;base64,AAA)). Function names and +// identifiers may use CSS escapes (\75rl( == url(), matching browser +// preprocessing). Path-absolute references get the proxy prefix plus the +// capability; relative references keep their resolution but still get the +// capability appended so cookie-less CSS loads stay authorized. Scheme-bearing +// (incl. data:), network-relative, and fragment-only references are left +// untouched by rewriteURLReference. +type cssScanner struct { + out strings.Builder + css string + prefix string + capability string + basePath string + i int +} + +func rewriteCSSFragment(css, prefix, capability, basePath string) string { + s := &cssScanner{css: css, prefix: prefix, capability: capability, basePath: basePath} + for s.i < len(css) { + if s.consumeComment() || s.consumeString() || s.consumeURL() || s.consumeImport() { + continue + } + s.out.WriteByte(s.css[s.i]) + s.i++ + } + return s.out.String() +} + +// consumeComment copies a /* ... */ comment (or the remainder when +// unterminated) and reports whether it consumed anything. +func (s *cssScanner) consumeComment() bool { + css := s.css + if s.i+1 >= len(css) || css[s.i] != '/' || css[s.i+1] != '*' { + return false + } + if end := strings.Index(css[s.i+2:], "*/"); end >= 0 { + s.out.WriteString(css[s.i : s.i+2+end+2]) + s.i += 2 + end + 2 + } else { + s.out.WriteString(css[s.i:]) + s.i = len(css) + } + return true +} + +// consumeString copies a quoted CSS string (escape-aware) and reports whether +// it consumed anything. +func (s *cssScanner) consumeString() bool { + css := s.css + c := css[s.i] + if c != '\'' && c != '"' { + return false + } + j := s.i + 1 + for j < len(css) { + if css[j] == '\\' && j+1 < len(css) { + j += 2 + continue + } + if css[j] == c { + break + } + j++ + } + if j >= len(css) { + s.out.WriteString(css[s.i:]) + s.i = len(css) + return true + } + s.out.WriteString(css[s.i : j+1]) + s.i = j + 1 + return true +} + +// consumeURL rewrites a url() function token (raw or escape-written function +// name) and reports whether it consumed anything at s.i. +func (s *cssScanner) consumeURL() bool { + css := s.css + c := css[s.i] + if c != 'u' && c != 'U' && c != '\\' { + return false + } + if s.i > 0 && isCSSIdentChar(css[s.i-1]) { + return false // noturl(/x) is not a url() token + } + open := cssFuncOpen(css, s.i, "url") + if open < 0 { + return false + } + // The original function-name bytes are preserved in the output. + start := s.i + j := open + 1 + for j < len(css) && isCSSSpace(css[j]) { + j++ + } + spacing := css[open+1 : j] + if j < len(css) && (css[j] == '\'' || css[j] == '"') { + s.quotedURL(start, open, j, spacing) + return true + } + s.unquotedURL(start, open, j, spacing) + return true +} + +// quotedURL rewrites url('...') with an escape-aware quoted token. +func (s *cssScanner) quotedURL(start, open, j int, spacing string) { + css := s.css + q := css[j] + k := j + 1 + for k < len(css) { + if css[k] == '\\' && k+1 < len(css) { + k += 2 + continue + } + if css[k] == q { + break + } + k++ + } + if k >= len(css) { + s.out.WriteString(css[start:]) + s.i = len(css) + return + } + l := k + 1 + for l < len(css) && isCSSSpace(css[l]) { + l++ + } + if l < len(css) && css[l] == ')' { + s.out.WriteString(css[start:open+1] + spacing + string(q) + rewriteCSSURLToken(css[j+1:k], s.prefix, s.capability, s.basePath) + string(q) + css[k+1:l] + ")") + s.i = l + 1 + return + } + s.out.WriteString(css[start : k+1]) + s.i = k + 1 +} + +// unquotedURL rewrites url(token) with an unquoted token (runs to +// whitespace or ')', but a whitespace after a hex escape is the escape's +// terminator and part of the token; var() is not a URL token and is +// preserved). +func (s *cssScanner) unquotedURL(start, open, j int, spacing string) { + css := s.css + k := j + for k < len(css) && css[k] != ')' { + if css[k] == '\\' { + _, adv := cssEscapeDecode(css, k) + k += adv + continue + } + if isCSSSpace(css[k]) { + break + } + k++ + } + l := k + for l < len(css) && isCSSSpace(css[l]) { + l++ + } + if l < len(css) && css[l] == ')' { + tok := css[j:k] + decoded := cssDecodeToken(tok) + if !strings.HasPrefix(strings.ToLower(decoded), "var(") { + s.out.WriteString(css[start:open+1] + spacing + rewriteCSSURLToken(tok, s.prefix, s.capability, s.basePath) + css[k:l] + ")") + s.i = l + 1 + return + } + s.out.WriteString(css[start : l+1]) + s.i = l + 1 + return + } + s.out.WriteString(css[start:k]) + s.i = k +} + +// consumeImport rewrites @import "…"; strings (comments are CSS whitespace +// and are accepted between @import and the string) and reports whether it +// consumed anything. +func (s *cssScanner) consumeImport() bool { + css := s.css + if s.i+7 > len(css) || css[s.i] != '@' || !strings.EqualFold(css[s.i:s.i+7], "@import") { + return false + } + j := skipCSSSpaceAndComments(css, s.i+7) + if j < len(css) && (css[j] == '\'' || css[j] == '"') { + q := css[j] + k := j + 1 + for k < len(css) { + if css[k] == '\\' && k+1 < len(css) { + k += 2 + continue + } + if css[k] == q { + break + } + k++ + } + if k >= len(css) { + s.out.WriteString(css[s.i:]) + s.i = len(css) + return true + } + s.out.WriteString(css[s.i:j]) // "@import " + whitespace/comments + s.out.WriteString(string(q) + rewriteCSSURLToken(css[j+1:k], s.prefix, s.capability, s.basePath) + string(q)) + s.i = k + 1 + return true + } + // @import url(...) — the url() case rewrites it. + return false +} + +// cssDecodeToken decodes every CSS escape in a url() token value: the +// browser preprocesses escapes before resolving the URL, so classification +// must see the decoded form (url(\2f asset.css) is url(/asset.css)). +func cssDecodeToken(tok string) string { + var b strings.Builder + for i := 0; i < len(tok); { + if tok[i] == '\\' { + dec, adv := cssEscapeDecode(tok, i) + b.WriteString(dec) + i += adv + continue + } + b.WriteByte(tok[i]) + i++ + } + return b.String() +} + +// cssEscapeToken re-escapes CSS-significant characters in a rewritten URL so +// the emitted token survives CSS tokenization: whitespace, quotes, parens, +// backslashes, and control characters become \XX hex escapes (the trailing +// space is the optional hex-escape terminator and is consumed by the parser). +// Plain proxy URLs need no escaping; this covers decoded-token rewrites whose +// decoded value contained CSS-significant characters. +func cssEscapeToken(tok string) string { + const hex = "0123456789ABCDEF" + var b strings.Builder + for i := 0; i < len(tok); i++ { + c := tok[i] + if c == '\\' || c == '\'' || c == '"' || c == '(' || c == ')' || isCSSSpace(c) || c < 0x20 || c == 0x7F { + b.WriteByte('\\') + b.WriteByte(hex[c>>4]) + b.WriteByte(hex[c&0xF]) + b.WriteByte(' ') + continue + } + b.WriteByte(c) + } + return b.String() +} + +// rewriteCSSURLToken classifies a url() token value (with CSS escapes +// decoded for classification) and returns the bytes to emit: the decoded +// rewritten URL when the reference changed, or the ORIGINAL raw token bytes +// when it did not (external/scheme/network-relative references keep their +// spelling). +func rewriteCSSURLToken(rawToken, prefix, capability, basePath string) string { + decoded := cssDecodeToken(rawToken) + rewritten := rewriteURLReferenceBase(decoded, prefix, capability, basePath) + if rewritten == decoded { + return rawToken + } + return cssEscapeToken(rewritten) +} + +// isCSSSpace reports whether c is CSS whitespace (per the CSS syntax spec). +func isCSSSpace(c byte) bool { + switch c { + case ' ', '\t', '\n', '\r', '\f': + return true + } + return false +} + +// isCSSIdentChar reports whether c can be part of a CSS identifier (per the +// CSS syntax spec's ident-token: ASCII letters, digits, '-', '_', and +// non-ASCII code points). Used to require a token boundary before `url(` so +// `noturl(/x)` is not mistaken for a url() token. +func isCSSIdentChar(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c >= 0x80 +} + +// isCSSNameStart reports whether c can start a CSS identifier: ASCII letters, +// '_', or a non-ASCII code point (digits and '-' are excluded per the CSS +// syntax spec; escapes are handled separately by cssIdentAt). +func isCSSNameStart(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_' || c >= 0x80 +} + +func isCSSHexDigit(c byte) bool { + return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' +} + +func cssHexVal(c byte) int { + switch { + case c >= '0' && c <= '9': + return int(c - '0') + case c >= 'a' && c <= 'f': + return int(c-'a') + 10 + default: + return int(c-'A') + 10 + } +} + +// cssEscapeDecode decodes one CSS escape at css[i] (css[i] must be '\'): +// a hex escape (\75, up to 6 hex digits, optional whitespace terminator) +// yields its code point; an escaped non-newline character yields itself; an +// escaped newline is removed (it is not a character). Returns the decoded +// text and the number of bytes consumed. +func cssEscapeDecode(css string, i int) (string, int) { + if i+1 >= len(css) { + return "", 1 + } + switch c := css[i+1]; { + case isCSSHexDigit(c): + return cssHexEscape(css, i) + case c == '\n' || c == '\r' || c == '\f': + return "", 2 + default: + return string(c), 2 + } +} + +// cssHexEscape decodes the \XXXXXX form: at most 6 hex digits with an +// optional single whitespace terminator; invalid code points become U+FFFD. +func cssHexEscape(css string, i int) (string, int) { + j := i + 1 + v := 0 + for j < len(css) && j-i < 7 && isCSSHexDigit(css[j]) { + v = v*16 + cssHexVal(css[j]) + j++ + } + if j < len(css) && isCSSSpace(css[j]) { + j++ // consume the optional whitespace terminator + } + if v == 0 || v > 0x10FFFF || v >= 0xD800 && v <= 0xDFFF { + return "\uFFFD", j - i + } + return string(rune(v)), j - i +} + +// cssIdentAt decodes the identifier (raw or escape-containing) starting at +// css[i], returning the decoded name, the byte length consumed, and whether +// an identifier was actually found. \75rl( and u\72l( both decode to "url", +// matching how the CSS tokenizer preprocesses escapes before recognizing +// function names. +func cssIdentAt(css string, i int) (string, int, bool) { + if i >= len(css) { + return "", 0, false + } + var b strings.Builder + j := i + first := true +loop: + for j < len(css) { + c := css[j] + switch { + case c == '\\': + dec, adv := cssEscapeDecode(css, j) + if dec == "" { + break loop + } + if !first && !isCSSIdentChar(dec[0]) { + break loop + } + b.WriteString(dec) + j += adv + case first && isCSSNameStart(c): + b.WriteByte(c) + j++ + case !first && isCSSIdentChar(c): + b.WriteByte(c) + j++ + default: + break loop + } + first = false + } + if j == i { + return "", 0, false + } + return b.String(), j - i, true +} + +// cssFuncOpen returns the index of the '(' that terminates a CSS function +// token with the given decoded name starting at css[i] (which may use CSS +// escapes, e.g. \75rl( decodes to "url"), or -1 when css[i] does not start +// such a function. The caller is responsible for the identifier boundary +// check on the raw previous byte. +func cssFuncOpen(css string, i int, name string) int { + dec, adv, ok := cssIdentAt(css, i) + if !ok || !strings.EqualFold(dec, name) { + return -1 + } + if i+adv < len(css) && css[i+adv] == '(' { + return i + adv + } + return -1 +} + +// skipCSSSpaceAndComments advances j past CSS whitespace and comments (both +// are whitespace per the CSS syntax spec), returning the new position. +func skipCSSSpaceAndComments(css string, j int) int { + for j < len(css) { + if isCSSSpace(css[j]) { + j++ + continue + } + if css[j] == '/' && j+1 < len(css) && css[j+1] == '*' { + if end := strings.Index(css[j+2:], "*/"); end >= 0 { + j += 2 + end + 2 + continue + } + } + break + } + return j +} diff --git a/apps/backend/internal/gateway/websocket/proxy_rewriter_test.go b/apps/backend/internal/gateway/websocket/proxy_rewriter_test.go index 038451b2e6..6d93637ae0 100644 --- a/apps/backend/internal/gateway/websocket/proxy_rewriter_test.go +++ b/apps/backend/internal/gateway/websocket/proxy_rewriter_test.go @@ -1,10 +1,15 @@ package websocket import ( + "context" + "fmt" "io" "net/http" + "net/http/httptest" "strings" "testing" + + "golang.org/x/net/html" ) const proxyPrefix = "/port-proxy/abc/3001" @@ -27,7 +32,7 @@ func TestRewriteAbsolutePath(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - got := rewriteAbsolutePath(c.in, proxyPrefix) + got := rewriteAbsolutePath(c.in, proxyPrefix, "") if got != c.want { t.Fatalf("rewriteAbsolutePath(%q) = %q, want %q", c.in, got, c.want) } @@ -53,7 +58,7 @@ func TestRewriteHTMLURLs(t *testing.T) { ` - got := string(rewriteHTMLURLs([]byte(in), proxyPrefix)) + got := string(rewriteHTMLURLs([]byte(in), proxyPrefix, "", "")) mustContain(t, got, `href="/port-proxy/abc/3001/styles/main.css"`) mustContain(t, got, `href="//cdn.example.com/lib.css"`) @@ -80,7 +85,7 @@ func TestRewriteCSSURLs(t *testing.T) { .abs { background: url(http://example.com/x.png); } .rel { background: url(foo.png); }` - got := string(rewriteCSSURLs([]byte(in), proxyPrefix)) + got := string(rewriteCSSURLsAt([]byte(in), proxyPrefix, "", proxyPrefix+"/")) mustContain(t, got, `@import "/port-proxy/abc/3001/theme.css"`) mustContain(t, got, `url("/port-proxy/abc/3001/print.css")`) @@ -93,10 +98,11 @@ func TestRewriteCSSURLs(t *testing.T) { func TestRewriteProxyResponse_HTML(t *testing.T) { body := `x` resp := &http.Response{ - Header: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}}, - Body: io.NopCloser(strings.NewReader(body)), + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}}, + Body: io.NopCloser(strings.NewReader(body)), } - if err := rewriteProxyResponse(resp, proxyPrefix); err != nil { + if err := rewriteProxyResponse(resp, proxyPrefix, ""); err != nil { t.Fatalf("unexpected error: %v", err) } got, _ := io.ReadAll(resp.Body) @@ -111,10 +117,11 @@ func TestRewriteProxyResponse_HTML(t *testing.T) { func TestRewriteProxyResponse_CSS(t *testing.T) { body := `body { background: url(/bg.png); }` resp := &http.Response{ - Header: http.Header{"Content-Type": []string{"text/css"}}, - Body: io.NopCloser(strings.NewReader(body)), + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/css"}}, + Body: io.NopCloser(strings.NewReader(body)), } - if err := rewriteProxyResponse(resp, proxyPrefix); err != nil { + if err := rewriteProxyResponse(resp, proxyPrefix, ""); err != nil { t.Fatalf("unexpected error: %v", err) } got, _ := io.ReadAll(resp.Body) @@ -126,10 +133,11 @@ func TestRewriteProxyResponse_CSS(t *testing.T) { func TestRewriteProxyResponse_OtherContentTypeUnchanged(t *testing.T) { body := `{"href":"/foo"}` resp := &http.Response{ - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(body)), + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), } - if err := rewriteProxyResponse(resp, proxyPrefix); err != nil { + if err := rewriteProxyResponse(resp, proxyPrefix, ""); err != nil { t.Fatalf("unexpected error: %v", err) } got, _ := io.ReadAll(resp.Body) @@ -140,13 +148,14 @@ func TestRewriteProxyResponse_OtherContentTypeUnchanged(t *testing.T) { func TestRewriteHTMLURLs_InjectsRuntimeShim(t *testing.T) { in := `x` - got := string(rewriteHTMLURLs([]byte(in), proxyPrefix)) + got := string(rewriteHTMLURLs([]byte(in), proxyPrefix, "", "")) - // The shim must appear exactly once, immediately after the `` open tag - // (so it executes before any other script that may follow). - const marker = "window.fetch=" + // The shim script tag must appear exactly once, immediately after the + // `` open tag (so it executes before any other script that may + // follow), and load from the proxy's reserved same-origin path. + const marker = `` if strings.Count(got, marker) != 1 { - t.Fatalf("expected exactly one runtime shim, got %d copies\n%s", + t.Fatalf("expected exactly one runtime shim tag, got %d copies\n%s", strings.Count(got, marker), got) } headIdx := strings.Index(got, "") @@ -156,9 +165,16 @@ func TestRewriteHTMLURLs_InjectsRuntimeShim(t *testing.T) { t.Fatalf("shim must come between and : head=%d shim=%d title=%d\n%s", headIdx, shimIdx, titleIdx, got) } - // The prefix must be baked into the JS literal. - if !strings.Contains(got, `var P="/port-proxy/abc/3001"`) { - t.Fatalf("shim missing baked-in prefix:\n%s", got) + // With a capability minted, the shim src carries it so the shim loads even + // in cookie-less contexts. + withCap := string(rewriteHTMLURLs([]byte(in), proxyPrefix, "cap-shim", "")) + if !strings.Contains(withCap, `__kandev_runtime_shim.js?kandev_cap=cap-shim`) { + t.Fatalf("capability-bearing shim src missing the capability:\n%s", withCap) + } + // The prefix must be baked into the served shim body. + shim := runtimeShim(proxyPrefix, "") + if !strings.Contains(shim, `var P="/port-proxy/abc/3001";`) { + t.Fatalf("shim body missing the baked-in prefix:\n%s", shim) } } @@ -167,7 +183,7 @@ func TestRewriteHTMLURLs_NoHeadStillRewritesURLs(t *testing.T) { // injecting the shim in that case (no good anchor point), but URL // rewriting must still work. in := `<a href="/foo">x</a>` - got := string(rewriteHTMLURLs([]byte(in), proxyPrefix)) + got := string(rewriteHTMLURLs([]byte(in), proxyPrefix, "", "")) if !strings.Contains(got, `href="/port-proxy/abc/3001/foo"`) { t.Fatalf("URL not rewritten: %q", got) } @@ -185,7 +201,7 @@ func TestRewriteHTMLURLs_PreservesScriptContentVerbatim(t *testing.T) { `<script src="/static/app.js"></script>` + `</body></html>` - got := string(rewriteHTMLURLs([]byte(in), proxyPrefix)) + got := string(rewriteHTMLURLs([]byte(in), proxyPrefix, "", "")) // Inline script body must come through unescaped. for _, needle := range []string{ @@ -209,24 +225,32 @@ func TestRewriteHTMLURLs_PreservesScriptContentVerbatim(t *testing.T) { } func TestRuntimeShim_InstallsMutationObserver(t *testing.T) { - shim := runtimeShim(proxyPrefix) + shim := runtimeShim(proxyPrefix, "") // MutationObserver is installed so dynamically-inserted DOM nodes (e.g. // Next.js `ReactDOM.preload()` for fonts) get their URL attributes // rewritten too, not just whatever was in the initial HTML. mustContain(t, shim, `new MO(function(rs)`) - mustContain(t, shim, `.observe(document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:ATTRS})`) + mustContain(t, shim, `.observe(document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:OBS})`) // The attribute list mirrors the static HTML rewriter's coverage so the // runtime path doesn't silently miss attributes the static path catches. mustContain(t, shim, `'href','src','action','formaction','cite','data','poster','background','manifest','srcset'`) + // rel changes are observed so link classification (metadata vs fetching) + // stays current; style and meta content are observed and runtime-rewritten; + // srcdoc is intentionally not observed (static-only, documented). + mustContain(t, shim, `'rel','style','content','http-equiv'`) + if strings.Contains(shim, "'srcdoc']") { + t.Fatal("srcdoc must not be observed (static-only contract)") + } + // srcset has its own splitter (whitespace-separated descriptors). - mustContain(t, shim, `if(a==='srcset')`) + mustContain(t, shim, `if(a==='srcset'||a==='imagesrcset')`) } func TestRuntimeShim_ExposesProxyPrefixToInspector(t *testing.T) { - shim := runtimeShim(proxyPrefix) + shim := runtimeShim(proxyPrefix, "") // The inspector script uses this to report app-local routes in annotation // prompts instead of the gateway's /port-proxy/... path. @@ -234,7 +258,7 @@ func TestRuntimeShim_ExposesProxyPrefixToInspector(t *testing.T) { } func TestRuntimeShim_ForwardsConsoleToParent(t *testing.T) { - shim := runtimeShim(proxyPrefix) + shim := runtimeShim(proxyPrefix, "") // Console levels are intercepted so iframe diagnostics surface in the // parent window's console alongside other preview events. @@ -246,8 +270,19 @@ func TestRuntimeShim_ForwardsConsoleToParent(t *testing.T) { mustContain(t, shim, `return orig.apply(console,arguments)`) } +func TestRuntimeShim_BindsConsoleToParentOrigin(t *testing.T) { + shim := runtimeShim(proxyPrefix, "cap-console") + + // The child announces readiness without sending console data. The parent + // replies with its origin, and the child uses that origin for console data. + mustContain(t, shim, "console-ready") + mustContain(t, shim, "console-bind") + mustContain(t, shim, `window.parent.postMessage({source:'kandev-inspector',type:'console-ready',payload:{}},'*')`) + mustContain(t, shim, `window.parent.postMessage({source:'kandev-inspector',type:'console',payload:{level:lv,args:out}},PO)`) +} + func TestRuntimeShim_PatchesNavigationAPIs(t *testing.T) { - shim := runtimeShim(proxyPrefix) + shim := runtimeShim(proxyPrefix, "") // Patches history.pushState and history.replaceState so SPA routers keep // the proxy prefix in the URL bar on client-side navigation. @@ -259,11 +294,13 @@ func TestRuntimeShim_PatchesNavigationAPIs(t *testing.T) { mustContain(t, shim, `'assign','replace'`) mustContain(t, shim, `location[op]=function(u)`) - // Both patches must reuse the existing path rewriter `r()` rather than - // rolling their own prefix logic. + // Both patches must reuse the prefix-only navigation rewriter rn() rather + // than rolling their own prefix logic — and must NOT carry the capability + // (the subtree cookie covers same-origin navigations; a bearer in the + // address bar/history would leak). for _, needle := range []string{ - `u=r(u);return orig.call(this,s,t,u)`, // history APIs - `u=r(u);return orig.call(location,u)`, // location APIs + `history[op]=function(s,t,u){if(u!==null&&u!==undefined){if(typeof u==='symbol')throw new TypeError('Cannot convert a Symbol value to a string');if(typeof u!=='string')u=String(u);u=rn(u)}return orig.call(this,s,t,u)}`, // history APIs (any DOMString-able arg) + `location[op]=function(u){if(u!==null&&u!==undefined){if(typeof u==='symbol')throw new TypeError('Cannot convert a Symbol value to a string');if(typeof u!=='string')u=String(u);u=rn(u)}return orig.call(location,u)}`, // location APIs } { mustContain(t, shim, needle) } @@ -271,13 +308,1515 @@ func TestRuntimeShim_PatchesNavigationAPIs(t *testing.T) { func TestRewriteSrcSet(t *testing.T) { in := "/a.png 1x, /b.png 2x, //cdn.example.com/c.png 3x" - got := rewriteSrcSet(in, proxyPrefix) + got := rewriteSrcSet(in, proxyPrefix, "", proxyPrefix+"/") want := "/port-proxy/abc/3001/a.png 1x, /port-proxy/abc/3001/b.png 2x, //cdn.example.com/c.png 3x" if got != want { t.Fatalf("rewriteSrcSet = %q, want %q", got, want) } } +// The subtree capability must ride on every rewritten SUBRESOURCE URL: plain +// URLs get it as the first query parameter, URLs that already carry a query +// get it appended. Network-relative and absolute URLs stay untouched, and +// navigation references (anchor href) get the prefix without the capability. +func TestRewriteHTMLURLs_AppendsCapabilityToRewrittenURLs(t *testing.T) { + in := `<a href="/foo">x</a>` + + `<script type="module" src="/src/main.tsx?t=123"></script>` + + `<img srcset="/a.png 1x, //cdn.example.com/b.png 2x">` + + `<link rel="manifest" href="/manifest.webmanifest">` + + `<a href="/docs#installation">docs</a>` + got := string(rewriteHTMLURLs([]byte(in), proxyPrefix, "cap-123", "")) + + // Navigation hrefs are prefixed but capability-free. + mustContain(t, got, `href="/port-proxy/abc/3001/foo"`) + // The tokenizer HTML-escapes the separator inside the attribute value. + mustContain(t, got, `src="/port-proxy/abc/3001/src/main.tsx?t=123&kandev_cap=cap-123"`) + mustContain(t, got, `href="/port-proxy/abc/3001/manifest.webmanifest?kandev_cap=cap-123"`) + mustContain(t, got, `srcset="/port-proxy/abc/3001/a.png?kandev_cap=cap-123 1x, //cdn.example.com/b.png 2x"`) + // A fragment must stay after the capability query on subresources. + mustContain(t, got, `href="/port-proxy/abc/3001/docs#installation"`) +} + +// The capability is appended to rewritten CSS url() and @import references too, +// so cookie-less CSS loads stay authorized in the same contexts as the +// manifest fetch. Relative references get the capability appended as well; +// scheme-bearing and network-relative references stay untouched. +func TestRewriteCSSURLs_AppendsCapability(t *testing.T) { + in := `@import "/theme.css"; .x { background: url("/img/bg.png"); }` + + `.y { background: url(rel.png); } @import "print.css";` + + `.z { background: url(http://cdn.example.com/x.png); }` + got := string(rewriteCSSURLsAt([]byte(in), proxyPrefix, "cap-456", proxyPrefix+"/")) + + mustContain(t, got, `@import "/port-proxy/abc/3001/theme.css?kandev_cap=cap-456"`) + mustContain(t, got, `url("/port-proxy/abc/3001/img/bg.png?kandev_cap=cap-456")`) + mustContain(t, got, `url(rel.png?kandev_cap=cap-456)`) + mustContain(t, got, `@import "print.css?kandev_cap=cap-456"`) + mustContain(t, got, `url(http://cdn.example.com/x.png)`) +} + +// hasURLScheme follows RFC 3986 scheme grammar: a scheme starts with an ASCII +// letter and continues with letters, digits, +, -, or . until ":". A colon in +// a non-scheme position (digit first, or after a path/query delimiter) means +// the reference is relative. +func TestHasURLScheme(t *testing.T) { + cases := []struct { + raw string + want bool + }{ + {"http://x", true}, + {"https://x", true}, + {"data:image/png;base64,AAA", true}, + {"javascript:alert(1)", true}, + {"mailto:x@y.dev", true}, + {"v1:chunk.js", true}, // RFC-valid scheme + {"C:foo", true}, + {"foo", false}, + {"./foo", false}, + {"../foo", false}, + {"foo/bar:baz", false}, // colon after a path delimiter + {"1x:y", false}, // scheme must start with a letter + {"?a=b", false}, + {"#frag", false}, + } + for _, tc := range cases { + if got := hasURLScheme(tc.raw); got != tc.want { + t.Errorf("hasURLScheme(%q) = %v, want %v", tc.raw, got, tc.want) + } + } +} + +// stripCapabilityParam removes only the capability pair from a raw query, +// matching percent-encoded spellings and preserving every other parameter's +// bytes and order. +func TestStripCapabilityParam(t *testing.T) { + cases := []struct { + name, in, want string + }{ + {"empty", "", ""}, + {"absent", "a=1&b=2", "a=1&b=2"}, + {"plain", "a=1&kandev_cap=x&b=2", "a=1&b=2"}, + {"first", "kandev_cap=x&a=1", "a=1"}, + {"last", "a=1&kandev_cap=x", "a=1"}, + {"encoded key", "a=1&%6bandev_cap=x", "a=1"}, + {"encoded key full", "a=1&%6B%61%6E%64%65%76%5F%63%61%70=x", "a=1"}, + {"no value", "kandev_cap", ""}, + {"preserves order and bytes", "b=2&a=%2F&kandev_cap=x&c=3", "b=2&a=%2F&c=3"}, + {"value contains cap name", "a=kandev_cap=x", "a=kandev_cap=x"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := stripCapabilityParam(tc.in); got != tc.want { + t.Fatalf("stripCapabilityParam(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// stripReservedProxyParams additionally removes the ?token= PAT credential the +// gateway consumes, in any encoding, while preserving everything else. +func TestStripReservedProxyParams(t *testing.T) { + cases := []struct { + name, in, want string + }{ + {"absent", "a=1&b=2", "a=1&b=2"}, + {"cap", "a=1&kandev_cap=x", "a=1"}, + {"token", "a=1&token=pat-secret&b=2", "a=1&b=2"}, + {"encoded token", "a=1&%74oken=pat&b=2", "a=1&b=2"}, + {"both", "token=pat&a=1&kandev_cap=x", "a=1"}, + {"value contains names", "a=kandev_cap=x&b=token=y", "a=kandev_cap=x&b=token=y"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := stripReservedProxyParams(tc.in); got != tc.want { + t.Fatalf("stripReservedProxyParams(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// Same-origin relative references (a relative <link rel="manifest">, relative +// srcset entries) resolve inside the proxy subtree but carry no cookie on +// fetch; they must still get the capability appended. Scheme-bearing, +// network-relative, and fragment-only values stay untouched. +func TestRewriteHTMLURLs_AppendsCapabilityToRelativeReferences(t *testing.T) { + in := `<link rel="manifest" href="manifest.webmanifest">` + + `<a href="page">x</a>` + + `<img srcset="a.png 1x, ./b.png 2x">` + + `<a href="javascript:alert(1)">js</a>` + + `<a href="mailto:x@y.dev">mail</a>` + + `<a href="//cdn.example.com/lib.js">cdn</a>` + + `<a href="#section">frag</a>` + + `<img src="data:image/png;base64,AAA">` + + `<a href=" https://evil.example/x">spaced-scheme</a>` + + `<a href=" //evil.example/y">newline-netrel</a>` + + `<a href=" /rooted">spaced-root</a>` + + `<form action="/submit"><button formaction="/alt"></button></form>` + + `<a href="ht tps://evil2.example/z">embedded-tab-scheme</a>` + got := string(rewriteHTMLURLs([]byte(in), proxyPrefix, "cap-789", "")) + + mustContain(t, got, `href="manifest.webmanifest?kandev_cap=cap-789"`) + // Navigation references: prefixed, capability-free. + mustContain(t, got, `<a href="page">x</a>`) + mustContain(t, got, `action="/port-proxy/abc/3001/submit"`) + mustContain(t, got, `formaction="/port-proxy/abc/3001/alt"`) + mustContain(t, got, `srcset="a.png?kandev_cap=cap-789 1x, ./b.png?kandev_cap=cap-789 2x"`) + mustContain(t, got, `href="javascript:alert(1)"`) + mustContain(t, got, `href="mailto:x@y.dev"`) + mustContain(t, got, `href="//cdn.example.com/lib.js"`) + mustContain(t, got, `href="#section"`) + mustContain(t, got, `src="data:image/png;base64,AAA"`) + // Leading whitespace must not make an external URL look relative: the + // browser trims it and goes external, so the capability must not be + // appended. + mustContain(t, got, `href=" https://evil.example/x"`) + // The tokenizer decodes to a literal newline; the network-relative + // URL behind it must still be left untouched. + mustContain(t, got, "href=\"\n//evil.example/y\"") + // A spaced root-absolute anchor reference is rewritten from its trimmed + // form, capability-free (navigation). + mustContain(t, got, `href="/port-proxy/abc/3001/rooted"`) + // Embedded tab/newline inside the scheme: WHATWG normalization reveals an + // external https URL, so the capability must not be appended. + mustContain(t, got, "href=\"ht\ntps://evil2.example/z\"") +} + +// The runtime shim appends the capability to every URL its network path +// rewriter produces: fragments stay last, existing query strings are preserved, +// and the append is skipped only for an exact kandev_cap query key. WebSocket +// URLs (string and same-origin URL-object inputs) go through the same rewriter. +// Navigation APIs (history/location) use a prefix-only rewriter so the bearer +// never lands in the address bar, history, or cross-origin Referers. +func TestRuntimeShim_AppendsCapabilityToRewrittenURLs(t *testing.T) { + shim := runtimeShim(proxyPrefix, "cap-shim") + mustContain(t, shim, `var K="cap-shim";`) + // Fragment split: the query lands before any #fragment. + mustContain(t, shim, `var fh=u.indexOf('#');if(fh!==-1){fr=u.slice(fh);u=u.slice(0,fh)}`) + // The append is unconditional: an app's own kandev_cap parameter must not + // suppress the issued capability (the gateway accepts any valid value + // among duplicates). + mustContain(t, shim, `u+=(u.indexOf('?')===-1?'?':'&')+"kandev_cap="+K`) + if strings.Contains(shim, `if(!/([?&])kandev_cap=/.test(x))`) { + t.Fatal("the shim must not let an app kandev_cap key suppress the issued capability") + } + // WebSocket wrapper rewrites string AND URL-object inputs through norm(), + // which carries the capability; same-origin ws/wss origins are compared + // as http/https via the URL API and the scheme is swapped for the + // matching ws/wss form. + mustContain(t, shim, `'//'+l.host`) + mustContain(t, shim, `s.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i,w)`) + mustContain(t, shim, `x.origin.replace(/^ws:/,'http:').replace(/^wss:/,'https:')`) + mustContain(t, shim, `new URL(s,(typeof document!=='undefined'&&document.baseURI)||window.location.href)`) + // fetch/XHR/WS all route URL-like inputs through norm() (URL objects, + // same-origin absolute strings). + mustContain(t, shim, `Reflect.get(URL.prototype,'href',i)`) + mustContain(t, shim, `arguments[1]=u;return oo.apply(this,arguments)`) + // Navigation APIs use the prefix-only rewriter, never the capability. + mustContain(t, shim, `if(u!==null&&u!==undefined){if(typeof u==='symbol')throw new TypeError('Cannot convert a Symbol value to a string');if(typeof u!=='string')u=String(u);u=rn(u)}return orig.call(this,s,t,u)`) + mustContain(t, shim, `if(u!==null&&u!==undefined){if(typeof u==='symbol')throw new TypeError('Cannot convert a Symbol value to a string');if(typeof u!=='string')u=String(u);u=rn(u)}return orig.call(location,u)`) + mustContain(t, shim, `function rn(u){if(typeof u!=='string')return u;if(!u||u.charAt(0)==='#')return u;if(u.indexOf('//')===0)return u;try{var ru=new URL(u,(typeof document!=='undefined'&&document.baseURI)||window.location.href);if(ru.protocol!=='http:'&&ru.protocol!=='https:'||ru.origin!==window.location.origin)return u}catch(e){return u}u=nz(u);if(u.charAt(0)==='/'){u=sc(u);if(!(u===P||u.indexOf(P+'/')===0||u.indexOf(P+'?')===0||u.indexOf(P+'#')===0))u=P+u;if(u.indexOf('/.')!==-1){var dd2=rd(u);if(!(dd2===P||dd2.indexOf(P+'/')===0||dd2.indexOf(P+'?')===0||dd2.indexOf(P+'#')===0))return u;u=dd2}return u}if(/^[a-z][a-z0-9+.-]*:\/\//i.test(u)){var o=ru.origin;var pn=ru.pathname;var tail=sc(pn+(ru.search||''));if(!(tail===P||tail.indexOf(P+'/')===0||tail.indexOf(P+'?')===0||tail.indexOf(P+'#')===0))tail=P+tail;return o+tail+(ru.hash||'')}return sc(u)}`) + // Anchor navigation needs no click interception: the MutationObserver + // prefixes hrefs, so the browser's own default navigation stays inside + // the subtree and app click handlers keep control. + if strings.Contains(shim, "addEventListener('click'") { + t.Fatal("click interception must not be installed (it breaks app delegation)") + } + // norm() rewrites only http/https/ws/wss inputs; other schemes pass through. + mustContain(t, shim, `var p=x.protocol;if(p!=='http:'&&p!=='https:'&&p!=='ws:'&&p!=='wss:')return u;`) + // MutationObserver distinguishes navigation attributes (rn, incl. metadata + // links with no fetching rel token via lf()) from subresource attributes + // (r), and scans the full attribute set on inserted subtrees. + mustContain(t, shim, `el.tagName==='A'||el.tagName==='AREA'||el.tagName==='BASE'||(el.tagName==='LINK'&&!lf(el))`) + mustContain(t, shim, `function lf(el){var rel=el.rel;if(typeof rel!=='string')return true;var toks=rel.toLowerCase().split(/\s+/);`) + mustContain(t, shim, `var rr=nav?rn:r;`) + mustContain(t, shim, `'[href],[src],[action],[formaction],[cite],[data],[poster],[background],[manifest],[srcset],[imagesrcset],[ping],[style],[content]'`) +} + +// The navigation rewriter must not carry the capability even when one is +// minted: the bearer stays out of the address bar and browser history. +func TestRuntimeShim_NavigationRewriterOmitsCapability(t *testing.T) { + shim := runtimeShim(proxyPrefix, "cap-shim") + // rn() is the prefix-only form: it strips any previously issued capability + // and never appends a new one. + mustContain(t, shim, `function rn(u){if(typeof u!=='string')return u;if(!u||u.charAt(0)==='#')return u;if(u.indexOf('//')===0)return u;try{var ru=new URL(u,(typeof document!=='undefined'&&document.baseURI)||window.location.href);if(ru.protocol!=='http:'&&ru.protocol!=='https:'||ru.origin!==window.location.origin)return u}catch(e){return u}u=nz(u);if(u.charAt(0)==='/'){u=sc(u);if(!(u===P||u.indexOf(P+'/')===0||u.indexOf(P+'?')===0||u.indexOf(P+'#')===0))u=P+u;if(u.indexOf('/.')!==-1){var dd2=rd(u);if(!(dd2===P||dd2.indexOf(P+'/')===0||dd2.indexOf(P+'?')===0||dd2.indexOf(P+'#')===0))return u;u=dd2}return u}if(/^[a-z][a-z0-9+.-]*:\/\//i.test(u)){var o=ru.origin;var pn=ru.pathname;var tail=sc(pn+(ru.search||''));if(!(tail===P||tail.indexOf(P+'/')===0||tail.indexOf(P+'?')===0||tail.indexOf(P+'#')===0))tail=P+tail;return o+tail+(ru.hash||'')}return sc(u)}`) + // history and location rewrite through rn(), never r(). + mustContain(t, shim, `history[op]=function(s,t,u){if(u!==null&&u!==undefined){if(typeof u==='symbol')throw new TypeError('Cannot convert a Symbol value to a string');if(typeof u!=='string')u=String(u);u=rn(u)}return orig.call(this,s,t,u)}`) + mustContain(t, shim, `location[op]=function(u){if(u!==null&&u!==undefined){if(typeof u==='symbol')throw new TypeError('Cannot convert a Symbol value to a string');if(typeof u!=='string')u=String(u);u=rn(u)}return orig.call(location,u)}`) + if strings.Contains(shim, `history[op]=function(s,t,u){if(typeof u==='string')u=r(u)`) { + t.Fatal("history APIs must not use the capability-bearing rewriter") + } +} + +// The auth-off contract is "no ISSUED capability": with an empty capability +// the shim carries zero issued-capability logic — no capability constant, no +// exact-match helper, no query append. The unconditional sc()/rn() still +// reference the reserved KEY NAME (d!=='kandev_cap') because stripping a +// reserved-named parameter from navigation URLs is the gateway's documented +// behavior in every mode; the contract is that no capability is minted, +// embedded, or appended when auth is off. +func TestRuntimeShim_NoIssuedCapabilityLogic(t *testing.T) { + shim := runtimeShim(proxyPrefix, "") + if strings.Contains(shim, `"kandev_cap="+K`) { + t.Fatalf("shim without capability appends kandev_cap:\n%s", shim) + } + if strings.Contains(shim, "function hcp(") { + t.Fatalf("shim without capability carries the exact-match helper:\n%s", shim) + } + if strings.Contains(shim, `var K=`) { + t.Fatalf("shim without capability carries an issued-capability constant:\n%s", shim) + } +} + +// Bodyless responses (1xx/204/304) must pass through untouched: no body +// rewrite, no synthesized Content-Length, no capability query rewriting. +func TestRewriteProxyResponse_LeavesBodylessResponsesUntouched(t *testing.T) { + for _, status := range []int{http.StatusNoContent, http.StatusNotModified, http.StatusContinue} { + resp := &http.Response{ + StatusCode: status, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("")), + } + resp.Header.Set("Content-Type", "text/html; charset=utf-8") + resp.Header.Set("Content-Length", "100") // 304: describes the selected representation + if err := rewriteProxyResponse(resp, proxyPrefix, "cap-304"); err != nil { + t.Fatalf("status %d: unexpected error: %v", status, err) + } + if got := resp.Header.Get("Content-Length"); got != "100" { + t.Fatalf("status %d: Content-Length = %q, want original %q", status, got, "100") + } + } +} + +// Capability-bearing rewritten responses must be uncacheable (per-user body + +// Set-Cookie) and must not leak the embedded capability through the Referer +// header to external origins. The headers are applied by the gateway's +// ModifyResponse for every response class (JS/JSON/redirects included), which +// is covered by the end-to-end proxy test; the rewriter itself performs the +// body rewrite only. +func TestRewriteProxyResponse_DoesNotSetCacheHeadersItself(t *testing.T) { + body := `<!doctype html><html><head><link rel="manifest" href="/manifest.webmanifest"></head><body></body></html>` + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + } + resp.Header.Set("Content-Type", "text/html; charset=utf-8") + if err := rewriteProxyResponse(resp, proxyPrefix, "cap-cache"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Header.Get("Cache-Control") != "" || resp.Header.Get("Referrer-Policy") != "" { + t.Fatalf("cache/referrer headers must be applied by ModifyResponse, not the rewriter: %v", resp.Header) + } +} + +// Inline iframe documents (srcdoc) inherit the proxy origin; their +// root-absolute references must be rewritten like a nested page, with the +// capability on subresources and none on navigation. +func TestRewriteHTMLURLs_RewritesSrcdocDocuments(t *testing.T) { + in := `<iframe srcdoc="<a href="/page">x</a><img src="/logo.png">"></iframe>` + got := string(rewriteHTMLURLs([]byte(in), proxyPrefix, "cap-srcdoc", "")) + // The nested document is rewritten (navigation no cap, subresource cap); + // the serializer re-escapes the srcdoc attribute value. + mustContain(t, got, `srcdoc="<a href="/port-proxy/abc/3001/page">x</a><img src="/port-proxy/abc/3001/logo.png?kandev_cap=cap-srcdoc">"`) +} + +// Meta refresh navigation targets must be re-anchored on the proxy subtree +// without a capability. +func TestRewriteHTMLURLs_RewritesMetaRefresh(t *testing.T) { + in := `<meta http-equiv="refresh" content="5; url=/next">` + + `<meta http-equiv="refresh" content="0;url='https://external.example/x'">` + got := string(rewriteHTMLURLs([]byte(in), proxyPrefix, "cap-meta", "")) + + mustContain(t, got, `content="5; url=/port-proxy/abc/3001/next"`) + mustContain(t, got, `content="0;url='https://external.example/x'"`) +} + +// Metadata links (rel=canonical and other non-fetching rels) must not carry +// the capability; fetching rels (stylesheet, manifest, preload, …) keep it. +func TestRewriteHTMLURLs_CanonicalLinkOmitsCapability(t *testing.T) { + in := `<link rel="canonical" href="/canonical">` + + `<link rel="stylesheet" href="/theme.css">` + + `<link rel="manifest" href="/manifest.webmanifest">` + + `<link rel="alternate" href="/feed.xml">` + got := string(rewriteHTMLURLs([]byte(in), proxyPrefix, "cap-link", "")) + + mustContain(t, got, `rel="canonical" href="/port-proxy/abc/3001/canonical"`) + mustContain(t, got, `rel="alternate" href="/port-proxy/abc/3001/feed.xml"`) + mustContain(t, got, `rel="stylesheet" href="/port-proxy/abc/3001/theme.css?kandev_cap=cap-link"`) + mustContain(t, got, `rel="manifest" href="/port-proxy/abc/3001/manifest.webmanifest?kandev_cap=cap-link"`) +} + +// When the app's Content-Security-Policy uses a script nonce, the injected +// shim tag must carry it so nonce- and strict-dynamic-based policies allow the +// shim; the meta-tag form is honored too. +func TestRewriteHTMLURLs_ShimTagCarriesCSPNonce(t *testing.T) { + in := `<!DOCTYPE html><html><head><title>x` + // Header-based policy. + headerResp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": {"text/html"}, + "Content-Security-Policy": {"default-src 'self'; script-src 'self' 'nonce-app123'"}, + }, + Body: io.NopCloser(strings.NewReader(in)), + } + if err := rewriteProxyResponse(headerResp, proxyPrefix, "cap-nonce"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + got, _ := io.ReadAll(headerResp.Body) + mustContain(t, string(got), ``) + + // Meta-tag policy. + metaResp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"text/html"}}, + Body: io.NopCloser(strings.NewReader( + `x`)), + } + if err := rewriteProxyResponse(metaResp, proxyPrefix, ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + gotMeta, _ := io.ReadAll(metaResp.Body) + mustContain(t, string(gotMeta), `nonce="meta456"`) + + // No nonce in the policy: the tag stays plain. + plain := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": {"text/html"}, + "Content-Security-Policy": {"default-src 'self'"}, + }, + Body: io.NopCloser(strings.NewReader(in)), + } + if err := rewriteProxyResponse(plain, proxyPrefix, ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + gotPlain, _ := io.ReadAll(plain.Body) + if strings.Contains(string(gotPlain), "nonce=") { + t.Fatalf("shim tag must not carry a nonce when the policy has none:\n%s", gotPlain) + } + + // A malformed nonce-shaped token from a hostile policy must not be + // interpolated into the injected markup. + hostile := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": {"text/html"}, + "Content-Security-Policy": {`script-src 'self' 'nonce-x">'`}, + }, + Body: io.NopCloser(strings.NewReader(in)), + } + if err := rewriteProxyResponse(hostile, proxyPrefix, ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + gotHostile, _ := io.ReadAll(hostile.Body) + if strings.Contains(string(gotHostile), `` + + `` + + `nav` + + `
` + got := string(rewriteHTMLURLs([]byte(external), proxyPrefix, "cap-base", "")) + + if strings.Contains(got, "kandev_cap") { + t.Fatalf("capability leaked under an external base:\n%s", got) + } + mustContain(t, got, ``) + mustContain(t, got, ``) + mustContain(t, got, `nav`) + mustContain(t, got, `style="background:url(bg.png)"`) + mustContain(t, got, ``) + + // Network-relative base is external too. + netRel := `` + `` + gotNet := string(rewriteHTMLURLs([]byte(netRel), proxyPrefix, "cap-base2", "")) + if strings.Contains(gotNet, "kandev_cap") { + t.Fatalf("capability leaked under a network-relative base:\n%s", gotNet) + } + mustContain(t, gotNet, ``) + + // Same-origin (path) base: refs resolve inside the subtree, cap applies. + sameOrigin := `` + `` + `` + gotSame := string(rewriteHTMLURLs([]byte(sameOrigin), proxyPrefix, "cap-base3", "")) + mustContain(t, gotSame, ``) + mustContain(t, gotSame, ``) + mustContain(t, gotSame, ``) +} + +// 206 Partial Content bodies are byte ranges described by Content-Range: +// rewriting would desynchronize length/range metadata, so they pass through. +func TestRewriteProxyResponse_Leaves206Untouched(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusPartialContent, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("partial")), + } + resp.Header.Set("Content-Type", "text/html; charset=utf-8") + resp.Header.Set("Content-Range", "bytes 0-21/100") + resp.Header.Set("Content-Length", "22") + if err := rewriteProxyResponse(resp, proxyPrefix, "cap-206"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := resp.Header.Get("Content-Length"); got != "22" { + t.Fatalf("206: Content-Length = %q, want original %q", got, "22") + } + if got := resp.Header.Get("Content-Range"); got != "bytes 0-21/100" { + t.Fatalf("206: Content-Range = %q, want original", got) + } + body, _ := io.ReadAll(resp.Body) + if string(body) != "partial" { + t.Fatalf("206 body changed: %q", body) + } +} + +// imagesrcset (link preload) is a fetching srcset attribute: it gets the +// capability like srcset, statically and at runtime. +func TestRewriteHTMLURLs_Imagesrcset(t *testing.T) { + in := `` + got := string(rewriteHTMLURLs([]byte(in), proxyPrefix, "cap-is", "")) + + mustContain(t, got, `imagesrcset="/port-proxy/abc/3001/hero.png?kandev_cap=cap-is 1x, /port-proxy/abc/3001/hero@2x.png?kandev_cap=cap-is 2x"`) +} + +// norm() rejects credentialed URLs (native Request/fetch would reject too), +// leaving the input untouched so native validation runs. +func TestRuntimeShim_NormRejectsCredentials(t *testing.T) { + shim := runtimeShim(proxyPrefix, "cap-shim") + mustContain(t, shim, `if(x.username||x.password)return u;`) +} + +// A srcdoc child under an external parent base can declare its OWN , +// resetting the inherited external policy: its refs then resolve inside the +// subtree and get the capability (dispatch happens before the generic +// suppression, since a srcdoc value looks relative). +func TestRewriteHTMLURLs_ExternalBaseSrcdocOwnBase(t *testing.T) { + in := `` + got := string(rewriteHTMLURLs([]byte(in), proxyPrefix, "cap-sd", "")) + + mustContain(t, got, `srcdoc="<base href="/port-proxy/abc/3001/cdn/"><img src="a.png?kandev_cap=cap-sd"><img src="/port-proxy/abc/3001/logo.png?kandev_cap=cap-sd">"`) +} + +// imagesrcset is observed at runtime: it is in the MutationObserver attribute +// filter and the descendant selector. +func TestRuntimeShim_ImagesrcsetObserved(t *testing.T) { + shim := runtimeShim(proxyPrefix, "cap-shim") + mustContain(t, shim, `'srcset','imagesrcset','ping','rel'`) + mustContain(t, shim, `[srcset],[imagesrcset],[ping],[style]`) +} + +// ` + got := string(rewriteHTMLURLs([]byte(external), proxyPrefix, "cap-sb", "")) + + if strings.Contains(got, "kandev_cap") { + t.Fatalf("capability leaked from a style body under an external base:\n%s", got) + } + mustContain(t, got, ``) + + // Network-relative base: same suppression. + netRel := `` + `` + gotNet := string(rewriteHTMLURLs([]byte(netRel), proxyPrefix, "cap-sb2", "")) + if strings.Contains(gotNet, "kandev_cap") { + t.Fatalf("capability leaked under a network-relative base:\n%s", gotNet) + } + + // No base: style bodies are still rewritten normally. + plain := `` + gotPlain := string(rewriteHTMLURLs([]byte(plain), proxyPrefix, "cap-sb3", "")) + mustContain(t, gotPlain, ``) +} + +// a[ping] is a whitespace-separated URL list POSTed on activation: every +// candidate gets the capability-bearing subresource rewrite, statically and +// at runtime, and ping is observed. +func TestRewriteHTMLURLs_PingList(t *testing.T) { + in := `x` + got := string(rewriteHTMLURLs([]byte(in), proxyPrefix, "cap-ping", "")) + + mustContain(t, got, `x`) +} + +func TestRuntimeShim_PingObservedAndRewritten(t *testing.T) { + shim := runtimeShim(proxyPrefix, "cap-shim") + mustContain(t, shim, `'ping','rel'`) + mustContain(t, shim, `else if(a==='ping'){nv=v.split(/\s+/).map(function(u){return r(u)}).join(' ')}`) +} + +// WHATWG strips leading AND trailing C0/space for special URLs: a trailing +// space must not survive into the emitted prefixed URL (it would become %20 +// in the pathname and 404). +func TestRewriteHTMLURLs_TrailingWhitespace(t *testing.T) { + in := `` + `n` + got := string(rewriteHTMLURLs([]byte(in), proxyPrefix, "cap-tw", "")) + + mustContain(t, got, ``) + mustContain(t, got, `n`) +} + +// Relative capability-bearing references emit the WHATWG-normalized form: a +// trailing space must not survive into `x ?kandev_cap=…` (the browser would +// percent-encode it into the pathname). Covers HTML and quoted CSS url(). +func TestRewriteHTMLURLs_RelativeTrailingSpace(t *testing.T) { + in := `` + "" + got := string(rewriteHTMLURLs([]byte(in), proxyPrefix, "cap-rt", "")) + + mustContain(t, got, ``) + mustContain(t, got, ``) + + css := `a{background:url('z ')}` + gotCSS := rewriteCSSFragment(css, proxyPrefix, "cap-rt", proxyPrefix+"/") + mustContain(t, gotCSS, `url('z?kandev_cap=cap-rt')`) +} + +// Relative references whose dot-segment resolution escapes the proxy +// subtree must not carry the capability: ../x resolves to a sibling +// gateway path, and a relative base with .. escapes the prefix entirely. +func TestRewriteHTMLURLs_DotSegmentEscapesSuppressCapability(t *testing.T) { + in := `` + + `` + + `` + + `
` + got := string(rewriteHTMLURLs([]byte(in), proxyPrefix, "cap-dot", "")) + + mustContain(t, got, ``) + mustContain(t, got, ``) + // The escaping ../ candidate stays uncapped; the in-subtree ok.png caps. + mustContain(t, got, ``) + mustContain(t, got, `style="background:url(../z.png)"`) + + // A relative base that escapes P is treated as external: everything + // path/relative is suppressed. + escaping := `` + `` + `` + gotEsc := string(rewriteHTMLURLs([]byte(escaping), proxyPrefix, "cap-dot2", "")) + if strings.Contains(gotEsc, "kandev_cap") { + t.Fatalf("capability leaked under an escaping relative base:\n%s", gotEsc) + } + mustContain(t, gotEsc, ``) + + // An in-subtree base keeps capping refs that resolve inside it, but a + // ../ ref escaping even that base stays uncapped. + inSub := `` + `` + `` + gotIn := string(rewriteHTMLURLs([]byte(inSub), proxyPrefix, "cap-dot3", "")) + // The relative base stays as authored (nav relative refs are never + // prefixed); the browser resolves it to P/sub/. + mustContain(t, gotIn, ``) + mustContain(t, gotIn, ``) + mustContain(t, gotIn, ``) +} + +// A query or fragment attached to a dot segment (../?q, ../#f) must not +// hide the escape: the path-only resolution leaves such references uncapped. +func TestRewriteHTMLURLs_DotSegmentWithQueryOrFragment(t *testing.T) { + in := `` + + `` + + `` + + `` + got := string(rewriteHTMLURLs([]byte(in), proxyPrefix, "cap-qf", "")) + + mustContain(t, got, ``) + mustContain(t, got, ``) + mustContain(t, got, ``) + mustContain(t, got, ``) +} + +// HTML documents served BELOW the proxy root resolve relative references +// against their own directory (stashed in the request context), so ../ refs +// that stay in-subtree keep the capability. +func TestRewriteProxyResponse_HTMLUsesDocumentDirectoryBase(t *testing.T) { + body := `` + `` + req := httptest.NewRequest(http.MethodGet, "/port-proxy/s/5173/pages/index.html", nil) + req = req.WithContext(context.WithValue(req.Context(), proxyCSSBaseContextKey{}, "/port-proxy/s/5173/pages/")) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + } + resp.Header.Set("Content-Type", "text/html; charset=utf-8") + if err := rewriteProxyResponse(resp, "/port-proxy/s/5173", "cap-htmlbase"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + got, _ := io.ReadAll(resp.Body) + // ../logo.png resolves to /port-proxy/s/5173/logo.png (in-subtree): capped. + mustContain(t, string(got), ``) + mustContain(t, string(got), ``) +} + +// A response still content-encoded after transport processing (Go could not +// decode it) must not be rewritten: rewriting compressed bytes and deleting +// Content-Encoding would corrupt the document. +func TestRewriteProxyResponse_SkipsUndecodedEncodings(t *testing.T) { + for _, enc := range []string{"br", "deflate", "zstd"} { + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("\x1f\x8b-compressed")), + } + resp.Header.Set("Content-Type", "text/html; charset=utf-8") + resp.Header.Set("Content-Encoding", enc) + if err := rewriteProxyResponse(resp, proxyPrefix, "cap-enc"); err != nil { + t.Fatalf("%s: unexpected error: %v", enc, err) + } + if got := resp.Header.Get("Content-Encoding"); got != enc { + t.Fatalf("%s: Content-Encoding = %q, want preserved", enc, got) + } + body, _ := io.ReadAll(resp.Body) + if string(body) != "\x1f\x8b-compressed" { + t.Fatalf("%s: body changed: %q", enc, body) + } + } +} + func mustContain(t *testing.T, haystack, needle string) { t.Helper() if !strings.Contains(haystack, needle) { diff --git a/apps/backend/internal/gateway/websocket/shim_probe_test.go b/apps/backend/internal/gateway/websocket/shim_probe_test.go new file mode 100644 index 0000000000..defadf5082 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/shim_probe_test.go @@ -0,0 +1,33 @@ +package websocket + +import ( + "os" + "strings" + "testing" +) + +// TestDumpShimProbe writes the REAL runtime shim (with the probe export +// injected) to /tmp/shim_probe.js so the node validator can exercise the +// actual functions r/rn/sc/nz/norm/rwaStyle/srcsetParts/mref exactly as +// shipped, including the observer-loop idempotency behavior. +// +// It is a diagnostic, not a behavioral test (Go cannot execute the shim's +// JS), so it is skipped unless KANDEV_DUMP_SHIM=1 is set — normal test runs +// never write the /tmp artifact. Invocation: +// +// KANDEV_DUMP_SHIM=1 go test -run TestDumpShimProbe ./internal/gateway/websocket/ +// node /tmp/shim_probe_validate.js +func TestDumpShimProbe(t *testing.T) { + if os.Getenv("KANDEV_DUMP_SHIM") == "" { + t.Skip("diagnostic only: set KANDEV_DUMP_SHIM=1 to dump the shim for the node validator") + } + shim := runtimeShim(proxyPrefix, "cap.ABC-_123") + idx := strings.LastIndex(shim, "})();") + if idx < 0 { + t.Fatal("shim template does not end with the IIFE close") + } + probe := shim[:idx] + `window.__kandev__={r:r,rn:rn,sc:sc,nz:nz,norm:norm,rwaStyle:rwaStyle,srcsetParts:srcsetParts,mref:mref,rwa:rwa};` + shim[idx:] + if err := os.WriteFile("/tmp/shim_probe.js", []byte(probe), 0o644); err != nil { + t.Fatalf("write probe: %v", err) + } +} diff --git a/apps/web/hooks/use-preview-console-forwarder.test.ts b/apps/web/hooks/use-preview-console-forwarder.test.ts new file mode 100644 index 0000000000..d9897a921b --- /dev/null +++ b/apps/web/hooks/use-preview-console-forwarder.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import type { RefObject } from "react"; +import { usePreviewConsoleForwarder } from "./use-preview-console-forwarder"; + +const INSPECTOR_SOURCE = "kandev-inspector"; + +// The console forwarder must only accept messages from the preview iframe +// origin and contentWindow; wrong-origin or wrong-source messages are ignored. +describe("usePreviewConsoleForwarder", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const PREVIEW_ORIGIN = "http://api.example"; + + function makeIframeRef( + contentWindow: unknown, + src = `${window.location.origin}/port-proxy/session/3000/`, + ) { + return { + current: { + contentWindow, + src, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }, + } as unknown as RefObject; + } + + function dispatchMessage(data: unknown, origin: string, source: unknown) { + window.dispatchEvent( + new MessageEvent("message", { data, origin, source: source as MessageEventSource }), + ); + } + + function consoleMessage(level: string, args: unknown[]) { + return { source: INSPECTOR_SOURCE, type: "console", payload: { level, args } }; + } + + it("forwards same-origin console messages from the preview iframe", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const contentWindow = { postMessage: vi.fn() }; + renderHook(() => usePreviewConsoleForwarder(makeIframeRef(contentWindow))); + + act(() => { + dispatchMessage(consoleMessage("warn", ["boom"]), window.location.origin, contentWindow); + }); + + expect(warn).toHaveBeenCalledWith("[preview]", "boom"); + }); + + it("binds the console bridge and forwards messages from a separate preview origin", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const contentWindow = { postMessage: vi.fn() }; + const iframeRef = makeIframeRef(contentWindow, `${PREVIEW_ORIGIN}/port-proxy/session/3000/`); + renderHook(() => usePreviewConsoleForwarder(iframeRef)); + + act(() => { + dispatchMessage( + { source: INSPECTOR_SOURCE, type: "console-ready", payload: {} }, + PREVIEW_ORIGIN, + contentWindow, + ); + }); + + expect(contentWindow.postMessage).toHaveBeenCalledWith( + { source: INSPECTOR_SOURCE, type: "console-bind", payload: {} }, + PREVIEW_ORIGIN, + ); + + act(() => { + dispatchMessage(consoleMessage("warn", ["boom"]), PREVIEW_ORIGIN, contentWindow); + }); + + expect(warn).toHaveBeenCalledWith("[preview]", "boom"); + }); + + it("ignores messages from a different origin", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const contentWindow = { postMessage: vi.fn() }; + renderHook(() => usePreviewConsoleForwarder(makeIframeRef(contentWindow))); + + act(() => { + dispatchMessage(consoleMessage("warn", ["boom"]), "https://evil.example", contentWindow); + }); + + expect(warn).not.toHaveBeenCalled(); + }); + + it("ignores messages from a different source window", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const contentWindow = { postMessage: vi.fn() }; + renderHook(() => usePreviewConsoleForwarder(makeIframeRef(contentWindow))); + + act(() => { + dispatchMessage(consoleMessage("warn", ["boom"]), window.location.origin, { other: true }); + }); + + expect(warn).not.toHaveBeenCalled(); + }); + + it("ignores non-console inspector messages", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const contentWindow = { postMessage: vi.fn() }; + renderHook(() => usePreviewConsoleForwarder(makeIframeRef(contentWindow))); + + act(() => { + dispatchMessage( + { source: INSPECTOR_SOURCE, type: "other" }, + window.location.origin, + contentWindow, + ); + }); + + expect(log).not.toHaveBeenCalled(); + }); + + it("ignores messages when the iframe source is not an HTTP origin", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const contentWindow = { postMessage: vi.fn() }; + renderHook(() => usePreviewConsoleForwarder(makeIframeRef(contentWindow, "about:blank"))); + + act(() => { + dispatchMessage(consoleMessage("log", ["x"]), PREVIEW_ORIGIN, contentWindow); + }); + + expect(log).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/hooks/use-preview-console-forwarder.ts b/apps/web/hooks/use-preview-console-forwarder.ts index 9e54549150..d9411ed19c 100644 --- a/apps/web/hooks/use-preview-console-forwarder.ts +++ b/apps/web/hooks/use-preview-console-forwarder.ts @@ -1,32 +1,67 @@ "use client"; import { useEffect } from "react"; -import { isInspectorMessage, isPreviewConsoleMessage } from "@/lib/preview-inspect-bridge"; +import { + isInspectorMessage, + isPreviewConsoleMessage, + isPreviewConsoleReadyMessage, + sendConsoleBind, +} from "@/lib/preview-inspect-bridge"; const PREFIX = "[preview]"; +function previewOrigin(iframe: HTMLIFrameElement): string | null { + try { + const origin = new URL(iframe.src || window.location.href, window.location.href); + if (origin.protocol !== "http:" && origin.protocol !== "https:") return null; + return origin.origin; + } catch { + return null; + } +} + // Pipes iframe `console.log/warn/error/info/debug` calls — forwarded by the // runtime shim injected by the gateway port-proxy — into the parent window's // console with a `[preview]` prefix. Lets developers see iframe diagnostics // without manually switching DevTools' execution context to the iframe. // // The `iframeRef` argument is used to verify that incoming messages came from -// the previewed iframe and not from another frame or extension; otherwise any -// page with access to the parent window could spam the developer console with -// fake `[preview]` lines. +// the previewed iframe and not from another frame or extension. The origin is +// pinned to the iframe's actual origin, derived from its src. This also lets +// the console bridge bind its target origin when the UI and gateway use +// different ports in dev mode. export function usePreviewConsoleForwarder( iframeRef: React.RefObject, ): void { useEffect(() => { + const bindCurrentConsole = () => { + const iframe = iframeRef.current; + const origin = iframe ? previewOrigin(iframe) : null; + if (iframe && origin) sendConsoleBind(iframe, origin); + }; + function handleMessage(event: MessageEvent) { - if (event.source !== iframeRef.current?.contentWindow) return; + const iframe = iframeRef.current; + if (!iframe || event.source !== iframe.contentWindow) return; + const origin = previewOrigin(iframe); + if (!origin || event.origin !== origin) return; if (!isInspectorMessage(event.data)) return; + if (isPreviewConsoleReadyMessage(event.data)) { + sendConsoleBind(iframe, event.origin); + return; + } if (!isPreviewConsoleMessage(event.data)) return; const { level, args } = event.data.payload; const fn = console[level] ?? console.log; fn.call(console, PREFIX, ...args); } + const iframe = iframeRef.current; window.addEventListener("message", handleMessage); - return () => window.removeEventListener("message", handleMessage); + iframe?.addEventListener?.("load", bindCurrentConsole); + bindCurrentConsole(); + return () => { + window.removeEventListener("message", handleMessage); + iframe?.removeEventListener?.("load", bindCurrentConsole); + }; }, [iframeRef]); } diff --git a/apps/web/lib/preview-inspect-bridge.ts b/apps/web/lib/preview-inspect-bridge.ts index 22393e7ae4..7c8b870dd2 100644 --- a/apps/web/lib/preview-inspect-bridge.ts +++ b/apps/web/lib/preview-inspect-bridge.ts @@ -69,6 +69,12 @@ interface InspectorReadyMessage { payload: Record; } +interface PreviewConsoleReadyMessage { + source: typeof INSPECTOR_SOURCE; + type: "console-ready"; + payload: Record; +} + export type PreviewConsoleLevel = "log" | "warn" | "error" | "info" | "debug"; const PREVIEW_CONSOLE_LEVELS: ReadonlySet = new Set([ "log", @@ -88,6 +94,7 @@ export type InspectorMessage = | AnnotationAddedMessage | InspectExitedMessage | InspectorReadyMessage + | PreviewConsoleReadyMessage | PreviewConsoleMessage; // Narrows past the union — and validates that `level` is one we handle and @@ -102,6 +109,12 @@ export function isPreviewConsoleMessage(msg: InspectorMessage): msg is PreviewCo ); } +export function isPreviewConsoleReadyMessage( + msg: InspectorMessage, +): msg is PreviewConsoleReadyMessage { + return msg.type === "console-ready"; +} + export function isInspectorMessage(data: unknown): data is InspectorMessage { if (typeof data !== "object" || data === null) return false; const d = data as { source?: unknown; type?: unknown; payload?: unknown }; @@ -146,6 +159,17 @@ export function sendRemoveMarker(iframe: HTMLIFrameElement, number: number): voi ); } +export function sendConsoleBind(iframe: HTMLIFrameElement, targetOrigin: string): void { + iframe.contentWindow?.postMessage( + { + source: INSPECTOR_SOURCE, + type: "console-bind", + payload: {}, + }, + targetOrigin, + ); +} + function formatElement(el: CapturedElement): string { let idPart = ""; if (el.id) idPart = `#${el.id}`;