fix(gateway): authorize credential-less port-proxy subresources after document auth (#2682)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Clément Fiere
2026-08-16 00:46:38 +02:00
committed by GitHub
parent 98db560d83
commit a5d9e5d696
13 changed files with 4719 additions and 130 deletions
+1 -1
View File
@@ -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.<verb>` 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 `<link rel="manifest">` 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
+7
View File
@@ -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,
@@ -34,6 +34,12 @@ type AuthPolicy struct {
// ResolveToken authenticates a ?token=<PAT> 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=<PAT> for headerless clients.
// any upgrade) and accepts ?token=<PAT> 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 <link rel="manifest"> 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=<PAT> 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.
@@ -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())
@@ -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: <link
// rel="manifest"> 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=<PAT>, 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=<PAT> 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))
}
@@ -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/<port>/* 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/<port>/<path> → /<path>
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 <link> 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, `<!doctype html><html><head><title>landed</title></head><body>ok</body></html>`)
})
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, `<!doctype html><html><head>`+
`<link rel="manifest" href="/manifest.webmanifest">`+
`<script type="module" src="/src/main.tsx"></script>`+
`</head><body>proxied app</body></html>`)
})
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, "<none>")
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=<PAT> 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 <script src>.
if !strings.Contains(doc.body, "/port-proxy/sess-auth/5173/__kandev_runtime_shim.js") {
t.Fatalf("document does not inject the external runtime shim:\n%.500s", doc.body)
}
// 9. Redirect Location headers are re-anchored on the proxy subtree.
redir := request(http.MethodGet, "/port-proxy/sess-auth/5173/redirect", cookieHeader)
if redir.status != http.StatusFound {
t.Fatalf("redirect status = %d, want %d", redir.status, http.StatusFound)
}
if got := redir.headers.Get("Location"); got != "/port-proxy/sess-auth/5173/landing" {
t.Fatalf("redirect Location = %q, want the proxied landing path", got)
}
// 10. A cookie-authenticated request's own ?token= parameter is preserved
// for the app — only the gateway-consumed credential is stripped.
appToken := request(http.MethodGet,
"/port-proxy/sess-auth/5173/src/main.tsx?token=app-own-token&x=1", cookieHeader)
if appToken.status != http.StatusOK {
t.Fatalf("app-token request status = %d, want %d", appToken.status, http.StatusOK)
}
forwarded, _ = recordings.snapshot()
last := forwarded[len(forwarded)-1]
if !strings.Contains(last, "token=app-own-token") {
t.Fatalf("cookie-authenticated app token was stripped from forwarded request %q", last)
}
if !strings.Contains(last, "x=1") {
t.Fatalf("unrelated query param lost in forwarded request %q", last)
}
}
// A browser request carrying Accept-Encoding: gzip must not corrupt the
// proxied HTML: the proxy drops the outbound header so Go's Transport
// auto-decompresses, and the rewriter sees identity bytes. The response is
// the REWRITTEN uncompressed HTML with no Content-Encoding.
func TestPortProxyDecompressesAndRewritesCompressedHTML(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
t.Fatalf("logger: %v", err)
}
html := `<!doctype html><html><head><link rel="manifest" href="/m.webmanifest"></head><body>gzip</body></html>`
app := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
w.Header().Set("Content-Encoding", "gzip")
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
_, _ = gz.Write([]byte(html))
_ = gz.Close()
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
_, _ = w.Write(buf.Bytes())
return
}
_, _ = io.WriteString(w, html)
}))
t.Cleanup(app.Close)
svc, sessionToken := newProxyAuthService(t)
agentctl, _ := newAuthEnforcingFakeAgentctl(t, app.URL, "agentctl-token")
manager := newProxyTestManager(t, log)
manager.SetSessionAccessChecker(func(ctx context.Context, sessionID string) error {
identity, ok := authn.IdentityFromContext(ctx)
if !ok || identity.UserID != "default-user" {
return errors.New("foreign session")
}
return nil
})
addExecutionForURL(t, manager, "sess-gz", 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)
headers := map[string]string{
"Cookie": svc.CookieName() + "=" + sessionToken,
"Accept-Encoding": "gzip",
}
doc := request(http.MethodGet, "/port-proxy/sess-gz/5173/", headers)
if doc.status != http.StatusOK {
t.Fatalf("document status = %d, want %d (body=%s)", doc.status, http.StatusOK, doc.body)
}
if got := doc.headers.Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want none (body must be decompressed before rewriting)", got)
}
// The body must be the REWRITTEN uncompressed HTML, not gzip bytes.
if strings.Contains(doc.body, "\x1f\x8b") {
t.Fatalf("body still gzip-compressed after proxying:\n%q", doc.body[:min(len(doc.body), 40)])
}
mustContain(t, doc.body, `/port-proxy/sess-gz/5173/m.webmanifest?kandev_cap=`)
mustContain(t, doc.body, `<body>gzip</body>`)
}
// Nested standalone CSS served through the real proxy wiring resolves ../ and
// relative references against the stylesheet's PUBLIC directory (stashed at
// the handler before the path is rewritten to the agentctl form).
func TestPortProxyRewritesNestedCSSWithPublicBase(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
t.Fatalf("logger: %v", err)
}
css := `body{background:url(../img.png)}a{background:url(rel.png)}`
app := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/css/main.css":
w.Header().Set("Content-Type", "text/css; charset=utf-8")
_, _ = io.WriteString(w, css)
default:
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, `<!doctype html><html><body>app</body></html>`)
}
}))
t.Cleanup(app.Close)
svc, sessionToken := newProxyAuthService(t)
agentctl, _ := newAuthEnforcingFakeAgentctl(t, app.URL, "agentctl-token")
manager := newProxyTestManager(t, log)
manager.SetSessionAccessChecker(func(ctx context.Context, sessionID string) error {
identity, ok := authn.IdentityFromContext(ctx)
if !ok || identity.UserID != "default-user" {
return errors.New("foreign session")
}
return nil
})
addExecutionForURL(t, manager, "sess-css", 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)
headers := map[string]string{"Cookie": svc.CookieName() + "=" + sessionToken}
doc := request(http.MethodGet, "/port-proxy/sess-css/5173/css/main.css", headers)
if doc.status != http.StatusOK {
t.Fatalf("css status = %d, want %d (body=%s)", doc.status, http.StatusOK, doc.body)
}
// ../img.png resolves to the public P/img.png (in-subtree) and caps;
// rel.png resolves inside the css directory and caps.
mustContain(t, doc.body, `url(../img.png?kandev_cap=`)
mustContain(t, doc.body, `url(rel.png?kandev_cap=`)
}
// TestPortProxyCapabilityRoundTrip covers the signed capability itself:
// valid capabilities validate, and tampering, expiry, or a mismatched
// session:port target are all rejected.
func TestPortProxyCapabilityRoundTrip(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
t.Fatalf("logger: %v", err)
}
handler := NewPortProxyHandler(newProxyTestManager(t, log), log)
identity := authn.Identity{UserID: "user-1", Role: authn.RoleAdmin}
cap := handler.issueCapability("sess-1", 5173, identity)
if got, ok := handler.validateCapability(cap, "sess-1", 5173); !ok {
t.Fatal("fresh capability did not validate")
} else if got.UserID != "user-1" {
t.Fatalf("validated identity UserID = %q, want %q", got.UserID, "user-1")
}
// Wrong session or port: rejected (the credential is subtree-scoped).
if _, ok := handler.validateCapability(cap, "sess-2", 5173); ok {
t.Fatal("capability validated for the wrong session")
}
if _, ok := handler.validateCapability(cap, "sess-1", 5174); ok {
t.Fatal("capability validated for the wrong port")
}
// Tampered payload: rejected.
tampered := cap[:len(cap)-2] + "00"
if _, ok := handler.validateCapability(tampered, "sess-1", 5173); ok {
t.Fatal("tampered capability validated")
}
// Expired: rejected.
old := handler.issueCapabilityWithTTL("sess-1", 5173, identity, -time.Minute)
if _, ok := handler.validateCapability(old, "sess-1", 5173); ok {
t.Fatal("expired capability validated")
}
// Exact boundary (now == ExpiresAt): expired per standard semantics.
atBoundary := handler.issueCapabilityWithTTL("sess-1", 5173, identity, 0)
if _, ok := handler.validateCapability(atBoundary, "sess-1", 5173); ok {
t.Fatal("capability at its expiry boundary validated")
}
// Garbage: rejected without panicking.
if _, ok := handler.validateCapability("not-a-capability", "sess-1", 5173); ok {
t.Fatal("garbage capability validated")
}
}
File diff suppressed because it is too large Load Diff
@@ -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
}
File diff suppressed because it is too large Load Diff
@@ -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)
}
}
@@ -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<HTMLIFrameElement | null>;
}
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();
});
});
@@ -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<HTMLIFrameElement | null>,
): 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]);
}
+24
View File
@@ -69,6 +69,12 @@ interface InspectorReadyMessage {
payload: Record<string, never>;
}
interface PreviewConsoleReadyMessage {
source: typeof INSPECTOR_SOURCE;
type: "console-ready";
payload: Record<string, never>;
}
export type PreviewConsoleLevel = "log" | "warn" | "error" | "info" | "debug";
const PREVIEW_CONSOLE_LEVELS: ReadonlySet<string> = 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}`;