mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
fix(stats): restrict local requests and serve the dashboard over HTTP (#263)
This commit is contained in:
@@ -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,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();
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user