From ebfd98226b43e43d40938d64d83b8d36cd7eae8b Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Wed, 12 Aug 2026 00:59:07 +0200 Subject: [PATCH] feat: harden remote diagnostics authentication Bind the diagnostics flow to bearer-only authentication and reject query token attempts with controlled responses. Compare bearer token bytes with timing-safe equality, remove wildcard CORS, and mark diagnostics/support responses as no-store. Move trace configuration mutation behind POST, add method failure handling, add a small per-IP/loopback in-memory request limit, and keep generated setup and support-manifest URLs token-free while pointing support access at a local bridge/tunnel flow. Sanitize backup remote diagnostics on export and restore so legacy token, endpoint, host mode, and port values are not persisted; restores only keep the allowlist and force local binding. Tests cover bearer accept/reject, query rejection, GET mutation rejection, no-store/CORS behavior, loopback default binding, rate limiting, token-free hints, and backup sanitation. --- .../task-3-report.md | 34 ++ src/main/backup-payload.ts | 70 ++-- src/main/debug-server.ts | 312 +++++++++++------- src/main/debug-setup.ts | 40 +-- tests/backup-remote-diagnostics.test.ts | 164 +++++---- tests/debug-server-allowlist.test.ts | 111 ++++--- tests/debug-server.test.ts | 235 ++++++++----- 7 files changed, 601 insertions(+), 365 deletions(-) create mode 100644 .superpowers/sdd/2026-08-11-hardening-performance-plan/task-3-report.md diff --git a/.superpowers/sdd/2026-08-11-hardening-performance-plan/task-3-report.md b/.superpowers/sdd/2026-08-11-hardening-performance-plan/task-3-report.md new file mode 100644 index 0000000..bae7c2c --- /dev/null +++ b/.superpowers/sdd/2026-08-11-hardening-performance-plan/task-3-report.md @@ -0,0 +1,34 @@ +# Task 3 Report: Ferndiagnose-Authentifizierung + +Status: umgesetzt. + +Umgesetzt: + +- Ferndiagnose-Endpunkte akzeptieren nur noch `Authorization: Bearer`. +- Query-Token werden kontrolliert mit `query_token_rejected` abgewiesen. +- Tokenvergleich nutzt `crypto.timingSafeEqual` nur bei gleicher Byte-Länge. +- Diagnoseantworten setzen `Cache-Control: no-store` und keine CORS-Wildcard. +- Nicht erlaubte Methoden liefern kontrolliert `method_not_allowed`. +- `/trace/config` liest per GET, Änderungen laufen nur noch per POST. +- Pro IP beziehungsweise Loopback gibt es ein kleines In-Memory-Limit mit `rate_limited`. +- Support-Manifest und Setup-Hinweise enthalten keine Token-URLs mehr und verweisen auf lokale Bridge/Tunnel-Nutzung. +- Backup-Remote-Diagnostics werden beim Export und Restore auf Allowlist plus lokale Bindung saniert; Ports, Host-Modus, Tokens und Endpoint-Felder werden nicht übernommen. + +TDD-Nachweis: + +- Baseline vor Teständerung: `npm run test:client -- tests/debug-server.test.ts tests/debug-server-allowlist.test.ts tests/backup-remote-diagnostics.test.ts` mit 33/33 grün. +- RED nach Teständerung: gleicher fokussierter Lauf mit erwarteten Fehlschlägen für no-store, Query-Rejection, GET-Mutation, Methodengate, Rate-Limit und Backup-Sanitizing. +- GREEN nach Implementierung: gleicher fokussierter Lauf mit 38/38 grün. + +Verifikation: + +- `npm run test:client -- tests/debug-server.test.ts tests/debug-server-allowlist.test.ts tests/backup-remote-diagnostics.test.ts` +- `npx tsc --noEmit` +- `npm run build` +- `git diff --check -- src/main/debug-server.ts src/main/debug-setup.ts src/main/backup-payload.ts tests/debug-server.test.ts tests/debug-server-allowlist.test.ts tests/backup-remote-diagnostics.test.ts` +- Feste Suchstrings in den geänderten Main-Dateien ohne Treffer: `?token=`, `Access-Control-Allow-Origin`, `remoteBaseUrlTemplate`, `searchParams.get("token")`, `Bearer ${authToken}`, `Cache-Control": "no-cache`. + +Hinweise: + +- Kein Release, Push oder Deployment ausgeführt. +- Der fokussierte Vitest-Lauf meldet weiterhin die bestehende Vite-CJS-Deprecation-Warnung. diff --git a/src/main/backup-payload.ts b/src/main/backup-payload.ts index 8e22fbf..9abc2b4 100644 --- a/src/main/backup-payload.ts +++ b/src/main/backup-payload.ts @@ -3,10 +3,10 @@ import type { AppSettings, SessionState, HistoryEntry } from "../shared/types"; export type BackupKind = "full" | "settings-only"; export interface BackupRemoteDiagnostics { - allowlist: string[]; - port: number; - hostMode: "local" | "network"; -} + allowlist?: string[]; + port?: number; + hostMode?: "local" | "network"; +} export interface BackupPayload { version: 2; @@ -35,7 +35,7 @@ export interface BuildBackupInput { * bundled solely when settings.backupIncludeDownloads is true. An explicit kind * marker makes the import side unambiguous and survives hand-edited files. */ -export function buildBackupPayload(input: BuildBackupInput): BackupPayload { +export function buildBackupPayload(input: BuildBackupInput): BackupPayload { const includeDownloads = Boolean(input.settings.backupIncludeDownloads); const base: BackupPayload = { version: 2, @@ -49,37 +49,39 @@ export function buildBackupPayload(input: BuildBackupInput): BackupPayload { base.history = input.history; } if (Boolean(input.settings.backupIncludeRemoteDiagnostics) && input.remoteDiagnostics) { - base.remoteDiagnostics = input.remoteDiagnostics; - } - return base; -} - + base.remoteDiagnostics = sanitizeBackupRemoteDiagnostics(input.remoteDiagnostics); + } + return base; +} + export interface RemoteDiagnosticsRestore { - host?: "127.0.0.1" | "0.0.0.0"; - port?: number; - allowlist?: string[]; -} - + host?: "127.0.0.1"; + port?: number; + allowlist?: string[]; +} + +function sanitizeAllowlist(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim()) + : []; +} + +function sanitizeBackupRemoteDiagnostics(section: BackupRemoteDiagnostics): BackupRemoteDiagnostics { + return { + allowlist: sanitizeAllowlist(section.allowlist) + }; +} + export function resolveRemoteDiagnosticsRestore(section: unknown): RemoteDiagnosticsRestore | null { - if (!section || typeof section !== "object") { - return null; - } - const s = section as { allowlist?: unknown; port?: unknown; hostMode?: unknown }; - const allowlist = Array.isArray(s.allowlist) - ? s.allowlist.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim()) - : undefined; - const port = (typeof s.port === "number" && Number.isInteger(s.port) && s.port >= 1024 && s.port <= 65535) ? s.port : undefined; - let host: "127.0.0.1" | "0.0.0.0" | undefined; - if (s.hostMode === "network") { - host = allowlist && allowlist.length > 0 ? "0.0.0.0" : "127.0.0.1"; - } else if (s.hostMode === "local") { - host = "127.0.0.1"; - } - if (host === undefined && port === undefined && allowlist === undefined) { - return null; - } - return { host, port, allowlist }; -} + if (!section || typeof section !== "object") { + return null; + } + const s = section as { allowlist?: unknown }; + return { + host: "127.0.0.1", + allowlist: sanitizeAllowlist(s.allowlist) + }; +} export interface ImportPlan { valid: boolean; diff --git a/src/main/debug-server.ts b/src/main/debug-server.ts index 4140600..6806389 100644 --- a/src/main/debug-server.ts +++ b/src/main/debug-server.ts @@ -21,16 +21,18 @@ import { getWindowsHostDiagnostics } from "./windows-host-diagnostics"; import type { DownloadManager } from "./download-manager"; import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types"; -const DEFAULT_PORT = 9868; -const DEFAULT_HOST = "127.0.0.1"; -const MAX_LOG_LINES = 10000; +const DEFAULT_PORT = 9868; +const DEFAULT_HOST = "127.0.0.1"; +const MAX_LOG_LINES = 10000; const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json"; - -type DebugEndpointDescriptor = { - method: "GET"; - path: string; - queryExample?: string; - description: string; +const RATE_LIMIT_WINDOW_MS = 60_000; +const RATE_LIMIT_MAX_REQUESTS = 120; + +type DebugEndpointDescriptor = { + method: "GET" | "POST"; + path: string; + queryExample?: string; + description: string; }; const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [ @@ -48,8 +50,9 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [ { method: "GET", path: "/logs/conversion", queryExample: "lines=100&grep=keyword", description: "Reads the per-item link conversion/unrestrict lifecycle log (token, API getLink, web, account rotation, aborts with timings)." }, { method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." }, { method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." }, - { method: "GET", path: "/errors", queryExample: "level=ERROR&limit=100", description: "Returns the in-memory ring of the most recent WARN/ERROR log lines." }, - { method: "GET", path: "/trace/config", queryExample: "enable=1¬e=support&durationMinutes=120", description: "Reads or updates the support trace configuration." }, + { method: "GET", path: "/errors", queryExample: "level=ERROR&limit=100", description: "Returns the in-memory ring of the most recent WARN/ERROR log lines." }, + { method: "GET", path: "/trace/config", description: "Reads the support trace configuration." }, + { method: "POST", path: "/trace/config", queryExample: "enable=1¬e=support&durationMinutes=120", description: "Updates the support trace configuration." }, { method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." }, { method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." }, { method: "GET", path: "/providers", description: "Live provider runtime state: per-account/key cooldowns (until/remaining/reason/category), in-flight depth, Mega rotation cursor, empty-response streaks. The 'why is it cooling down right now' view." }, @@ -67,9 +70,10 @@ let server: http.Server | null = null; let manager: DownloadManager | null = null; let authToken = ""; let bindHost = DEFAULT_HOST; -let bindPort = DEFAULT_PORT; -let runtimeBaseDir = ""; -let allowlist: string[] = []; +let bindPort = DEFAULT_PORT; +let runtimeBaseDir = ""; +let allowlist: string[] = []; +let requestLimits = new Map(); export interface DebugServerRuntimeStatus { running: boolean; @@ -241,27 +245,40 @@ function isClientAllowed(clientIp: string): boolean { return evaluateClientAllowed(clientIp, allowlist); } -function checkAuth(req: http.IncomingMessage): boolean { - if (!authToken) { - return false; - } - const header = req.headers.authorization || ""; - if (header === `Bearer ${authToken}`) { - return true; - } - const url = new URL(req.url || "/", "http://localhost"); - return url.searchParams.get("token") === authToken; -} - -function jsonResponse(res: http.ServerResponse, status: number, data: unknown): void { - const body = JSON.stringify(data, null, 2); - res.writeHead(status, { - "Content-Type": "application/json; charset=utf-8", - "Access-Control-Allow-Origin": "*", - "Cache-Control": "no-cache" - }); - res.end(body); -} +function checkAuth(req: http.IncomingMessage): boolean { + if (!authToken) { + return false; + } + const header = typeof req.headers.authorization === "string" ? req.headers.authorization : ""; + const match = /^Bearer\s+(.+)$/i.exec(header); + if (!match) { + return false; + } + const supplied = Buffer.from(match[1] || "", "utf8"); + const expected = Buffer.from(authToken, "utf8"); + if (supplied.length !== expected.length) { + return false; + } + return crypto.timingSafeEqual(supplied, expected); +} + +function noStoreHeaders(extra: Record = {}): Record { + return { + "Cache-Control": "no-store", + "Pragma": "no-cache", + "Expires": "0", + ...extra + }; +} + +function jsonResponse(res: http.ServerResponse, status: number, data: unknown, headers: Record = {}): void { + const body = JSON.stringify(data, null, 2); + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + ...noStoreHeaders(headers) + }); + res.end(body); +} function binaryResponse( res: http.ServerResponse, @@ -270,15 +287,37 @@ function binaryResponse( contentType: string, fileName?: string ): void { - res.writeHead(status, { - "Content-Type": contentType, - "Content-Length": String(body.length), - "Access-Control-Allow-Origin": "*", - "Cache-Control": "no-cache", - ...(fileName ? { "Content-Disposition": `attachment; filename="${fileName}"` } : {}) - }); - res.end(body); -} + res.writeHead(status, { + "Content-Type": contentType, + "Content-Length": String(body.length), + ...noStoreHeaders(), + ...(fileName ? { "Content-Disposition": `attachment; filename="${fileName}"` } : {}) + }); + res.end(body); +} + +function rateLimitKey(clientIp: string): string { + const normalized = normalizeIp(clientIp); + return isLoopbackIp(normalized) || normalized === "" ? "loopback" : normalized; +} + +function checkRateLimit(clientIp: string): { allowed: boolean; retryAfterSeconds: number } { + const now = Date.now(); + const key = rateLimitKey(clientIp); + const current = requestLimits.get(key); + if (!current || now - current.startedAt >= RATE_LIMIT_WINDOW_MS) { + requestLimits.set(key, { startedAt: now, count: 1 }); + return { allowed: true, retryAfterSeconds: 0 }; + } + current.count += 1; + if (current.count <= RATE_LIMIT_MAX_REQUESTS) { + return { allowed: true, retryAfterSeconds: 0 }; + } + return { + allowed: false, + retryAfterSeconds: Math.max(1, Math.ceil((RATE_LIMIT_WINDOW_MS - (now - current.startedAt)) / 1000)) + }; +} function normalizeLinesParam(rawValue: string | null, fallback: number): number { const parsed = Number(rawValue || String(fallback)); @@ -351,11 +390,10 @@ function buildSupportManifest(baseDir: string): Record { purpose: "Machine-readable manifest for support tooling and remote troubleshooting.", auth: { required: true, - methods: [ - "Authorization: Bearer ", - "?token=" - ], - tokenFile: path.join(baseDir, "debug_token.txt") + methods: [ + "Authorization: Bearer " + ], + tokenFile: path.join(baseDir, "debug_token.txt") }, runtimeFiles: { hostFile: path.join(baseDir, "debug_host.txt"), @@ -374,19 +412,18 @@ function buildSupportManifest(baseDir: string): Record { historyFile: path.join(baseDir, "rd_history.json") }, debugServer: { - enabled: Boolean(authToken), - host: bindHost, - port: bindPort, - localBaseUrl: `http://127.0.0.1:${bindPort}`, - remoteBaseUrlTemplate: `http://:${bindPort}` + enabled: Boolean(authToken), + host: bindHost, + port: bindPort, + localBaseUrl: `http://127.0.0.1:${bindPort}`, + remoteBridgeBaseUrlTemplate: `http://127.0.0.1:${bindPort}` }, setupCheckEndpoint: "/debug/setup", selfCheckEndpoint: "/self-check", remoteAccessRequirements: [ - "A reachable server IP or DNS name.", - "The configured diagnostics port.", - "The token stored in debug_token.txt.", - "A network route and firewall rule that permit access to the configured port." + "Use a trusted local bridge or tunnel to forward support traffic to the loopback diagnostics port.", + "Send Authorization: Bearer with the token stored in debug_token.txt.", + "Do not expose the diagnostics port directly on LAN or WAN." ], endpoints: DEBUG_ENDPOINTS.map((endpoint) => ({ ...endpoint, @@ -499,7 +536,7 @@ function getItemLogPathForQuery(snapshot: UiSnapshot, query: string): { item: Do return { item: null, logPath: directPath }; } -function buildStatusPayload(snapshot: UiSnapshot): Record { +function buildStatusPayload(snapshot: UiSnapshot): Record { const items = Object.values(snapshot.session.items); const packages = Object.values(snapshot.session.packages); @@ -527,33 +564,54 @@ function buildStatusPayload(snapshot: UiSnapshot): Record { packages: packages.map((pkg) => summarizePackage(snapshot, pkg, false)), activeItems, failedItems: failedItems.length > 0 ? failedItems : undefined - }; -} - -function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { - const url = new URL(req.url || "/", "http://localhost"); - const pathname = url.pathname; - const traceConfig = getTraceConfig(); - if (traceConfig.enabled && traceConfig.logDebugRequests) { - logTraceEvent("INFO", "debug-http", "Request", { - method: req.method || "GET", - url: sanitizeRequestUrlForTrace(req.url || "/"), - clientIp: extractDebugClientIp(req) - }); - } - - if (req.method === "OPTIONS") { - res.writeHead(204, { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "Authorization", - "Access-Control-Allow-Methods": "GET,OPTIONS" - }); - res.end(); - return; - } - - const peerIp = getPeerIp(req); - if (!isClientAllowed(peerIp)) { + }; +} + +function hasTraceMutationParams(url: URL): boolean { + return ["enable", "includeMainLog", "includeAudit", "logDebugRequests", "note", "durationMinutes"] + .some((key) => url.searchParams.has(key)); +} + +function allowedMethods(pathname: string, url: URL): string[] { + if (pathname === "/trace/config") { + return hasTraceMutationParams(url) ? ["POST"] : ["GET", "POST"]; + } + return ["GET"]; +} + +function rejectMethod(res: http.ServerResponse, methods: string[]): void { + jsonResponse(res, 405, { + error: "Method Not Allowed", + code: "method_not_allowed", + allowedMethods: methods + }, { + "Allow": methods.join(", ") + }); +} + +function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + const url = new URL(req.url || "/", "http://localhost"); + const pathname = url.pathname; + const method = (req.method || "GET").toUpperCase(); + const traceConfig = getTraceConfig(); + if (traceConfig.enabled && traceConfig.logDebugRequests) { + logTraceEvent("INFO", "debug-http", "Request", { + method, + url: sanitizeRequestUrlForTrace(req.url || "/"), + clientIp: extractDebugClientIp(req) + }); + } + + if (url.searchParams.has("token")) { + jsonResponse(res, 400, { + error: "Bad Request", + code: "query_token_rejected" + }); + return; + } + + const peerIp = getPeerIp(req); + if (!isClientAllowed(peerIp)) { if (traceConfig.enabled && traceConfig.logDebugRequests) { logTraceEvent("WARN", "debug-http", "Durch Allowlist blockiert", { peerIp, @@ -561,21 +619,41 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi url: sanitizeRequestUrlForTrace(req.url || "/") }); } - jsonResponse(res, 403, { error: "Forbidden", reason: "Client-IP nicht in Allowlist", clientIp: peerIp }); - return; - } - - if (!checkAuth(req)) { - if (traceConfig.enabled && traceConfig.logDebugRequests) { - logTraceEvent("WARN", "debug-http", "Unauthorized request", { - method: req.method || "GET", - url: sanitizeRequestUrlForTrace(req.url || "/"), - clientIp: extractDebugClientIp(req) - }); - } - jsonResponse(res, 401, { error: "Unauthorized" }); - return; - } + jsonResponse(res, 403, { error: "Forbidden", reason: "Client-IP nicht in Allowlist", clientIp: peerIp }); + return; + } + + const limit = checkRateLimit(peerIp); + if (!limit.allowed) { + jsonResponse(res, 429, { + error: "Too Many Requests", + code: "rate_limited", + retryAfterSeconds: limit.retryAfterSeconds + }, { + "Retry-After": String(limit.retryAfterSeconds) + }); + return; + } + + if (!checkAuth(req)) { + if (traceConfig.enabled && traceConfig.logDebugRequests) { + logTraceEvent("WARN", "debug-http", "Unauthorized request", { + method, + url: sanitizeRequestUrlForTrace(req.url || "/"), + clientIp: extractDebugClientIp(req) + }); + } + jsonResponse(res, 401, { error: "Unauthorized", code: "unauthorized" }, { + "WWW-Authenticate": "Bearer" + }); + return; + } + + const methods = allowedMethods(pathname, url); + if (!methods.includes(method)) { + rejectMethod(res, methods); + return; + } if (pathname === "/health") { jsonResponse(res, 200, { @@ -719,8 +797,16 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi return; } - if (pathname === "/trace/config") { - const patch: Record = {}; + if (pathname === "/trace/config") { + if (method === "GET") { + jsonResponse(res, 200, { + path: getTraceConfigPath(), + logPath: getTraceLogPath(), + config: getTraceConfig() + }); + return; + } + const patch: Record = {}; const enabled = toBooleanQuery(url.searchParams.get("enable")); const includeMainLog = toBooleanQuery(url.searchParams.get("includeMainLog")); const includeAudit = toBooleanQuery(url.searchParams.get("includeAudit")); @@ -1055,9 +1141,10 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi }); } -function openServerSocket(): Promise { - return new Promise((resolve) => { - authToken = loadToken(runtimeBaseDir); +function openServerSocket(): Promise { + return new Promise((resolve) => { + requestLimits = new Map(); + authToken = loadToken(runtimeBaseDir); bindPort = getPort(runtimeBaseDir); bindHost = getHost(runtimeBaseDir); allowlist = loadAllowlist(runtimeBaseDir); @@ -1168,14 +1255,15 @@ export function clearDebugToken(): void { writeSupportManifest(runtimeBaseDir); } -export function stopDebugServer(): void { - if (server) { +export function stopDebugServer(): void { + if (server) { server.close(); try { server.closeAllConnections?.(); } catch { } - server = null; - logger.info("Debug-Server gestoppt"); - } -} + server = null; + requestLimits = new Map(); + logger.info("Debug-Server gestoppt"); + } +} diff --git a/src/main/debug-setup.ts b/src/main/debug-setup.ts index 48005d2..6147dcc 100644 --- a/src/main/debug-setup.ts +++ b/src/main/debug-setup.ts @@ -348,14 +348,14 @@ export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult { }; const supportBundle = getSupportBundleEstimate(baseDir, logSummary); - if (!token) { - warnings.push("debug_token.txt fehlt oder ist leer. Der Debug-Server startet dann nicht."); - } - if (localOnly) { - warnings.push("Der Debug-Server ist aktuell nur lokal erreichbar. Für Remote-Support debug_host.txt auf 0.0.0.0 setzen."); - } else { - notes.push("Der Debug-Server ist für Remote-Zugriff konfiguriert. Firewall oder Provider-Regeln müssen separat offen sein."); - } + if (!token) { + warnings.push("debug_token.txt fehlt oder ist leer. Der Debug-Server startet dann nicht."); + } + if (localOnly) { + notes.push("Der Debug-Server ist lokal gebunden. Remote-Support nutzt eine vertrauenswürdige Bridge oder einen Tunnel auf diese lokale Adresse."); + } else { + warnings.push("Der Debug-Server ist nicht lokal gebunden. Für Support sicherheitshalber auf 127.0.0.1 zurückstellen und eine Bridge oder einen Tunnel nutzen."); + } if (!fs.existsSync(supportManifestPath)) { warnings.push("debug_support_manifest.json fehlt. App einmal neu starten, damit das Support-Manifest neu geschrieben wird."); } @@ -421,15 +421,15 @@ export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult { supportBundle, warnings, notes, - localUrls: { - health: `http://127.0.0.1:${port}/health?token=${token || ""}`, - meta: `http://127.0.0.1:${port}/meta?token=${token || ""}`, - diagnostics: `http://127.0.0.1:${port}/diagnostics?token=${token || ""}` - }, - remoteUrlTemplates: { - health: `http://:${port}/health?token=${token || ""}`, - meta: `http://:${port}/meta?token=${token || ""}`, - diagnostics: `http://:${port}/diagnostics?token=${token || ""}` - } - }; -} + localUrls: { + health: `http://127.0.0.1:${port}/health`, + meta: `http://127.0.0.1:${port}/meta`, + diagnostics: `http://127.0.0.1:${port}/diagnostics` + }, + remoteUrlTemplates: { + health: `http://127.0.0.1:${port}/health`, + meta: `http://127.0.0.1:${port}/meta`, + diagnostics: `http://127.0.0.1:${port}/diagnostics` + } + }; +} diff --git a/tests/backup-remote-diagnostics.test.ts b/tests/backup-remote-diagnostics.test.ts index a01315a..866fd21 100644 --- a/tests/backup-remote-diagnostics.test.ts +++ b/tests/backup-remote-diagnostics.test.ts @@ -45,20 +45,24 @@ async function getFreePort(): Promise { return address.port; } -async function waitForReady(url: string): Promise { - const deadline = Date.now() + 5000; - while (Date.now() < deadline) { - try { - const res = await fetch(url); - if (res.ok) { - return; - } +async function waitForReady(url: string, token = "rt-secret"): Promise { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + try { + const res = await fetch(url, { headers: bearerHeaders(token) }); + if (res.ok) { + return; + } } catch { } await new Promise((resolve) => setTimeout(resolve, 40)); } - throw new Error(`debug server not ready: ${url}`); -} + throw new Error(`debug server not ready: ${url}`); +} + +function bearerHeaders(token = "rt-secret"): HeadersInit { + return { Authorization: `Bearer ${token}` }; +} afterEach(() => { stopDebugServer(); @@ -75,7 +79,7 @@ afterEach(() => { }); describe("backup remoteDiagnostics export gating", () => { - it("includes remoteDiagnostics when backupIncludeRemoteDiagnostics is on", () => { + it("includes only sanitized remoteDiagnostics when backupIncludeRemoteDiagnostics is on", () => { const settings = { ...defaultSettings(), backupIncludeRemoteDiagnostics: true @@ -89,14 +93,15 @@ describe("backup remoteDiagnostics export gating", () => { remoteDiagnostics: { allowlist: ["192.0.2.0/24"], port: 8976, - hostMode: "network" - } + hostMode: "network", + token: "legacy-backup-token", + publicHost: "legacy.example.test", + endpoint: "http://legacy.example.test:8976" + } as unknown as BackupRemoteDiagnostics }); expect(payload.remoteDiagnostics).toEqual({ - allowlist: ["192.0.2.0/24"], - port: 8976, - hostMode: "network" + allowlist: ["192.0.2.0/24"] }); }); @@ -110,15 +115,18 @@ describe("backup remoteDiagnostics export gating", () => { expect(payload.remoteDiagnostics).toBeUndefined(); }); - it("the remoteDiagnostics section carries ONLY allowlist/port/hostMode (no token, publicHost, name)", () => { + it("the remoteDiagnostics section carries ONLY allowlist", () => { const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: true }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" })); - expect(payload.remoteDiagnostics && Object.keys(payload.remoteDiagnostics).sort()).toEqual(["allowlist", "hostMode", "port"]); + expect(payload.remoteDiagnostics && Object.keys(payload.remoteDiagnostics).sort()).toEqual(["allowlist"]); const sectionJson = JSON.stringify(payload.remoteDiagnostics); - expect(sectionJson.toLowerCase()).not.toContain("token"); - expect(sectionJson).not.toContain("publicHost"); - expect(sectionJson.toLowerCase()).not.toContain("\"name\""); - }); -}); + expect(sectionJson.toLowerCase()).not.toContain("token"); + expect(sectionJson).not.toContain("publicHost"); + expect(sectionJson.toLowerCase()).not.toContain("\"name\""); + expect(sectionJson.toLowerCase()).not.toContain("endpoint"); + expect(sectionJson.toLowerCase()).not.toContain("hostmode"); + expect(sectionJson.toLowerCase()).not.toContain("port"); + }); +}); describe("backupIncludeRemoteDiagnostics settings persistence", () => { it("normalizeSettings preserves backupIncludeRemoteDiagnostics (the toggle survives save/load)", () => { @@ -129,35 +137,51 @@ describe("backupIncludeRemoteDiagnostics settings persistence", () => { }); describe("resolveRemoteDiagnosticsRestore", () => { - it("maps network + non-empty allowlist to 0.0.0.0", () => { + it("scrubs network restores to loopback and keeps only allowlist", () => { expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" })) - .toEqual({ host: "0.0.0.0", port: 9868, allowlist: ["10.0.0.5"] }); - }); - - it("SAFETY: network with EMPTY allowlist binds local, never 0.0.0.0", () => { - expect(resolveRemoteDiagnosticsRestore({ allowlist: [], port: 9868, hostMode: "network" })?.host).toBe("127.0.0.1"); - }); - - it("maps local to 127.0.0.1", () => { - expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "local" })?.host).toBe("127.0.0.1"); - }); - - it("rejects an out-of-range or non-integer port", () => { - expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })?.port).toBeUndefined(); - expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })?.port).toBeUndefined(); - expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868.5, hostMode: "network" })?.port).toBeUndefined(); - }); + .toEqual({ host: "127.0.0.1", allowlist: ["10.0.0.5"] }); + }); + + it("scrubs legacy token and endpoint fields during restore planning", () => { + const restore = resolveRemoteDiagnosticsRestore({ + allowlist: ["198.51.100.8"], + port: 9999, + hostMode: "network", + token: "legacy-backup-token", + publicHost: "legacy.example.test", + endpoint: "http://legacy.example.test:9999" + }); + expect(restore).toEqual({ host: "127.0.0.1", allowlist: ["198.51.100.8"] }); + expect(JSON.stringify(restore).toLowerCase()).not.toContain("token"); + expect(JSON.stringify(restore).toLowerCase()).not.toContain("endpoint"); + expect(JSON.stringify(restore).toLowerCase()).not.toContain("publichost"); + expect(JSON.stringify(restore).toLowerCase()).not.toContain("9999"); + }); + + it("maps local to 127.0.0.1", () => { + expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "local" })) + .toEqual({ host: "127.0.0.1", allowlist: ["10.0.0.5"] }); + }); + + it("does not restore any port values", () => { + expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })).toEqual({ host: "127.0.0.1", allowlist: ["10.0.0.5"] }); + expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })).toEqual({ host: "127.0.0.1", allowlist: ["10.0.0.5"] }); + expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868.5, hostMode: "network" })).toEqual({ host: "127.0.0.1", allowlist: ["10.0.0.5"] }); + }); it("filters non-string and blank allowlist entries and trims", () => { const r = resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5", "", " ", 5, null, " 8.8.8.8 "], port: 9868, hostMode: "network" }); expect(r?.allowlist).toEqual(["10.0.0.5", "8.8.8.8"]); }); - it("returns null for missing or empty/invalid sections", () => { + it("returns null for missing sections", () => { expect(resolveRemoteDiagnosticsRestore(undefined)).toBeNull(); expect(resolveRemoteDiagnosticsRestore(null)).toBeNull(); expect(resolveRemoteDiagnosticsRestore("x")).toBeNull(); - expect(resolveRemoteDiagnosticsRestore({})).toBeNull(); + }); + + it("migrates empty legacy sections to loopback", () => { + expect(resolveRemoteDiagnosticsRestore({})).toEqual({ host: "127.0.0.1", allowlist: [] }); }); }); @@ -167,12 +191,12 @@ describe("backup remoteDiagnostics live restore round-trip", () => { tempDirs.push(baseDir); const startPort = await getFreePort(); const restorePort = await getFreePort(); - fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt-secret", "utf8"); + fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt-secret", "utf8"); fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8"); fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8"); fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8"); startDebugServer({} as unknown as DownloadManager, baseDir); - await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt-secret`); + await waitForReady(`http://127.0.0.1:${startPort}/health`); expect(getDebugAllowlist()).toEqual([]); const payload = buildBackupPayload(input( @@ -186,35 +210,35 @@ describe("backup remoteDiagnostics live restore round-trip", () => { const status = await restartDebugServer(); expect(getDebugAllowlist()).toEqual(["203.0.113.4", "10.0.0.0/24"]); - expect(status.port).toBe(restorePort); - expect(status.host).toBe("0.0.0.0"); - expect(status.allowlistCount).toBe(2); - - expect(fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim()).toBe("rt-secret"); - expect(fs.existsSync(path.join(baseDir, "debug_remote.json"))).toBe(false); - - await waitForReady(`http://127.0.0.1:${restorePort}/health?token=rt-secret`); - }); - - it("full-backup path writes the debug_* files to disk without a restart (boot picks them up)", async () => { + expect(status.port).toBe(startPort); + expect(status.host).toBe("127.0.0.1"); + expect(status.allowlistCount).toBe(2); + + expect(fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim()).toBe("rt-secret"); + expect(fs.existsSync(path.join(baseDir, "debug_remote.json"))).toBe(false); + + await waitForReady(`http://127.0.0.1:${startPort}/health`); + }); + + it("full-backup path writes only safe debug files to disk without a restart", async () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-backup-remote2-")); tempDirs.push(baseDir); const startPort = await getFreePort(); - fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt2", "utf8"); + fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt2", "utf8"); fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8"); fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8"); fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8"); startDebugServer({} as unknown as DownloadManager, baseDir); - await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt2`); - + await waitForReady(`http://127.0.0.1:${startPort}/health`, "rt2"); + const restore = resolveRemoteDiagnosticsRestore({ allowlist: ["198.51.100.9"], port: 9100, hostMode: "network" }); - writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist }); - - expect(fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim()).toBe("0.0.0.0"); - expect(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim()).toBe("9100"); - expect(fs.readFileSync(path.join(baseDir, "debug_allowlist.txt"), "utf8")).toContain("198.51.100.9"); - expect(getDebugServerRuntimeStatus().port).toBe(startPort); - }); + writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist }); + + expect(fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim()).toBe("127.0.0.1"); + expect(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim()).toBe(String(startPort)); + expect(fs.readFileSync(path.join(baseDir, "debug_allowlist.txt"), "utf8")).toContain("198.51.100.9"); + expect(getDebugServerRuntimeStatus().port).toBe(startPort); + }); }); describe("debug-server live diagnostics endpoints", () => { @@ -222,14 +246,14 @@ describe("debug-server live diagnostics endpoints", () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-prov-")); tempDirs.push(baseDir); const port = await getFreePort(); - fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "prov-secret", "utf8"); + fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "prov-secret", "utf8"); fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8"); fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8"); fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8"); startDebugServer({} as unknown as DownloadManager, baseDir); - await waitForReady(`http://127.0.0.1:${port}/health?token=prov-secret`); - - const provRes = await fetch(`http://127.0.0.1:${port}/providers?token=prov-secret`); + await waitForReady(`http://127.0.0.1:${port}/health`, "prov-secret"); + + const provRes = await fetch(`http://127.0.0.1:${port}/providers`, { headers: bearerHeaders("prov-secret") }); expect(provRes.status).toBe(200); const prov = await provRes.json(); expect(typeof prov.capturedAtMs).toBe("number"); @@ -242,7 +266,7 @@ describe("debug-server live diagnostics endpoints", () => { const unauth = await fetch(`http://127.0.0.1:${port}/providers`); expect(unauth.status).toBe(401); - const convRes = await fetch(`http://127.0.0.1:${port}/logs/conversion?token=prov-secret`); + const convRes = await fetch(`http://127.0.0.1:${port}/logs/conversion`, { headers: bearerHeaders("prov-secret") }); expect(convRes.status).toBe(200); const conv = await convRes.json(); expect(Array.isArray(conv.lines)).toBe(true); diff --git a/tests/debug-server-allowlist.test.ts b/tests/debug-server-allowlist.test.ts index 8fc4a7f..b84aaeb 100644 --- a/tests/debug-server-allowlist.test.ts +++ b/tests/debug-server-allowlist.test.ts @@ -32,35 +32,51 @@ async function getFreePort(): Promise { return address.port; } -async function waitForReady(url: string): Promise { - const deadline = Date.now() + 5000; - while (Date.now() < deadline) { - try { - const res = await fetch(url); - if (res.ok) { - return; - } +async function waitForReady(url: string): Promise { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + try { + const res = await fetch(url, { headers: bearerHeaders(TOKEN) }); + if (res.ok) { + return; + } } catch { } await new Promise((resolve) => setTimeout(resolve, 40)); } - throw new Error(`debug server not ready: ${url}`); -} - -async function startWithAllowlist(allowlist: string[], host = "0.0.0.0"): Promise<{ baseUrl: string }> { - const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-allow-")); - tempDirs.push(baseDir); - const port = await getFreePort(); - fs.writeFileSync(path.join(baseDir, "debug_token.txt"), TOKEN, "utf8"); - fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8"); - fs.writeFileSync(path.join(baseDir, "debug_host.txt"), host, "utf8"); - fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), allowlist.join("\n"), "utf8"); - const manager = {} as unknown as DownloadManager; - startDebugServer(manager, baseDir); - const baseUrl = `http://127.0.0.1:${port}`; - await waitForReady(`${baseUrl}/health?token=${TOKEN}`); - return { baseUrl }; -} + throw new Error(`debug server not ready: ${url}`); +} + +function bearerHeaders(token: string): HeadersInit { + return { Authorization: `Bearer ${token}` }; +} + +function authedFetch(url: string, init: RequestInit = {}): Promise { + return fetch(url, { + ...init, + headers: { + ...(init.headers || {}), + ...bearerHeaders(TOKEN) + } + }); +} + +async function startWithAllowlist(allowlist: string[], host = "127.0.0.1", writeHost = true): Promise<{ baseUrl: string }> { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-allow-")); + tempDirs.push(baseDir); + const port = await getFreePort(); + fs.writeFileSync(path.join(baseDir, "debug_token.txt"), TOKEN, "utf8"); + fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8"); + if (writeHost) { + fs.writeFileSync(path.join(baseDir, "debug_host.txt"), host, "utf8"); + } + fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), allowlist.join("\n"), "utf8"); + const manager = {} as unknown as DownloadManager; + startDebugServer(manager, baseDir); + const baseUrl = `http://127.0.0.1:${port}`; + await waitForReady(`${baseUrl}/health`); + return { baseUrl }; +} afterEach(() => { stopDebugServer(); @@ -116,14 +132,21 @@ describe("debug-server allowlist matcher (pure)", () => { }); }); -describe("debug-server allowlist enforcement (wired)", () => { - it("allows a loopback connection and ignores a spoofed X-Forwarded-For", async () => { - const { baseUrl } = await startWithAllowlist(["8.8.8.8"]); - const plain = await fetch(`${baseUrl}/health?token=${TOKEN}`); - expect(plain.status).toBe(200); - const spoofed = await fetch(`${baseUrl}/health?token=${TOKEN}`, { - headers: { "X-Forwarded-For": "203.0.113.9" } - }); +describe("debug-server allowlist enforcement (wired)", () => { + it("binds to loopback when no host file exists", async () => { + await startWithAllowlist([], "0.0.0.0", false); + const status = getDebugServerRuntimeStatus(); + expect(status.host).toBe("127.0.0.1"); + expect(status.localOnly).toBe(true); + }); + + it("allows a loopback connection and ignores a spoofed X-Forwarded-For", async () => { + const { baseUrl } = await startWithAllowlist(["8.8.8.8"]); + const plain = await authedFetch(`${baseUrl}/health`); + expect(plain.status).toBe(200); + const spoofed = await authedFetch(`${baseUrl}/health`, { + headers: { "X-Forwarded-For": "203.0.113.9" } + }); expect(spoofed.status).toBe(200); }); @@ -133,14 +156,14 @@ describe("debug-server allowlist enforcement (wired)", () => { expect(res.status).toBe(401); }); - it("reloads the allowlist live via restartDebugServer", async () => { - const { baseUrl } = await startWithAllowlist(["8.8.8.8"]); - expect(getDebugServerRuntimeStatus().allowlistCount).toBe(1); - writeDebugServerConfig({ allowlist: ["9.9.9.9", "10.0.0.0/24"] }); - const status = await restartDebugServer(); - expect(status.running).toBe(true); - expect(status.allowlistCount).toBe(2); - await waitForReady(`${baseUrl}/health?token=${TOKEN}`); - expect((await fetch(`${baseUrl}/health?token=${TOKEN}`)).status).toBe(200); - }); -}); + it("reloads the allowlist live via restartDebugServer", async () => { + const { baseUrl } = await startWithAllowlist(["8.8.8.8"]); + expect(getDebugServerRuntimeStatus().allowlistCount).toBe(1); + writeDebugServerConfig({ allowlist: ["9.9.9.9", "10.0.0.0/24"] }); + const status = await restartDebugServer(); + expect(status.running).toBe(true); + expect(status.allowlistCount).toBe(2); + await waitForReady(`${baseUrl}/health`); + expect((await authedFetch(`${baseUrl}/health`)).status).toBe(200); + }); +}); diff --git a/tests/debug-server.test.ts b/tests/debug-server.test.ts index 7d231c9..a4e4e16 100644 --- a/tests/debug-server.test.ts +++ b/tests/debug-server.test.ts @@ -94,20 +94,34 @@ async function getFreePort(): Promise { return address.port; } -async function waitForReady(url: string): Promise { - const deadline = Date.now() + 5000; - while (Date.now() < deadline) { - try { - const response = await fetch(url); - if (response.ok) { - return; - } +async function waitForReady(url: string): Promise { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + try { + const response = await fetch(url, { headers: bearerHeaders("debug-secret") }); + if (response.ok) { + return; + } } catch { } await new Promise((resolve) => setTimeout(resolve, 50)); } - throw new Error(`debug server not ready: ${url}`); -} + throw new Error(`debug server not ready: ${url}`); +} + +function bearerHeaders(token: string): HeadersInit { + return { Authorization: `Bearer ${token}` }; +} + +function authedFetch(url: string, token: string, init: RequestInit = {}): Promise { + return fetch(url, { + ...init, + headers: { + ...(init.headers || {}), + ...bearerHeaders(token) + } + }); +} function buildSnapshot(baseDir: string): UiSnapshot { const settings = { @@ -225,10 +239,9 @@ async function createFixture() { const snapshot = buildSnapshot(baseDir); const storagePaths = createStoragePaths(baseDir); - fs.writeFileSync(path.join(baseDir, "debug_token.txt"), token, "utf8"); - fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8"); - fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "0.0.0.0", "utf8"); - const debridLinkApiKeys = "key-a\nkey-b"; + fs.writeFileSync(path.join(baseDir, "debug_token.txt"), token, "utf8"); + fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8"); + const debridLinkApiKeys = "key-a\nkey-b"; const debridLinkKeyIds = getDebridLinkApiKeyIds(debridLinkApiKeys); saveSettings(storagePaths, { @@ -313,10 +326,10 @@ async function createFixture() { getItemLogPath: (itemId: string) => itemId === "item-2" ? itemLogPath : null } as unknown as DownloadManager; - startDebugServer(manager, baseDir); - const baseUrl = `http://127.0.0.1:${port}`; - await waitForReady(`${baseUrl}/health?token=${token}`); - await new Promise((resolve) => setTimeout(resolve, 300)); + startDebugServer(manager, baseDir); + const baseUrl = `http://127.0.0.1:${port}`; + await waitForReady(`${baseUrl}/health`); + await new Promise((resolve) => setTimeout(resolve, 300)); return { baseUrl, @@ -345,15 +358,17 @@ afterEach(() => { } }); -describe("debug-server", () => { - it("serves diagnostics with main, session, and package log tails", async () => { - const fixture = await createFixture(); - const response = await fetch(`${fixture.baseUrl}/diagnostics?token=${fixture.token}&package=server-package&lines=20`); - expect(response.ok).toBe(true); - const payload = await response.json() as Record; - - expect(payload.meta?.appVersion).toBeTruthy(); - expect(payload.meta?.debugServer?.host).toBe("0.0.0.0"); +describe("debug-server", () => { + it("serves diagnostics with main, session, and package log tails", async () => { + const fixture = await createFixture(); + const response = await authedFetch(`${fixture.baseUrl}/diagnostics?package=server-package&lines=20`, fixture.token); + expect(response.ok).toBe(true); + expect(response.headers.get("cache-control")).toContain("no-store"); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + const payload = await response.json() as Record; + + expect(payload.meta?.appVersion).toBeTruthy(); + expect(payload.meta?.debugServer?.host).toBe("127.0.0.1"); expect(payload.status?.running).toBe(true); expect(payload.host?.platform).toBe("win32"); expect(payload.host?.recentKernelPower?.[0]?.id).toBe(41); @@ -379,15 +394,17 @@ describe("debug-server", () => { expect(JSON.stringify(manifest)).not.toMatch(new RegExp(`\\b(?:${forbiddenSupportMarkers.join("|")})\\b`, "i")); expect(JSON.stringify(manifest)).not.toContain(fixture.token); expect(manifest.debugServer?.port).toBeGreaterThan(0); - expect(manifest.debugServer?.remoteBaseUrlTemplate).toContain(""); - expect(manifest.remoteAccessRequirements).toContain("A reachable server IP or DNS name."); + expect(manifest.auth?.methods).toEqual(["Authorization: Bearer "]); + expect(JSON.stringify(manifest)).not.toContain("?token="); + expect(manifest.debugServer?.remoteBridgeBaseUrlTemplate).toContain("127.0.0.1"); + expect(manifest.remoteAccessRequirements.join("\n")).toContain("local bridge"); expect(manifest.setupCheckEndpoint).toBe("/debug/setup"); expect(manifest.selfCheckEndpoint).toBe("/self-check"); expect(manifest.runtimeFiles?.tokenFile).toContain("debug_token.txt"); expect(manifest.endpoints?.some((entry: Record) => entry.path === "/diagnostics")).toBe(true); expect(manifest.endpoints?.some((entry: Record) => entry.path === "/logs/main")).toBe(true); - const metaResponse = await fetch(`${fixture.baseUrl}/meta?token=${fixture.token}`); + const metaResponse = await authedFetch(`${fixture.baseUrl}/meta`, fixture.token); expect(metaResponse.ok).toBe(true); const metaPayload = await metaResponse.json() as Record; expect(metaPayload.supportFiles?.supportManifest).toBe(manifestPath); @@ -399,19 +416,19 @@ describe("debug-server", () => { expect(metaPayload.supportChecks?.selfCheck).toBe("/self-check"); }); - it("serves a debug setup check with trace expiry details", async () => { - const fixture = await createFixture(); - const response = await fetch(`${fixture.baseUrl}/debug/setup?token=${fixture.token}`); - expect(response.ok).toBe(true); - const payload = await response.json() as Record; - + it("serves a debug setup check with trace expiry details", async () => { + const fixture = await createFixture(); + const response = await authedFetch(`${fixture.baseUrl}/debug/setup`, fixture.token); + expect(response.ok).toBe(true); + const payload = await response.json() as Record; + expect(payload.enabled).toBe(true); expect(Array.isArray(payload.warnings)).toBe(true); expect(payload.status).toBe(payload.warnings.length > 0 ? "warn" : "ok"); - expect(payload.runtimeBaseDir).toBe(fixture.baseDir); - expect(payload.host).toBe("0.0.0.0"); - expect(payload.localOnly).toBe(false); - expect(payload.tokenConfigured).toBe(true); + expect(payload.runtimeBaseDir).toBe(fixture.baseDir); + expect(payload.host).toBe("127.0.0.1"); + expect(payload.localOnly).toBe(true); + expect(payload.tokenConfigured).toBe(true); expect(payload.supportManifestPresent).toBe(true); expect(payload[`${legacyManifestField}Present`]).toBeUndefined(); expect(payload.supportManifestPath).toBe(path.join(fixture.baseDir, "debug_support_manifest.json")); @@ -423,15 +440,19 @@ describe("debug-server", () => { expect(payload.logSummary?.totalBytes).toBeGreaterThan(0); expect(payload.logSummary?.rename?.bytes).toBeGreaterThan(0); expect(payload.logSummary?.packageLogs?.fileCount).toBe(1); - expect(payload.logSummary?.itemLogs?.fileCount).toBe(1); - expect(payload.supportBundle?.estimatedBytes).toBeGreaterThan(0); - expect(payload.remoteUrlTemplates?.health).toContain(""); - expect(Array.isArray(payload.notes)).toBe(true); - }); - - it("serves the self-check alias", async () => { - const fixture = await createFixture(); - const response = await fetch(`${fixture.baseUrl}/self-check?token=${fixture.token}`); + expect(payload.logSummary?.itemLogs?.fileCount).toBe(1); + expect(payload.supportBundle?.estimatedBytes).toBeGreaterThan(0); + expect(JSON.stringify(payload.localUrls)).not.toContain(fixture.token); + expect(JSON.stringify(payload.remoteUrlTemplates)).not.toContain(fixture.token); + expect(JSON.stringify(payload.localUrls)).not.toContain("?token="); + expect(JSON.stringify(payload.remoteUrlTemplates)).not.toContain("?token="); + expect(payload.remoteUrlTemplates?.health).toContain("127.0.0.1"); + expect(Array.isArray(payload.notes)).toBe(true); + }); + + it("serves the self-check alias", async () => { + const fixture = await createFixture(); + const response = await authedFetch(`${fixture.baseUrl}/self-check`, fixture.token); expect(response.ok).toBe(true); const payload = await response.json() as Record; expect(Array.isArray(payload.warnings)).toBe(true); @@ -439,12 +460,12 @@ describe("debug-server", () => { expect(payload.supportBundle?.estimatedEntries).toBeGreaterThan(0); }); - it("writes the client IP into the debug trace log", async () => { - const fixture = await createFixture(); - const response = await fetch(`${fixture.baseUrl}/health?token=${fixture.token}`, { - headers: { + it("writes the client IP into the debug trace log", async () => { + const fixture = await createFixture(); + const response = await authedFetch(`${fixture.baseUrl}/health`, fixture.token, { + headers: { "X-Forwarded-For": "203.0.113.46" - } + } }); expect(response.ok).toBe(true); @@ -455,26 +476,26 @@ describe("debug-server", () => { expect(traceText).toContain("clientIp=203.0.113.46"); }); - it("serves package details and package log by package query", async () => { - const fixture = await createFixture(); - - const packagesResponse = await fetch(`${fixture.baseUrl}/packages?token=${fixture.token}&package=server&includeItems=1`); + it("serves package details and package log by package query", async () => { + const fixture = await createFixture(); + + const packagesResponse = await authedFetch(`${fixture.baseUrl}/packages?package=server&includeItems=1`, fixture.token); expect(packagesResponse.ok).toBe(true); const packagesPayload = await packagesResponse.json() as Record; expect(packagesPayload.count).toBe(1); expect(packagesPayload.packages?.[0]?.items?.length).toBe(2); - const logResponse = await fetch(`${fixture.baseUrl}/logs/package?token=${fixture.token}&package=server-package&lines=20`); + const logResponse = await authedFetch(`${fixture.baseUrl}/logs/package?package=server-package&lines=20`, fixture.token); expect(logResponse.ok).toBe(true); const logPayload = await logResponse.json() as Record; expect(logPayload.package?.name).toBe("server-package"); expect((logPayload.lines || []).join("\n")).toContain("PACKAGE-LINE"); }); - it("serves item log by item query", async () => { - const fixture = await createFixture(); - - const response = await fetch(`${fixture.baseUrl}/logs/item?token=${fixture.token}&item=episode.part2.rar&lines=20`); + it("serves item log by item query", async () => { + const fixture = await createFixture(); + + const response = await authedFetch(`${fixture.baseUrl}/logs/item?item=episode.part2.rar&lines=20`, fixture.token); expect(response.ok).toBe(true); const payload = await response.json() as Record; expect(payload.item?.id).toBe("item-2"); @@ -482,9 +503,9 @@ describe("debug-server", () => { expect((payload.lines || []).join("\n")).toContain("ITEM-LINE"); }); - it("serves host diagnostics separately", async () => { - const fixture = await createFixture(); - const response = await fetch(`${fixture.baseUrl}/host/diagnostics?token=${fixture.token}`); + it("serves host diagnostics separately", async () => { + const fixture = await createFixture(); + const response = await authedFetch(`${fixture.baseUrl}/host/diagnostics`, fixture.token); expect(response.ok).toBe(true); const payload = await response.json() as Record; expect(payload.platform).toBe("win32"); @@ -493,30 +514,32 @@ describe("debug-server", () => { }); it("serves audit log, settings, accounts, stats, and history", async () => { - const fixture = await createFixture(); - - const auditResponse = await fetch(`${fixture.baseUrl}/logs/audit?token=${fixture.token}&lines=20`); + const fixture = await createFixture(); + + const auditResponse = await authedFetch(`${fixture.baseUrl}/logs/audit?lines=20`, fixture.token); expect(auditResponse.ok).toBe(true); const auditPayload = await auditResponse.json() as Record; expect((auditPayload.lines || []).join("\n")).toContain("AUDIT-LINE"); - const renameResponse = await fetch(`${fixture.baseUrl}/logs/rename?token=${fixture.token}&lines=20`); + const renameResponse = await authedFetch(`${fixture.baseUrl}/logs/rename?lines=20`, fixture.token); expect(renameResponse.ok).toBe(true); const renamePayload = await renameResponse.json() as Record; expect((renamePayload.lines || []).join("\n")).toContain("RENAME-LINE"); - const traceResponse = await fetch(`${fixture.baseUrl}/logs/trace?token=${fixture.token}&lines=50`); + const traceResponse = await authedFetch(`${fixture.baseUrl}/logs/trace?lines=50`, fixture.token); expect(traceResponse.ok).toBe(true); const tracePayload = await traceResponse.json() as Record; expect((tracePayload.lines || []).join("\n")).toContain("TRACE-EVENT"); expect((tracePayload.lines || []).join("\n")).toContain("TRACE-MAIN-LINE"); - const traceConfigResponse = await fetch(`${fixture.baseUrl}/trace/config?token=${fixture.token}&enable=0¬e=test`); + const traceConfigGetResponse = await authedFetch(`${fixture.baseUrl}/trace/config?enable=0¬e=test`, fixture.token); + expect(traceConfigGetResponse.status).toBe(405); + const traceConfigResponse = await authedFetch(`${fixture.baseUrl}/trace/config?enable=0¬e=test`, fixture.token, { method: "POST" }); expect(traceConfigResponse.ok).toBe(true); const traceConfigPayload = await traceConfigResponse.json() as Record; expect(traceConfigPayload.config?.enabled).toBe(false); - const settingsResponse = await fetch(`${fixture.baseUrl}/settings?token=${fixture.token}`); + const settingsResponse = await authedFetch(`${fixture.baseUrl}/settings`, fixture.token); expect(settingsResponse.ok).toBe(true); const settingsPayload = await settingsResponse.json() as Record; expect(settingsPayload.accounts?.realDebrid?.configured).toBe(true); @@ -525,19 +548,19 @@ describe("debug-server", () => { expect(JSON.stringify(settingsPayload)).not.toContain("key-a"); expect(JSON.stringify(settingsPayload)).not.toContain("key-b"); - const accountsResponse = await fetch(`${fixture.baseUrl}/accounts?token=${fixture.token}`); + const accountsResponse = await authedFetch(`${fixture.baseUrl}/accounts`, fixture.token); expect(accountsResponse.ok).toBe(true); const accountsPayload = await accountsResponse.json() as Record; expect(accountsPayload.debridLink?.keyCount).toBe(2); expect(accountsPayload.debridLink?.disabledKeyCount).toBe(1); - const statsResponse = await fetch(`${fixture.baseUrl}/stats?token=${fixture.token}`); + const statsResponse = await authedFetch(`${fixture.baseUrl}/stats`, fixture.token); expect(statsResponse.ok).toBe(true); const statsPayload = await statsResponse.json() as Record; expect(statsPayload.session?.totalDownloaded).toBeGreaterThan(0); expect(statsPayload.allTime?.totalDownloadedAllTime).toBeGreaterThan(0); - const historyResponse = await fetch(`${fixture.baseUrl}/history?token=${fixture.token}&limit=10`); + const historyResponse = await authedFetch(`${fixture.baseUrl}/history?limit=10`, fixture.token); expect(historyResponse.ok).toBe(true); const historyPayload = await historyResponse.json() as Record; expect(historyPayload.total).toBe(1); @@ -548,9 +571,11 @@ describe("debug-server", () => { it("downloads a support bundle zip", async () => { const fixture = await createFixture(); fs.writeFileSync(path.join(fixture.baseDir, legacyManifestFile), JSON.stringify({ purpose: "legacy" }), "utf8"); - const response = await fetch(`${fixture.baseUrl}/support/bundle?token=${fixture.token}`); - expect(response.ok).toBe(true); - expect(response.headers.get("content-type")).toContain("application/zip"); + const response = await authedFetch(`${fixture.baseUrl}/support/bundle`, fixture.token); + expect(response.ok).toBe(true); + expect(response.headers.get("content-type")).toContain("application/zip"); + expect(response.headers.get("cache-control")).toContain("no-store"); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); const buffer = Buffer.from(await response.arrayBuffer()); const zip = new AdmZip(buffer); @@ -570,9 +595,49 @@ describe("debug-server", () => { expect(entries).not.toContain("runtime/debug_token.txt"); }); - it("rejects unauthenticated requests", async () => { - const fixture = await createFixture(); - const response = await fetch(`${fixture.baseUrl}/status`); - expect(response.status).toBe(401); - }); -}); + it("rejects unauthenticated requests", async () => { + const fixture = await createFixture(); + const response = await fetch(`${fixture.baseUrl}/status`); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain("Bearer"); + }); + + it("accepts bearer auth and rejects query tokens", async () => { + const fixture = await createFixture(); + const bearer = await authedFetch(`${fixture.baseUrl}/health`, fixture.token); + expect(bearer.status).toBe(200); + + const queryOnly = await fetch(`${fixture.baseUrl}/health?token=${fixture.token}`); + expect(queryOnly.status).toBe(400); + const queryOnlyPayload = await queryOnly.json() as Record; + expect(queryOnlyPayload.code).toBe("query_token_rejected"); + + const mixed = await authedFetch(`${fixture.baseUrl}/health?token=bad-fixture-token`, fixture.token); + expect(mixed.status).toBe(400); + }); + + it("rejects unsupported methods with a controlled method response", async () => { + const fixture = await createFixture(); + const response = await authedFetch(`${fixture.baseUrl}/status`, fixture.token, { method: "PUT" }); + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toContain("GET"); + const payload = await response.json() as Record; + expect(payload.code).toBe("method_not_allowed"); + }); + + it("limits repeated loopback requests with a controlled response", async () => { + const fixture = await createFixture(); + let limited: Response | null = null; + for (let i = 0; i < 130; i += 1) { + const response = await authedFetch(`${fixture.baseUrl}/health`, fixture.token); + if (response.status === 429) { + limited = response; + break; + } + } + expect(limited).not.toBeNull(); + expect(limited!.headers.get("retry-after")).toBeTruthy(); + const payload = await limited!.json() as Record; + expect(payload.code).toBe("rate_limited"); + }); +});