feat: secure React dashboard cutover
This commit is contained in:
1 parent
a51e0b6b31
commit
ad770a792a
9 files changed
+477
-73
No files matched your search
+14
-1
@@ -151,7 +151,11 @@ export const ConfigSchema = z.object({
|
||||
web: z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
port: z.number().default(3000),
|
||||
}).default({ enabled: true, port: 3000 }),
|
||||
/** Prefer WEB_DASHBOARD_TOKEN so secrets do not live in config.yaml. */
|
||||
authToken: z.string().min(8).optional(),
|
||||
/** Dangerous diagnostic endpoints are disabled unless explicitly enabled. */
|
||||
debugEnabled: z.boolean().default(false),
|
||||
}).default({ enabled: true, port: 3000, debugEnabled: false }),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
@@ -186,6 +190,11 @@ export function loadConfig(): Config {
|
||||
rawConfig.browser.headless = process.env.BROWSER_HEADLESS === 'true';
|
||||
}
|
||||
|
||||
if (process.env.WEB_DASHBOARD_TOKEN !== undefined) {
|
||||
if (!rawConfig.web) rawConfig.web = {};
|
||||
rawConfig.web.authToken = process.env.WEB_DASHBOARD_TOKEN;
|
||||
}
|
||||
|
||||
if (!rawConfig.openrouter.apiKey) {
|
||||
throw new Error('OPENROUTER_API_KEY environment variable is required');
|
||||
}
|
||||
@@ -199,5 +208,9 @@ export function loadConfig(): Config {
|
||||
throw new Error(`Invalid configuration:\n${errors}`);
|
||||
}
|
||||
|
||||
if (result.data.web.enabled && !result.data.web.authToken) {
|
||||
throw new Error('WEB_DASHBOARD_TOKEN environment variable or web.authToken configuration is required when the dashboard is enabled');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import { Router, type RequestHandler } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
const SessionRequestSchema = z.object({ token: z.string().min(1).max(4096) }).strict();
|
||||
const SESSION_COOKIE = 'reddit_bot_dashboard_session';
|
||||
|
||||
function tokenDigest(token: string): Buffer {
|
||||
return createHash('sha256').update(token, 'utf8').digest();
|
||||
}
|
||||
|
||||
function matchesToken(candidate: string | undefined, expected: string): boolean {
|
||||
return candidate !== undefined && timingSafeEqual(tokenDigest(candidate), tokenDigest(expected));
|
||||
}
|
||||
|
||||
function parseCookies(cookieHeader: string | undefined): Record<string, string> {
|
||||
if (!cookieHeader) return {};
|
||||
const cookies: Record<string, string> = {};
|
||||
for (const part of cookieHeader.split(';')) {
|
||||
const [name, ...value] = part.trim().split('=');
|
||||
if (!name) continue;
|
||||
try {
|
||||
cookies[name] = decodeURIComponent(value.join('='));
|
||||
} catch {
|
||||
// Ignore malformed cookies and continue checking the remaining values.
|
||||
}
|
||||
}
|
||||
return cookies;
|
||||
}
|
||||
|
||||
function requestToken(authorization: string | undefined): string | undefined {
|
||||
if (!authorization) return undefined;
|
||||
const bearer = /^Bearer\s+(.+)$/i.exec(authorization);
|
||||
return bearer?.[1];
|
||||
}
|
||||
|
||||
export function createDashboardAuth(token: string): RequestHandler {
|
||||
if (!token.trim()) throw new Error('A non-empty dashboard authentication token is required');
|
||||
return (req, res, next) => {
|
||||
if (req.path === '/auth/session' || req.path === '/auth/login') return next();
|
||||
const supplied = requestToken(req.header('authorization'))
|
||||
?? parseCookies(req.header('cookie'))[SESSION_COOKIE];
|
||||
if (matchesToken(supplied, token)) return next();
|
||||
if (!req.path.startsWith('/api') && !req.path.startsWith('/media')) {
|
||||
res.redirect(302, '/auth/login');
|
||||
return;
|
||||
}
|
||||
res.status(401).set('WWW-Authenticate', 'Bearer realm="RedditBot Dashboard"')
|
||||
.json({ error: 'Dashboard authentication required' });
|
||||
};
|
||||
}
|
||||
|
||||
export function createDashboardSessionRouter(token: string): Router {
|
||||
const router = Router();
|
||||
router.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><title>Dashboard sign in</title><form id="login"><label>Dashboard token <input name="token" type="password" required autofocus></label><button>Sign in</button></form><script>document.getElementById('login').addEventListener('submit',async(e)=>{e.preventDefault();const r=await fetch('/auth/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:new FormData(e.target).get('token')})});if(r.ok)location='/';else alert('Invalid dashboard token');});</script>`);
|
||||
});
|
||||
router.post('/auth/session', (req, res) => {
|
||||
const payload = SessionRequestSchema.safeParse(req.body);
|
||||
if (!payload.success) {
|
||||
res.status(400).json({ error: 'Invalid dashboard authentication payload' });
|
||||
return;
|
||||
}
|
||||
if (!matchesToken(payload.data.token, token)) {
|
||||
res.status(401).json({ error: 'Dashboard authentication required' });
|
||||
return;
|
||||
}
|
||||
const forwardedProtocol = req.get('x-forwarded-proto')?.split(',', 1)[0]?.trim();
|
||||
res.cookie(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: req.secure || forwardedProtocol === 'https',
|
||||
path: '/',
|
||||
});
|
||||
res.status(204).end();
|
||||
});
|
||||
router.post('/auth/logout', (_req, res) => {
|
||||
res.clearCookie(SESSION_COOKIE, { httpOnly: true, sameSite: 'strict', path: '/' });
|
||||
res.status(204).end();
|
||||
});
|
||||
return router;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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) {
|
||||
@@ -55,7 +49,6 @@ export function createDebugRouter(ctx: WebContext): Router {
|
||||
sameSite: c.sameSite,
|
||||
expires: c.expires,
|
||||
valueLen: c.value.length,
|
||||
value: reveal ? c.value : undefined,
|
||||
}));
|
||||
}
|
||||
res.json(out);
|
||||
@@ -65,59 +58,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' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const chatUrl = req.body?.url;
|
||||
if (!chatUrl) {
|
||||
res.status(400).json({ error: 'url is required' });
|
||||
return;
|
||||
}
|
||||
const result = await ctx.services.chat.debugInspectDom(chatUrl);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
ctx.logger.error('DOM inspection failed:', error);
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/debug/check-responses', async (_req, res) => {
|
||||
if (!ctx.services.responseChecker) {
|
||||
res.status(500).json({ error: 'Response checker not initialized' });
|
||||
|
||||
+45
-11
@@ -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 { createDashboardAuth, createDashboardSessionRouter } from './auth.js';
|
||||
import { resolveDashboardAssets } from './dashboard-assets.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,29 @@ export class WebServer {
|
||||
}
|
||||
|
||||
private setupRoutes(): void {
|
||||
const dashboardToken = this.config.web.authToken;
|
||||
if (!dashboardToken) {
|
||||
throw new Error('Web dashboard requires web.authToken or WEB_DASHBOARD_TOKEN');
|
||||
}
|
||||
this.app.use(express.json({ limit: '64kb' }));
|
||||
this.app.use(createDashboardSessionRouter(dashboardToken));
|
||||
this.app.use(createDashboardAuth(dashboardToken));
|
||||
|
||||
const dashboardAssets = resolveDashboardAssets(this.workspaceDirectory);
|
||||
|
||||
// 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);
|
||||
return res.sendFile(dashboardAssets.indexFile);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
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());
|
||||
// Media can contain private chat attachments, so it is intentionally behind
|
||||
// the same dashboard authentication middleware as the API and UI.
|
||||
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 +139,21 @@ 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'));
|
||||
res.sendFile(dashboardAssets.indexFile);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -200,10 +220,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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -214,4 +242,10 @@ export class WebServer {
|
||||
this.server = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Exposed for health probes and integration tests when configured with port 0. */
|
||||
getListeningPort(): number | null {
|
||||
const address = this.server?.address();
|
||||
return address && typeof address !== 'string' ? (address as AddressInfo).port : null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user