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:
parent
63fb7fc834
commit
d7de2a2e00
1 changed files with 22 additions and 4 deletions
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in a new issue