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
+32
-1
@@ -294,7 +294,7 @@ describe('ConfigSchema', () => {
|
||||
describe('loadConfig', () => {
|
||||
const originalCwd = process.cwd;
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
const trackedEnvKeys = ['OPENROUTER_API_KEY', 'REDDIT_USERNAME', 'REDDIT_PASSWORD', 'BROWSER_HEADLESS'];
|
||||
const trackedEnvKeys = ['OPENROUTER_API_KEY', 'REDDIT_USERNAME', 'REDDIT_PASSWORD', 'BROWSER_HEADLESS', 'WEB_DASHBOARD_TOKEN'];
|
||||
|
||||
beforeEach(() => {
|
||||
// Snapshot then clear any env vars that loadConfig consults — these may be
|
||||
@@ -303,6 +303,7 @@ describe('loadConfig', () => {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
process.env.WEB_DASHBOARD_TOKEN = 'test-dashboard-token';
|
||||
|
||||
const mockDir = '/tmp/test-config-' + Date.now();
|
||||
fs.mkdirSync(mockDir, { recursive: true });
|
||||
@@ -411,4 +412,34 @@ openrouter:
|
||||
|
||||
expect(() => loadConfig()).toThrow('OPENROUTER_API_KEY environment variable is required');
|
||||
});
|
||||
|
||||
it('requires a dashboard token when the dashboard is enabled', () => {
|
||||
delete process.env.WEB_DASHBOARD_TOKEN;
|
||||
const configPath = path.join(process.cwd(), 'config.yaml');
|
||||
fs.writeFileSync(configPath, `
|
||||
reddit:
|
||||
subreddits: [test]
|
||||
openrouter:
|
||||
apiKey: key
|
||||
imageAnalysisPrompt: analyze this
|
||||
messageGenerationPrompt: write message
|
||||
`);
|
||||
|
||||
expect(() => loadConfig()).toThrow('WEB_DASHBOARD_TOKEN');
|
||||
});
|
||||
|
||||
it('injects the dashboard token from the environment', () => {
|
||||
process.env.WEB_DASHBOARD_TOKEN = 'a-secure-dashboard-token';
|
||||
const configPath = path.join(process.cwd(), 'config.yaml');
|
||||
fs.writeFileSync(configPath, `
|
||||
reddit:
|
||||
subreddits: [test]
|
||||
openrouter:
|
||||
apiKey: key
|
||||
imageAnalysisPrompt: analyze this
|
||||
messageGenerationPrompt: write message
|
||||
`);
|
||||
|
||||
expect(loadConfig().web.authToken).toBe('a-secure-dashboard-token');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ConfigSchema } from '../src/config/index.js';
|
||||
|
||||
describe('dashboard token configuration', () => {
|
||||
it('accepts an eight-character local dashboard token', () => {
|
||||
const result = ConfigSchema.safeParse({
|
||||
reddit: { subreddits: ['test'] },
|
||||
openrouter: {
|
||||
apiKey: 'test-key',
|
||||
imageAnalysisPrompt: 'test',
|
||||
messageGenerationPrompt: 'test',
|
||||
},
|
||||
web: { enabled: true, authToken: 'create01' },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('still rejects trivially short dashboard tokens', () => {
|
||||
const result = ConfigSchema.safeParse({
|
||||
reddit: { subreddits: ['test'] },
|
||||
openrouter: {
|
||||
apiKey: 'test-key',
|
||||
imageAnalysisPrompt: 'test',
|
||||
messageGenerationPrompt: 'test',
|
||||
},
|
||||
web: { enabled: true, authToken: 'short' },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import express from 'express';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { createDashboardAuth } from '../src/web/auth.js';
|
||||
|
||||
const servers: Array<ReturnType<express.Express['listen']>> = [];
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
servers.splice(0).map(
|
||||
(server) => new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
),
|
||||
);
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('private media authentication', () => {
|
||||
it('rejects anonymous media requests and permits an authenticated session', async () => {
|
||||
const mediaDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'redditbot-media-auth-'));
|
||||
temporaryDirectories.push(mediaDirectory);
|
||||
fs.writeFileSync(path.join(mediaDirectory, 'private.txt'), 'private-media');
|
||||
|
||||
const app = express();
|
||||
app.use(createDashboardAuth('create01'));
|
||||
app.use('/media', express.static(mediaDirectory));
|
||||
|
||||
const server = app.listen(0);
|
||||
servers.push(server);
|
||||
await new Promise<void>((resolve) => server.once('listening', resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
const url = `http://127.0.0.1:${port}/media/private.txt`;
|
||||
|
||||
const anonymousResponse = await fetch(url);
|
||||
const authenticatedResponse = await fetch(url, {
|
||||
headers: { Authorization: 'Bearer create01' },
|
||||
});
|
||||
|
||||
expect(anonymousResponse.status).toBe(401);
|
||||
expect(authenticatedResponse.status).toBe(200);
|
||||
expect(await authenticatedResponse.text()).toBe('private-media');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { BrowserClient } from '../src/browser/client.js';
|
||||
import { ActivityLogRepository } from '../src/database/repositories.js';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { createMockLogger } from './mockLogger.js';
|
||||
import { createTestDb, createTestRepositories, getTestConfig } from './testUtils.js';
|
||||
|
||||
const AUTH_TOKEN = 'create01';
|
||||
|
||||
interface RunningServer {
|
||||
baseUrl: string;
|
||||
server: WebServer;
|
||||
workspace: string;
|
||||
}
|
||||
|
||||
function createWorkspace(includeReactBuild = true): string {
|
||||
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'redditbot-web-'));
|
||||
fs.mkdirSync(path.join(workspace, 'public'), { recursive: true });
|
||||
fs.writeFileSync(path.join(workspace, 'public', 'index.html'), '<h1>Legacy dashboard</h1>');
|
||||
fs.mkdirSync(path.join(workspace, 'data', 'media'), { recursive: true });
|
||||
fs.writeFileSync(path.join(workspace, 'data', 'media', 'sample.txt'), 'private media');
|
||||
if (includeReactBuild) {
|
||||
fs.mkdirSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'assets'), { recursive: true });
|
||||
fs.writeFileSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'index.html'), '<h1>React dashboard</h1><script src="/assets/app.js"></script>');
|
||||
fs.writeFileSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'assets', 'app.js'), 'react-build');
|
||||
}
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async function startServer(options: { debugEnabled?: boolean; includeReactBuild?: boolean } = {}): Promise<RunningServer> {
|
||||
const database = createTestDb();
|
||||
const repositories = createTestRepositories(database);
|
||||
const workspace = createWorkspace(options.includeReactBuild);
|
||||
const server = new WebServer(
|
||||
0,
|
||||
getTestConfig({ web: { enabled: true, port: 0, authToken: AUTH_TOKEN, debugEnabled: options.debugEnabled ?? false } }),
|
||||
repositories.postsRepo,
|
||||
repositories.messagesRepo,
|
||||
repositories.successfulMessagesRepo,
|
||||
new ActivityLogRepository(database),
|
||||
createMockLogger(),
|
||||
undefined,
|
||||
workspace,
|
||||
);
|
||||
const port = await server.start();
|
||||
return { baseUrl: `http://127.0.0.1:${port}`, server, workspace };
|
||||
}
|
||||
|
||||
describe('authenticated React dashboard cutover', () => {
|
||||
const runningServers: RunningServer[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const running of runningServers) {
|
||||
running.server.stop();
|
||||
fs.rmSync(running.workspace, { recursive: true, force: true });
|
||||
}
|
||||
runningServers.length = 0;
|
||||
});
|
||||
|
||||
it('protects API, SSE, media, React assets, and SPA navigation', async () => {
|
||||
const running = await startServer();
|
||||
runningServers.push(running);
|
||||
const anonymous = await Promise.all([
|
||||
fetch(`${running.baseUrl}/api/stats`),
|
||||
fetch(`${running.baseUrl}/api/events/stream`),
|
||||
fetch(`${running.baseUrl}/media/sample.txt`),
|
||||
fetch(running.baseUrl, { redirect: 'manual' }),
|
||||
]);
|
||||
expect(anonymous.map((response) => response.status)).toEqual([401, 401, 401, 302]);
|
||||
|
||||
const headers = { Authorization: `Bearer ${AUTH_TOKEN}` };
|
||||
const api = await fetch(`${running.baseUrl}/api/stats`, { headers });
|
||||
const media = await fetch(`${running.baseUrl}/media/sample.txt`, { headers });
|
||||
const root = await fetch(running.baseUrl, { headers });
|
||||
const asset = await fetch(`${running.baseUrl}/assets/app.js`, { headers });
|
||||
const spa = await fetch(`${running.baseUrl}/conversations/example`, { headers: { ...headers, Accept: 'text/html' } });
|
||||
|
||||
expect(api.status).toBe(200);
|
||||
expect(await media.text()).toBe('private media');
|
||||
expect(await root.text()).toContain('React dashboard');
|
||||
expect(await asset.text()).toBe('react-build');
|
||||
expect(await spa.text()).toContain('React dashboard');
|
||||
});
|
||||
|
||||
it('creates a secure session and keeps debug credentials unavailable', async () => {
|
||||
const running = await startServer({ debugEnabled: true });
|
||||
runningServers.push(running);
|
||||
const accepted = await fetch(`${running.baseUrl}/auth/session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Forwarded-Proto': 'https' },
|
||||
body: JSON.stringify({ token: AUTH_TOKEN }),
|
||||
});
|
||||
const setCookie = accepted.headers.get('set-cookie');
|
||||
expect(accepted.status).toBe(204);
|
||||
expect(setCookie).toContain('HttpOnly');
|
||||
expect(setCookie).toContain('SameSite=Strict');
|
||||
expect(setCookie).toContain('Secure');
|
||||
|
||||
const browserContext = { cookies: async () => [{ name: 'reddit_session', value: 'never-return-this-secret', domain: '.reddit.com', path: '/', expires: -1, httpOnly: true, secure: true, sameSite: 'Lax' as const }] } as unknown as BrowserContext;
|
||||
running.server.setBrowserClient({ getContext: () => browserContext } as unknown as BrowserClient);
|
||||
const headers = { Authorization: `Bearer ${AUTH_TOKEN}` };
|
||||
const cookies = await fetch(`${running.baseUrl}/api/debug/cookies?reveal=1`, { headers });
|
||||
const upload = await fetch(`${running.baseUrl}/api/debug/matrix-credentials`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ homeserver: 'https://matrix.example', accessToken: 'secret' }) });
|
||||
|
||||
expect(await cookies.text()).not.toContain('never-return-this-secret');
|
||||
expect(upload.status).toBe(404);
|
||||
});
|
||||
|
||||
it('uses the legacy dashboard only when a React build is absent', async () => {
|
||||
const running = await startServer({ includeReactBuild: false });
|
||||
runningServers.push(running);
|
||||
const response = await fetch(running.baseUrl, { headers: { Authorization: `Bearer ${AUTH_TOKEN}` } });
|
||||
expect(await response.text()).toContain('Legacy dashboard');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import express from 'express';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { createDashboardAuth, createDashboardSessionRouter } from '../src/web/auth.js';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { ConfigSchema } from '../src/config/index.js';
|
||||
|
||||
const servers: Array<ReturnType<express.Express['listen']>> = [];
|
||||
|
||||
async function request(
|
||||
app: express.Express,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
const server = app.listen(0);
|
||||
servers.push(server);
|
||||
await new Promise<void>((resolve) => server.once('listening', resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
return fetch(`http://127.0.0.1:${port}${path}`, init);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
||||
});
|
||||
|
||||
describe('dashboard authentication', () => {
|
||||
it('rejects protected dashboard and API requests without a token', async () => {
|
||||
const app = express();
|
||||
app.use(createDashboardAuth('dashboard-secret'));
|
||||
app.get('/api/stats', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const response = await request(app, '/api/stats');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toEqual({ error: 'Dashboard authentication required' });
|
||||
});
|
||||
|
||||
it('accepts a bearer token without exposing it in the URL', async () => {
|
||||
const app = express();
|
||||
app.use(createDashboardAuth('dashboard-secret'));
|
||||
app.get('/api/stats', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const response = await request(app, '/api/stats', {
|
||||
headers: { Authorization: 'Bearer dashboard-secret' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('issues an HttpOnly session only for a schema-valid token payload', async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createDashboardSessionRouter('dashboard-secret'));
|
||||
|
||||
const invalid = await request(app, '/auth/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: 123 }),
|
||||
});
|
||||
const valid = await request(app, '/auth/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: 'dashboard-secret' }),
|
||||
});
|
||||
|
||||
expect(invalid.status).toBe(400);
|
||||
expect(valid.status).toBe(204);
|
||||
expect(valid.headers.get('set-cookie')).toMatch(/HttpOnly/);
|
||||
expect(valid.headers.get('set-cookie')).toMatch(/SameSite=Strict/);
|
||||
});
|
||||
|
||||
it('does not mount debug endpoints unless explicitly enabled', async () => {
|
||||
const config = ConfigSchema.parse({
|
||||
reddit: { subreddits: ['test'] },
|
||||
openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' },
|
||||
web: { enabled: true, port: 0, authToken: 'dashboard-secret-token' },
|
||||
});
|
||||
const activityRepo = { getRecent: () => [], insert: () => undefined, prune: () => undefined, countSentSkipped: () => 0 };
|
||||
const server = new WebServer(
|
||||
0,
|
||||
config,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
activityRepo as never,
|
||||
{ info: () => undefined } as never,
|
||||
);
|
||||
server.start();
|
||||
const port = server.getListeningPort();
|
||||
expect(port).not.toBeNull();
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/debug/cookies`, {
|
||||
headers: { Authorization: 'Bearer dashboard-secret-token' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ error: 'API endpoint not found' });
|
||||
server.stop();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user