feat: secure React dashboard cutover

This commit is contained in:
jjbrunton committed 2026-08-08 15:46:11 +01:00
1 parent 12a853fa42
commit 8b1ec85f1d
8 files changed
+408 -71

No files matched your search

+7 -1
View File
@@ -151,7 +151,9 @@ export const ConfigSchema = z.object({
web: z.object({
enabled: z.boolean().default(true),
port: z.number().default(3000),
}).default({ enabled: true, port: 3000 }),
authToken: z.string().min(8, 'Dashboard token must be at least 8 characters'),
debugEnabled: z.boolean().default(false),
}),
});
export type Config = z.infer<typeof ConfigSchema>;
@@ -186,6 +188,10 @@ export function loadConfig(): Config {
rawConfig.browser.headless = process.env.BROWSER_HEADLESS === 'true';
}
// Dashboard access is a deployment secret, not source-controlled config.
if (!rawConfig.web) rawConfig.web = {};
rawConfig.web.authToken = process.env.WEB_DASHBOARD_TOKEN || rawConfig.web.authToken;
if (!rawConfig.openrouter.apiKey) {
throw new Error('OPENROUTER_API_KEY environment variable is required');
}
+96
View File
@@ -0,0 +1,96 @@
import { createHash, timingSafeEqual } from 'node:crypto';
import type { Application, NextFunction, Request, Response } from 'express';
const SESSION_COOKIE = 'redditbot_dashboard';
function tokenDigest(token: string): Buffer {
return createHash('sha256').update(token, 'utf8').digest();
}
export function tokensMatch(candidate: string, expected: string): boolean {
return timingSafeEqual(tokenDigest(candidate), tokenDigest(expected));
}
function bearerToken(req: Request): string | null {
const match = /^Bearer\s+(.+)$/i.exec(req.get('authorization') ?? '');
return match?.[1] ?? null;
}
function cookieToken(req: Request): string | null {
const cookieHeader = req.get('cookie');
if (!cookieHeader) return null;
for (const part of cookieHeader.split(';')) {
const [name, ...valueParts] = part.trim().split('=');
if (name === SESSION_COOKIE) {
try {
return decodeURIComponent(valueParts.join('='));
} catch {
return null;
}
}
}
return null;
}
function requestToken(req: Request): string | null {
return bearerToken(req) ?? cookieToken(req);
}
function isApiRequest(req: Request): boolean {
return req.path.startsWith('/api') || req.path.startsWith('/media');
}
export function createDashboardAuthMiddleware(expectedToken: string) {
return (req: Request, res: Response, next: NextFunction): void => {
const candidate = requestToken(req);
if (candidate !== null && tokensMatch(candidate, expectedToken)) {
next();
return;
}
if (isApiRequest(req)) {
res.status(401).json({ error: 'Authentication required' });
return;
}
res.redirect(302, '/auth/login');
};
}
export function configureDashboardSessionRoutes(app: Application, expectedToken: string): void {
app.get('/auth/login', (_req, res) => {
res
.set('Cache-Control', 'no-cache, no-store, must-revalidate')
.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; form-action 'self'")
.type('html')
.send(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>Dashboard sign in</title><style>body{font:16px system-ui;max-width:28rem;margin:10vh auto;padding:1rem}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{font:inherit;padding:.75rem;margin-top:.5rem}</style></head>
<body><main><h1>Dashboard sign in</h1><form id="login"><label for="token">Access token</label><input id="token" name="token" type="password" required autocomplete="current-password"><button>Sign in</button><p id="error" role="alert"></p></form></main>
<script>document.getElementById('login').addEventListener('submit',async(e)=>{e.preventDefault();const token=document.getElementById('token').value;const response=await fetch('/auth/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token})});if(response.ok){location.href='/'}else{document.getElementById('error').textContent='Invalid access token';}});</script></body></html>`);
});
app.post('/auth/session', (req, res) => {
const body = req.body as { token?: unknown } | undefined;
const candidate = typeof body?.token === 'string' ? body.token : '';
if (!tokensMatch(candidate, expectedToken)) {
res.status(401).json({ error: 'Invalid access token' });
return;
}
const forwardedProtocol = req.get('x-forwarded-proto')?.split(',', 1)[0]?.trim();
res.cookie(SESSION_COOKIE, candidate, {
httpOnly: true,
sameSite: 'strict',
secure: req.secure || forwardedProtocol === 'https',
path: '/',
});
res.status(204).end();
});
app.post('/auth/logout', (_req, res) => {
res.clearCookie(SESSION_COOKIE, { httpOnly: true, sameSite: 'strict', path: '/' });
res.status(204).end();
});
}
+3 -45
View File
@@ -2,11 +2,6 @@ import { Router } from 'express';
import path from 'path';
import fs from 'fs';
import type { WebContext } from '../context.js';
import {
MatrixClient,
saveCredentials,
type MatrixCredentials,
} from '../../services/chat/index.js';
export function createDebugRouter(ctx: WebContext): Router {
const router = Router();
@@ -26,10 +21,10 @@ export function createDebugRouter(ctx: WebContext): Router {
res.sendFile(p);
});
// Dumps the browser context's cookies (names + metadata only — values are
// hashed) so we can tell which session/OAuth cookies are set after the
// Dumps the browser context's cookie names and non-secret metadata so we
// can tell which session/OAuth cookies are set after the
// auto-login and which the chat surface might be missing.
router.get('/debug/cookies', async (req, res) => {
router.get('/debug/cookies', async (_req, res) => {
const browser = ctx.services.browser;
if (!browser) {
res.status(500).json({ error: 'Browser client not initialized' });
@@ -41,7 +36,6 @@ export function createDebugRouter(ctx: WebContext): Router {
return;
}
try {
const reveal = req.query.reveal === '1';
const urls = ['https://www.reddit.com', 'https://chat.reddit.com', 'https://matrix.redditspace.com'];
const out: Record<string, unknown> = {};
for (const u of urls) {
@@ -54,8 +48,6 @@ export function createDebugRouter(ctx: WebContext): Router {
secure: c.secure,
sameSite: c.sameSite,
expires: c.expires,
valueLen: c.value.length,
value: reveal ? c.value : undefined,
}));
}
res.json(out);
@@ -65,40 +57,6 @@ export function createDebugRouter(ctx: WebContext): Router {
}
});
// Accepts a Matrix credentials JSON body, persists it to the cache file,
// and swaps it into the running ChatService. Lets us seed the deployed bot
// from a sniff run on a developer's machine when the in-container sniff
// can't reach an authed /chat/ surface and noVNC isn't an option.
//
// Body shape: { homeserver, accessToken, userId?, capturedAt? }
router.post('/debug/matrix-credentials', (req, res) => {
const body = (req.body ?? {}) as Partial<MatrixCredentials>;
if (!body.homeserver || !body.accessToken) {
res.status(400).json({ error: 'homeserver and accessToken are required' });
return;
}
const creds: MatrixCredentials = {
homeserver: body.homeserver,
accessToken: body.accessToken,
userId: body.userId ?? '',
capturedAt: body.capturedAt ?? Date.now(),
};
try {
saveCredentials(creds);
ctx.logger.info(
`[matrix] credentials uploaded via /api/debug/matrix-credentials (user=${creds.userId || 'unknown'})`,
);
if (ctx.services.chat) {
ctx.services.chat.setMatrixClient(new MatrixClient(creds, ctx.logger));
ctx.logger.info('[matrix] uploaded credentials swapped into running ChatService');
}
res.json({ success: true, homeserver: creds.homeserver, userId: creds.userId });
} catch (error) {
ctx.logger.error('Matrix credential upload failed:', error);
res.status(500).json({ error: String(error) });
}
});
router.post('/debug/inspect-dom', async (req, res) => {
if (!ctx.services.chat) {
res.status(500).json({ error: 'Chat service not initialized' });
+43 -18
View File
@@ -1,6 +1,7 @@
import express from 'express';
import path from 'path';
import type { Server } from 'http';
import type { AddressInfo } from 'node:net';
import type { Logger } from '../utils/logger.js';
import type { Config } from '../config/index.js';
import type {
@@ -31,6 +32,8 @@ import { createLogsRouter } from './routes/logs.js';
import { createAnalyticsRouter } from './routes/analytics.js';
import { createConfigRouter } from './routes/config.js';
import { createLoginRouter } from './routes/login.js';
import { resolveDashboardAssets } from './dashboard-assets.js';
import { configureDashboardSessionRoutes, createDashboardAuthMiddleware } from './dashboard-auth.js';
export type { BotStats, ActivityItem, LogEntry, RuntimeConfig } from './types.js';
@@ -63,6 +66,7 @@ export class WebServer {
private activityLogRepo: ActivityLogRepository,
private logger: Logger,
broadcaster?: LogBroadcaster,
private workspaceDirectory = process.cwd(),
) {
this.broadcaster = broadcaster ?? new LogBroadcaster();
this.activity = new ActivityLog(activityLogRepo);
@@ -105,19 +109,21 @@ export class WebServer {
}
private setupRoutes(): void {
// index.html serves the entire inline JS — never cache it.
this.app.use((req, res, next) => {
if (req.path === '/' || req.path === '/index.html') {
const p = path.join(process.cwd(), 'public', 'index.html');
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
return res.sendFile(p);
}
next();
const dashboardAssets = resolveDashboardAssets(this.workspaceDirectory);
this.app.use(express.json({ limit: '32kb' }));
configureDashboardSessionRoutes(this.app, this.config.web.authToken);
this.app.use(createDashboardAuthMiddleware(this.config.web.authToken));
// The shell may change between deployments, so never cache index.html.
this.app.get(['/', '/index.html'], (_req, res) => {
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(dashboardAssets.indexFile);
});
this.app.use('/media', express.static(path.join(process.cwd(), 'data', 'media')));
this.app.use(express.static(path.join(process.cwd(), 'public')));
this.app.use(express.json());
this.app.use('/media', express.static(path.join(this.workspaceDirectory, 'data', 'media'), {
index: false,
}));
this.app.use(express.static(dashboardAssets.directory, { index: false }));
const ctx = this.context();
this.app.use('/api', createStatsRouter(ctx));
@@ -125,15 +131,26 @@ export class WebServer {
this.app.use('/api', createConversationsRouter(ctx));
this.app.use('/api', createMediaRouter(ctx));
this.app.use('/api', createControlRouter(ctx));
this.app.use('/api', createDebugRouter(ctx));
if (this.config.web.debugEnabled) {
this.app.use('/api', createDebugRouter(ctx));
}
this.app.use('/api', createLogsRouter(ctx));
this.app.use('/api', createAnalyticsRouter(ctx));
this.app.use('/api', createConfigRouter(ctx));
this.app.use('/api', createLoginRouter(ctx));
this.app.use('/api', (_req, res) => {
res.status(404).json({ error: 'API endpoint not found' });
});
// SPA catch-all.
this.app.get(/.*/, (_req, res) => {
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
this.app.get(/.*/, (req, res) => {
if (!req.get('accept')?.includes('text/html')) {
res.status(404).end();
return;
}
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(dashboardAssets.indexFile);
});
}
@@ -200,10 +217,18 @@ export class WebServer {
this.messagesThisHour = messagesThisHour;
}
start(): void {
this.server = this.app.listen(this.port, () => {
this.logger.info(`Web dashboard running at http://localhost:${this.port}`);
this.broadcaster.push('info', `Web dashboard started on port ${this.port}`);
start(): Promise<number> {
return new Promise((resolve, reject) => {
const server = this.app.listen(this.port);
this.server = server;
server.once('error', reject);
server.once('listening', () => {
const address = server.address() as AddressInfo | null;
const listeningPort = address?.port ?? this.port;
this.logger.info(`Web dashboard running at http://localhost:${listeningPort}`);
this.broadcaster.push('info', `Web dashboard started on port ${listeningPort}`);
resolve(listeningPort);
});
});
}