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:<port> 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.
This commit is contained in:
Doxa Maps 2026-09-29 04:53:27 +00:00
parent 63fb7fc834
commit d7de2a2e00

View file

@ -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).