fix(stats): restrict local requests and serve the dashboard over HTTP (#263)

This commit is contained in:
2026-09-20 23:36:55 -07:00
committed by GitHub
parent 2f21582666
commit ab48a5678e
16 changed files with 221 additions and 157 deletions
@@ -444,6 +444,73 @@ async function withFakeAnkiConnect<T>(
}
describe('stats server API routes', () => {
it('rejects untrusted mutation requests before merging anime', async () => {
let merges = 0;
const app = createStatsApp(
createMockTracker({
mergeAnime: async () => {
merges += 1;
return { survivingAnimeId: 1, mergedAnimeIds: [2], movedVideos: 1 };
},
}),
);
const rejectedHeaders: Record<string, string>[] = [
{ Origin: 'https://attacker.example', 'Content-Type': 'text/plain' },
{ Origin: 'https://attacker.example', 'Content-Type': 'application/json' },
{ Origin: 'null', 'Content-Type': 'application/json' },
{ Origin: 'http://localhost:4321', 'Content-Type': 'application/json' },
{ Origin: 'http://localhost/', 'Content-Type': 'application/json' },
{ 'Sec-Fetch-Site': 'cross-site', 'Content-Type': 'application/json' },
{ Host: 'attacker.example', 'Content-Type': 'application/json' },
];
for (const headers of rejectedHeaders) {
const response = await app.request('/api/stats/anime/1/merge', {
method: 'POST',
headers,
body: JSON.stringify({ sourceAnimeIds: [2] }),
});
assert.equal(response.status, 403, JSON.stringify(headers));
}
assert.equal(merges, 0);
for (const origin of [undefined, 'http://localhost']) {
const headers = new Headers({ 'Content-Type': 'application/json; charset=utf-8' });
if (origin) headers.set('Origin', origin);
const response = await app.request('/api/stats/anime/1/merge', {
method: 'POST',
headers,
body: JSON.stringify({ sourceAnimeIds: [2] }),
});
assert.equal(response.status, 200);
}
assert.equal(merges, 2);
});
it('requires JSON for mutation bodies and preserves bodyless deletion', async () => {
let deletions = 0;
const app = createStatsApp(
createMockTracker({
deleteSession: async () => {
deletions += 1;
},
}),
);
const invalid = await app.request('/api/stats/sessions/1', {
method: 'DELETE',
body: '{}',
});
assert.equal(invalid.status, 415);
assert.equal(deletions, 0);
const valid = await app.request('/api/stats/sessions/1', { method: 'DELETE' });
assert.equal(valid.status, 200);
assert.equal(deletions, 1);
const rebound = await app.request('http://attacker.example/api/stats/sessions/1', {
method: 'DELETE',
headers: { Origin: 'http://attacker.example' },
});
assert.equal(rebound.status, 403);
assert.equal(deletions, 1);
});
it('GET /api/stats/overview returns overview data', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/overview');
@@ -1153,7 +1220,7 @@ describe('stats server API routes', () => {
body: JSON.stringify({ dryRun: false, lookbackDays: null }),
});
assert.equal(res.status, 415);
assert.equal(res.status, 403);
assert.equal(cleanupCalls, 0);
});
@@ -4128,4 +4195,60 @@ Aligned English subtitle
});
}
});
it('enforces request safety through node:http without rejecting bodyless DELETEs', async () => {
await withTempDir(async (staticDir) => {
let deletions = 0;
const tracker = createMockTracker({
deleteSession: async () => {
deletions += 1;
},
});
const listener = http.createServer();
const server = await startNodeHttpServer(
createStatsApp(tracker),
{ port: 0, staticDir, tracker },
(handler) => {
listener.on('request', handler);
return listener;
},
);
try {
const address = listener.address();
assert.ok(address && typeof address !== 'string');
const origin = `http://127.0.0.1:${address.port}`;
const url = `${origin}/api/stats/sessions/1`;
for (const headers of [undefined, { 'Content-Length': '0' }]) {
const response = await fetch(url, { method: 'DELETE', headers });
assert.equal(response.status, 200);
await response.arrayBuffer();
}
assert.equal(deletions, 2);
for (const headers of [
new Headers({ Origin: 'https://attacker.example' }),
new Headers({ Origin: 'null' }),
new Headers({ Host: 'attacker.example' }),
new Headers({ 'Sec-Fetch-Site': 'same-site' }),
]) {
const response = await fetch(url, { method: 'DELETE', headers });
assert.equal(response.status, 403, JSON.stringify(headers));
await response.arrayBuffer();
}
const invalid = await fetch(url, { method: 'DELETE', body: '{}' });
assert.equal(invalid.status, 415);
await invalid.arrayBuffer();
assert.equal(deletions, 2);
const valid = await fetch(url, {
method: 'DELETE',
headers: { Origin: origin, 'Content-Type': 'application/json' },
body: '{}',
});
assert.equal(valid.status, 200);
await valid.arrayBuffer();
assert.equal(deletions, 3);
} finally {
await server.close();
}
});
});
});
+6 -1
View File
@@ -6,6 +6,7 @@ import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
import type { RetimedSecondarySubtitleInput } from './secondary-subtitle-sidecar.js';
import type { StatsServerMediaGenerator } from './stats-server/mining-support.js';
import { enforceStatsRequestSafety } from './stats-server/request-safety.js';
import {
registerStatsAnalyticsRoutes,
registerStatsIntegrationRoutes,
@@ -37,7 +38,10 @@ function toFetchRequest(req: IncomingMessage): Request {
method,
headers: toFetchHeaders(req.headers),
};
if (method !== 'GET' && method !== 'HEAD') {
const hasBody =
req.headers['transfer-encoding'] !== undefined ||
Number(req.headers['content-length'] ?? 0) > 0;
if (method !== 'GET' && method !== 'HEAD' && hasBody) {
init.body = Readable.toWeb(req) as BodyInit;
init.duplex = 'half';
}
@@ -156,6 +160,7 @@ export function createStatsApp(
},
) {
const app = new Hono();
app.use('*', enforceStatsRequestSafety);
registerStatsAnalyticsRoutes(app, tracker, options);
registerStatsLibraryRoutes(app, tracker, options);
registerStatsIntegrationRoutes(app, tracker, options);
@@ -0,0 +1,42 @@
import type { MiddlewareHandler } from 'hono';
function isLoopbackUrl(url: URL): boolean {
return (
url.protocol === 'http:' &&
!url.username &&
!url.password &&
['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)
);
}
/** Protect the local API even when a browser can reach the loopback listener. */
export const enforceStatsRequestSafety: MiddlewareHandler = async (c, next) => {
const url = new URL(c.req.url);
if (!isLoopbackUrl(url)) return c.body(null, 403);
const host = c.req.header('host');
if (host !== undefined) {
if (!/^(localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(host)) {
return c.body(null, 403);
}
// Node derives the request URL from Host; Bun provides them independently.
try {
if (new URL(`http://${host}`).origin !== url.origin) return c.body(null, 403);
} catch {
return c.body(null, 403);
}
}
// Compare the serialized origin exactly. Opaque origins and malformed values
// containing credentials, paths, or multiple origins must not gain trust.
const origin = c.req.header('origin');
if (origin !== undefined && origin !== url.origin) return c.body(null, 403);
const site = c.req.header('sec-fetch-site');
if (site === 'cross-site' || site === 'same-site') return c.body(null, 403);
if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method) && c.req.raw.body !== null) {
const contentType = c.req.header('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
if (contentType !== 'application/json') return c.body(null, 415);
}
await next();
};
+4 -9
View File
@@ -219,13 +219,8 @@ export function scheduleStatsWindowPostShowReconciles(
}
}
export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): {
query: Record<string, string>;
} {
return {
query: {
overlay: '1',
...(apiBaseUrl ? { apiBase: apiBaseUrl } : {}),
},
};
export function buildStatsWindowUrl(apiBaseUrl: string): string {
const url = new URL('/', apiBaseUrl);
url.searchParams.set('overlay', '1');
return url.toString();
}
+5 -14
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildStatsWindowLoadFileOptions,
buildStatsWindowUrl,
buildStatsWindowOptions,
buildStatsNativeConfirmDialogOptions,
demoteVisibleStatsWindowBelowDialogs,
@@ -168,21 +168,12 @@ test('shouldHideStatsWindowForInput matches Escape and configured bare toggle ke
);
});
test('buildStatsWindowLoadFileOptions enables overlay rendering mode', () => {
assert.deepEqual(buildStatsWindowLoadFileOptions(), {
query: {
overlay: '1',
},
});
test('buildStatsWindowUrl enables overlay rendering on the local HTTP origin', () => {
assert.equal(buildStatsWindowUrl('http://127.0.0.1:6969'), 'http://127.0.0.1:6969/?overlay=1');
});
test('buildStatsWindowLoadFileOptions includes provided stats API base URL', () => {
assert.deepEqual(buildStatsWindowLoadFileOptions('http://127.0.0.1:6123'), {
query: {
overlay: '1',
apiBase: 'http://127.0.0.1:6123',
},
});
test('buildStatsWindowUrl uses the active server port as the document origin', () => {
assert.equal(buildStatsWindowUrl('http://127.0.0.1:6123'), 'http://127.0.0.1:6123/?overlay=1');
});
test('resolveStatsWindowOuterBoundsForContent compensates for Wayland content insets', () => {
+4 -8
View File
@@ -1,10 +1,9 @@
import { BrowserWindow, dialog, ipcMain } from 'electron';
import * as path from 'path';
import { createLogger } from '../../logger.js';
import type { WindowGeometry } from '../../types.js';
import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
import {
buildStatsWindowLoadFileOptions,
buildStatsWindowUrl,
buildStatsWindowOptions,
demoteVisibleStatsWindowBelowDialogs,
presentStatsWindow,
@@ -34,12 +33,10 @@ const nativeDialogLayerSuspension = createStatsWindowLayerSuspensionState();
const logger = createLogger('main:stats-window');
export interface StatsWindowOptions {
/** Absolute path to stats/dist/ directory */
staticDir: string;
/** Absolute path to the compiled preload-stats.js */
preloadPath: string;
/** Resolve the active stats API base URL */
getApiBaseUrl?: () => Promise<string> | string;
getApiBaseUrl: () => Promise<string> | string;
/** Report server startup failure through the configured notification surface. */
onStartupError?: (error: unknown) => void;
/** Resolve the active stats toggle key from config */
@@ -188,7 +185,7 @@ export async function toggleStatsOverlay(options: StatsWindowOptions): Promise<v
if (!statsWindow) {
const generation = statsWindowGeneration;
const apiBaseUrl = await Promise.resolve()
.then(() => options.getApiBaseUrl?.())
.then(() => options.getApiBaseUrl())
.catch((error: unknown) => {
options.onStartupError?.(error);
throw error;
@@ -207,8 +204,7 @@ export async function toggleStatsOverlay(options: StatsWindowOptions): Promise<v
statsWindow?.setTitle(STATS_WINDOW_TITLE);
});
const indexPath = path.join(options.staticDir, 'index.html');
statsWindow.loadFile(indexPath, buildStatsWindowLoadFileOptions(apiBaseUrl));
statsWindow.loadURL(buildStatsWindowUrl(apiBaseUrl));
statsWindow.on('closed', () => {
options.onVisibilityChanged?.(false);