SmbOS dashboard
This dashboard needs its access " +
+ 'token. Open it with the full URL ending in ?t=<token> from your dashboard ' +
+ 'launcher.
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 = + 'This dashboard needs its access " +
+ 'token. Open it with the full URL ending in ?t=<token> from your dashboard ' +
+ 'launcher.