From 4f7da6e8b13b70d7c2e722e85cdfde1ffe6f750e Mon Sep 17 00:00:00 2001 From: yannickmonney Date: Thu, 8 Oct 2026 15:48:46 +0200 Subject: [PATCH] fix(proxy): keep deployment control off public routes --- .../tests/integration/container-smoke-test.ts | 34 +++++++++++++++++++ .../tests/manual/reference/automation.md | 1 + services/proxy/docker-entrypoint.sh | 12 +++++-- .../compose/services/compose-parity.test.ts | 19 +++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/services/platform/tests/integration/container-smoke-test.ts b/services/platform/tests/integration/container-smoke-test.ts index f0626d81e9..e1f5bf94e0 100644 --- a/services/platform/tests/integration/container-smoke-test.ts +++ b/services/platform/tests/integration/container-smoke-test.ts @@ -334,6 +334,40 @@ async function main(): Promise { // Platform: Vite server with platform-ready marker. const platformContainer = await compose.containerName('platform'); + + // The deployment control channel is docker-exec localhost, never public. + // The plain edge refusal distinguishes it from a backend token refusal. + for (const path of [ + '/api/control', + '/api/control/', + '/api/control/drain-status?probe=1', + '/api/%63ontrol/drain-status', + ]) { + const probe = await capture([ + 'docker', + 'exec', + platformContainer, + // nosemgrep: tools.opengrep.rules.trailofbits.generic.curl-insecure.curl-insecure -- Isolated smoke network and self-signed proxy; no credentials or external traffic. + 'curl', + '--insecure', + '--silent', + '--show-error', + '--max-time', + '10', + '--connect-to', + 'localhost:443:proxy:443', + '--write-out', + '\n%{http_code}', + `https://localhost${path}`, + ]); + const label = `Public proxy refuses deployment control ${path}`; + if (probe.exitCode === 0 && probe.stdout === 'Not found\n404') { + r.pass(label); + } else { + r.fail(label); + } + } + const viteCode = await httpStatus('http://localhost:13000/api/health', 10); if (viteCode === '200') { r.pass(`Platform /api/health: HTTP ${viteCode}`); diff --git a/services/platform/tests/manual/reference/automation.md b/services/platform/tests/manual/reference/automation.md index 0501535a5f..4b51e27b1e 100644 --- a/services/platform/tests/manual/reference/automation.md +++ b/services/platform/tests/manual/reference/automation.md @@ -401,6 +401,7 @@ Legend: โœ… fully automated ยท ๐Ÿ”ถ partially automated ยท โ›” manual-only (no s | [navigation](../suites/navigation.md) | Optional analytics: runtime disablement, safe SPA pageviews, private route templates, DNT/GPC and collector boundary | โœ… automated | `packages/ui/src/analytics/browser.test.ts`, `packages/ui/src/analytics/server.test.ts` | | [navigation](../suites/navigation.md) | `NAV-B12` (the logic half): a read TanStack Query cancels when a navigation unmounts its page rejects as an `AbortError` that never flags the backend unreachable, so the offline overlay stays down; an event whose thrown value is an `AbortError` โ€” also in the SDK's `AbortError: โ€ฆ` and stringified `Error: AbortError: โ€ฆ` shapes โ€” is dropped before it is sent, while a failure merely caused by one is kept | ๐Ÿ”ถ unit + component | `app/lib/backend/api-client.test.ts`, `app/components/connectivity/online-gate.test.tsx` (a real `QueryClient` read cancelled by an unmount, under the real connection state), `app/lib/sentry-normalize.test.ts`, `lib/utils/abort-error.test.ts`; the throttled real-browser round with a live DSN stays manual | | [navigation](../suites/navigation.md) | `NAV-B14`: the error displays' **contact support** link follows `TALE_CONTACT_SUPPORT_URL` from the environment through `window.__ENV__` and the app-root `SupportUrlProvider` to the rendered link, which opens in a new tab with the organization as `organizationId` (replacing one the URL carries). The web tier hands the page the value only as an absolute http(s) URL, verbatim and script-safe, and leaves anything else out with a warning, so the link keeps `https://tale.dev/contact`; a display's own `supportUrl` wins over the provider | โœ… e2e + component + server | `tests/e2e/specs/navigation.spec.ts` (*the contact-support link opens the operator's page*), `packages/ui/src/components/error-boundaries/core/support-url.test.tsx`, `packages/ui/src/components/error-boundaries/displays/error-display-compact.test.tsx`, `packages/ui/src/components/error-boundaries/displays/global-error-display.test.tsx`, `lib/contact-support-url.test.ts`, `lib/utils/inline-script.test.ts`, `vite-plugins/inject-env.test.ts`, `server.test.ts` | +| [settings](../suites/settings.md) | Public deployment-control paths are refused by the proxy while the CLI keeps its authenticated in-container channel | โœ… automated | `tests/integration/container-smoke-test.ts` (root, descendants, query and encoded path through the real proxy), `tools/cli/src/lib/compose/services/compose-parity.test.ts`, `tools/cli/src/lib/docker/control-call.test.ts` | | [settings](../suites/settings.md) | Foreground egress proxy boots, stops gracefully and restarts with read-only `/tmp` | โœ… automated | `tests/integration/container-smoke-test.ts` and `compose.test.yml` (real proxy health, refused temporary-file creation, shutdown exit/log and same-container restart) | | [settings](../suites/settings.md) | Sandbox memory lifetime: completed commands release request/consumer data while a sibling remains active, and disposed replay releases its opaque checkpoint state; held-open stdin refuses whole lines above its 8 MiB pending queue and accepts again after draining; HTTP intake and device tunnel completion detach their listeners | ๐Ÿ”ถ unit + real processes + HTTP | `services/sandbox-runtime/daemon/src/exec-manager.memory.test.ts` (bundled manager under Node), `exec-manager.test.ts`, `http-body.test.ts`, `main.test.ts`; `services/sandbox/src/devices/tunnel.test.ts`; production workload memory profiling remains operational | | [settings](../suites/settings.md) | The egress proxy serves `SANDBOX_EGRESS_MAX_CLIENTS` connections at once (2000 by default) for the whole fleet, keeps its 600 s tunnel timeout, refuses to start on a value that is no whole number above 0, raises its soft open-file limit to two descriptors per connection as far as the hard limit allows (a Kubernetes Pod sets none) and leaves a limit that already holds them alone, and warns when even the hard limit cannot hold them; both compose pipelines pass the same default and size the container's pids and open-file limits for it | โœ… automated | `services/sandbox/src/egress-proxy-config.test.ts` (the real entrypoint and template), `tools/cli/src/lib/compose/services/compose-parity.test.ts` (`egress connection capacity parity`) | diff --git a/services/proxy/docker-entrypoint.sh b/services/proxy/docker-entrypoint.sh index a66e347dc8..544796513e 100644 --- a/services/proxy/docker-entrypoint.sh +++ b/services/proxy/docker-entrypoint.sh @@ -245,8 +245,8 @@ sed -i "s|{[\$]DOCS_ORIGIN:[^}]*}|${DOCS_URL}|" "$CADDYFILE" # Everything the pg backend owns is listed here explicitly โ€” auth, the app # API, the hint stream, both machine doors, SSO/SCIM/trusted-headers on BOTH # their 0.5-native and 0.4 `/http_api/...` paths (registered IdP redirect -# URIs carry the old ones), the control channel the CLI drains through, the -# cloud-import OAuth callbacks and the WebDAV protocol door. +# URIs carry the old ones), the cloud-import OAuth callbacks and the WebDAV +# protocol door. Deployment control stays internal to the backend container. # # BACKEND_UPSTREAM began life as the cutover's reversibility switch (unset โ‡’ # lanes fall back to Convex). The Convex runtime is gone, so an unset value @@ -338,8 +338,14 @@ BACKEND_BLOCK=$(cat < { + const entrypoint = readFileSync( + resolve(repoRoot, 'services/proxy/docker-entrypoint.sh'), + 'utf8', + ); + for (const path of ['/api/control', '/api/control/*']) { + const handle = entrypoint.split(`handle ${path} {`)[1]?.split('\n\t}')[0]; + expect(handle).toBeDefined(); + expect(handle).toContain('respond "Not found" 404'); + expect(handle).not.toContain('reverse_proxy'); + } + // Removing the explicit control route alone leaves /api/* forwarding it. + const caddyfile = readFileSync( + resolve(repoRoot, 'services/proxy/Caddyfile'), + 'utf8', + ); + expect(caddyfile).toContain('handle /api/* {'); + }); + test('nothing routes to the retired runtime any more', () => { // The proxy used to fall back to `convex:*` for everything the backend // list did not name. That service is gone, so a fallback is a 502 โ€” every