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
+6
View File
@@ -0,0 +1,6 @@
type: changed
breaking: true
area: stats
- Reject requests from untrusted browser origins and hosts before stats data, media, or Anki operations run, and require JSON for mutation bodies.
- Load the in-app stats overlay from the local server so it uses the same origin protection as the browser dashboard.
+22
View File
@@ -31,6 +31,28 @@ Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_
The same immersion data powers the stats dashboard.
The browser dashboard and in-app stats overlay both load from the local HTTP server.
The server accepts loopback hosts only and rejects requests from other browser origins,
including opaque origins such as `file://`. API clients without a browser origin can
still use the local API. Mutation requests with a body must use `application/json`;
bodyless deletion and Anki browse requests remain supported. Requests rejected by the
host or origin checks receive `403`; mutation bodies without a JSON content type
receive `415`.
Use the loopback dashboard URL directly. Reverse-proxied dashboards and Tailscale
Serve URLs are unsupported because their host or browser origin is not the local
server's origin. SSH stats synchronization is unchanged.
Scripts sending a JSON body must include the content type. For example, this
requests a duplicate-line cleanup preview without changing the database. Replace
the port if you configured a different `stats.serverPort`:
```bash
curl http://127.0.0.1:6969/api/stats/maintenance/duplicate-lines \
-H 'Content-Type: application/json' \
-d '{"dryRun":true}'
```
- In-app overlay: focus the visible overlay, then press the key from `stats.toggleKey` (default: `` ` `` / `Backquote`).
- Launcher command: run `subminer stats` to start the local stats server on demand (it also opens the dashboard in your browser when `stats.autoOpenBrowser` is enabled; the default is `false`).
- Background server: run `subminer stats -b` to start or reuse a dedicated background stats daemon without keeping the launcher attached, and `subminer stats -s` to stop that daemon.
@@ -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);
-2
View File
@@ -4138,7 +4138,6 @@ const immersionTrackerStartupMainDeps: Parameters<
// Register stats overlay toggle IPC handler (idempotent)
registerStatsOverlayToggle({
staticDir: statsDistPath,
preloadPath: statsPreloadPath,
getApiBaseUrl: async () => (await ensureStatsServerStarted()).url,
onStartupError: (error) =>
@@ -5442,7 +5441,6 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
await dispatchSessionActionCore(request, {
toggleStatsOverlay: async () =>
await toggleStatsOverlayWindow({
staticDir: statsDistPath,
preloadPath: statsPreloadPath,
getApiBaseUrl: async () => (await ensureStatsServerStarted()).url,
onStartupError: (error) =>
+1 -2
View File
@@ -4,7 +4,6 @@ import { DeleteProgressToast } from './components/common/DeleteProgressToast';
import { TabBar } from './components/layout/TabBar';
import { OverviewTab } from './components/overview/OverviewTab';
import { useExcludedWords } from './hooks/useExcludedWords';
import { assetUrl } from './lib/asset-url';
import type { TabId } from './components/layout/TabBar';
import {
closeMediaDetail,
@@ -142,7 +141,7 @@ export function App() {
onClick={() => handleTabChange('overview')}
className="flex items-center gap-2 mb-2 hover:opacity-80 transition-opacity"
>
<img src={assetUrl('favicon.png')} alt="" className="h-6 object-contain" />
<img src={'/favicon.png'} alt="" className="h-6 object-contain" />
<h1 className="text-lg font-semibold text-ctp-text">SubMiner Stats</h1>
</button>
<TabBar activeTab={activeTab} onTabChange={handleTabChange} />
@@ -1,7 +1,6 @@
import { useRef, useState, useEffect } from 'react';
import { useWordDetail } from '../../hooks/useWordDetail';
import { apiClient } from '../../lib/api-client';
import { assetUrl } from '../../lib/asset-url';
import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters';
import {
buildStatsMineCardParams,
@@ -167,7 +166,7 @@ export function WordDetailPanel({
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
new Notification('Anki Card Created', {
body: `Mined: ${label}`,
icon: assetUrl('favicon.png'),
icon: '/favicon.png',
});
} else if (typeof Notification !== 'undefined' && Notification.permission !== 'denied') {
Notification.requestPermission().then((p) => {
+2 -35
View File
@@ -1,36 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { apiClient, BASE_URL, resolveStatsBaseUrl } from './api-client';
test('resolveStatsBaseUrl prefers apiBase query parameter for file-based overlay mode', () => {
const baseUrl = resolveStatsBaseUrl({
protocol: 'file:',
origin: 'null',
search: '?overlay=1&apiBase=http%3A%2F%2F127.0.0.1%3A6123',
});
assert.equal(baseUrl, 'http://127.0.0.1:6123');
});
test('resolveStatsBaseUrl falls back to configured window origin for browser mode', () => {
const baseUrl = resolveStatsBaseUrl({
protocol: 'http:',
origin: 'http://127.0.0.1:6123',
search: '',
});
assert.equal(baseUrl, 'http://127.0.0.1:6123');
});
test('resolveStatsBaseUrl keeps legacy localhost fallback for file mode without apiBase', () => {
const baseUrl = resolveStatsBaseUrl({
protocol: 'file:',
origin: 'null',
search: '?overlay=1',
});
assert.equal(baseUrl, 'http://127.0.0.1:6969');
});
import { apiClient, BASE_URL } from './api-client';
test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => {
const getAnimeCoverUrl = apiClient.getAnimeCoverUrl as (
@@ -38,10 +8,7 @@ test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => {
retryToken?: number,
) => string;
assert.equal(
getAnimeCoverUrl(42, 3),
'http://127.0.0.1:6969/api/stats/anime/42/cover?coverRetry=3',
);
assert.equal(getAnimeCoverUrl(42, 3), `${BASE_URL}/api/stats/anime/42/cover?coverRetry=3`);
});
test('getAnimeMergeRecommendations loads pending duplicate pairs', async () => {
+2 -18
View File
@@ -23,24 +23,8 @@ import type { StatsMineCardParams, StatsMineCardResponse } from './mining';
import { appendCoverRetryToken } from './cover-retry';
import { trackDelete } from './delete-progress';
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
export function resolveStatsBaseUrl(location?: StatsLocationLike): string {
const resolvedLocation =
location ??
(typeof window === 'undefined'
? { protocol: 'file:', origin: 'null', search: '' }
: window.location);
const queryApiBase = new URLSearchParams(resolvedLocation.search).get('apiBase')?.trim();
if (queryApiBase) {
return queryApiBase;
}
return resolvedLocation.protocol === 'file:' ? 'http://127.0.0.1:6969' : resolvedLocation.origin;
}
export const BASE_URL = resolveStatsBaseUrl();
// Both browser and in-app dashboards use the server that served the page.
export const BASE_URL = typeof window === 'undefined' ? '' : window.location.origin;
async function fetchResponse(path: string, init?: RequestInit): Promise<Response> {
const res = await fetch(`${BASE_URL}${path}`, init);
-39
View File
@@ -1,39 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { assetUrl, resolveAssetUrl } from './asset-url';
// vite.config.ts sets `base: './'`, so this is what the built bundle sees.
const BUILT_BASE = './';
test('built asset URLs are never root-absolute', () => {
assert.equal(resolveAssetUrl('favicon.png', BUILT_BASE).startsWith('/'), false);
});
test('built asset URL resolves next to a file:// index.html', () => {
const resolved = new URL(
resolveAssetUrl('favicon.png', BUILT_BASE),
'file:///opt/SubMiner/stats/dist/index.html',
);
assert.equal(resolved.href, 'file:///opt/SubMiner/stats/dist/favicon.png');
});
test('built asset URL resolves against the server root when served over http', () => {
const resolved = new URL(resolveAssetUrl('favicon.png', BUILT_BASE), 'http://127.0.0.1:8770/');
assert.equal(resolved.href, 'http://127.0.0.1:8770/favicon.png');
});
test('dev server base stays root-absolute', () => {
assert.equal(resolveAssetUrl('favicon.png', '/'), '/favicon.png');
});
test('a base without a trailing slash still joins cleanly', () => {
assert.equal(resolveAssetUrl('favicon.png', '/stats'), '/stats/favicon.png');
});
test('a leading slash in the requested path is tolerated', () => {
assert.equal(resolveAssetUrl('/favicon.png', BUILT_BASE), './favicon.png');
});
test('assetUrl falls back to a relative base outside a Vite bundle', () => {
assert.equal(assetUrl('favicon.png').startsWith('/'), false);
});
-24
View File
@@ -1,24 +0,0 @@
/**
* Resolve a bundled public asset against Vite's base URL.
*
* The in-player stats window is loaded with `loadFile`, so the document lives on
* `file://`. A root-absolute path like `/favicon.png` resolves to the filesystem
* root there and 404s, while the HTTP-served web app resolves it fine. Vite
* rewrites asset refs in `index.html` but not string literals in JSX, so build
* the URL from the configured base instead of hardcoding a leading slash.
*/
export function resolveAssetUrl(path: string, base: string): string {
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
return `${normalizedBase}${path.replace(/^\/+/, '')}`;
}
function currentBase(): string {
// Vite injects BASE_URL at build time ('./' per vite.config.ts) and serves '/'
// in dev. Outside a Vite bundle (tests) there is no env, so fall back to './'.
const env = (import.meta as { env?: Record<string, string | undefined> }).env;
return env?.BASE_URL || './';
}
export function assetUrl(path: string): string {
return resolveAssetUrl(path, currentBase());
}
@@ -169,14 +169,14 @@ test('CoverImage renders explicit remote artwork when src is provided', () => {
test('MediaCard uses the proxied cover endpoint instead of metadata artwork urls', () => {
const markup = renderToStaticMarkup(<MediaCard item={youtubeEpisodeA} onClick={() => {}} />);
assert.match(markup, /src="http:\/\/127\.0\.0\.1:6969\/api\/stats\/media\/1\/cover"/);
assert.match(markup, /src="\/api\/stats\/media\/1\/cover"/);
assert.doesNotMatch(markup, /https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg/);
});
test('resolveMediaCoverApiUrl appends retry tokens for late cover refreshes', () => {
assert.equal(
resolveMediaCoverApiUrl(youtubeEpisodeA.videoId, 2),
'http://127.0.0.1:6969/api/stats/media/1/cover?coverRetry=2',
'/api/stats/media/1/cover?coverRetry=2',
);
});