From f6146d28a2e2ecd906d5358518c2c9b8441c42d5 Mon Sep 17 00:00:00 2001 From: zombee0 Date: Fri, 25 Sep 2026 08:46:39 +0800 Subject: [PATCH 01/10] feat(proxy): gate public tunnel traffic by persisted port Direct public hosts and CONNECT requests previously accepted any guest port. Resolve the current tunnel declaration through the API before either path reaches the runner, including requests over pooled HTTP connections. Preserve signed and terminal access. --- apps/proxy/README.md | 7 +- apps/proxy/pkg/proxy/get_box_target.go | 20 +++- apps/proxy/pkg/proxy/tunnel.go | 9 ++ apps/proxy/pkg/proxy/tunnel_access.go | 45 ++++++++ apps/proxy/pkg/proxy/tunnel_access_test.go | 119 +++++++++++++++++++++ apps/proxy/pkg/proxy/tunnel_test.go | 4 + docs/concepts/networking.md | 3 + 7 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 apps/proxy/pkg/proxy/tunnel_access.go create mode 100644 apps/proxy/pkg/proxy/tunnel_access_test.go diff --git a/apps/proxy/README.md b/apps/proxy/README.md index 280388e0a..fe2079a0f 100644 --- a/apps/proxy/README.md +++ b/apps/proxy/README.md @@ -73,9 +73,9 @@ A host without a `-` label serves only the utility routes listed in | Request | Upstream | Authentication | | ------------------------------------------ | --------------------------------------------------------------- | --------------------------------- | -| HTTP or WebSocket to any port except 22222 | Reverse proxy over a runner CONNECT tunnel to the guest port | Private boxes only | +| HTTP or WebSocket to a declared port | Reverse proxy over a runner CONNECT tunnel to the guest port | Active public tunnel, or signed access | | Port 22222 | The runner's web terminal at `/boxes//toolbox/proxy/22222` | Always | -| `CONNECT` | Raw TCP tunnel through the runner | Public boxes only; others get 403 | +| `CONNECT` | Raw TCP tunnel through the runner | Active public tunnel; others are denied | The HTTP path in code: @@ -86,6 +86,7 @@ The HTTP path in code: ├─ parseHost (Proxy · apps/proxy/pkg/proxy/get_box_target.go:357) — port plus box ID or signed token ├─ getBoxPublic (Proxy · apps/proxy/pkg/proxy/get_box_target.go:250) — ask the API, cached 3 s ├─ Authenticate (Proxy · apps/proxy/pkg/proxy/auth.go:18) — private box or terminal port only + ├─ hasPublicTunnelAccess — check direct public guest ports before proxying └─ updateLastActivity (Proxy · apps/proxy/pkg/proxy/get_box_target.go:430) — renew activity every 50 s └─ dialGuestPort (Proxy · apps/proxy/pkg/proxy/get_box_target.go:165) — dial each new upstream connection ├─ getBoxRunnerInfo (Proxy · apps/proxy/pkg/proxy/get_box_target.go:211) — runner URL and key, cached 2 min @@ -168,7 +169,7 @@ is internal and changes together with the API and the runner. | Service | Call | Credential | | ------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | | API | `GET /api/config` at startup, for unset OIDC settings | `PROXY_API_KEY` | -| API | `GET /api/preview/{boxId}/public`, `/validate/{token}`; `GET /api/preview/{token}/{port}/box-id` | `PROXY_API_KEY` | +| API | `GET /api/preview/{boxId}/public`, `/validate/{token}`, `/tunnels/{port}`; `GET /api/preview/{token}/{port}/box-id` | `PROXY_API_KEY` | | API | `GET /api/preview/{boxId}/access` | The caller's bearer token | | API | `GET /api/runners/by-box/{boxId}`, `POST /api/box/{boxId}/last-activity` | `PROXY_API_KEY` | | Runner | `CONNECT /v1/boxes/{boxId}/network/tunnel?port={port}`; `/boxes/{boxId}/toolbox/proxy/22222/...` | The runner's key from `by-box`, in `X-BoxLite-Authorization` | diff --git a/apps/proxy/pkg/proxy/get_box_target.go b/apps/proxy/pkg/proxy/get_box_target.go index ed490235e..4255b3707 100644 --- a/apps/proxy/pkg/proxy/get_box_target.go +++ b/apps/proxy/pkg/proxy/get_box_target.go @@ -70,12 +70,14 @@ func (p *Proxy) GetProxyTarget(ctx *gin.Context) (*common_proxy.RequestTarget, e } boxId := boxIdOrSignedToken + isDirectHost := isValidDirectPreviewBoxID(boxIdOrSignedToken) if decodedBoxId, ok, decodeErr := decodeDirectPreviewBoxID(boxIdOrSignedToken); decodeErr != nil { ctx.Error(common_errors.NewBadRequestError(decodeErr)) return nil, decodeErr } else if ok { boxId = decodedBoxId boxIdOrSignedToken = decodedBoxId + isDirectHost = true } isPublic, err := p.getBoxPublic(ctx, boxIdOrSignedToken) @@ -132,11 +134,25 @@ func (p *Proxy) GetProxyTarget(ctx *gin.Context) (*common_proxy.RequestTarget, e } if targetPort != TERMINAL_PORT { - if _, err := strconv.ParseUint(targetPort, 10, 16); err != nil { - wrappedErr := fmt.Errorf("invalid target port: %w", err) + port, err := strconv.ParseUint(targetPort, 10, 16) + if err != nil || port == 0 { + wrappedErr := fmt.Errorf("invalid target port %q", targetPort) ctx.Error(common_errors.NewBadRequestError(wrappedErr)) return nil, wrappedErr } + if isDirectHost { + allowed, err := p.hasPublicTunnelAccess(ctx.Request.Context(), boxId, uint16(port)) + if err != nil { + wrappedErr := fmt.Errorf("check tunnel access: %w", err) + ctx.Error(common_errors.NewInternalServerError(wrappedErr)) + return nil, wrappedErr + } + if !allowed { + wrappedErr := errors.New("tunnel not found") + ctx.Error(common_errors.NewNotFoundError(wrappedErr)) + return nil, wrappedErr + } + } target, err := url.Parse("http://" + net.JoinHostPort(boxId, targetPort) + targetPath) if err != nil { return nil, fmt.Errorf("failed to parse guest target URL: %w", err) diff --git a/apps/proxy/pkg/proxy/tunnel.go b/apps/proxy/pkg/proxy/tunnel.go index ba8824370..8358478d1 100644 --- a/apps/proxy/pkg/proxy/tunnel.go +++ b/apps/proxy/pkg/proxy/tunnel.go @@ -44,6 +44,15 @@ func (p *Proxy) handleTunnelConnect(writer http.ResponseWriter, request *http.Re http.Error(writer, "box is not public", http.StatusForbidden) return } + allowed, err := p.hasPublicTunnelAccess(request.Context(), boxID, port) + if err != nil { + http.Error(writer, "tunnel access unavailable", http.StatusBadGateway) + return + } + if !allowed { + http.Error(writer, "tunnel not found", http.StatusNotFound) + return + } // A CONNECT tunnel outlives any single HTTP request, so for tunnels the poll // is the renewal rather than a fallback: bind its ticker to the stream, which diff --git a/apps/proxy/pkg/proxy/tunnel_access.go b/apps/proxy/pkg/proxy/tunnel_access.go new file mode 100644 index 000000000..c57bbf3b1 --- /dev/null +++ b/apps/proxy/pkg/proxy/tunnel_access.go @@ -0,0 +1,45 @@ +// Copyright 2026 BoxLite AI +// SPDX-License-Identifier: AGPL-3.0 + +package proxy + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "time" +) + +func (p *Proxy) hasPublicTunnelAccess(ctx context.Context, boxID string, port uint16) (bool, error) { + configuration := p.apiclient.GetConfig() + endpoint, err := url.JoinPath(configuration.Servers[0].URL, "preview", boxID, "tunnels", strconv.Itoa(int(port))) + if err != nil { + return false, fmt.Errorf("build tunnel access endpoint: %w", err) + } + checkCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + request, err := http.NewRequestWithContext(checkCtx, http.MethodGet, endpoint, nil) + if err != nil { + return false, fmt.Errorf("build tunnel access request: %w", err) + } + request.Header.Set("Authorization", configuration.DefaultHeader["Authorization"]) + client := configuration.HTTPClient + if client == nil { + client = http.DefaultClient + } + response, err := client.Do(request) + if err != nil { + return false, fmt.Errorf("check tunnel access for box %s port %d: %w", boxID, port, err) + } + defer response.Body.Close() + switch response.StatusCode { + case http.StatusOK: + return true, nil + case http.StatusNotFound: + return false, nil + default: + return false, fmt.Errorf("check tunnel access for box %s port %d: API returned %d", boxID, port, response.StatusCode) + } +} diff --git a/apps/proxy/pkg/proxy/tunnel_access_test.go b/apps/proxy/pkg/proxy/tunnel_access_test.go new file mode 100644 index 000000000..690ca5f1c --- /dev/null +++ b/apps/proxy/pkg/proxy/tunnel_access_test.go @@ -0,0 +1,119 @@ +// Copyright 2026 BoxLite AI +// SPDX-License-Identifier: AGPL-3.0 + +package proxy + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + apiclient "github.com/boxlite-ai/boxlite/libs/api-client-go" + common_cache "github.com/boxlite-ai/common-go/pkg/cache" + "github.com/gin-gonic/gin" +) + +func newTunnelProxy(t *testing.T, accessStatus int) (*Proxy, func()) { + t.Helper() + api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/api/preview/AbCdEf123456/tunnels/3000" { + writer.WriteHeader(accessStatus) + return + } + writer.WriteHeader(http.StatusNotFound) + })) + clientConfig := apiclient.NewConfiguration() + clientConfig.Servers[0].URL = api.URL + "/api" + clientConfig.AddDefaultHeader("Authorization", "Bearer proxy-key") + ctx := context.Background() + publicCache := common_cache.NewMapCache[bool](ctx) + if err := publicCache.Set(ctx, "AbCdEf123456", true, time.Minute); err != nil { + t.Fatal(err) + } + activityCache := common_cache.NewMapCache[bool](ctx) + if err := activityCache.Set(ctx, "AbCdEf123456", true, time.Minute); err != nil { + t.Fatal(err) + } + runnerCache := common_cache.NewMapCache[RunnerInfo](ctx) + if err := runnerCache.Set(ctx, "AbCdEf123456", RunnerInfo{ApiUrl: "http://127.0.0.1:1", ApiKey: "runner-key"}, time.Minute); err != nil { + t.Fatal(err) + } + proxy := &Proxy{ + apiclient: apiclient.NewAPIClient(clientConfig), + boxPublicCache: publicCache, + boxRunnerCache: runnerCache, + boxLastActivityUpdateCache: activityCache, + } + return proxy, api.Close +} + +func TestUndeclaredTunnelRejectsHTTP(t *testing.T) { + proxy, closeAPI := newTunnelProxy(t, http.StatusNotFound) + defer closeAPI() + request := httptest.NewRequest(http.MethodGet, "http://3000-d-416243644566313233343536.proxy.test/", nil) + response := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(response) + ctx.Request = request + + target, err := proxy.GetProxyTarget(ctx) + stopActivityPoll(ctx) + if err == nil || target != nil { + t.Fatalf("undeclared HTTP port reached proxy target: target=%v err=%v", target, err) + } +} + +func TestUndeclaredTunnelRejectsConnect(t *testing.T) { + proxy, closeAPI := newTunnelProxy(t, http.StatusNotFound) + defer closeAPI() + request := httptest.NewRequest(http.MethodConnect, "http://proxy.test", nil) + request.Host = "3000-d-416243644566313233343536.proxy.test:443" + response := httptest.NewRecorder() + + proxy.handleTunnelConnect(response, request) + if response.Code != http.StatusNotFound { + t.Fatalf("undeclared CONNECT port status = %d, want 404", response.Code) + } +} + +func TestUndeclaredTunnelRejectsRawBoxIDHost(t *testing.T) { + proxy, closeAPI := newTunnelProxy(t, http.StatusNotFound) + defer closeAPI() + request := httptest.NewRequest(http.MethodGet, "http://3000-AbCdEf123456.proxy.test/", nil) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = request + + target, err := proxy.GetProxyTarget(ctx) + stopActivityPoll(ctx) + if err == nil || target != nil { + t.Fatalf("raw box ID host bypassed declaration: target=%v err=%v", target, err) + } +} + +func TestDeclaredTunnelAllowsHTTP(t *testing.T) { + proxy, closeAPI := newTunnelProxy(t, http.StatusOK) + defer closeAPI() + request := httptest.NewRequest(http.MethodGet, "http://3000-d-416243644566313233343536.proxy.test/", nil) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = request + + target, err := proxy.GetProxyTarget(ctx) + stopActivityPoll(ctx) + if err != nil || target == nil { + t.Fatalf("declared HTTP port was rejected: target=%v err=%v", target, err) + } +} + +func TestTunnelAccessAPIErrorFailsClosed(t *testing.T) { + proxy, closeAPI := newTunnelProxy(t, http.StatusServiceUnavailable) + defer closeAPI() + request := httptest.NewRequest(http.MethodConnect, "http://proxy.test", nil) + request.Host = "3000-d-416243644566313233343536.proxy.test:443" + response := httptest.NewRecorder() + + proxy.handleTunnelConnect(response, request) + if response.Code != http.StatusBadGateway { + t.Fatalf("access API failure status = %d, want 502", response.Code) + } +} diff --git a/apps/proxy/pkg/proxy/tunnel_test.go b/apps/proxy/pkg/proxy/tunnel_test.go index 9dae77873..9d48ee0b4 100644 --- a/apps/proxy/pkg/proxy/tunnel_test.go +++ b/apps/proxy/pkg/proxy/tunnel_test.go @@ -120,6 +120,10 @@ func TestTunnelConnectRenewsBoxActivityBeforeStreaming(t *testing.T) { renewed := make(chan string, 1) api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/preview/"+boxID+"/tunnels/3000" { + writer.WriteHeader(http.StatusOK) + return + } if request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/last-activity") { select { case renewed <- request.URL.Path: diff --git a/docs/concepts/networking.md b/docs/concepts/networking.md index c931c3fe8..fe362655a 100644 --- a/docs/concepts/networking.md +++ b/docs/concepts/networking.md @@ -52,6 +52,9 @@ The box network tunnel API is portable across local and REST runtimes, but its transport is backend-specific. `tunnel()` eagerly prepares one local gvproxy or remote service-proxy connection. `uri()` inspects its public URI, while `connect()` or `forward()` consumes that prepared one-shot tunnel into a byte stream or listener. +For a remote public box, preparing the tunnel also registers its guest port. The +proxy accepts direct HTTP/WebSocket and CONNECT access only while that port's +tunnel declaration remains active. Explicit host port publication is a separate local-runtime feature that owns a TCP listener and accepts repeated connections. From 41a27a1ce974570635ce37a6d8294805ad3404f7 Mon Sep 17 00:00:00 2001 From: zombee0 Date: Sat, 26 Sep 2026 10:09:15 +0800 Subject: [PATCH 02/10] fix(proxy): preserve private previews and reserve terminal CONNECT --- apps/proxy/README.md | 6 +++ apps/proxy/pkg/proxy/get_box_target.go | 2 +- apps/proxy/pkg/proxy/tunnel.go | 3 ++ apps/proxy/pkg/proxy/tunnel_access_test.go | 46 ++++++++++++++++++++++ apps/proxy/pkg/proxy/tunnel_test.go | 11 ++++++ 5 files changed, 67 insertions(+), 1 deletion(-) diff --git a/apps/proxy/README.md b/apps/proxy/README.md index fe2079a0f..7d0be0528 100644 --- a/apps/proxy/README.md +++ b/apps/proxy/README.md @@ -98,6 +98,12 @@ The upstream URL `http://:` is only a routing key. `dialGuestPort` pooling reuses tunnels per box and port. Raw `CONNECT` requests skip this router and go to `handleTunnelConnect` in [`tunnel.go`](pkg/proxy/tunnel.go). +The proxy checks each new direct HTTP/WebSocket request and CONNECT against the +API without caching an allowed declaration. Revocation blocks the next request +after its database commit, across proxy instances and restarts. Connections +already established continue until they close. The terminal port is unavailable +to raw CONNECT tunnels. + ## Authentication A request to a private box, or to port 22222, takes the first credential that works diff --git a/apps/proxy/pkg/proxy/get_box_target.go b/apps/proxy/pkg/proxy/get_box_target.go index 4255b3707..ff6cbf7f6 100644 --- a/apps/proxy/pkg/proxy/get_box_target.go +++ b/apps/proxy/pkg/proxy/get_box_target.go @@ -140,7 +140,7 @@ func (p *Proxy) GetProxyTarget(ctx *gin.Context) (*common_proxy.RequestTarget, e ctx.Error(common_errors.NewBadRequestError(wrappedErr)) return nil, wrappedErr } - if isDirectHost { + if isDirectHost && *isPublic { allowed, err := p.hasPublicTunnelAccess(ctx.Request.Context(), boxId, uint16(port)) if err != nil { wrappedErr := fmt.Errorf("check tunnel access: %w", err) diff --git a/apps/proxy/pkg/proxy/tunnel.go b/apps/proxy/pkg/proxy/tunnel.go index 8358478d1..f192030db 100644 --- a/apps/proxy/pkg/proxy/tunnel.go +++ b/apps/proxy/pkg/proxy/tunnel.go @@ -101,6 +101,9 @@ func (p *Proxy) tunnelTarget(request *http.Request) (string, uint16, error) { if err != nil || value == 0 { return "", 0, fmt.Errorf("invalid tunnel port") } + if strconv.FormatUint(value, 10) == TERMINAL_PORT { + return "", 0, fmt.Errorf("terminal port is reserved") + } return boxID, uint16(value), nil } diff --git a/apps/proxy/pkg/proxy/tunnel_access_test.go b/apps/proxy/pkg/proxy/tunnel_access_test.go index 690ca5f1c..445838bc7 100644 --- a/apps/proxy/pkg/proxy/tunnel_access_test.go +++ b/apps/proxy/pkg/proxy/tunnel_access_test.go @@ -7,6 +7,7 @@ import ( "context" "net/http" "net/http/httptest" + "sync/atomic" "testing" "time" @@ -43,6 +44,7 @@ func newTunnelProxy(t *testing.T, accessStatus int) (*Proxy, func()) { proxy := &Proxy{ apiclient: apiclient.NewAPIClient(clientConfig), boxPublicCache: publicCache, + boxAuthKeyValidCache: common_cache.NewMapCache[bool](ctx), boxRunnerCache: runnerCache, boxLastActivityUpdateCache: activityCache, } @@ -105,6 +107,28 @@ func TestDeclaredTunnelAllowsHTTP(t *testing.T) { } } +func TestAuthenticatedPrivatePreviewKeepsWorking(t *testing.T) { + proxy, closeAPI := newTunnelProxy(t, http.StatusNotFound) + defer closeAPI() + ctx := context.Background() + if err := proxy.boxPublicCache.Set(ctx, "AbCdEf123456", false, time.Minute); err != nil { + t.Fatal(err) + } + if err := proxy.boxAuthKeyValidCache.Set(ctx, "AbCdEf123456:owner-key", true, time.Minute); err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "http://3000-d-416243644566313233343536.proxy.test/", nil) + request.Header.Set(BOX_AUTH_KEY_HEADER, "owner-key") + ginCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ginCtx.Request = request + + target, err := proxy.GetProxyTarget(ginCtx) + stopActivityPoll(ginCtx) + if err != nil || target == nil { + t.Fatalf("authenticated private preview rejected: target=%v err=%v", target, err) + } +} + func TestTunnelAccessAPIErrorFailsClosed(t *testing.T) { proxy, closeAPI := newTunnelProxy(t, http.StatusServiceUnavailable) defer closeAPI() @@ -117,3 +141,25 @@ func TestTunnelAccessAPIErrorFailsClosed(t *testing.T) { t.Fatalf("access API failure status = %d, want 502", response.Code) } } + +func TestTunnelRevocationBlocksNextAccessCheck(t *testing.T) { + var status atomic.Int32 + status.Store(http.StatusOK) + api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(int(status.Load())) + })) + defer api.Close() + config := apiclient.NewConfiguration() + config.Servers[0].URL = api.URL + "/api" + proxy := &Proxy{apiclient: apiclient.NewAPIClient(config)} + + allowed, err := proxy.hasPublicTunnelAccess(context.Background(), "AbCdEf123456", 3000) + if err != nil || !allowed { + t.Fatalf("declared port rejected: allowed=%v err=%v", allowed, err) + } + status.Store(http.StatusNotFound) + allowed, err = proxy.hasPublicTunnelAccess(context.Background(), "AbCdEf123456", 3000) + if err != nil || allowed { + t.Fatalf("revoked port remained authorized: allowed=%v err=%v", allowed, err) + } +} diff --git a/apps/proxy/pkg/proxy/tunnel_test.go b/apps/proxy/pkg/proxy/tunnel_test.go index 9d48ee0b4..858414b63 100644 --- a/apps/proxy/pkg/proxy/tunnel_test.go +++ b/apps/proxy/pkg/proxy/tunnel_test.go @@ -97,6 +97,17 @@ func TestTunnelTargetUsesPreviewAuthority(t *testing.T) { } } +func TestTunnelTargetRejectsTerminalPort(t *testing.T) { + for _, port := range []string{"22222", "022222"} { + request := httptest.NewRequest(http.MethodConnect, "http://proxy.test", nil) + request.Host = port + "-d-416243644566313233343536.proxy.test:443" + + if _, _, err := (&Proxy{}).tunnelTarget(request); err == nil { + t.Fatalf("terminal port %s accepted as a public CONNECT tunnel", port) + } + } +} + func TestTunnelConnectRejectsPrivateBoxBeforeRunnerDial(t *testing.T) { ctx := context.Background() publicCache := common_cache.NewMapCache[bool](ctx) From 62ea40c228673b56765fb2cde7a303da583f93b2 Mon Sep 17 00:00:00 2001 From: zombee0 Date: Sat, 26 Sep 2026 20:49:59 +0800 Subject: [PATCH 03/10] fix(dashboard): avoid promising access to undeclared ports --- .../src/components/boxes/BoxNetworkPanel.tsx | 28 ++++--------------- .../components/boxes/BoxNetworkSection.tsx | 2 +- .../components/boxes/BoxPreviewUrlDialog.tsx | 5 ++-- .../useUpdateBoxPublicStatusMutation.ts | 2 +- 4 files changed, 10 insertions(+), 27 deletions(-) diff --git a/apps/dashboard/src/components/boxes/BoxNetworkPanel.tsx b/apps/dashboard/src/components/boxes/BoxNetworkPanel.tsx index 1c96d08a7..47410f50e 100644 --- a/apps/dashboard/src/components/boxes/BoxNetworkPanel.tsx +++ b/apps/dashboard/src/components/boxes/BoxNetworkPanel.tsx @@ -108,23 +108,13 @@ export function BoxNetworkPanel({ <> get url} /> - {/* `access` answers the only question this row is asked — can a stranger - open this box? — rather than naming the mechanism. - - The URLs it governs carry no credential (`getPortPreviewUrl` returns - `url` and `token` separately), so a non-member is bounced through - OIDC and then refused by the org-membership check in - preview.controller.ts:142. Access is credential-based, not - network-based: there is no inbound IP allowlist (openapi/box.openapi - .yaml — "no layer enforces an inbound allowlist today"). - - Sheet convention: grey key, foreground value; hue carries the risk - signal (amber when exposed). */} + {/* Sheet convention: grey key, foreground value; hue carries the risk + signal (amber when public). */}
access - {isPublic ? 'anyone' : 'your organization'} + {isPublic ? 'public' : 'your organization'} {canManage ? ( - Open this box's preview URLs to anyone? - {/* Scoped deliberately. The flag only makes the proxy skip - authentication for preview traffic (get_box_target.go:87) and - raw tunnel CONNECTs (tunnel.go:43). The web terminal stays - authenticated even when public — the same line exempts - TERMINAL_PORT — and the management API is untouched. An earlier - "Make this box public?" read as handing over the whole box. */} + Enable public access? - Anyone who knows a preview URL will reach any port your box is serving without signing - in. The URL is not a secret — it ends up in browser history, proxy logs and Referer headers. + Public access may allow preview URLs to open without signing in. A URL is not a secret — it ends up in + browser history, proxy logs and Referer headers.

The web terminal and the box's files, commands and settings are not affected; those still require diff --git a/apps/dashboard/src/components/boxes/BoxNetworkSection.tsx b/apps/dashboard/src/components/boxes/BoxNetworkSection.tsx index 7a0ce1c5a..4d8b37a2e 100644 --- a/apps/dashboard/src/components/boxes/BoxNetworkSection.tsx +++ b/apps/dashboard/src/components/boxes/BoxNetworkSection.tsx @@ -31,7 +31,7 @@ export function BoxNetworkSection({ box, canManage }: { box: Box; canManage: boo { // Named for what actually changed: preview reachability, not the // box as a whole. - onSuccess: () => toast.success(next ? 'Preview URLs are open to anyone' : 'Preview URLs require signing in'), + onSuccess: () => toast.success(next ? 'Public access enabled' : 'Preview URLs require signing in'), onError: () => toast.error('Could not change preview access'), }, ) diff --git a/apps/dashboard/src/components/boxes/BoxPreviewUrlDialog.tsx b/apps/dashboard/src/components/boxes/BoxPreviewUrlDialog.tsx index 55e97fe48..3e515b36f 100644 --- a/apps/dashboard/src/components/boxes/BoxPreviewUrlDialog.tsx +++ b/apps/dashboard/src/components/boxes/BoxPreviewUrlDialog.tsx @@ -96,10 +96,9 @@ export function BoxPreviewUrlDialog({
- {/* Who can use it matters at the moment of copying, which is here - rather than back on the sheet. */} + {/* Show the box's access setting beside the URL being copied. */}

- {isPublic ? 'anyone with this url can open it' : 'only your organization can open it'} + {isPublic ? 'public box' : 'only your organization can open it'}

{/* Stated here because it cannot be diagnosed afterwards: the URL diff --git a/apps/dashboard/src/hooks/mutations/useUpdateBoxPublicStatusMutation.ts b/apps/dashboard/src/hooks/mutations/useUpdateBoxPublicStatusMutation.ts index 6ad526ca1..0a9254907 100644 --- a/apps/dashboard/src/hooks/mutations/useUpdateBoxPublicStatusMutation.ts +++ b/apps/dashboard/src/hooks/mutations/useUpdateBoxPublicStatusMutation.ts @@ -14,7 +14,7 @@ interface UpdateBoxPublicStatusVariables { } /** - * Flips whether a box's preview URLs are reachable without a credential. + * Updates the box's public access setting. * * Invalidating the box detail query is the point: `public` lives on the box * record, so the detail sheet has to re-read it before it can show the new From 14b21269241928db70295fb6153cd0a48cedbd80 Mon Sep 17 00:00:00 2001 From: zombee0 Date: Sun, 27 Sep 2026 09:43:06 +0800 Subject: [PATCH 04/10] fix(proxy): check tunnel access before recording activity --- apps/proxy/pkg/proxy/get_box_target.go | 40 ++++++++++++---------- apps/proxy/pkg/proxy/tunnel_access_test.go | 3 ++ 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/apps/proxy/pkg/proxy/get_box_target.go b/apps/proxy/pkg/proxy/get_box_target.go index ff6cbf7f6..02e099bfc 100644 --- a/apps/proxy/pkg/proxy/get_box_target.go +++ b/apps/proxy/pkg/proxy/get_box_target.go @@ -101,6 +101,27 @@ func (p *Proxy) GetProxyTarget(ctx *gin.Context) (*common_proxy.RequestTarget, e return nil, err } } + if targetPort != TERMINAL_PORT { + port, err := strconv.ParseUint(targetPort, 10, 16) + if err != nil || port == 0 { + wrappedErr := fmt.Errorf("invalid target port %q", targetPort) + ctx.Error(common_errors.NewBadRequestError(wrappedErr)) + return nil, wrappedErr + } + if isDirectHost && *isPublic { + allowed, err := p.hasPublicTunnelAccess(ctx.Request.Context(), boxId, uint16(port)) + if err != nil { + wrappedErr := fmt.Errorf("check tunnel access: %w", err) + ctx.Error(common_errors.NewInternalServerError(wrappedErr)) + return nil, wrappedErr + } + if !allowed { + wrappedErr := errors.New("tunnel not found") + ctx.Error(common_errors.NewNotFoundError(wrappedErr)) + return nil, wrappedErr + } + } + } // Stamp the API's span vocabulary (boxlite.* — see the API's // ObservabilityContextInterceptor) so one key filters a box across @@ -134,25 +155,6 @@ func (p *Proxy) GetProxyTarget(ctx *gin.Context) (*common_proxy.RequestTarget, e } if targetPort != TERMINAL_PORT { - port, err := strconv.ParseUint(targetPort, 10, 16) - if err != nil || port == 0 { - wrappedErr := fmt.Errorf("invalid target port %q", targetPort) - ctx.Error(common_errors.NewBadRequestError(wrappedErr)) - return nil, wrappedErr - } - if isDirectHost && *isPublic { - allowed, err := p.hasPublicTunnelAccess(ctx.Request.Context(), boxId, uint16(port)) - if err != nil { - wrappedErr := fmt.Errorf("check tunnel access: %w", err) - ctx.Error(common_errors.NewInternalServerError(wrappedErr)) - return nil, wrappedErr - } - if !allowed { - wrappedErr := errors.New("tunnel not found") - ctx.Error(common_errors.NewNotFoundError(wrappedErr)) - return nil, wrappedErr - } - } target, err := url.Parse("http://" + net.JoinHostPort(boxId, targetPort) + targetPath) if err != nil { return nil, fmt.Errorf("failed to parse guest target URL: %w", err) diff --git a/apps/proxy/pkg/proxy/tunnel_access_test.go b/apps/proxy/pkg/proxy/tunnel_access_test.go index 445838bc7..4795de295 100644 --- a/apps/proxy/pkg/proxy/tunnel_access_test.go +++ b/apps/proxy/pkg/proxy/tunnel_access_test.go @@ -64,6 +64,9 @@ func TestUndeclaredTunnelRejectsHTTP(t *testing.T) { if err == nil || target != nil { t.Fatalf("undeclared HTTP port reached proxy target: target=%v err=%v", target, err) } + if _, started := ctx.Get(ACTIVITY_POLL_STOP_KEY); started { + t.Fatal("undeclared HTTP port started activity polling") + } } func TestUndeclaredTunnelRejectsConnect(t *testing.T) { From bbc957905784604f5b6d66667644fbd73bb422b2 Mon Sep 17 00:00:00 2001 From: zombee0 Date: Mon, 28 Sep 2026 16:28:22 +0800 Subject: [PATCH 05/10] perf(api): cache public tunnel access checks in Redis The proxy asks /preview/{boxId}/tunnels/{port} on every public preview request, and each call queried Postgres. Cache both verdicts for 3 s under preview:tunnel:{boxId}:{port}, the same window as the other preview checks (preview:public, preview:token), so revocation or making a box private takes effect within 3 s. declarePublic clears the key after its write, so a freshly declared port is not held behind a cached refusal. Co-authored-by: Cursor --- .../src/box/services/tunnel.service.spec.ts | 36 ++++++++++++++++++- apps/api/src/box/services/tunnel.service.ts | 24 +++++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/apps/api/src/box/services/tunnel.service.spec.ts b/apps/api/src/box/services/tunnel.service.spec.ts index 8895205a2..6bc486a72 100644 --- a/apps/api/src/box/services/tunnel.service.spec.ts +++ b/apps/api/src/box/services/tunnel.service.spec.ts @@ -17,7 +17,12 @@ function makeService() { query: jest.fn().mockResolvedValue([{ id: 'tunnel-1' }]), createQueryBuilder: jest.fn().mockReturnValue(builder), } - return { service: new TunnelService(repository as never), repository, builder } + const redis = { + get: jest.fn().mockResolvedValue(null), + setex: jest.fn().mockResolvedValue('OK'), + del: jest.fn().mockResolvedValue(1), + } + return { service: new TunnelService(repository as never, redis as never), repository, builder, redis } } describe('TunnelService', () => { @@ -54,4 +59,33 @@ describe('TunnelService', () => { expect(builder.andWhere).toHaveBeenCalledWith('box.public = true') }) + it('caches both access verdicts briefly', async () => { + const { service, builder, redis } = makeService() + builder.getExists.mockResolvedValueOnce(true).mockResolvedValueOnce(false) + + await expect(service.isPublicAccessAllowed('AbCdEf123456', 3000)).resolves.toBe(true) + await expect(service.isPublicAccessAllowed('AbCdEf123456', 4000)).resolves.toBe(false) + + expect(redis.setex).toHaveBeenCalledWith('preview:tunnel:AbCdEf123456:3000', 3, '1') + expect(redis.setex).toHaveBeenCalledWith('preview:tunnel:AbCdEf123456:4000', 3, '0') + }) + + it('answers from the cache without querying the database', async () => { + const { service, repository, redis } = makeService() + redis.get.mockResolvedValueOnce('1').mockResolvedValueOnce('0') + + await expect(service.isPublicAccessAllowed('AbCdEf123456', 3000)).resolves.toBe(true) + await expect(service.isPublicAccessAllowed('AbCdEf123456', 3000)).resolves.toBe(false) + + expect(redis.get).toHaveBeenCalledWith('preview:tunnel:AbCdEf123456:3000') + expect(repository.createQueryBuilder).not.toHaveBeenCalled() + }) + + it('drops a cached refusal when the port is declared', async () => { + const { service, redis } = makeService() + + await service.declarePublic('AbCdEf123456', 3000) + + expect(redis.del).toHaveBeenCalledWith('preview:tunnel:AbCdEf123456:3000') + }) }) diff --git a/apps/api/src/box/services/tunnel.service.ts b/apps/api/src/box/services/tunnel.service.ts index e51692b02..fb5b89136 100644 --- a/apps/api/src/box/services/tunnel.service.ts +++ b/apps/api/src/box/services/tunnel.service.ts @@ -5,15 +5,23 @@ import { ConflictException, Injectable } from '@nestjs/common' import { InjectRepository } from '@nestjs/typeorm' +import { InjectRedis } from '@nestjs-modules/ioredis' +import Redis from 'ioredis' import { Repository } from 'typeorm' import { BadRequestError } from '../../exceptions/bad-request.exception' import { Tunnel } from '../entities/tunnel.entity' const TERMINAL_PORT = 22222 +// Same window as the other preview checks (preview:public, preview:token): the proxy +// asks on every request, and a revoked or unpublished tunnel stays open this long. +const ACCESS_CACHE_TTL_SECONDS = 3 @Injectable() export class TunnelService { - constructor(@InjectRepository(Tunnel) private readonly tunnels: Repository) {} + constructor( + @InjectRepository(Tunnel) private readonly tunnels: Repository, + @InjectRedis() private readonly redis: Redis, + ) {} async declarePublic(boxId: string, port: number): Promise { this.assertPort(port) @@ -26,11 +34,17 @@ export class TunnelService { if (rows.length === 0) { throw new ConflictException('Port already has a non-public tunnel') } + await this.redis.del(this.accessCacheKey(boxId, port)) } async isPublicAccessAllowed(boxId: string, port: number): Promise { this.assertPort(port) - return this.tunnels + const cacheKey = this.accessCacheKey(boxId, port) + const cached = await this.redis.get(cacheKey) + if (cached) { + return cached === '1' + } + const allowed = await this.tunnels .createQueryBuilder('tunnel') .innerJoin('tunnel.box', 'box') .where('tunnel.box_id = :boxId', { boxId }) @@ -40,6 +54,12 @@ export class TunnelService { .andWhere('box.public = true') .andWhere('box.state NOT IN (:...excluded)', { excluded: ['destroyed', 'destroying', 'archived', 'archiving'] }) .getExists() + await this.redis.setex(cacheKey, ACCESS_CACHE_TTL_SECONDS, allowed ? '1' : '0') + return allowed + } + + private accessCacheKey(boxId: string, port: number): string { + return `preview:tunnel:${boxId}:${port}` } private assertPort(port: number): void { From 947ebed06cbce60f338bfaeb14537d9935a389e0 Mon Sep 17 00:00:00 2001 From: zombee0 Date: Mon, 28 Sep 2026 22:48:04 +0800 Subject: [PATCH 06/10] fix(dashboard): keep preview access copy from main The initial tunnel gate changes proxy and API behavior only. Keep the Dashboard copy on its established contract while the remaining proxy paths are completed in the next layer. --- .../src/components/boxes/BoxNetworkPanel.tsx | 28 +++++++++++++++---- .../components/boxes/BoxNetworkSection.tsx | 2 +- .../components/boxes/BoxPreviewUrlDialog.tsx | 5 ++-- .../useUpdateBoxPublicStatusMutation.ts | 2 +- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/apps/dashboard/src/components/boxes/BoxNetworkPanel.tsx b/apps/dashboard/src/components/boxes/BoxNetworkPanel.tsx index 47410f50e..1c96d08a7 100644 --- a/apps/dashboard/src/components/boxes/BoxNetworkPanel.tsx +++ b/apps/dashboard/src/components/boxes/BoxNetworkPanel.tsx @@ -108,13 +108,23 @@ export function BoxNetworkPanel({ <> get url} /> - {/* Sheet convention: grey key, foreground value; hue carries the risk - signal (amber when public). */} + {/* `access` answers the only question this row is asked — can a stranger + open this box? — rather than naming the mechanism. + + The URLs it governs carry no credential (`getPortPreviewUrl` returns + `url` and `token` separately), so a non-member is bounced through + OIDC and then refused by the org-membership check in + preview.controller.ts:142. Access is credential-based, not + network-based: there is no inbound IP allowlist (openapi/box.openapi + .yaml — "no layer enforces an inbound allowlist today"). + + Sheet convention: grey key, foreground value; hue carries the risk + signal (amber when exposed). */}
access - {isPublic ? 'public' : 'your organization'} + {isPublic ? 'anyone' : 'your organization'} {canManage ? ( - Enable public access? + Open this box's preview URLs to anyone? + {/* Scoped deliberately. The flag only makes the proxy skip + authentication for preview traffic (get_box_target.go:87) and + raw tunnel CONNECTs (tunnel.go:43). The web terminal stays + authenticated even when public — the same line exempts + TERMINAL_PORT — and the management API is untouched. An earlier + "Make this box public?" read as handing over the whole box. */} - Public access may allow preview URLs to open without signing in. A URL is not a secret — it ends up in - browser history, proxy logs and Referer headers. + Anyone who knows a preview URL will reach any port your box is serving without signing + in. The URL is not a secret — it ends up in browser history, proxy logs and Referer headers.

The web terminal and the box's files, commands and settings are not affected; those still require diff --git a/apps/dashboard/src/components/boxes/BoxNetworkSection.tsx b/apps/dashboard/src/components/boxes/BoxNetworkSection.tsx index 4d8b37a2e..7a0ce1c5a 100644 --- a/apps/dashboard/src/components/boxes/BoxNetworkSection.tsx +++ b/apps/dashboard/src/components/boxes/BoxNetworkSection.tsx @@ -31,7 +31,7 @@ export function BoxNetworkSection({ box, canManage }: { box: Box; canManage: boo { // Named for what actually changed: preview reachability, not the // box as a whole. - onSuccess: () => toast.success(next ? 'Public access enabled' : 'Preview URLs require signing in'), + onSuccess: () => toast.success(next ? 'Preview URLs are open to anyone' : 'Preview URLs require signing in'), onError: () => toast.error('Could not change preview access'), }, ) diff --git a/apps/dashboard/src/components/boxes/BoxPreviewUrlDialog.tsx b/apps/dashboard/src/components/boxes/BoxPreviewUrlDialog.tsx index 3e515b36f..55e97fe48 100644 --- a/apps/dashboard/src/components/boxes/BoxPreviewUrlDialog.tsx +++ b/apps/dashboard/src/components/boxes/BoxPreviewUrlDialog.tsx @@ -96,9 +96,10 @@ export function BoxPreviewUrlDialog({
- {/* Show the box's access setting beside the URL being copied. */} + {/* Who can use it matters at the moment of copying, which is here + rather than back on the sheet. */}

- {isPublic ? 'public box' : 'only your organization can open it'} + {isPublic ? 'anyone with this url can open it' : 'only your organization can open it'}

{/* Stated here because it cannot be diagnosed afterwards: the URL diff --git a/apps/dashboard/src/hooks/mutations/useUpdateBoxPublicStatusMutation.ts b/apps/dashboard/src/hooks/mutations/useUpdateBoxPublicStatusMutation.ts index 0a9254907..6ad526ca1 100644 --- a/apps/dashboard/src/hooks/mutations/useUpdateBoxPublicStatusMutation.ts +++ b/apps/dashboard/src/hooks/mutations/useUpdateBoxPublicStatusMutation.ts @@ -14,7 +14,7 @@ interface UpdateBoxPublicStatusVariables { } /** - * Updates the box's public access setting. + * Flips whether a box's preview URLs are reachable without a credential. * * Invalidating the box detail query is the point: `public` lives on the box * record, so the detail sheet has to re-read it before it can show the new From 933f7476eacaa49dd2e99d5c2398021a78377dff Mon Sep 17 00:00:00 2001 From: zombee0 Date: Mon, 28 Sep 2026 22:50:06 +0800 Subject: [PATCH 07/10] docs(proxy): describe cached tunnel verdicts in core layer --- apps/proxy/README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/proxy/README.md b/apps/proxy/README.md index 7d0be0528..b499c96a4 100644 --- a/apps/proxy/README.md +++ b/apps/proxy/README.md @@ -99,10 +99,11 @@ pooling reuses tunnels per box and port. Raw `CONNECT` requests skip this router `handleTunnelConnect` in [`tunnel.go`](pkg/proxy/tunnel.go). The proxy checks each new direct HTTP/WebSocket request and CONNECT against the -API without caching an allowed declaration. Revocation blocks the next request -after its database commit, across proxy instances and restarts. Connections -already established continue until they close. The terminal port is unavailable -to raw CONNECT tunnels. +API without caching the verdict itself. The API caches allowed and denied tunnel +checks in Redis for 3 seconds and clears a denial when a port is declared. +Revocation can still admit new requests until a cached allowance expires; +established connections continue until they close. The terminal port is +unavailable to raw CONNECT tunnels. ## Authentication From 25cffec32a9e88f3849dd4b6aa976bfc268aa7b5 Mon Sep 17 00:00:00 2001 From: zombee0 Date: Mon, 28 Sep 2026 16:28:27 +0800 Subject: [PATCH 08/10] fix(proxy): canonicalize preview ports and gate every public box parseHost returned the raw port label, so "022222" slipped past TERMINAL_PORT string comparisons: on the HTTP path it skipped terminal authentication and on the warning page it showed the interstitial. parseHost now returns a validated uint16 (1-65535) and TERMINAL_PORT is numeric, so every caller compares one canonical form and the separate re-parsing in GetProxyTarget and tunnelTarget is gone. The tunnel access check no longer depends on isDirectHost: any public box is gated, so a future box ID format cannot skip it. An API failure now answers 502 on the HTTP path, matching CONNECT. Co-authored-by: Cursor --- apps/proxy/README.md | 29 +++++---- apps/proxy/pkg/proxy/get_box_target.go | 74 ++++++++-------------- apps/proxy/pkg/proxy/parse_host_test.go | 67 ++++++++++++++++++++ apps/proxy/pkg/proxy/proxy.go | 2 +- apps/proxy/pkg/proxy/tunnel.go | 9 +-- apps/proxy/pkg/proxy/tunnel_access_test.go | 46 +++++++++----- 6 files changed, 141 insertions(+), 86 deletions(-) create mode 100644 apps/proxy/pkg/proxy/parse_host_test.go diff --git a/apps/proxy/README.md b/apps/proxy/README.md index b499c96a4..ed3bbdf3f 100644 --- a/apps/proxy/README.md +++ b/apps/proxy/README.md @@ -83,14 +83,14 @@ The HTTP path in code: StartProxy (— · apps/proxy/pkg/proxy/proxy.go:78) — registers the catch-all route for preview hosts └─ NewProxyRequestHandler (— · apps/libs/common-go/pkg/proxy/proxy.go:113) — reverse proxy for one request ├─ GetProxyTarget (Proxy · apps/proxy/pkg/proxy/get_box_target.go:49) — choose the upstream - ├─ parseHost (Proxy · apps/proxy/pkg/proxy/get_box_target.go:357) — port plus box ID or signed token - ├─ getBoxPublic (Proxy · apps/proxy/pkg/proxy/get_box_target.go:250) — ask the API, cached 3 s + ├─ parseHost (Proxy · apps/proxy/pkg/proxy/get_box_target.go:353) — canonical port plus box ID or signed token + ├─ getBoxPublic (Proxy · apps/proxy/pkg/proxy/get_box_target.go:244) — ask the API, cached 3 s ├─ Authenticate (Proxy · apps/proxy/pkg/proxy/auth.go:18) — private box or terminal port only - ├─ hasPublicTunnelAccess — check direct public guest ports before proxying - └─ updateLastActivity (Proxy · apps/proxy/pkg/proxy/get_box_target.go:430) — renew activity every 50 s - └─ dialGuestPort (Proxy · apps/proxy/pkg/proxy/get_box_target.go:165) — dial each new upstream connection - ├─ getBoxRunnerInfo (Proxy · apps/proxy/pkg/proxy/get_box_target.go:211) — runner URL and key, cached 2 min - └─ dialRunnerTunnel (— · apps/proxy/pkg/proxy/tunnel.go:117) — CONNECT through the runner to the guest port + ├─ hasPublicTunnelAccess (Proxy · apps/proxy/pkg/proxy/tunnel_access.go:15) — public box guest ports only + └─ updateLastActivity (Proxy · apps/proxy/pkg/proxy/get_box_target.go:424) — renew activity every 50 s + └─ dialGuestPort (Proxy · apps/proxy/pkg/proxy/get_box_target.go:159) — dial each new upstream connection + ├─ getBoxRunnerInfo (Proxy · apps/proxy/pkg/proxy/get_box_target.go:205) — runner URL and key, cached 2 min + └─ dialRunnerTunnel (— · apps/proxy/pkg/proxy/tunnel.go:124) — CONNECT through the runner to the guest port ``` The upstream URL `http://:` is only a routing key. `dialGuestPort` is the transport's @@ -98,12 +98,15 @@ The upstream URL `http://:` is only a routing key. `dialGuestPort` pooling reuses tunnels per box and port. Raw `CONNECT` requests skip this router and go to `handleTunnelConnect` in [`tunnel.go`](pkg/proxy/tunnel.go). -The proxy checks each new direct HTTP/WebSocket request and CONNECT against the -API without caching the verdict itself. The API caches allowed and denied tunnel -checks in Redis for 3 seconds and clears a denial when a port is declared. -Revocation can still admit new requests until a cached allowance expires; -established connections continue until they close. The terminal port is -unavailable to raw CONNECT tunnels. +The proxy checks each new public HTTP/WebSocket request and CONNECT against the +API without caching the answer itself. The API caches each verdict in Redis for +3 seconds, like its other preview checks, so a revoked tunnel or a box made +private stops admitting new requests within 3 seconds across proxy instances. A +newly declared port is reachable immediately, because declaring clears its +cached refusal. Connections already established continue until they close. If +the API cannot answer, both +paths fail closed with 502. The proxy compares ports in canonical form, so +`022222` is the terminal port too; it is unavailable to raw CONNECT tunnels. ## Authentication diff --git a/apps/proxy/pkg/proxy/get_box_target.go b/apps/proxy/pkg/proxy/get_box_target.go index 02e099bfc..c118d6875 100644 --- a/apps/proxy/pkg/proxy/get_box_target.go +++ b/apps/proxy/pkg/proxy/get_box_target.go @@ -47,22 +47,14 @@ const ( ) func (p *Proxy) GetProxyTarget(ctx *gin.Context) (*common_proxy.RequestTarget, error) { - var targetPort, targetPath, boxIdOrSignedToken string - // Extract port and box ID from the host header. // Expected format: 1234-.proxy.domain - var err error - targetPort, boxIdOrSignedToken, _, err = p.parseHost(ctx.Request.Host) + targetPort, boxIdOrSignedToken, _, err := p.parseHost(ctx.Request.Host) if err != nil { ctx.Error(common_errors.NewBadRequestError(err)) return nil, err } - targetPath = requestEscapedPath(ctx.Request.URL, ctx.Param("path")) - - if targetPort == "" { - ctx.Error(common_errors.NewBadRequestError(errors.New("target port is required"))) - return nil, errors.New("target port is required") - } + targetPath := requestEscapedPath(ctx.Request.URL, ctx.Param("path")) if boxIdOrSignedToken == "" { ctx.Error(common_errors.NewBadRequestError(errors.New("box ID or signed token is required"))) @@ -70,14 +62,12 @@ func (p *Proxy) GetProxyTarget(ctx *gin.Context) (*common_proxy.RequestTarget, e } boxId := boxIdOrSignedToken - isDirectHost := isValidDirectPreviewBoxID(boxIdOrSignedToken) if decodedBoxId, ok, decodeErr := decodeDirectPreviewBoxID(boxIdOrSignedToken); decodeErr != nil { ctx.Error(common_errors.NewBadRequestError(decodeErr)) return nil, decodeErr } else if ok { boxId = decodedBoxId boxIdOrSignedToken = decodedBoxId - isDirectHost = true } isPublic, err := p.getBoxPublic(ctx, boxIdOrSignedToken) @@ -87,39 +77,25 @@ func (p *Proxy) GetProxyTarget(ctx *gin.Context) (*common_proxy.RequestTarget, e } if !*isPublic || targetPort == TERMINAL_PORT { - portFloat, err := strconv.ParseFloat(targetPort, 64) - if err != nil { - ctx.Error(common_errors.NewBadRequestError(fmt.Errorf("failed to parse target port: %w", err))) - return nil, fmt.Errorf("failed to parse target port: %w", err) - } var didRedirect bool - boxId, didRedirect, err = p.Authenticate(ctx, boxIdOrSignedToken, float32(portFloat)) + boxId, didRedirect, err = p.Authenticate(ctx, boxIdOrSignedToken, float32(targetPort)) if err != nil { if !didRedirect { ctx.Error(err) } return nil, err } - } - if targetPort != TERMINAL_PORT { - port, err := strconv.ParseUint(targetPort, 10, 16) - if err != nil || port == 0 { - wrappedErr := fmt.Errorf("invalid target port %q", targetPort) - ctx.Error(common_errors.NewBadRequestError(wrappedErr)) + } else { + allowed, err := p.hasPublicTunnelAccess(ctx.Request.Context(), boxId, targetPort) + if err != nil { + wrappedErr := fmt.Errorf("check tunnel access: %w", err) + ctx.Error(common_errors.NewCustomError(http.StatusBadGateway, wrappedErr.Error(), "BAD_GATEWAY")) return nil, wrappedErr } - if isDirectHost && *isPublic { - allowed, err := p.hasPublicTunnelAccess(ctx.Request.Context(), boxId, uint16(port)) - if err != nil { - wrappedErr := fmt.Errorf("check tunnel access: %w", err) - ctx.Error(common_errors.NewInternalServerError(wrappedErr)) - return nil, wrappedErr - } - if !allowed { - wrappedErr := errors.New("tunnel not found") - ctx.Error(common_errors.NewNotFoundError(wrappedErr)) - return nil, wrappedErr - } + if !allowed { + wrappedErr := errors.New("tunnel not found") + ctx.Error(common_errors.NewNotFoundError(wrappedErr)) + return nil, wrappedErr } } @@ -155,7 +131,7 @@ func (p *Proxy) GetProxyTarget(ctx *gin.Context) (*common_proxy.RequestTarget, e } if targetPort != TERMINAL_PORT { - target, err := url.Parse("http://" + net.JoinHostPort(boxId, targetPort) + targetPath) + target, err := url.Parse("http://" + net.JoinHostPort(boxId, strconv.Itoa(int(targetPort))) + targetPath) if err != nil { return nil, fmt.Errorf("failed to parse guest target URL: %w", err) } @@ -172,7 +148,7 @@ func (p *Proxy) GetProxyTarget(ctx *gin.Context) (*common_proxy.RequestTarget, e ctx.Error(common_errors.NewBadRequestError(fmt.Errorf("failed to get runner info: %w", err))) return nil, fmt.Errorf("failed to get runner info: %w", err) } - target, err := url.Parse(fmt.Sprintf("%s/boxes/%s/toolbox/proxy/%s%s", strings.TrimRight(runnerInfo.ApiUrl, "/"), boxId, targetPort, targetPath)) + target, err := url.Parse(fmt.Sprintf("%s/boxes/%s/toolbox/proxy/%d%s", strings.TrimRight(runnerInfo.ApiUrl, "/"), boxId, targetPort, targetPath)) if err != nil { return nil, fmt.Errorf("failed to parse terminal target URL: %w", err) } @@ -372,42 +348,42 @@ func (p *Proxy) validateAndCache( return &isValid, nil } -func (p *Proxy) parseHost(host string) (targetPort string, boxIdOrSignedToken string, baseHost string, err error) { +// parseHost returns the target port as a number, so every caller compares one +// canonical form: a label like "022222" is TERMINAL_PORT, not a guest port. +func (p *Proxy) parseHost(host string) (targetPort uint16, boxIdOrSignedToken string, baseHost string, err error) { // Extract port and box ID from the host header // Expected format: 1234-some-id-uuid.proxy.domain if host == "" { - return "", "", "", errors.New("host is required") + return 0, "", "", errors.New("host is required") } // Split the host to extract the port and box ID parts := strings.Split(host, ".") if len(parts) == 0 { - return "", "", "", errors.New("invalid host format") + return 0, "", "", errors.New("invalid host format") } if len(parts) < 2 { - return "", "", "", errors.New("invalid host format: must have subdomain") + return 0, "", "", errors.New("invalid host format: must have subdomain") } // Extract port from the first part (e.g., "1234-some-id-uuid") hostPrefix := parts[0] before, after, ok := strings.Cut(hostPrefix, "-") if !ok { - return "", "", "", errors.New("invalid host format: port and box ID not found") + return 0, "", "", errors.New("invalid host format: port and box ID not found") } - targetPort = before - - // Check that port is numeric - if _, err := strconv.Atoi(targetPort); err != nil { - return "", "", "", fmt.Errorf("invalid port '%s': must be numeric", targetPort) + port, err := strconv.ParseUint(before, 10, 16) + if err != nil || port == 0 { + return 0, "", "", fmt.Errorf("invalid port '%s': must be 1-65535", before) } boxIdOrSignedToken = after // Join remaining parts to form the base domain (e.g., "proxy.domain") baseHost = strings.Join(parts[1:], ".") - return targetPort, boxIdOrSignedToken, baseHost, nil + return uint16(port), boxIdOrSignedToken, baseHost, nil } func decodeDirectPreviewBoxID(value string) (string, bool, error) { diff --git a/apps/proxy/pkg/proxy/parse_host_test.go b/apps/proxy/pkg/proxy/parse_host_test.go new file mode 100644 index 000000000..c5d1e2f7d --- /dev/null +++ b/apps/proxy/pkg/proxy/parse_host_test.go @@ -0,0 +1,67 @@ +// Copyright 2026 BoxLite AI +// SPDX-License-Identifier: AGPL-3.0 + +package proxy + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/boxlite-ai/proxy/cmd/proxy/config" + "github.com/gin-gonic/gin" +) + +func TestParseHostReturnsCanonicalPort(t *testing.T) { + for _, label := range []string{"3000", "03000", "003000"} { + port, _, _, err := (&Proxy{}).parseHost(label + "-AbCdEf123456.proxy.test") + if err != nil || port != 3000 { + t.Fatalf("parseHost(%q) port = %d, err = %v; want 3000", label, port, err) + } + } +} + +func TestParseHostRejectsOutOfRangePort(t *testing.T) { + for _, label := range []string{"0", "+3000", "-1", "65536"} { + if _, _, _, err := (&Proxy{}).parseHost(label + "-AbCdEf123456.proxy.test"); err == nil { + t.Fatalf("parseHost accepted port label %q", label) + } + } +} + +func TestZeroPaddedTerminalPortRoutesToTerminal(t *testing.T) { + proxy := newTunnelProxy(t, http.StatusNotFound) + if err := proxy.boxAuthKeyValidCache.Set(context.Background(), "AbCdEf123456:owner-key", true, time.Minute); err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "http://022222-d-416243644566313233343536.proxy.test/", nil) + request.Header.Set(BOX_AUTH_KEY_HEADER, "owner-key") + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = request + + target, err := proxy.GetProxyTarget(ctx) + stopActivityPoll(ctx) + if err != nil || target == nil { + t.Fatalf("zero-padded terminal port rejected: target=%v err=%v", target, err) + } + if target.URL.Path != "/boxes/AbCdEf123456/toolbox/proxy/22222/" { + t.Fatalf("zero-padded terminal port reached %s, want the runner terminal", target.URL) + } +} + +func TestZeroPaddedTerminalPortSkipsBrowserWarning(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.Use((&Proxy{config: &config.Config{}}).browserWarningMiddleware()) + engine.GET("/", func(ctx *gin.Context) { ctx.Status(http.StatusNoContent) }) + request := httptest.NewRequest(http.MethodGet, "http://022222-d-416243644566313233343536.proxy.test/", nil) + request.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36") + response := httptest.NewRecorder() + + engine.ServeHTTP(response, request) + if response.Code != http.StatusNoContent { + t.Fatalf("zero-padded terminal port got status %d, want the terminal to skip the warning page", response.Code) + } +} diff --git a/apps/proxy/pkg/proxy/proxy.go b/apps/proxy/pkg/proxy/proxy.go index b43499801..a151ab58b 100644 --- a/apps/proxy/pkg/proxy/proxy.go +++ b/apps/proxy/pkg/proxy/proxy.go @@ -38,7 +38,7 @@ const BOX_AUTH_KEY_HEADER = "X-BoxLite-Preview-Token" const BOX_AUTH_KEY_QUERY_PARAM = "BOXLITE_BOX_AUTH_KEY" const BOX_AUTH_COOKIE_NAME = "boxlite-box-auth-" const ACTIVITY_POLL_STOP_KEY = "boxlite-activity-poll-stop" -const TERMINAL_PORT = "22222" +const TERMINAL_PORT uint16 = 22222 type activityPollController struct { done chan struct{} diff --git a/apps/proxy/pkg/proxy/tunnel.go b/apps/proxy/pkg/proxy/tunnel.go index f192030db..512c13a3e 100644 --- a/apps/proxy/pkg/proxy/tunnel.go +++ b/apps/proxy/pkg/proxy/tunnel.go @@ -14,7 +14,6 @@ import ( "net" "net/http" "net/url" - "strconv" "strings" "time" @@ -97,14 +96,10 @@ func (p *Proxy) tunnelTarget(request *http.Request) (string, uint16, error) { } else if ok { boxID = decoded } - value, err := strconv.ParseUint(port, 10, 16) - if err != nil || value == 0 { - return "", 0, fmt.Errorf("invalid tunnel port") - } - if strconv.FormatUint(value, 10) == TERMINAL_PORT { + if port == TERMINAL_PORT { return "", 0, fmt.Errorf("terminal port is reserved") } - return boxID, uint16(value), nil + return boxID, port, nil } func decodeTunnelBoxID(value string) (string, bool, error) { diff --git a/apps/proxy/pkg/proxy/tunnel_access_test.go b/apps/proxy/pkg/proxy/tunnel_access_test.go index 4795de295..dbac09330 100644 --- a/apps/proxy/pkg/proxy/tunnel_access_test.go +++ b/apps/proxy/pkg/proxy/tunnel_access_test.go @@ -5,6 +5,7 @@ package proxy import ( "context" + "errors" "net/http" "net/http/httptest" "sync/atomic" @@ -13,10 +14,11 @@ import ( apiclient "github.com/boxlite-ai/boxlite/libs/api-client-go" common_cache "github.com/boxlite-ai/common-go/pkg/cache" + common_errors "github.com/boxlite-ai/common-go/pkg/errors" "github.com/gin-gonic/gin" ) -func newTunnelProxy(t *testing.T, accessStatus int) (*Proxy, func()) { +func newTunnelProxy(t *testing.T, accessStatus int) *Proxy { t.Helper() api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { if request.URL.Path == "/api/preview/AbCdEf123456/tunnels/3000" { @@ -25,6 +27,7 @@ func newTunnelProxy(t *testing.T, accessStatus int) (*Proxy, func()) { } writer.WriteHeader(http.StatusNotFound) })) + t.Cleanup(api.Close) clientConfig := apiclient.NewConfiguration() clientConfig.Servers[0].URL = api.URL + "/api" clientConfig.AddDefaultHeader("Authorization", "Bearer proxy-key") @@ -48,12 +51,11 @@ func newTunnelProxy(t *testing.T, accessStatus int) (*Proxy, func()) { boxRunnerCache: runnerCache, boxLastActivityUpdateCache: activityCache, } - return proxy, api.Close + return proxy } func TestUndeclaredTunnelRejectsHTTP(t *testing.T) { - proxy, closeAPI := newTunnelProxy(t, http.StatusNotFound) - defer closeAPI() + proxy := newTunnelProxy(t, http.StatusNotFound) request := httptest.NewRequest(http.MethodGet, "http://3000-d-416243644566313233343536.proxy.test/", nil) response := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(response) @@ -70,8 +72,7 @@ func TestUndeclaredTunnelRejectsHTTP(t *testing.T) { } func TestUndeclaredTunnelRejectsConnect(t *testing.T) { - proxy, closeAPI := newTunnelProxy(t, http.StatusNotFound) - defer closeAPI() + proxy := newTunnelProxy(t, http.StatusNotFound) request := httptest.NewRequest(http.MethodConnect, "http://proxy.test", nil) request.Host = "3000-d-416243644566313233343536.proxy.test:443" response := httptest.NewRecorder() @@ -83,8 +84,7 @@ func TestUndeclaredTunnelRejectsConnect(t *testing.T) { } func TestUndeclaredTunnelRejectsRawBoxIDHost(t *testing.T) { - proxy, closeAPI := newTunnelProxy(t, http.StatusNotFound) - defer closeAPI() + proxy := newTunnelProxy(t, http.StatusNotFound) request := httptest.NewRequest(http.MethodGet, "http://3000-AbCdEf123456.proxy.test/", nil) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) ctx.Request = request @@ -97,8 +97,7 @@ func TestUndeclaredTunnelRejectsRawBoxIDHost(t *testing.T) { } func TestDeclaredTunnelAllowsHTTP(t *testing.T) { - proxy, closeAPI := newTunnelProxy(t, http.StatusOK) - defer closeAPI() + proxy := newTunnelProxy(t, http.StatusOK) request := httptest.NewRequest(http.MethodGet, "http://3000-d-416243644566313233343536.proxy.test/", nil) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) ctx.Request = request @@ -111,8 +110,7 @@ func TestDeclaredTunnelAllowsHTTP(t *testing.T) { } func TestAuthenticatedPrivatePreviewKeepsWorking(t *testing.T) { - proxy, closeAPI := newTunnelProxy(t, http.StatusNotFound) - defer closeAPI() + proxy := newTunnelProxy(t, http.StatusNotFound) ctx := context.Background() if err := proxy.boxPublicCache.Set(ctx, "AbCdEf123456", false, time.Minute); err != nil { t.Fatal(err) @@ -132,9 +130,25 @@ func TestAuthenticatedPrivatePreviewKeepsWorking(t *testing.T) { } } -func TestTunnelAccessAPIErrorFailsClosed(t *testing.T) { - proxy, closeAPI := newTunnelProxy(t, http.StatusServiceUnavailable) - defer closeAPI() +func TestTunnelAccessAPIErrorFailsHTTPWithBadGateway(t *testing.T) { + proxy := newTunnelProxy(t, http.StatusServiceUnavailable) + request := httptest.NewRequest(http.MethodGet, "http://3000-d-416243644566313233343536.proxy.test/", nil) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = request + + target, err := proxy.GetProxyTarget(ctx) + stopActivityPoll(ctx) + if err == nil || target != nil { + t.Fatalf("access API failure reached proxy target: target=%v err=%v", target, err) + } + var customErr *common_errors.CustomError + if len(ctx.Errors) != 1 || !errors.As(ctx.Errors[0].Err, &customErr) || customErr.StatusCode != http.StatusBadGateway { + t.Fatalf("access API failure recorded %v, want 502", ctx.Errors) + } +} + +func TestTunnelAccessAPIErrorFailsConnectWithBadGateway(t *testing.T) { + proxy := newTunnelProxy(t, http.StatusServiceUnavailable) request := httptest.NewRequest(http.MethodConnect, "http://proxy.test", nil) request.Host = "3000-d-416243644566313233343536.proxy.test:443" response := httptest.NewRecorder() @@ -145,7 +159,7 @@ func TestTunnelAccessAPIErrorFailsClosed(t *testing.T) { } } -func TestTunnelRevocationBlocksNextAccessCheck(t *testing.T) { +func TestProxyDoesNotCacheTunnelAccess(t *testing.T) { var status atomic.Int32 status.Store(http.StatusOK) api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { From d5de477f47b284a841500a55f7dff79414f57005 Mon Sep 17 00:00:00 2001 From: zombee0 Date: Sat, 26 Sep 2026 22:58:54 +0800 Subject: [PATCH 09/10] fix(proxy): require public tunnel for service previews --- apps/proxy/README.md | 15 ++--- apps/proxy/pkg/proxy/get_box_target.go | 3 +- apps/proxy/pkg/proxy/tunnel_access_test.go | 62 ++++++++++++++++++- docs/concepts/networking.md | 6 +- .../2026-09-26-preview-tunnel-gate.md | 23 +++++++ 5 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 docs/contributing/investigations/2026-09-26-preview-tunnel-gate.md diff --git a/apps/proxy/README.md b/apps/proxy/README.md index ed3bbdf3f..6bef17d65 100644 --- a/apps/proxy/README.md +++ b/apps/proxy/README.md @@ -54,7 +54,7 @@ flowchart TB The proxy asks the API whether the box is public, whether the caller may reach it, which runner hosts it, and records activity. It then opens a tunnel through that runner to the guest port. -Browsers opening a private box log in through the OIDC provider first. +The web terminal still requires authentication, including for private boxes. ## Preview hosts @@ -73,7 +73,7 @@ A host without a `-` label serves only the utility routes listed in | Request | Upstream | Authentication | | ------------------------------------------ | --------------------------------------------------------------- | --------------------------------- | -| HTTP or WebSocket to a declared port | Reverse proxy over a runner CONNECT tunnel to the guest port | Active public tunnel, or signed access | +| HTTP or WebSocket to a declared port | Reverse proxy over a runner CONNECT tunnel to the guest port | Public box and active tunnel declaration | | Port 22222 | The runner's web terminal at `/boxes//toolbox/proxy/22222` | Always | | `CONNECT` | Raw TCP tunnel through the runner | Active public tunnel; others are denied | @@ -85,8 +85,8 @@ The HTTP path in code: ├─ GetProxyTarget (Proxy · apps/proxy/pkg/proxy/get_box_target.go:49) — choose the upstream ├─ parseHost (Proxy · apps/proxy/pkg/proxy/get_box_target.go:353) — canonical port plus box ID or signed token ├─ getBoxPublic (Proxy · apps/proxy/pkg/proxy/get_box_target.go:244) — ask the API, cached 3 s - ├─ Authenticate (Proxy · apps/proxy/pkg/proxy/auth.go:18) — private box or terminal port only - ├─ hasPublicTunnelAccess (Proxy · apps/proxy/pkg/proxy/tunnel_access.go:15) — public box guest ports only + ├─ Authenticate (Proxy · apps/proxy/pkg/proxy/auth.go:18) — resolve signed hosts or authorize terminal access + ├─ hasPublicTunnelAccess (Proxy · apps/proxy/pkg/proxy/tunnel_access.go:15) — check every guest service port └─ updateLastActivity (Proxy · apps/proxy/pkg/proxy/get_box_target.go:424) — renew activity every 50 s └─ dialGuestPort (Proxy · apps/proxy/pkg/proxy/get_box_target.go:159) — dial each new upstream connection ├─ getBoxRunnerInfo (Proxy · apps/proxy/pkg/proxy/get_box_target.go:205) — runner URL and key, cached 2 min @@ -98,7 +98,7 @@ The upstream URL `http://:` is only a routing key. `dialGuestPort` pooling reuses tunnels per box and port. Raw `CONNECT` requests skip this router and go to `handleTunnelConnect` in [`tunnel.go`](pkg/proxy/tunnel.go). -The proxy checks each new public HTTP/WebSocket request and CONNECT against the +The proxy checks each new HTTP/WebSocket guest service request and CONNECT against the API without caching the answer itself. The API caches each verdict in Redis for 3 seconds, like its other preview checks, so a revoked tunnel or a box made private stops admitting new requests within 3 seconds across proxy instances. A @@ -110,7 +110,7 @@ paths fail closed with 502. The proxy compares ports in canonical form, so ## Authentication -A request to a private box, or to port 22222, takes the first credential that works +A signed preview host, a private box, or port 22222 takes the first credential that works ([`auth.go`](pkg/proxy/auth.go)): 1. `Authorization: Bearer `: the API checks box access with the caller's own token. @@ -131,7 +131,8 @@ re-checks it with the API. | Situation | HTTP or WebSocket | `CONNECT` | | ---------------------------------------------------------- | ------------------------------------------------------------------- | ---------------------------- | -| The box is private | `307` to the OIDC login unless a credential works, for API clients too | `403`, whatever the credential | +| The box is private and the port is not 22222 | `404` after credential resolution; private service previews are unavailable | `403`, whatever the credential | +| The public box has no active declaration for the port | `404`, including for a signed preview URL | `404` | | The host has no `-` label | `404`, except the utility routes | `400` | | The API still fails the visibility check after its retries | `400` | `502` | | The runner or the guest port is unreachable | `502` | `502` | diff --git a/apps/proxy/pkg/proxy/get_box_target.go b/apps/proxy/pkg/proxy/get_box_target.go index c118d6875..aa79f3fe3 100644 --- a/apps/proxy/pkg/proxy/get_box_target.go +++ b/apps/proxy/pkg/proxy/get_box_target.go @@ -85,7 +85,8 @@ func (p *Proxy) GetProxyTarget(ctx *gin.Context) (*common_proxy.RequestTarget, e } return nil, err } - } else { + } + if targetPort != TERMINAL_PORT { allowed, err := p.hasPublicTunnelAccess(ctx.Request.Context(), boxId, targetPort) if err != nil { wrappedErr := fmt.Errorf("check tunnel access: %w", err) diff --git a/apps/proxy/pkg/proxy/tunnel_access_test.go b/apps/proxy/pkg/proxy/tunnel_access_test.go index dbac09330..332901978 100644 --- a/apps/proxy/pkg/proxy/tunnel_access_test.go +++ b/apps/proxy/pkg/proxy/tunnel_access_test.go @@ -15,7 +15,9 @@ import ( apiclient "github.com/boxlite-ai/boxlite/libs/api-client-go" common_cache "github.com/boxlite-ai/common-go/pkg/cache" common_errors "github.com/boxlite-ai/common-go/pkg/errors" + "github.com/boxlite-ai/proxy/cmd/proxy/config" "github.com/gin-gonic/gin" + "github.com/gorilla/securecookie" ) func newTunnelProxy(t *testing.T, accessStatus int) *Proxy { @@ -109,7 +111,7 @@ func TestDeclaredTunnelAllowsHTTP(t *testing.T) { } } -func TestAuthenticatedPrivatePreviewKeepsWorking(t *testing.T) { +func TestAuthenticatedPrivateServicePreviewIsDenied(t *testing.T) { proxy := newTunnelProxy(t, http.StatusNotFound) ctx := context.Background() if err := proxy.boxPublicCache.Set(ctx, "AbCdEf123456", false, time.Minute); err != nil { @@ -123,10 +125,66 @@ func TestAuthenticatedPrivatePreviewKeepsWorking(t *testing.T) { ginCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) ginCtx.Request = request + target, err := proxy.GetProxyTarget(ginCtx) + stopActivityPoll(ginCtx) + if err == nil || target != nil { + t.Fatalf("private service preview reached guest port: target=%v err=%v", target, err) + } +} + +func TestSignedServicePreviewRequiresTunnel(t *testing.T) { + for _, test := range []struct { + name string + accessStatus int + allowed bool + }{ + {name: "undeclared", accessStatus: http.StatusNotFound}, + {name: "declared", accessStatus: http.StatusOK, allowed: true}, + } { + t.Run(test.name, func(t *testing.T) { + proxy := newTunnelProxy(t, test.accessStatus) + proxy.config = &config.Config{} + proxy.secureCookie = securecookie.New([]byte("test-cookie-signing-key-with-32-bytes"), nil) + token := "signedtoken12345" + if err := proxy.boxPublicCache.Set(context.Background(), token, false, time.Minute); err != nil { + t.Fatal(err) + } + cookieValue, err := proxy.secureCookie.Encode(BOX_AUTH_COOKIE_NAME+token, "AbCdEf123456") + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "http://3000-"+token+".proxy.test/", nil) + request.AddCookie(&http.Cookie{Name: BOX_AUTH_COOKIE_NAME + token, Value: cookieValue}) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = request + + target, err := proxy.GetProxyTarget(ctx) + stopActivityPoll(ctx) + if (err == nil && target != nil) != test.allowed { + t.Fatalf("signed preview allowed = %t, want %t (target=%v, err=%v)", err == nil && target != nil, test.allowed, target, err) + } + }) + } +} + +func TestPrivateTerminalPreviewRemainsAvailable(t *testing.T) { + proxy := newTunnelProxy(t, http.StatusNotFound) + ctx := context.Background() + if err := proxy.boxPublicCache.Set(ctx, "AbCdEf123456", false, time.Minute); err != nil { + t.Fatal(err) + } + if err := proxy.boxAuthKeyValidCache.Set(ctx, "AbCdEf123456:owner-key", true, time.Minute); err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "http://22222-AbCdEf123456.proxy.test/", nil) + request.Header.Set(BOX_AUTH_KEY_HEADER, "owner-key") + ginCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ginCtx.Request = request + target, err := proxy.GetProxyTarget(ginCtx) stopActivityPoll(ginCtx) if err != nil || target == nil { - t.Fatalf("authenticated private preview rejected: target=%v err=%v", target, err) + t.Fatalf("private terminal rejected: target=%v err=%v", target, err) } } diff --git a/docs/concepts/networking.md b/docs/concepts/networking.md index fe362655a..d2a1ecf7d 100644 --- a/docs/concepts/networking.md +++ b/docs/concepts/networking.md @@ -53,8 +53,10 @@ transport is backend-specific. `tunnel()` eagerly prepares one local gvproxy or remote service-proxy connection. `uri()` inspects its public URI, while `connect()` or `forward()` consumes that prepared one-shot tunnel into a byte stream or listener. For a remote public box, preparing the tunnel also registers its guest port. The -proxy accepts direct HTTP/WebSocket and CONNECT access only while that port's -tunnel declaration remains active. +proxy accepts HTTP/WebSocket previews, including signed URLs, and CONNECT access +only while that port's tunnel declaration remains active. Private boxes cannot +open guest service ports through previews; the authenticated web terminal uses +its separate reserved port. Explicit host port publication is a separate local-runtime feature that owns a TCP listener and accepts repeated connections. diff --git a/docs/contributing/investigations/2026-09-26-preview-tunnel-gate.md b/docs/contributing/investigations/2026-09-26-preview-tunnel-gate.md new file mode 100644 index 000000000..bac9d648f --- /dev/null +++ b/docs/contributing/investigations/2026-09-26-preview-tunnel-gate.md @@ -0,0 +1,23 @@ +## TL;DR + +Guest service previews require a public box and an active tunnel declaration, regardless of URL form. + +## Problem + +The proxy checks declarations for direct public hosts, but a signed host resolves through authentication and bypasses that check. Authenticated private previews also reach guest ports without a declaration. Both paths conflict with the desired service-port policy. + +## Approach + +- After resolving the box ID, require a public box and an active tunnel for every HTTP/WebSocket guest service port. Keep port 22222 on its existing authenticated terminal path. +- Reuse the API's existing public tunnel check, already used by raw CONNECT (`apps/proxy/pkg/proxy/tunnel.go:47`). The API check confirms both the active declaration and public box state (`apps/api/src/box/services/tunnel.service.ts:50`). +- Resolve signed hosts before checking the declaration. Do not trust the initial public lookup for a signed token as the box's visibility. +- Keep the URL APIs compatible; the proxy is the enforcement boundary, including for URLs issued before this change. +- Update proxy documentation and focused tests for direct, signed, private, terminal, and revoked access. + +## Alternatives and trade-offs + +Checking declarations while issuing URLs would leave old or manually constructed URLs accessible and cannot enforce revocation. Disabling signed preview URL issuance would also disrupt the Dashboard terminal, which uses port 22222. Rechecking the API for each service request costs one control-plane call, as the existing direct-host gate already does, but avoids stale positive authorization. + +## Validation + +Run focused proxy tests with production code reverted to capture the old bypass, then restore the fix and rerun. Check the proxy package through the repository's Make target. Confirm the final branch diff and preserve the local terminal flow. From 211ea84aae26b6008f4340e0d7acca3d26e37550 Mon Sep 17 00:00:00 2001 From: zombee0 Date: Mon, 28 Sep 2026 22:45:21 +0800 Subject: [PATCH 10/10] docs(proxy): align preview tunnel access documentation The proxy now gates every guest service preview on a public tunnel, while terminal traffic follows its authenticated route. Clarify URL issuance, access checks, cache behavior, and source references so the docs match the current implementation. --- apps/proxy/README.md | 46 +++++++++---------- docs/concepts/networking.md | 11 +++-- .../2026-09-26-preview-tunnel-gate.md | 22 ++++++--- 3 files changed, 44 insertions(+), 35 deletions(-) diff --git a/apps/proxy/README.md b/apps/proxy/README.md index 6bef17d65..04caeb403 100644 --- a/apps/proxy/README.md +++ b/apps/proxy/README.md @@ -52,9 +52,9 @@ flowchart TB class runner_process scope_execution ``` -The proxy asks the API whether the box is public, whether the caller may reach it, which runner -hosts it, and records activity. It then opens a tunnel through that runner to the guest port. -The web terminal still requires authentication, including for private boxes. +The proxy asks the API whether the box is public and whether the requested guest port has an +active public tunnel. It resolves the box's runner and records activity before forwarding. +The web terminal uses the runner's terminal endpoint and always requires authentication. ## Preview hosts @@ -71,11 +71,11 @@ A host without a `-` label serves only the utility routes listed in ## Request paths -| Request | Upstream | Authentication | -| ------------------------------------------ | --------------------------------------------------------------- | --------------------------------- | -| HTTP or WebSocket to a declared port | Reverse proxy over a runner CONNECT tunnel to the guest port | Public box and active tunnel declaration | -| Port 22222 | The runner's web terminal at `/boxes//toolbox/proxy/22222` | Always | -| `CONNECT` | Raw TCP tunnel through the runner | Active public tunnel; others are denied | +| Request | Upstream | Access rule | +| -------------------------------- | ------------------------------------------------------------- | ---------------------------------------- | +| HTTP or WebSocket to a guest port | Reverse proxy over a runner CONNECT tunnel to the guest port | Public box and active tunnel declaration | +| Port 22222 | Runner's `/boxes//toolbox/proxy/22222` terminal endpoint | Authenticated; no tunnel declaration | +| Raw `CONNECT` to a guest port | TCP tunnel through the runner | Public box and active tunnel declaration | The HTTP path in code: @@ -83,13 +83,13 @@ The HTTP path in code: StartProxy (— · apps/proxy/pkg/proxy/proxy.go:78) — registers the catch-all route for preview hosts └─ NewProxyRequestHandler (— · apps/libs/common-go/pkg/proxy/proxy.go:113) — reverse proxy for one request ├─ GetProxyTarget (Proxy · apps/proxy/pkg/proxy/get_box_target.go:49) — choose the upstream - ├─ parseHost (Proxy · apps/proxy/pkg/proxy/get_box_target.go:353) — canonical port plus box ID or signed token - ├─ getBoxPublic (Proxy · apps/proxy/pkg/proxy/get_box_target.go:244) — ask the API, cached 3 s - ├─ Authenticate (Proxy · apps/proxy/pkg/proxy/auth.go:18) — resolve signed hosts or authorize terminal access + ├─ parseHost (Proxy · apps/proxy/pkg/proxy/get_box_target.go:354) — canonical port plus box ID or signed token + ├─ getBoxPublic (Proxy · apps/proxy/pkg/proxy/get_box_target.go:245) — ask the API, cached 3 s + ├─ Authenticate (Proxy · apps/proxy/pkg/proxy/auth.go:18) — resolve signed hosts or authorize private/terminal access ├─ hasPublicTunnelAccess (Proxy · apps/proxy/pkg/proxy/tunnel_access.go:15) — check every guest service port - └─ updateLastActivity (Proxy · apps/proxy/pkg/proxy/get_box_target.go:424) — renew activity every 50 s - └─ dialGuestPort (Proxy · apps/proxy/pkg/proxy/get_box_target.go:159) — dial each new upstream connection - ├─ getBoxRunnerInfo (Proxy · apps/proxy/pkg/proxy/get_box_target.go:205) — runner URL and key, cached 2 min + └─ updateLastActivity (Proxy · apps/proxy/pkg/proxy/get_box_target.go:425) — renew activity every 50 s + └─ dialGuestPort (Proxy · apps/proxy/pkg/proxy/get_box_target.go:160) — dial each new upstream connection + ├─ getBoxRunnerInfo (Proxy · apps/proxy/pkg/proxy/get_box_target.go:206) — runner URL and key, cached 2 min └─ dialRunnerTunnel (— · apps/proxy/pkg/proxy/tunnel.go:124) — CONNECT through the runner to the guest port ``` @@ -98,15 +98,14 @@ The upstream URL `http://:` is only a routing key. `dialGuestPort` pooling reuses tunnels per box and port. Raw `CONNECT` requests skip this router and go to `handleTunnelConnect` in [`tunnel.go`](pkg/proxy/tunnel.go). -The proxy checks each new HTTP/WebSocket guest service request and CONNECT against the -API without caching the answer itself. The API caches each verdict in Redis for -3 seconds, like its other preview checks, so a revoked tunnel or a box made -private stops admitting new requests within 3 seconds across proxy instances. A -newly declared port is reachable immediately, because declaring clears its -cached refusal. Connections already established continue until they close. If -the API cannot answer, both -paths fail closed with 502. The proxy compares ports in canonical form, so -`022222` is the terminal port too; it is unavailable to raw CONNECT tunnels. +Every new HTTP/WebSocket guest service request and raw CONNECT checks the API's public tunnel +endpoint. It requires an unrevoked public declaration, a public box, and a box outside the +destroying/archiving states. The API caches allowed and denied verdicts in Redis for 3 seconds; +the proxy does not cache tunnel verdicts. Declaring a port clears its cached denial. A revoked +tunnel or a box made private can still admit new requests until a cached allowance expires; +existing connections continue until they close. If the tunnel check fails, both paths return +502. Ports are parsed as numbers, so `022222` is still the terminal port and cannot be used +for raw CONNECT. ## Authentication @@ -133,6 +132,7 @@ re-checks it with the API. | ---------------------------------------------------------- | ------------------------------------------------------------------- | ---------------------------- | | The box is private and the port is not 22222 | `404` after credential resolution; private service previews are unavailable | `403`, whatever the credential | | The public box has no active declaration for the port | `404`, including for a signed preview URL | `404` | +| The tunnel access API is unavailable | `502` | `502` | | The host has no `-` label | `404`, except the utility routes | `400` | | The API still fails the visibility check after its retries | `400` | `502` | | The runner or the guest port is unreachable | `502` | `502` | diff --git a/docs/concepts/networking.md b/docs/concepts/networking.md index d2a1ecf7d..631d5333c 100644 --- a/docs/concepts/networking.md +++ b/docs/concepts/networking.md @@ -52,11 +52,12 @@ The box network tunnel API is portable across local and REST runtimes, but its transport is backend-specific. `tunnel()` eagerly prepares one local gvproxy or remote service-proxy connection. `uri()` inspects its public URI, while `connect()` or `forward()` consumes that prepared one-shot tunnel into a byte stream or listener. -For a remote public box, preparing the tunnel also registers its guest port. The -proxy accepts HTTP/WebSocket previews, including signed URLs, and CONNECT access -only while that port's tunnel declaration remains active. Private boxes cannot -open guest service ports through previews; the authenticated web terminal uses -its separate reserved port. +For a remote public box, preparing the tunnel registers that guest port as public. +The proxy accepts HTTP/WebSocket previews, including signed URLs, and raw CONNECT +only when the box is public and the port has an active declaration. Getting a +preview URL alone does not declare the port. Private boxes cannot expose guest +service ports through previews, even with a credential. The authenticated web +terminal uses reserved port 22222 without a tunnel declaration. Explicit host port publication is a separate local-runtime feature that owns a TCP listener and accepts repeated connections. diff --git a/docs/contributing/investigations/2026-09-26-preview-tunnel-gate.md b/docs/contributing/investigations/2026-09-26-preview-tunnel-gate.md index bac9d648f..123314e4a 100644 --- a/docs/contributing/investigations/2026-09-26-preview-tunnel-gate.md +++ b/docs/contributing/investigations/2026-09-26-preview-tunnel-gate.md @@ -1,23 +1,31 @@ ## TL;DR -Guest service previews require a public box and an active tunnel declaration, regardless of URL form. +Guest service previews require a public box and an active tunnel declaration, regardless of URL form or credential. ## Problem -The proxy checks declarations for direct public hosts, but a signed host resolves through authentication and bypasses that check. Authenticated private previews also reach guest ports without a declaration. Both paths conflict with the desired service-port policy. +The original proxy forwarded signed preview hosts and authenticated private previews to guest ports without checking a tunnel declaration. A preview URL only identifies a port; it does not open one. The access decision must apply to every guest-port request, while the authenticated web terminal remains available on port 22222. + +## Related work and lessons + +- [`boxlite-proxy.controller.ts`](../../../apps/api/src/boxlite-rest/boxlite-proxy.controller.ts#L242) requires a public box before opening a remote tunnel; [`tunnel.service.ts`](../../../apps/api/src/box/services/tunnel.service.ts#L26) records the per-port declaration. URL issuance in [`box.service.ts`](../../../apps/api/src/box/services/box.service.ts#L784) is separate, so issuing a URL cannot enforce access. +- [`preview.controller.ts`](../../../apps/api/src/box/controllers/preview.controller.ts#L29) exposes the proxy-only tunnel check. [`tunnel.service.ts`](../../../apps/api/src/box/services/tunnel.service.ts#L40) checks the declaration, revocation, box visibility, and lifecycle state. Reusing this endpoint keeps the HTTP and CONNECT decisions aligned. +- [`get_box_target.go`](../../../apps/proxy/pkg/proxy/get_box_target.go#L49) routes HTTP/WebSocket previews; [`tunnel.go`](../../../apps/proxy/pkg/proxy/tunnel.go#L27) handles raw CONNECT separately. Both paths need the same access check, while terminal traffic takes a separate authenticated route. ## Approach -- After resolving the box ID, require a public box and an active tunnel for every HTTP/WebSocket guest service port. Keep port 22222 on its existing authenticated terminal path. -- Reuse the API's existing public tunnel check, already used by raw CONNECT (`apps/proxy/pkg/proxy/tunnel.go:47`). The API check confirms both the active declaration and public box state (`apps/api/src/box/services/tunnel.service.ts:50`). +- After resolving the box ID, check the API's public tunnel endpoint for every HTTP/WebSocket guest service port and raw CONNECT. Keep port 22222 on its authenticated terminal path and reject raw CONNECT to it. +- Use the same API check on both paths. It confirms an active public declaration and public box state, and denies boxes being destroyed or archived (`apps/api/src/box/services/tunnel.service.ts:40`). - Resolve signed hosts before checking the declaration. Do not trust the initial public lookup for a signed token as the box's visibility. +- Parse host ports into one numeric form, so `022222` cannot bypass the terminal rule. Return 404 for a missing declaration and 502 when the tunnel check is unavailable. +- Cache both API verdicts for 3 seconds in Redis; clear a denial when declaring a port. Do not cache the tunnel verdict in the proxy. - Keep the URL APIs compatible; the proxy is the enforcement boundary, including for URLs issued before this change. -- Update proxy documentation and focused tests for direct, signed, private, terminal, and revoked access. +- Update proxy and networking documentation and focused tests for direct, signed, private, terminal, and failed API access. ## Alternatives and trade-offs -Checking declarations while issuing URLs would leave old or manually constructed URLs accessible and cannot enforce revocation. Disabling signed preview URL issuance would also disrupt the Dashboard terminal, which uses port 22222. Rechecking the API for each service request costs one control-plane call, as the existing direct-host gate already does, but avoids stale positive authorization. +Checking declarations only when issuing URLs would leave old or manually constructed URLs accessible and cannot enforce revocation. Disabling signed preview URL issuance would disrupt legitimate signed previews. Checking the API on each new request adds one control-plane call; its 3-second cache limits database reads but allows a recently revoked declaration to admit new requests until the cached allowance expires. Existing connections remain open. ## Validation -Run focused proxy tests with production code reverted to capture the old bypass, then restore the fix and rerun. Check the proxy package through the repository's Make target. Confirm the final branch diff and preserve the local terminal flow. +Reproduce the old bypass with the focused proxy tests, then rerun them with the fix. Cover direct, signed, private, terminal, canonical-port, and API-failure paths; verify API cache invalidation separately. Run the proxy and API suites through repository Make targets and confirm the final diff matches the documented behavior.