From 4642934dc8b671155828d61c7f1cb37ad033725c Mon Sep 17 00:00:00 2001 From: thekannman Date: Thu, 18 Jun 2026 15:30:49 -0500 Subject: [PATCH] Desktop: broker serves the built SPA (/ + /assets) -- FastAPI off the critical path The broker now serves the dashboard page itself instead of forwarding it to FastAPI, the last piece of the switchover. Mirrors dashboard_app's index + assets routes: - GET / token-gated (?t=); reads frontend/dist/index.html and injects window.__SMBOS_TOKEN__ before (the server token, not the ?t= value); the friendly no-token 401 page byte-for-byte; 503 unbuilt / 500 no . - GET /assets/ the hashed, secret-free bundle (no token), path-contained against traversal, content-type by extension. dist defaults to ../frontend/dist, or for a packaged app. With this, the broker owns the WHOLE surface -- the SPA, every read, the SSE live mirror, and all 11 actions; the only thing still forwarded to FastAPI is /api/settings (its echoed terminal is env-detected). Verified end-to-end against the REAL built SPA (/?t= -> 200 with the token injected + the hashed bundle loads; no token -> 401; encoded traversal + missing asset -> 404) and by a broker unit test (token gate, injection, asset content-type, traversal containment). 36 node. Co-Authored-By: Claude Opus 4.8 (1M context) --- desktop/broker.js | 13 ++++++ desktop/broker.test.js | 28 +++++++++++++ desktop/spa.js | 93 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 desktop/spa.js diff --git a/desktop/broker.js b/desktop/broker.js index 9c3e05b..6ded623 100644 --- a/desktop/broker.js +++ b/desktop/broker.js @@ -15,6 +15,7 @@ const store = require('./store') const liveness = require('./liveness') const sse = require('./sse') const actions = require('./actions') +const spa = require('./spa') const { token } = require('./resolve') // POST action endpoints the broker owns (Phase 4): it gates the HTTP (Host + HEADER token, the CSRF @@ -143,6 +144,18 @@ function createBroker({ targetHost = '127.0.0.1', targetPort, sopDir }) { // Serve a static read directly from the store (Phase 3). The broker owns the token gate for // these, since FastAPI's check never runs on a broker-served response. const pathname = req.url.split('?')[0] + // The broker serves the built SPA itself (/ token-gated with the token injected, /assets the + // secret-free bundle), so FastAPI no longer serves the page. + if (req.method === 'GET' && pathname === '/' && sopDir) { + try { spa.serveIndex(req, res, sopDir) } catch (_) { try { if (!res.headersSent) res.writeHead(500); res.end() } catch (_) { /* sent */ } } + return + } + if (req.method === 'GET' && pathname.startsWith('/assets/') && sopDir) { + let rel + try { rel = decodeURIComponent(pathname.slice('/assets/'.length)) } catch (_) { res.writeHead(404); res.end(); return } + try { spa.serveAsset(req, res, rel) } catch (_) { try { if (!res.headersSent) res.writeHead(500); res.end() } catch (_) { /* sent */ } } + return + } // The /events live-mirror stream is served by the broker (Phase 3 complete): token-gated, then // a long-lived SSE response instead of a JSON body. if (req.method === 'GET' && pathname === '/events' && sopDir) { diff --git a/desktop/broker.test.js b/desktop/broker.test.js index b69aa9f..05619c7 100644 --- a/desktop/broker.test.js +++ b/desktop/broker.test.js @@ -230,6 +230,34 @@ test('POST actions: header-token gated; maps each engine exit code to the HTTP s } }) +test('serves the SPA: / token-gated + token-injected, /assets bundle, traversal blocked', async () => { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'smbos-spa-')) + fs.writeFileSync(path.join(d, '.dashboard-token'), 'tok') + const dist = path.join(d, 'dist'); fs.mkdirSync(path.join(dist, 'assets'), { recursive: true }) + fs.writeFileSync(path.join(dist, 'index.html'), 'app') + fs.writeFileSync(path.join(dist, 'assets', 'app.js'), 'console.log(1)') + fs.writeFileSync(path.join(d, 'secret.txt'), 'SECRET') // outside assets/, for the traversal test + const prev = process.env.SMBOS_DIST; process.env.SMBOS_DIST = dist + try { + const broker = createBroker({ targetPort: 9, sopDir: d }); const brPort = await listen(broker) + const noTok = await request(brPort, '/') + assert.equal(noTok.status, 401); assert.ok(noTok.body.includes('needs its access token')) + const ok = await request(brPort, '/?t=tok') + assert.equal(ok.status, 200) + assert.ok(ok.body.includes('window.__SMBOS_TOKEN__="tok"')) // the server token, injected + assert.equal(ok.headers['cache-control'], 'no-store') + const asset = await request(brPort, '/assets/app.js') // no token needed for the bundle + assert.equal(asset.status, 200); assert.equal(asset.body, 'console.log(1)') + assert.ok(asset.headers['content-type'].includes('javascript')) + assert.equal((await request(brPort, '/assets/..%2f..%2fsecret.txt')).status, 404) // traversal blocked + fs.symlinkSync(path.join(d, 'secret.txt'), path.join(dist, 'assets', 'link')) // symlink escaping assets/ + assert.equal((await request(brPort, '/assets/link')).status, 404) // realpath containment blocks it + broker.close() + } finally { + if (prev === undefined) delete process.env.SMBOS_DIST; else process.env.SMBOS_DIST = prev + } +}) + test('rejects a non-loopback Host (DNS-rebinding defense) before forwarding', async () => { let reached = false const upstream = http.createServer((req, res) => { reached = true; res.end('ok') }) diff --git a/desktop/spa.js b/desktop/spa.js new file mode 100644 index 0000000..3df975c --- /dev/null +++ b/desktop/spa.js @@ -0,0 +1,93 @@ +// The broker serves the built SPA itself (/ and /assets) instead of forwarding it to FastAPI -- the +// last piece before FastAPI is off the critical path. Mirrors dashboard_app's index + assets routes: +// / is token-gated and injects window.__SMBOS_TOKEN__ into the built index.html; /assets/* is the +// hashed, secret-free bundle served with path containment. dist defaults to ../frontend/dist, or +// $SMBOS_DIST for a packaged app. + +const fs = require('fs') +const path = require('path') +const crypto = require('crypto') +const { token } = require('./resolve') + +const PAGE_HEADERS = { 'referrer-policy': 'no-referrer', 'cache-control': 'no-store' } + +// Friendly page when opened without a token (byte-for-byte the FastAPI _NO_TOKEN_PAGE). +const NO_TOKEN_PAGE = + 'SmbOS" + + "

