From a3072f149ba057a3cf0cd81bd927b7862a964105 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 04:12:43 +0000 Subject: [PATCH 1/5] Cloud container: PocketBase's dashboard renders through /pb, its redirects keep /pb, embed-test reads the data API Three bugs rega found on a laptop run of the container (2026-09-29): - /pb/_/ never rendered. admin.mjs proxied with fetch(), which returns the body decompressed, but forwarded PocketBase's Content-Encoding: gzip and Content-Length, so the browser tried to gunzip plain HTML. The proxy now drops both headers (for /pb and /api-prod). - PocketBase's own redirects left the mount: /pb/_ went to /_/, and /pb/_/index.html to http://127.0.0.1:8090/_/ (the internal address). Root-relative and upstream-absolute locations are put back under the mount: /pb/_/. - The research host's /embed-test set no MAP_APP_API_URL, so the maps asked that static host's own /api (404s; dashboard-plus-grid drew 489 nodes, not 1,399). The page now sets it from the manifest's runtime.apiBaseUrl before any bundle loads, as every generated page does. Checked both ways against an upstream that answers like PocketBase (gzipped HTML, redirects to /_/). The old proxy sends a gzip label on a plain body and /_/ locations; the new one sends a decodable body and /pb/_/. --- deployment/cloud/admin.mjs | 15 ++++++++++++++- deployment/cloud/embed-test.html | 4 ++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/deployment/cloud/admin.mjs b/deployment/cloud/admin.mjs index a39939e50..464063eca 100644 --- a/deployment/cloud/admin.mjs +++ b/deployment/cloud/admin.mjs @@ -22,10 +22,23 @@ const groupsOf = (req) => (req.headers.get('x-forwarded-groups') || '').split(', const isAdmin = (req) => ADMIN.length > 0 && groupsOf(req).some((g) => ADMIN.includes(g)) const text = (s, status = 200) => new Response(s + '\n', { status, headers: { 'content-type': 'text/plain; charset=utf-8' } }) +// 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/_/). 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}`) const r = await fetch(target, { method: req.method, headers: req.headers, body: ['GET', 'HEAD'].includes(req.method) ? undefined : req.body, redirect: 'manual' }) - return new Response(r.body, { status: r.status, headers: r.headers }) + const headers = new Headers(r.headers) + headers.delete('content-encoding') + headers.delete('content-length') + const loc = headers.get('location') + if (loc) { + const mount = prefix.slice(0, -1) // '/pb/' -> '/pb' + const upstream = `http://127.0.0.1:${port}` + const path = loc.startsWith(upstream) ? loc.slice(upstream.length) || '/' : loc + if (path.startsWith('/') && !path.startsWith('//') && !path.startsWith(`${mount}/`)) headers.set('location', mount + path) + } + return new Response(r.body, { status: r.status, headers }) } // One public build at a time (build-cdn.sh also holds a lock), started by an admin or by the watcher below. diff --git a/deployment/cloud/embed-test.html b/deployment/cloud/embed-test.html index 7f5a60972..e5669e4f3 100644 --- a/deployment/cloud/embed-test.html +++ b/deployment/cloud/embed-test.html @@ -59,6 +59,10 @@ const r = await fetch(`${base}/manifest.json`, { cache: 'no-cache' }) if (!r.ok) throw new Error(`HTTP ${r.status}`) manifest = await r.json() + // The data API the maps read (people groups, statistics). Every generated page and copy-paste snippet sets it + // before its bundle loads; this page builds its cards itself, so it sets it from the manifest. Without it the + // bundles asked this host's own /api, which the research host doesn't serve (404s, fewer nodes drawn). + if (!window.MAP_APP_API_URL && manifest.runtime && manifest.runtime.apiBaseUrl) window.MAP_APP_API_URL = manifest.runtime.apiBaseUrl } catch (e) { status.textContent = `The manifest did not load from ${base}/manifest.json (${e.message}).` return From c4cac08bc181d04e329e1323201e9aee7a1526f3 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 04:53:27 +0000 Subject: [PATCH 2/5] 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). From 4f5255fd21e2925414c6be382a61855c311b49df Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 04:41:17 +0000 Subject: [PATCH 3/5] Cloud container: ACCESS=public, a public Plugin Studio with the admin parts behind a password The container needed an OIDC provider to run PocketBase at all. With ACCESS=public: - The Studio, every map and the backend's public API are open, with no sign-in. Vite owns the public port and forwards /cdn/, /api-prod/, /pb/ and /admin/ to admin.mjs, whose gates decide. - /admin/ (publishing), PocketBase's dashboard and _superusers ask for ADMIN_USER / ADMIN_PASSWORD over HTTP Basic, compared in constant time. The container refuses to start without ADMIN_PASSWORD. In this mode X-Forwarded-Groups is never read: with no oauth2-proxy in front, it is whatever a visitor sends. - Rudder's voice engine stays off. Gated bundles (gatedProfiles) are refused even by URL: source, @fs, dev leaf. Vite's /__open-in-editor is refused. OIDC stays the other mode, unchanged. In BOTH modes the Studio's own /api/pb (the dev backend plugin) now goes through admin.mjs's /pb (DX_PB_URL). The dashboard and _superusers stay behind the same gates, and plugins that use PocketBase work in the container's Studio; they got a 503 before. Checked: bash -n; the mode selection (public / gated / open); a missing password stops the container. The admin gate and the public guard, taken from these files, pass 20/20: - password right / wrong / wrong user; - a spoofed groups header ignored; - 401 + Basic prompt; - gated mode unchanged; - no password = no admin; - every form of a gated path refused and the rest passed. --- deployment/cloud/README.md | 24 +++++++++++ deployment/cloud/admin.mjs | 28 +++++++++++-- deployment/cloud/entry.sh | 57 +++++++++++++++++++------- deployment/cloud/vite.cloud.config.mjs | 29 ++++++++++++- 4 files changed, 118 insertions(+), 20 deletions(-) diff --git a/deployment/cloud/README.md b/deployment/cloud/README.md index 0000e9307..59fa9e358 100644 --- a/deployment/cloud/README.md +++ b/deployment/cloud/README.md @@ -60,6 +60,30 @@ with the redirect URL `https:///oauth2/callback`; PKCE (S256) is used Secrets reach oauth2-proxy through its environment, never its command line. +## Public Studio (`ACCESS=public`) + +For a team whose Plugin Studio should be open to everyone: no sign-in provider. The Studio, every map and the +backend's public API are open. The admin parts ask for a username and password, set when you deploy. + +| Variable | Meaning | +|---|---| +| `ACCESS` | `public` turns this mode on. OIDC settings are then ignored; unset `ACCESS` to use the sign-in | +| `ADMIN_USER`, `ADMIN_PASSWORD` | guard `/admin/` (publishing), PocketBase's dashboard `/pb/_/` and its `_superusers`. `ADMIN_PASSWORD` is required (the container refuses to start without it); use a long random one. `ADMIN_USER` defaults to `admin` | + +| Route | Who | +|---|---| +| `/` | anyone: the Plugin Studio, with hot reload | +| `/cdn/dx-maps/`, `/api-prod/api/` | anyone, as in the signed-in mode | +| `/pb/api/` | anyone, as PocketBase's own record rules allow. People sign in with PocketBase's own accounts | +| `/pb/_/`, `/pb/api/collections/_…`, `/admin/*` | `ADMIN_USER` / `ADMIN_PASSWORD` (the browser asks) | + +Also in this mode: +- Rudder's voice engine stays off. +- Gated bundles (`gatedProfiles`) are refused even by URL. +- Vite's `/__open-in-editor` is refused. + +In both modes, the Studio's own `/api/pb` goes through the same gates as `/pb/`. + ## Backend and production (v1, experimental) With the sign-in on, the container also runs PocketBase and can publish a production build: diff --git a/deployment/cloud/admin.mjs b/deployment/cloud/admin.mjs index d16a48251..1f5064b7a 100644 --- a/deployment/cloud/admin.mjs +++ b/deployment/cloud/admin.mjs @@ -9,6 +9,7 @@ // /admin/pb-signin-check a page that signs in to the team PocketBase and shows who it thinks you are // Groups come from oauth2-proxy's X-Forwarded-Groups, which it sets on signed-in routes only. import { spawn } from 'node:child_process' +import { createHash, timingSafeEqual } from 'node:crypto' import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' import { basename, join, normalize } from 'node:path' import { serveTreeFile } from './cdn-cache.mjs' @@ -19,8 +20,25 @@ const CDN = '/data/cdn/current' let last = { state: 'idle' } const groupsOf = (req) => (req.headers.get('x-forwarded-groups') || '').split(',').map((g) => g.trim()) -const isAdmin = (req) => ADMIN.length > 0 && groupsOf(req).some((g) => ADMIN.includes(g)) +// ACCESS=public (entry.sh): the Plugin Studio is open and nobody signs in, so X-Forwarded-Groups is whatever a +// visitor sends and is never read. The admin parts (/admin/, PocketBase's dashboard, _superusers) ask for +// ADMIN_USER / ADMIN_PASSWORD over HTTP Basic instead, compared in constant time. +const PUBLIC = env.ACCESS === 'public' +const ADMIN_USER = env.ADMIN_USER || 'admin' +const same = (a, b) => timingSafeEqual(createHash('sha256').update(a).digest(), createHash('sha256').update(b).digest()) +function passwordOk(req) { + const h = req.headers.get('authorization') || '' + if (!env.ADMIN_PASSWORD || !h.startsWith('Basic ')) return false + const [user, ...rest] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':') + const userOk = same(user, ADMIN_USER), passOk = same(rest.join(':'), env.ADMIN_PASSWORD) + return userOk && passOk +} +const isAdmin = (req) => (PUBLIC ? passwordOk(req) : ADMIN.length > 0 && groupsOf(req).some((g) => ADMIN.includes(g))) const text = (s, status = 200) => new Response(s + '\n', { status, headers: { 'content-type': 'text/plain; charset=utf-8' } }) +// Refusing an admin part: in public mode a 401 that makes the browser ask for the password; with the sign-in, 403. +const refuse = (msg) => (PUBLIC + ? new Response(msg + '\n', { status: 401, headers: { 'content-type': 'text/plain; charset=utf-8', 'www-authenticate': 'Basic realm="dx-maps admin", charset="UTF-8"' } }) + : text(msg, 403)) // 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 @@ -165,18 +183,20 @@ Bun.serve({ if (p.startsWith('/pb/')) { 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) + if ((bp === '/pb/_' || bp.startsWith('/pb/_/')) && !isAdmin(req)) return refuse('the PocketBase dashboard is for admins') // 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 (bp.startsWith('/pb/api/collections/_') && !isAdmin(req)) return text('for admins only', 403) + if (bp.startsWith('/pb/api/collections/_') && !isAdmin(req)) return refuse('for admins only') return proxy(req, url, '/pb/', 8090) } + // With the sign-in, oauth2-proxy guards every /admin/ path; in public mode the admin password does. + if (PUBLIC && p.startsWith('/admin/') && !isAdmin(req)) return refuse('the admin pages ask for the admin password') // A one-click check of the team PocketBase sign-in (behind the site sign-in like every /admin/ path). if (p === '/admin/pb-signin-check') return new Response(Bun.file('/app/repo/deployment/cloud/pb-signin-check.html'), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } }) if (p === '/admin/status') return Response.json({ ...last, production: { branch: PROD, autoBuildMinutes: AUTO_MIN, head: watch.seen, published: publishedCommit(), prodSchema: prodSchema(), error: watch.error } }) if (p === '/admin/build') { if (req.method !== 'POST') return text('POST only', 405) - if (!isAdmin(req)) return text('builds are for team admins', 403) + if (!isAdmin(req)) return refuse('builds are for team admins') if (last.state === 'building') return text('a build is already running', 409) startBuild('admin') return text('build started', 202) diff --git a/deployment/cloud/entry.sh b/deployment/cloud/entry.sh index 6b04d65ff..4debc036c 100755 --- a/deployment/cloud/entry.sh +++ b/deployment/cloud/entry.sh @@ -1,7 +1,8 @@ #!/bin/bash # dx-maps cloud dev server, run by the image's /boot.sh from the cloned repo (see Dockerfile). # 1. install, 2. pull BRANCH every PULL_INTERVAL s (Vite hot-reloads what changed; 0 = never pull), -# 3. serve: Vite alone, or behind an OIDC sign-in when OIDC_ISSUER is set. +# 3. serve: Vite alone; behind an OIDC sign-in when OIDC_ISSUER is set; or, with ACCESS=public, the Studio +# open to everyone and the admin parts behind ADMIN_USER/ADMIN_PASSWORD. set -u cd /app/repo BRANCH="${BRANCH:-main}"; PORT="${PORT:-5173}" @@ -47,6 +48,18 @@ fi CONFIG=/app/repo/deployment/cloud/vite.cloud.config.mjs +# ACCESS=public: the Plugin Studio (and every map, and the backend's public API) is open to everyone, with no +# sign-in. /admin/ (publishing), PocketBase's dashboard and _superusers ask for ADMIN_USER / ADMIN_PASSWORD +# instead (admin.mjs). OIDC stays the other mode: unset ACCESS and set OIDC_ISSUER. +if [ "${ACCESS:-}" = public ]; then + : "${ADMIN_PASSWORD:?ACCESS=public needs ADMIN_PASSWORD (and ADMIN_USER, default admin): it guards publishing, /admin/ and the PocketBase dashboard}" + if [ "${RUDDER_ENGINE:-0}" = 1 ]; then + echo "[access] public: Rudder's voice engine stays off (a public one would take anyone's microphone traffic)" >&2 + RUDDER_ENGINE=0 + fi + [ -z "${OIDC_ISSUER:-}" ] || echo "[access] public: OIDC_ISSUER is ignored; unset ACCESS to use the sign-in" >&2 +fi + # Rudder (optional, RUDDER_ENGINE=1): its voice-to-text engine runs here, fed by the BROWSER's # microphone (no server mic). The image carries the runtime and the pinned model (/opt/rudder); a copy on # the persistent volume (/data/rudder) wins when it exists. Setup (checksums + building the engine) runs @@ -79,25 +92,27 @@ if [ "${RUDDER_ENGINE:-0}" = 1 ]; then disown fi -if [ -z "${OIDC_ISSUER:-}" ]; then - # Open, like a laptop: no sign-in. +if [ -z "${OIDC_ISSUER:-}" ] && [ "${ACCESS:-}" != public ]; then + # Open, like a laptop: no sign-in, no backend. export VITE_HOST=0.0.0.0 VITE_PORT="$PORT" exec bun x vite --config "$CONFIG" fi -# OIDC gate: oauth2-proxy owns the public port; Vite listens on localhost behind it, so the Plugin -# Studio, every map and the hot-reload websocket are all behind the same sign-in. -: "${OIDC_CLIENT_ID:?}" "${OIDC_CLIENT_SECRET:?}" "${OIDC_REDIRECT_URL:?}" -if [ -z "${OIDC_ALLOWED_GROUPS:-}" ]; then - echo "refusing to start: set OIDC_ALLOWED_GROUPS (comma-separated). With open sign-up, anyone could log in." >&2 - exit 1 -fi -ALLOW=(); IFS=',' read -ra G <<< "$OIDC_ALLOWED_GROUPS" -for g in "${G[@]}"; do g="${g// /}"; [ -n "$g" ] && ALLOW+=(--allowed-group="$g"); done +if [ "${ACCESS:-}" != public ]; then + # OIDC gate: oauth2-proxy owns the public port; Vite listens on localhost behind it, so the Plugin + # Studio, every map and the hot-reload websocket are all behind the same sign-in. + : "${OIDC_CLIENT_ID:?}" "${OIDC_CLIENT_SECRET:?}" "${OIDC_REDIRECT_URL:?}" + if [ -z "${OIDC_ALLOWED_GROUPS:-}" ]; then + echo "refusing to start: set OIDC_ALLOWED_GROUPS (comma-separated). With open sign-up, anyone could log in." >&2 + exit 1 + fi + ALLOW=(); IFS=',' read -ra G <<< "$OIDC_ALLOWED_GROUPS" + for g in "${G[@]}"; do g="${g// /}"; [ -n "$g" ] && ALLOW+=(--allowed-group="$g"); done -# Secrets reach oauth2-proxy through its environment, never its command line. -export OAUTH2_PROXY_CLIENT_SECRET="$OIDC_CLIENT_SECRET" -export OAUTH2_PROXY_COOKIE_SECRET="${OIDC_COOKIE_SECRET:-$(head -c 32 /dev/urandom | base64 | tr -d '=+/' | head -c 32)}" + # Secrets reach oauth2-proxy through its environment, never its command line. + export OAUTH2_PROXY_CLIENT_SECRET="$OIDC_CLIENT_SECRET" + export OAUTH2_PROXY_COOKIE_SECRET="${OIDC_COOKIE_SECRET:-$(head -c 32 /dev/urandom | base64 | tr -d '=+/' | head -c 32)}" +fi # Backend: PocketBase DEV (/pb/, signed-in) and PROD (/api-prod/, public API only); data = the /data volume. # The superuser comes from env, never printed. SCHEMA: dev runs the migrations in this checkout (BRANCH, the @@ -130,6 +145,18 @@ pocketbase serve --http 127.0.0.1:8090 --dir /data/pb-dev --migrationsDir "$MIG" echo "$(date -u +%FT%TZ) [pb-prod] exited ($?); starting again on $(basename "$(readlink "$PROD_MIG")")"; sleep 1 done ) & bun /app/repo/deployment/cloud/admin.mjs & +# The Studio's own /api/pb (the repo's dev backend plugin) goes through admin.mjs's /pb, so the same gates apply +# (the dashboard and _superusers for admins only), and plugins that use PocketBase reach it here too. Without +# this the plugin answered 503 (it found the port taken and started nothing). +export DX_PB_URL="${DX_PB_URL:-http://127.0.0.1:5175/pb}" +if [ "${ACCESS:-}" = public ]; then + # Vite owns the public port; vite.cloud.config.mjs forwards /cdn/, /api-prod/, /pb/ and /admin/ to admin.mjs. + export DX_PUBLIC_STUDIO=1 + echo "[access] public: the Plugin Studio is open on :$PORT; /admin/, publishing and PocketBase's dashboard ask for ADMIN_USER/ADMIN_PASSWORD" + VITE_HOST=0.0.0.0 VITE_PORT="$PORT" bun x vite --config "$CONFIG" & + wait -n + exit $? +fi # TEAM PB: laptops reach pb-dev's API WITHOUT the site sign-in (DX_PB_URL=https:///pb), and PocketBase # itself signs people in through the same OIDC provider, members of OIDC_ALLOWED_GROUPS only (the baked diff --git a/deployment/cloud/vite.cloud.config.mjs b/deployment/cloud/vite.cloud.config.mjs index 76a501db7..34138a879 100644 --- a/deployment/cloud/vite.cloud.config.mjs +++ b/deployment/cloud/vite.cloud.config.mjs @@ -3,6 +3,7 @@ import base from '/app/repo/vite.config.js' import { mergeConfig } from 'vite' import { readFileSync } from 'node:fs' +import { GATED_PROFILES } from '/app/repo/internal/_tree-name.mjs' const env = process.env const SETUP_STATE = '/tmp/rudder-setup.state' // written by entry.sh: running -> done | failed @@ -40,13 +41,38 @@ const rudderStarting = { }, } +// ACCESS=public (entry.sh sets DX_PUBLIC_STUDIO=1): Vite owns the public port with no sign-in in front of it. +// - The backend's paths go to admin.mjs, whose gates decide: /cdn/ and /api-prod/api/ public, /pb/ records +// by PocketBase's own rules, and /pb/_/, _superusers and /admin/ for the admin password. +// - GATED bundles (gatedProfiles, e.g. key-cities) must never reach a public surface. The Studio already leaves +// them out of its list; here their source and dev pages are refused too, so a typed URL can't load them. +// - /__open-in-editor (Vite's own) would start an editor process on this server for a visitor: refused. +const PUBLIC = env.DX_PUBLIC_STUDIO === '1' +const publicGuard = { + name: 'cloud-public-guard', + enforce: 'pre', + apply: 'serve', + configureServer(server) { + if (!PUBLIC) return + server.middlewares.use((req, res, next) => { + let p = (req.url || '').split('?')[0] + try { p = decodeURIComponent(p) } catch { /* keep it raw */ } + const gated = GATED_PROFILES.some((g) => p.includes(`/plugin-designs/${g}/`) || p === `/__leaf/${g}` || p.startsWith(`/__leaf/${g}/`)) + if (!gated && !p.startsWith('/__open-in-editor')) return next() + res.statusCode = 404 + res.setHeader('content-type', 'text/plain; charset=utf-8') + res.end('not found\n') + }) + }, +} + export default async (e) => { const b = typeof base === 'function' ? await base(e) : base const hmr = env.HMR_CLIENT_PORT ? { clientPort: Number(env.HMR_CLIENT_PORT), protocol: env.HMR_PROTOCOL || 'wss' } : undefined return mergeConfig(b, { - plugins: [rudderStarting], + plugins: [publicGuard, rudderStarting], cacheDir: '/tmp/vite-cache', server: { host: env.VITE_HOST || '0.0.0.0', @@ -54,6 +80,7 @@ export default async (e) => { strictPort: true, allowedHosts: true, ...(hmr ? { hmr } : {}), + ...(PUBLIC ? { proxy: { '^/(cdn|api-prod|pb|admin)/': { target: 'http://127.0.0.1:5175', ws: true } } } : {}), }, }) } From 56d546d8b82ec31c94a78451478699efa568d64b Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 05:07:45 +0000 Subject: [PATCH 4/5] Public Studio: the checkout's .git is not served (fs.deny), with Vite's defaults kept The resolved config in ACCESS=public (Vite 5.4.21) had strict file serving with /app/repo as the only allowed root, and a deny list of Vite's defaults (.env, .env.*, certificates). The .git folder sat inside the allowed root and was not denied, so a public Studio could serve the repository's git internals. In public mode the deny list adds **/.git/**; the defaults are restated because setting deny replaces them. --- deployment/cloud/vite.cloud.config.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/deployment/cloud/vite.cloud.config.mjs b/deployment/cloud/vite.cloud.config.mjs index 34138a879..3a304120a 100644 --- a/deployment/cloud/vite.cloud.config.mjs +++ b/deployment/cloud/vite.cloud.config.mjs @@ -81,6 +81,9 @@ export default async (e) => { allowedHosts: true, ...(hmr ? { hmr } : {}), ...(PUBLIC ? { proxy: { '^/(cdn|api-prod|pb|admin)/': { target: 'http://127.0.0.1:5175', ws: true } } } : {}), + // Public: the checkout's git internals are not served either (the private history, .git/config). Vite's + // defaults are restated because setting deny replaces them. + ...(PUBLIC ? { fs: { strict: true, deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**'] } } : {}), }, }) } From 21330066e5e100f947bc0a036d2b0f594256aa61 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 05:25:03 +0000 Subject: [PATCH 5/5] Cloud container: the production PocketBase's superuser and _system collections are closed on /api-prod too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /pb already refused them to anyone who is not an admin. /api-prod, the production PocketBase's public API, passed every /api/ path, so its superuser sign-in was reachable from the public side: no data was exposed, but it was a place to guess the production admin's password. Now any /api-prod/api/collections/_… path is refused (403), checked on the decoded path like the /pb gate. No public client calls these; the one caller in the repo (pb-oidc.mjs) talks to PocketBase directly. Found by dx-base's independent review of the public Studio (V2). --- deployment/cloud/README.md | 2 +- deployment/cloud/admin.mjs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/deployment/cloud/README.md b/deployment/cloud/README.md index 59fa9e358..a5685ec4e 100644 --- a/deployment/cloud/README.md +++ b/deployment/cloud/README.md @@ -92,7 +92,7 @@ With the sign-in on, the container also runs PocketBase and can publish a produc |---|---|---| | `/` | signed-in | the dev server (Plugin Studio, hot reload) | | `/pb/` | signed-in (its `/_/` dashboard: `OIDC_ADMIN_GROUPS` only). With `PB_OIDC_CLIENT_SECRET`, its `/api/` is public and PocketBase signs people in itself (see below); `_superusers` and system collections stay admin-only | PocketBase **dev**, the team's shared backend | -| `/api-prod/api/` | **public** | PocketBase **prod**'s API; its dashboard is never exposed | +| `/api-prod/api/` | **public** | PocketBase **prod**'s API; its dashboard, its superuser and its `_` system collections are never exposed | | `/cdn/dx-maps/` | **public** | the last published build: `manifest.json` at a stable URL, CORS open for embeds | | `POST /admin/build` · `GET /admin/status` | `OIDC_ADMIN_GROUPS` only | build and publish the pulled commit | diff --git a/deployment/cloud/admin.mjs b/deployment/cloud/admin.mjs index 1f5064b7a..204bfeed9 100644 --- a/deployment/cloud/admin.mjs +++ b/deployment/cloud/admin.mjs @@ -1,6 +1,6 @@ // admin.mjs — the container's internal router (127.0.0.1:5175), behind oauth2-proxy. // /cdn/* the last good build (/data/cdn/current), PUBLIC (oauth2-proxy skips auth here) -// /api-prod/* PocketBase PROD, PUBLIC; only /api/ passes, never the /_/ dashboard +// /api-prod/* PocketBase PROD, PUBLIC; only /api/ passes, never the /_/ dashboard, _superusers or _system collections // /pb/* PocketBase DEV, signed-in; its /_/ dashboard only for OIDC_ADMIN_GROUPS. With PB_OIDC_CLIENT_SECRET, // its /api/ is PUBLIC (PocketBase signs people in itself), except _superusers and _system collections // /admin/build POST: build PRODUCTION_BRANCH now (OIDC_ADMIN_GROUPS only); /admin/status GET. @@ -178,6 +178,8 @@ Bun.serve({ 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) + // As on /pb: the superuser and every _system collection stay closed. No public client uses them (dx-base, V2 review). + if (bp.startsWith('/api-prod/api/collections/_')) return text('for admins only', 403) return proxy(req, url, '/api-prod/', 8091) } if (p.startsWith('/pb/')) {