From d7de2a2e008d7244ad6a780acbd56c4b56ae9f96 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 04:53:27 +0000 Subject: [PATCH] Cloud container: the backend proxy can't reach another host, and the admin gates see decoded paths Two holes studio found in admin.mjs, there since 2026-09-27: - proxy() parsed the remaining path as a URL. /pb//evil.example/x is a protocol-relative URL, so admin.mjs fetched ANOTHER host (any internal address too) with the visitor's own headers, and returned its body. The path now goes onto a fixed http://127.0.0.1: origin and is never parsed as a URL, and a path starting with // is refused. - The /pb/_/ and _superusers checks tested the raw path, so /pb/%5F/ and /pb/api/collections/%5Fsuperusers/... passed them, and PocketBase decoded them to the real thing. The checks now run on the decoded, slash-collapsed path. Encoded slashes or backslashes, and undecodable input, get 400. Reach today: maps-dev answers all four attack paths with its sign-in page to an anonymous visitor (measured), so a signed-in team member could use them. In ACCESS=public they would be anonymous; that mode is held until this lands. Checked both ways against a PocketBase-like upstream plus an "evil" host. The old code let /pb/%5F/, %5Fsuperusers and %2F through, fetched http://_/ and hit the evil host once. The new code refuses them (403/400) with 0 evil hits, and health and records calls still pass. --- deployment/cloud/admin.mjs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/deployment/cloud/admin.mjs b/deployment/cloud/admin.mjs index 464063eca..d16a48251 100644 --- a/deployment/cloud/admin.mjs +++ b/deployment/cloud/admin.mjs @@ -25,8 +25,22 @@ const text = (s, status = 200) => new Response(s + '\n', { status, headers: { 'c // fetch() hands back the body DECOMPRESSED, so the upstream Content-Encoding and Content-Length no longer describe // what is sent on: forwarding them made browsers try to gunzip plain HTML, and PocketBase's dashboard never rendered // (rega, 2026-09-29). And PocketBase redirects to its own root (/_ -> /_/), which is put back under the mount (/pb/_/). +// A backend path as the backend will read it: percent-decoded, runs of slashes collapsed. The admin gates run on +// THIS form, so /pb/%5F/ (PocketBase decodes it to /_/) meets the same check as /pb/_/ (studio, 2026-09-29). +// Encoded slashes or backslashes and undecodable input are refused: nothing legitimate sends them. +function backendPath(p) { + if (/%2f|%5c/i.test(p) || p.includes('\\')) return null + try { return decodeURIComponent(p).replace(/\/{2,}/g, '/') } catch { return null } +} + async function proxy(req, url, prefix, port) { - const target = new URL(url.pathname.slice(prefix.length - 1) + url.search, `http://127.0.0.1:${port}`) + // The path goes onto a FIXED origin and is never parsed as a URL itself: /pb//evil.example/x made a + // protocol-relative URL, and the proxy fetched another host with the visitor's headers (studio, 2026-09-29). + const rest = url.pathname.slice(prefix.length - 1) + if (!rest.startsWith('/') || rest.startsWith('//') || backendPath(rest) === null) return text('bad path', 400) + const target = new URL(`http://127.0.0.1:${port}`) + target.pathname = rest + target.search = url.search const r = await fetch(target, { method: req.method, headers: req.headers, body: ['GET', 'HEAD'].includes(req.method) ? undefined : req.body, redirect: 'manual' }) const headers = new Headers(r.headers) headers.delete('content-encoding') @@ -143,14 +157,18 @@ Bun.serve({ return serveTreeFile(req, file, file.slice(CDN.length), { 'access-control-allow-origin': '*' }) } if (p.startsWith('/api-prod/')) { - if (!p.startsWith('/api-prod/api/')) return text('only /api/ is public', 403) + const bp = backendPath(p) + if (bp === null) return text('bad path', 400) + if (!p.startsWith('/api-prod/api/') || !bp.startsWith('/api-prod/api/')) return text('only /api/ is public', 403) return proxy(req, url, '/api-prod/', 8091) } if (p.startsWith('/pb/')) { - if (p.startsWith('/pb/_/') && !isAdmin(req)) return text('the PocketBase dashboard is for admins', 403) + const bp = backendPath(p) + if (bp === null) return text('bad path', 400) + if ((bp === '/pb/_' || bp.startsWith('/pb/_/')) && !isAdmin(req)) return text('the PocketBase dashboard is for admins', 403) // The superuser (and every _system collection) is for admins only. oauth2-proxy never opens these // paths to the public (its skip routes exclude names starting with _), so the groups header is its own. - if (p.startsWith('/pb/api/collections/_') && !isAdmin(req)) return text('for admins only', 403) + if (bp.startsWith('/pb/api/collections/_') && !isAdmin(req)) return text('for admins only', 403) return proxy(req, url, '/pb/', 8090) } // A one-click check of the team PocketBase sign-in (behind the site sign-in like every /admin/ path).