SmbOS dashboard

This dashboard needs its access " + + 'token. Open it with the full URL ending in ?t=<token> from your dashboard ' + + 'launcher.

' + +const MIME = { + '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', + '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.ico': 'image/x-icon', '.webp': 'image/webp', + '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.map': 'application/json', +} + +function distDir() { + return process.env.SMBOS_DIST || path.join(__dirname, '..', 'frontend', 'dist') +} + +function tokenOk(provided, expected) { + if (!expected || !provided) return false + const a = Buffer.from(String(provided)) + const b = Buffer.from(String(expected)) + return a.length === b.length && crypto.timingSafeEqual(a, b) +} + +// GET / : token-gated, the built index.html with the token injected before . +function serveIndex(req, res, sopDir) { + const t = new URL(req.url, 'http://x').searchParams.get('t') + const tok = token({ SOP_DIR: sopDir }) + if (!tokenOk(t, tok)) { + res.writeHead(401, { 'content-type': 'text/html', ...PAGE_HEADERS }) + res.end(NO_TOKEN_PAGE) + return + } + let html + try { + html = fs.readFileSync(path.join(distDir(), 'index.html'), 'utf8') + } catch (_) { + res.writeHead(503, { 'content-type': 'text/plain' }) + res.end('Dashboard UI not built. Run `npm run build` in frontend/.') + return + } + if (!html.includes('')) { // fail loud, not a silently tokenless (blank) dashboard + res.writeHead(500, { 'content-type': 'text/plain' }) + res.end('Dashboard UI is missing a anchor for the token; rebuild it.') + return + } + // the token charset is url-safe; JSON.stringify wraps it as a JS string literal + const inject = `` + res.writeHead(200, { 'content-type': 'text/html', ...PAGE_HEADERS }) + res.end(html.replace('', inject + '')) +} + +// GET /assets/ : the hashed bundle (no secrets, so no token), path-contained against traversal. +function serveAsset(req, res, assetPath) { + const base = path.resolve(distDir(), 'assets') + const target = path.resolve(base, assetPath) + if (target !== base && !target.startsWith(base + path.sep)) { // lexical containment: no ../ escape + res.writeHead(404); res.end(); return + } + let data + try { + // Re-check after realpath: path.resolve is purely lexical (it doesn't follow symlinks), so a + // symlink inside assets/ pointing outside would pass the lexical check. realpath dereferences it, + // matching Python's Path.resolve(); a link that escapes the (realpath'd) base 404s. + const realBase = fs.realpathSync(base) + const real = fs.realpathSync(target) + if (real !== realBase && !real.startsWith(realBase + path.sep)) throw new Error('escapes via symlink') + if (!fs.statSync(real).isFile()) throw new Error('not a file') + data = fs.readFileSync(real) + } catch (_) { + res.writeHead(404); res.end(); return + } + res.writeHead(200, { 'content-type': MIME[path.extname(target).toLowerCase()] || 'application/octet-stream' }) + res.end(data) +} + +module.exports = { serveIndex, serveAsset, distDir }