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:
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user