Repository navigation
fix(api): return public-safe errors from the tunnel and MITM routes - #11872
Merged
diegosouzapw merged 2 commits intoAug 28, 2026
Conversation
Two independent defects on the same surface.
1. Fourteen catch blocks in src/app/api/tunnels/** and
src/app/api/settings/mitm/route.ts answered with
`{ error: error instanceof Error ? error.message : "<fallback>" }`. Those
errors come from cloudflared / tailscale / tailscaled / ngrok child processes
and from filesystem I/O under the operator's home directory, so the body
disclosed host layout, binary install paths, the OS account name and — for
Tailscale — live tskey-* credentials. Hard Rule cheaper-inference#12 forbids this.
sanitizeErrorMessage() does not close it: it only rewrites tokens that look
like an absolute path ending in a source extension (SOURCE_EXT in
open-sse/utils/error.ts), so .json state paths, extension-less binary paths
and tskey-* keys survive it verbatim. Add
src/lib/api/publicSafeTunnelError.ts, which returns a route-owned literal
plus a coarse machine-readable `reason` and logs the real error server-side.
Reachability is not uniform: /api/tunnels/ngrok, /api/tunnels/tailscale and
/api/tunnels/tailscale/check are not in LOCAL_ONLY_API_PREFIXES, and
GET /api/tunnels/cloudflared is exempted via LOCAL_ONLY_API_GET_EXEMPTIONS
(cheaper-inference#11531), so those bodies can reach a non-loopback caller. The rest are
loopback-gated and are covered for one consistent contract.
2. validateBody() returns { success, error } and has no `response` field —
validatedJsonBody() is the helper that has one. Three call sites did
`return validation.response`, so a failed body validation returned undefined
and Next answered with a framework 500 instead of a 400. They now build the
400 explicitly, keeping the { error: string } shape the dashboard reads.
The response shape stays backward compatible: `error` is still a string, with
`reason` added alongside it.
diegosouzapw
merged commit Aug 28, 2026
dff3f8b
into
cheaper-inference:release/v3.8.51
7 of 16 checks passed
muhamadgalihsaputra
pushed a commit
to niyatna/NiyatnaRoute
that referenced
this pull request
Sep 27, 2026
…heaper-inference#11872) Closes a real Hard Rule cheaper-inference#12 violation — 14 catch blocks across the tunnel/MITM routes echoed a raw err.message, which for Tailscale could leak a live tskey-* credential and always disclosed host layout / OS account name. Routes all 14 sites through a new toPublicSafeTunnelError() classifier, verified by a dedicated regression suite (14/14 passing) asserting no route echoes a raw error.message. Thanks for the security fix!
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two independent defects on the same surface —
src/app/api/tunnels/**andsrc/app/api/settings/mitm/route.ts.1. Fourteen catch blocks returned a raw
error.message. All fourteen answered with{ error: error instanceof Error ? error.message : "<fallback>" }. Those errors come fromcloudflared/tailscale/tailscaled/ngrokchild processes and from filesystem I/O under the operator's home directory, so the body disclosed host layout, binary install locations, the OS account name and — for Tailscale — livetskey-*credentials. Hard Rule #12 forbids putting a rawerr.messagein an HTTP body.tunnels/cloudflared/route.tstunnels/ngrok/route.tstunnels/tailscale/route.tstunnels/tailscale/check/route.tstunnels/tailscale/enable/route.tstunnels/tailscale/disable/route.tstunnels/tailscale/login/route.tstunnels/tailscale/install/route.tserrorevent)tunnels/tailscale/start-daemon/route.tssettings/mitm/route.tsRouting these through the existing
sanitizeErrorMessage()does not close it. That helper only rewrites tokens that look like an absolute path ending in a source extension —SOURCE_EXT = ["ts","tsx","js","jsx","mjs","cjs"]inopen-sse/utils/error.ts:25— so every leak shape these routes actually produce survives it verbatim. Measured against the current tip:So this PR adds
src/lib/api/publicSafeTunnelError.ts, which returns a route-owned literal plus a coarse machine-readablereason, and logs the real error server-side where the operator can still read it. It never tries to scrub the upstream text — it does not echo it at all. A test pins thesanitizeErrorMessagegap above, so if the shared sanitizer ever grows to cover these shapes the test fails and tells you to simplify this module away.2.
validateBody()has noresponsefield, so three routes returnedundefined.validateBody()returns{ success, error }(src/shared/validation/helpers.ts:23-26);validatedJsonBody()is the sibling helper that carries a ready-maderesponse. Three call sites didreturn validation.responseanyway —tunnels/cloudflared/route.ts:53,tunnels/ngrok/route.ts:50, andtunnels/tailscale/routeUtils.ts:37(whose five POST callers then doif ("response" in parsed) return parsed.response, and the key is present, holdingundefined). A body that fails validation therefore returnedundefinedfrom the handler and Next answered with a framework 500 instead of a 400. They now build the 400 explicitly withformatValidationMessage().Reachability — stated precisely, and it is not uniform.
/api/tunnels/ngrok(both methods),/api/tunnels/tailscaleand/api/tunnels/tailscale/checkare not inLOCAL_ONLY_API_PREFIXES, andGET /api/tunnels/cloudflaredis explicitly exempted throughLOCAL_ONLY_API_GET_EXEMPTIONS(src/server/authz/routeGuard.ts:223-226, added by #11531 so remote dashboards keep read-only status). Those bodies can reach a non-loopback caller. Everything else here — the five Tailscale process routes,POST /api/tunnels/cloudflaredand all of/api/settings/mitm— is loopback-gated by #11531 and by the MITM entry, and is covered for one consistent contract rather than because it is remotely reachable.The response shape stays backward compatible:
erroris still a string, which is whatEndpointPageClient.tsxreads (new Error(data?.error || fallback));reasonis added alongside it.Related Issues
GET /api/tunnels/cloudflaredexemption this PR reasons about./api/skills/**.Validation
npm run lintRebased onto
release/v3.8.51@0ad03316and every check below re-run on that base.Focused suite (new file):
Red-before-green — same suite with only the route changes reverted (new module kept so the file still imports):
Zone suite — every tunnel, MITM, route-guard and error-sanitization test (62 files):
Dashboard consumer of these routes:
Gates:
Inherited lint state, declared up front.
npm run lintreports zero diagnostics for this branch — the exit 2 comes only from the stale-suppression check. The identical command on the pristine base (release/v3.8.51, no changes applied) produces the same single line and the same exit 2, so it is not introduced here. This PR intentionally does not touchconfig/quality/eslint-suppressions.json. Same observation as PR #11705.Tests Added Or Updated
tests/unit/tunnel-routes-error-sanitization.test.ts— added, 9 tests.Coverage Notes
src/lib/api/publicSafeTunnelError.ts(new) and eleven route files undersrc/app/api/tunnels/**plussrc/app/api/settings/mitm/route.ts.src/lib/api/publicSafeTunnelError.tsis fully covered:toPublicSafeTunnelErroragainst all five real leak shapes (asserting the secret appears nowhere in the serialized body, not just inerror), the server-side log call, and everyclassifyTunnelErrorbranch including theunknowndefault.GET /api/tunnels/ngrok— one of the routes that is not loopback-gated — driven through the real handler via theglobalThis.__ngrokListenerseamgetNgrokTunnelStatus()already reads; end-to-end 400s on the threevalidateBodycall sites; and a source sweep oversrc/app/api/tunnels/**andsrc/app/api/settings/mitm/**that fails if any line reintroduces theerror instanceof Error ? error.messagepattern, which covers the routes that cannot be driven hermetically (the five Tailscale process routes and the MITM CA flow both shell out).Reviewer Notes
validateBody().responsebug exists in two more routes outside this PR's scope, and I left them alone rather than widen the diff:src/app/api/settings/cache-config/route.ts:88andsrc/app/api/settings/models-dev/route.ts:68. Both doreturn validation.responseon avalidateBody()result, so both returnundefinedand produce a framework 500 where a 400 was intended. Happy to fold them in here or open a separate PR — your call.validationis narrowed to{ success: false; error }, sovalidation.responseis a genuine TS error. It does not surface becausetsconfig.typecheck-core.jsonis an explicit 27-file allowlist that contains nosrc/app/apiroute, andnext.config.mjs:321setstypescript.ignoreBuildErrors: true, so the production build does not typecheck either. Open PR ci: add API route TypeScript regression gate #11705 adds exactly the missing gate and freezes a baseline of 304 pre-existingsrc/app/apidiagnostics — these three are very likely among them. If ci: add API route TypeScript regression gate #11705 lands first, this PR should shrink that baseline rather than conflict with it.npm run check:route-validation:t06passes identically before and after this fix.scripts/check/check-route-validation.mjsis satisfied by the presence ofvalidateBody(or.safeParse(anywhere in a route file; it never checks that the validated value is the one the handler uses, nor that the failure branch returns a realResponse. All three broken call sites callvalidateBody(...)and so pass the gate while returningundefined. Separately, itsREQUEST_JSON_REGEXis/request\.json\s*\(/, which keys on the literal identifierrequest— six route files undersrc/app/apicallreq.json(and are skipped entirely.classifyTunnelErrorreads the upstream message only to pick one of six fixed labels; the message itself never reaches the client. If you would rather the routes emit noreasonat all, dropping it is a one-line change and the response stays backward compatible either way.cloudflared, hitGET /api/tunnels/cloudflaredand confirm the body is the fixed sentence while the real cause is in the server log.