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.
This commit is contained in:
Sucukdeluxe
2026-08-12 00:59:07 +02:00
parent ba413010c8
commit ebfd98226b
7 changed files with 601 additions and 365 deletions
@@ -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.
+36 -34
View File
@@ -3,10 +3,10 @@ import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
export type BackupKind = "full" | "settings-only"; export type BackupKind = "full" | "settings-only";
export interface BackupRemoteDiagnostics { export interface BackupRemoteDiagnostics {
allowlist: string[]; allowlist?: string[];
port: number; port?: number;
hostMode: "local" | "network"; hostMode?: "local" | "network";
} }
export interface BackupPayload { export interface BackupPayload {
version: 2; version: 2;
@@ -35,7 +35,7 @@ export interface BuildBackupInput {
* bundled solely when settings.backupIncludeDownloads is true. An explicit kind * bundled solely when settings.backupIncludeDownloads is true. An explicit kind
* marker makes the import side unambiguous and survives hand-edited files. * 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 includeDownloads = Boolean(input.settings.backupIncludeDownloads);
const base: BackupPayload = { const base: BackupPayload = {
version: 2, version: 2,
@@ -49,37 +49,39 @@ export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
base.history = input.history; base.history = input.history;
} }
if (Boolean(input.settings.backupIncludeRemoteDiagnostics) && input.remoteDiagnostics) { if (Boolean(input.settings.backupIncludeRemoteDiagnostics) && input.remoteDiagnostics) {
base.remoteDiagnostics = input.remoteDiagnostics; base.remoteDiagnostics = sanitizeBackupRemoteDiagnostics(input.remoteDiagnostics);
} }
return base; return base;
} }
export interface RemoteDiagnosticsRestore { export interface RemoteDiagnosticsRestore {
host?: "127.0.0.1" | "0.0.0.0"; host?: "127.0.0.1";
port?: number; port?: number;
allowlist?: string[]; 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 { export function resolveRemoteDiagnosticsRestore(section: unknown): RemoteDiagnosticsRestore | null {
if (!section || typeof section !== "object") { if (!section || typeof section !== "object") {
return null; return null;
} }
const s = section as { allowlist?: unknown; port?: unknown; hostMode?: unknown }; const s = section as { allowlist?: unknown };
const allowlist = Array.isArray(s.allowlist) return {
? s.allowlist.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim()) host: "127.0.0.1",
: undefined; allowlist: sanitizeAllowlist(s.allowlist)
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 };
}
export interface ImportPlan { export interface ImportPlan {
valid: boolean; valid: boolean;
+200 -112
View File
@@ -21,16 +21,18 @@ import { getWindowsHostDiagnostics } from "./windows-host-diagnostics";
import type { DownloadManager } from "./download-manager"; import type { DownloadManager } from "./download-manager";
import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types"; import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types";
const DEFAULT_PORT = 9868; const DEFAULT_PORT = 9868;
const DEFAULT_HOST = "127.0.0.1"; const DEFAULT_HOST = "127.0.0.1";
const MAX_LOG_LINES = 10000; const MAX_LOG_LINES = 10000;
const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json"; const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json";
const RATE_LIMIT_WINDOW_MS = 60_000;
type DebugEndpointDescriptor = { const RATE_LIMIT_MAX_REQUESTS = 120;
method: "GET";
path: string; type DebugEndpointDescriptor = {
queryExample?: string; method: "GET" | "POST";
description: string; path: string;
queryExample?: string;
description: string;
}; };
const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [ 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/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/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: "/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: "/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&note=support&durationMinutes=120", description: "Reads or updates the support trace configuration." }, { method: "GET", path: "/trace/config", description: "Reads the support trace configuration." },
{ method: "POST", path: "/trace/config", queryExample: "enable=1&note=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: "/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: "/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." }, { 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 manager: DownloadManager | null = null;
let authToken = ""; let authToken = "";
let bindHost = DEFAULT_HOST; let bindHost = DEFAULT_HOST;
let bindPort = DEFAULT_PORT; let bindPort = DEFAULT_PORT;
let runtimeBaseDir = ""; let runtimeBaseDir = "";
let allowlist: string[] = []; let allowlist: string[] = [];
let requestLimits = new Map<string, { startedAt: number; count: number }>();
export interface DebugServerRuntimeStatus { export interface DebugServerRuntimeStatus {
running: boolean; running: boolean;
@@ -241,27 +245,40 @@ function isClientAllowed(clientIp: string): boolean {
return evaluateClientAllowed(clientIp, allowlist); return evaluateClientAllowed(clientIp, allowlist);
} }
function checkAuth(req: http.IncomingMessage): boolean { function checkAuth(req: http.IncomingMessage): boolean {
if (!authToken) { if (!authToken) {
return false; return false;
} }
const header = req.headers.authorization || ""; const header = typeof req.headers.authorization === "string" ? req.headers.authorization : "";
if (header === `Bearer ${authToken}`) { const match = /^Bearer\s+(.+)$/i.exec(header);
return true; if (!match) {
} return false;
const url = new URL(req.url || "/", "http://localhost"); }
return url.searchParams.get("token") === authToken; const supplied = Buffer.from(match[1] || "", "utf8");
} const expected = Buffer.from(authToken, "utf8");
if (supplied.length !== expected.length) {
function jsonResponse(res: http.ServerResponse, status: number, data: unknown): void { return false;
const body = JSON.stringify(data, null, 2); }
res.writeHead(status, { return crypto.timingSafeEqual(supplied, expected);
"Content-Type": "application/json; charset=utf-8", }
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache" function noStoreHeaders(extra: Record<string, string> = {}): Record<string, string> {
}); return {
res.end(body); "Cache-Control": "no-store",
} "Pragma": "no-cache",
"Expires": "0",
...extra
};
}
function jsonResponse(res: http.ServerResponse, status: number, data: unknown, headers: Record<string, string> = {}): 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( function binaryResponse(
res: http.ServerResponse, res: http.ServerResponse,
@@ -270,15 +287,37 @@ function binaryResponse(
contentType: string, contentType: string,
fileName?: string fileName?: string
): void { ): void {
res.writeHead(status, { res.writeHead(status, {
"Content-Type": contentType, "Content-Type": contentType,
"Content-Length": String(body.length), "Content-Length": String(body.length),
"Access-Control-Allow-Origin": "*", ...noStoreHeaders(),
"Cache-Control": "no-cache", ...(fileName ? { "Content-Disposition": `attachment; filename="${fileName}"` } : {})
...(fileName ? { "Content-Disposition": `attachment; filename="${fileName}"` } : {}) });
}); res.end(body);
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 { function normalizeLinesParam(rawValue: string | null, fallback: number): number {
const parsed = Number(rawValue || String(fallback)); const parsed = Number(rawValue || String(fallback));
@@ -351,11 +390,10 @@ function buildSupportManifest(baseDir: string): Record<string, unknown> {
purpose: "Machine-readable manifest for support tooling and remote troubleshooting.", purpose: "Machine-readable manifest for support tooling and remote troubleshooting.",
auth: { auth: {
required: true, required: true,
methods: [ methods: [
"Authorization: Bearer <token>", "Authorization: Bearer <token>"
"?token=<token>" ],
], tokenFile: path.join(baseDir, "debug_token.txt")
tokenFile: path.join(baseDir, "debug_token.txt")
}, },
runtimeFiles: { runtimeFiles: {
hostFile: path.join(baseDir, "debug_host.txt"), hostFile: path.join(baseDir, "debug_host.txt"),
@@ -374,19 +412,18 @@ function buildSupportManifest(baseDir: string): Record<string, unknown> {
historyFile: path.join(baseDir, "rd_history.json") historyFile: path.join(baseDir, "rd_history.json")
}, },
debugServer: { debugServer: {
enabled: Boolean(authToken), enabled: Boolean(authToken),
host: bindHost, host: bindHost,
port: bindPort, port: bindPort,
localBaseUrl: `http://127.0.0.1:${bindPort}`, localBaseUrl: `http://127.0.0.1:${bindPort}`,
remoteBaseUrlTemplate: `http://<SERVER_IP_OR_DNS>:${bindPort}` remoteBridgeBaseUrlTemplate: `http://127.0.0.1:${bindPort}`
}, },
setupCheckEndpoint: "/debug/setup", setupCheckEndpoint: "/debug/setup",
selfCheckEndpoint: "/self-check", selfCheckEndpoint: "/self-check",
remoteAccessRequirements: [ remoteAccessRequirements: [
"A reachable server IP or DNS name.", "Use a trusted local bridge or tunnel to forward support traffic to the loopback diagnostics port.",
"The configured diagnostics port.", "Send Authorization: Bearer with the token stored in debug_token.txt.",
"The token stored in debug_token.txt.", "Do not expose the diagnostics port directly on LAN or WAN."
"A network route and firewall rule that permit access to the configured port."
], ],
endpoints: DEBUG_ENDPOINTS.map((endpoint) => ({ endpoints: DEBUG_ENDPOINTS.map((endpoint) => ({
...endpoint, ...endpoint,
@@ -499,7 +536,7 @@ function getItemLogPathForQuery(snapshot: UiSnapshot, query: string): { item: Do
return { item: null, logPath: directPath }; return { item: null, logPath: directPath };
} }
function buildStatusPayload(snapshot: UiSnapshot): Record<string, unknown> { function buildStatusPayload(snapshot: UiSnapshot): Record<string, unknown> {
const items = Object.values(snapshot.session.items); const items = Object.values(snapshot.session.items);
const packages = Object.values(snapshot.session.packages); const packages = Object.values(snapshot.session.packages);
@@ -527,33 +564,54 @@ function buildStatusPayload(snapshot: UiSnapshot): Record<string, unknown> {
packages: packages.map((pkg) => summarizePackage(snapshot, pkg, false)), packages: packages.map((pkg) => summarizePackage(snapshot, pkg, false)),
activeItems, activeItems,
failedItems: failedItems.length > 0 ? failedItems : undefined failedItems: failedItems.length > 0 ? failedItems : undefined
}; };
} }
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { function hasTraceMutationParams(url: URL): boolean {
const url = new URL(req.url || "/", "http://localhost"); return ["enable", "includeMainLog", "includeAudit", "logDebugRequests", "note", "durationMinutes"]
const pathname = url.pathname; .some((key) => url.searchParams.has(key));
const traceConfig = getTraceConfig(); }
if (traceConfig.enabled && traceConfig.logDebugRequests) {
logTraceEvent("INFO", "debug-http", "Request", { function allowedMethods(pathname: string, url: URL): string[] {
method: req.method || "GET", if (pathname === "/trace/config") {
url: sanitizeRequestUrlForTrace(req.url || "/"), return hasTraceMutationParams(url) ? ["POST"] : ["GET", "POST"];
clientIp: extractDebugClientIp(req) }
}); return ["GET"];
} }
if (req.method === "OPTIONS") { function rejectMethod(res: http.ServerResponse, methods: string[]): void {
res.writeHead(204, { jsonResponse(res, 405, {
"Access-Control-Allow-Origin": "*", error: "Method Not Allowed",
"Access-Control-Allow-Headers": "Authorization", code: "method_not_allowed",
"Access-Control-Allow-Methods": "GET,OPTIONS" allowedMethods: methods
}); }, {
res.end(); "Allow": methods.join(", ")
return; });
} }
const peerIp = getPeerIp(req); function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
if (!isClientAllowed(peerIp)) { 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) { if (traceConfig.enabled && traceConfig.logDebugRequests) {
logTraceEvent("WARN", "debug-http", "Durch Allowlist blockiert", { logTraceEvent("WARN", "debug-http", "Durch Allowlist blockiert", {
peerIp, peerIp,
@@ -561,21 +619,41 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
url: sanitizeRequestUrlForTrace(req.url || "/") url: sanitizeRequestUrlForTrace(req.url || "/")
}); });
} }
jsonResponse(res, 403, { error: "Forbidden", reason: "Client-IP nicht in Allowlist", clientIp: peerIp }); jsonResponse(res, 403, { error: "Forbidden", reason: "Client-IP nicht in Allowlist", clientIp: peerIp });
return; return;
} }
if (!checkAuth(req)) { const limit = checkRateLimit(peerIp);
if (traceConfig.enabled && traceConfig.logDebugRequests) { if (!limit.allowed) {
logTraceEvent("WARN", "debug-http", "Unauthorized request", { jsonResponse(res, 429, {
method: req.method || "GET", error: "Too Many Requests",
url: sanitizeRequestUrlForTrace(req.url || "/"), code: "rate_limited",
clientIp: extractDebugClientIp(req) retryAfterSeconds: limit.retryAfterSeconds
}); }, {
} "Retry-After": String(limit.retryAfterSeconds)
jsonResponse(res, 401, { error: "Unauthorized" }); });
return; 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") { if (pathname === "/health") {
jsonResponse(res, 200, { jsonResponse(res, 200, {
@@ -719,8 +797,16 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return; return;
} }
if (pathname === "/trace/config") { if (pathname === "/trace/config") {
const patch: Record<string, unknown> = {}; if (method === "GET") {
jsonResponse(res, 200, {
path: getTraceConfigPath(),
logPath: getTraceLogPath(),
config: getTraceConfig()
});
return;
}
const patch: Record<string, unknown> = {};
const enabled = toBooleanQuery(url.searchParams.get("enable")); const enabled = toBooleanQuery(url.searchParams.get("enable"));
const includeMainLog = toBooleanQuery(url.searchParams.get("includeMainLog")); const includeMainLog = toBooleanQuery(url.searchParams.get("includeMainLog"));
const includeAudit = toBooleanQuery(url.searchParams.get("includeAudit")); const includeAudit = toBooleanQuery(url.searchParams.get("includeAudit"));
@@ -1055,9 +1141,10 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
}); });
} }
function openServerSocket(): Promise<void> { function openServerSocket(): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
authToken = loadToken(runtimeBaseDir); requestLimits = new Map();
authToken = loadToken(runtimeBaseDir);
bindPort = getPort(runtimeBaseDir); bindPort = getPort(runtimeBaseDir);
bindHost = getHost(runtimeBaseDir); bindHost = getHost(runtimeBaseDir);
allowlist = loadAllowlist(runtimeBaseDir); allowlist = loadAllowlist(runtimeBaseDir);
@@ -1168,14 +1255,15 @@ export function clearDebugToken(): void {
writeSupportManifest(runtimeBaseDir); writeSupportManifest(runtimeBaseDir);
} }
export function stopDebugServer(): void { export function stopDebugServer(): void {
if (server) { if (server) {
server.close(); server.close();
try { try {
server.closeAllConnections?.(); server.closeAllConnections?.();
} catch { } catch {
} }
server = null; server = null;
logger.info("Debug-Server gestoppt"); requestLimits = new Map();
} logger.info("Debug-Server gestoppt");
} }
}
+20 -20
View File
@@ -348,14 +348,14 @@ export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult {
}; };
const supportBundle = getSupportBundleEstimate(baseDir, logSummary); const supportBundle = getSupportBundleEstimate(baseDir, logSummary);
if (!token) { if (!token) {
warnings.push("debug_token.txt fehlt oder ist leer. Der Debug-Server startet dann nicht."); warnings.push("debug_token.txt fehlt oder ist leer. Der Debug-Server startet dann nicht.");
} }
if (localOnly) { 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."); notes.push("Der Debug-Server ist lokal gebunden. Remote-Support nutzt eine vertrauenswürdige Bridge oder einen Tunnel auf diese lokale Adresse.");
} else { } else {
notes.push("Der Debug-Server ist für Remote-Zugriff konfiguriert. Firewall oder Provider-Regeln müssen separat offen sein."); 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)) { if (!fs.existsSync(supportManifestPath)) {
warnings.push("debug_support_manifest.json fehlt. App einmal neu starten, damit das Support-Manifest neu geschrieben wird."); 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, supportBundle,
warnings, warnings,
notes, notes,
localUrls: { localUrls: {
health: `http://127.0.0.1:${port}/health?token=${token || "<TOKEN>"}`, health: `http://127.0.0.1:${port}/health`,
meta: `http://127.0.0.1:${port}/meta?token=${token || "<TOKEN>"}`, meta: `http://127.0.0.1:${port}/meta`,
diagnostics: `http://127.0.0.1:${port}/diagnostics?token=${token || "<TOKEN>"}` diagnostics: `http://127.0.0.1:${port}/diagnostics`
}, },
remoteUrlTemplates: { remoteUrlTemplates: {
health: `http://<SERVER_IP_OR_DNS>:${port}/health?token=${token || "<TOKEN>"}`, health: `http://127.0.0.1:${port}/health`,
meta: `http://<SERVER_IP_OR_DNS>:${port}/meta?token=${token || "<TOKEN>"}`, meta: `http://127.0.0.1:${port}/meta`,
diagnostics: `http://<SERVER_IP_OR_DNS>:${port}/diagnostics?token=${token || "<TOKEN>"}` diagnostics: `http://127.0.0.1:${port}/diagnostics`
} }
}; };
} }
+94 -70
View File
@@ -45,20 +45,24 @@ async function getFreePort(): Promise<number> {
return address.port; return address.port;
} }
async function waitForReady(url: string): Promise<void> { async function waitForReady(url: string, token = "rt-secret"): Promise<void> {
const deadline = Date.now() + 5000; const deadline = Date.now() + 5000;
while (Date.now() < deadline) { while (Date.now() < deadline) {
try { try {
const res = await fetch(url); const res = await fetch(url, { headers: bearerHeaders(token) });
if (res.ok) { if (res.ok) {
return; return;
} }
} catch { } catch {
} }
await new Promise((resolve) => setTimeout(resolve, 40)); 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(() => { afterEach(() => {
stopDebugServer(); stopDebugServer();
@@ -75,7 +79,7 @@ afterEach(() => {
}); });
describe("backup remoteDiagnostics export gating", () => { describe("backup remoteDiagnostics export gating", () => {
it("includes remoteDiagnostics when backupIncludeRemoteDiagnostics is on", () => { it("includes only sanitized remoteDiagnostics when backupIncludeRemoteDiagnostics is on", () => {
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
backupIncludeRemoteDiagnostics: true backupIncludeRemoteDiagnostics: true
@@ -89,14 +93,15 @@ describe("backup remoteDiagnostics export gating", () => {
remoteDiagnostics: { remoteDiagnostics: {
allowlist: ["192.0.2.0/24"], allowlist: ["192.0.2.0/24"],
port: 8976, 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({ expect(payload.remoteDiagnostics).toEqual({
allowlist: ["192.0.2.0/24"], allowlist: ["192.0.2.0/24"]
port: 8976,
hostMode: "network"
}); });
}); });
@@ -110,15 +115,18 @@ describe("backup remoteDiagnostics export gating", () => {
expect(payload.remoteDiagnostics).toBeUndefined(); 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" })); 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); const sectionJson = JSON.stringify(payload.remoteDiagnostics);
expect(sectionJson.toLowerCase()).not.toContain("token"); expect(sectionJson.toLowerCase()).not.toContain("token");
expect(sectionJson).not.toContain("publicHost"); expect(sectionJson).not.toContain("publicHost");
expect(sectionJson.toLowerCase()).not.toContain("\"name\""); 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", () => { describe("backupIncludeRemoteDiagnostics settings persistence", () => {
it("normalizeSettings preserves backupIncludeRemoteDiagnostics (the toggle survives save/load)", () => { it("normalizeSettings preserves backupIncludeRemoteDiagnostics (the toggle survives save/load)", () => {
@@ -129,35 +137,51 @@ describe("backupIncludeRemoteDiagnostics settings persistence", () => {
}); });
describe("resolveRemoteDiagnosticsRestore", () => { 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" })) expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }))
.toEqual({ host: "0.0.0.0", port: 9868, allowlist: ["10.0.0.5"] }); .toEqual({ host: "127.0.0.1", allowlist: ["10.0.0.5"] });
}); });
it("SAFETY: network with EMPTY allowlist binds local, never 0.0.0.0", () => { it("scrubs legacy token and endpoint fields during restore planning", () => {
expect(resolveRemoteDiagnosticsRestore({ allowlist: [], port: 9868, hostMode: "network" })?.host).toBe("127.0.0.1"); const restore = resolveRemoteDiagnosticsRestore({
}); allowlist: ["198.51.100.8"],
port: 9999,
it("maps local to 127.0.0.1", () => { hostMode: "network",
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "local" })?.host).toBe("127.0.0.1"); token: "legacy-backup-token",
}); publicHost: "legacy.example.test",
endpoint: "http://legacy.example.test:9999"
it("rejects an out-of-range or non-integer port", () => { });
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })?.port).toBeUndefined(); expect(restore).toEqual({ host: "127.0.0.1", allowlist: ["198.51.100.8"] });
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })?.port).toBeUndefined(); expect(JSON.stringify(restore).toLowerCase()).not.toContain("token");
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868.5, hostMode: "network" })?.port).toBeUndefined(); 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", () => { 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" }); 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"]); 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(undefined)).toBeNull();
expect(resolveRemoteDiagnosticsRestore(null)).toBeNull(); expect(resolveRemoteDiagnosticsRestore(null)).toBeNull();
expect(resolveRemoteDiagnosticsRestore("x")).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); tempDirs.push(baseDir);
const startPort = await getFreePort(); const startPort = await getFreePort();
const restorePort = 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_port.txt"), String(startPort), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8"); fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8"); fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
startDebugServer({} as unknown as DownloadManager, baseDir); 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([]); expect(getDebugAllowlist()).toEqual([]);
const payload = buildBackupPayload(input( const payload = buildBackupPayload(input(
@@ -186,35 +210,35 @@ describe("backup remoteDiagnostics live restore round-trip", () => {
const status = await restartDebugServer(); const status = await restartDebugServer();
expect(getDebugAllowlist()).toEqual(["203.0.113.4", "10.0.0.0/24"]); expect(getDebugAllowlist()).toEqual(["203.0.113.4", "10.0.0.0/24"]);
expect(status.port).toBe(restorePort); expect(status.port).toBe(startPort);
expect(status.host).toBe("0.0.0.0"); expect(status.host).toBe("127.0.0.1");
expect(status.allowlistCount).toBe(2); expect(status.allowlistCount).toBe(2);
expect(fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim()).toBe("rt-secret"); 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); expect(fs.existsSync(path.join(baseDir, "debug_remote.json"))).toBe(false);
await waitForReady(`http://127.0.0.1:${restorePort}/health?token=rt-secret`); await waitForReady(`http://127.0.0.1:${startPort}/health`);
}); });
it("full-backup path writes the debug_* files to disk without a restart (boot picks them up)", async () => { 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-")); const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-backup-remote2-"));
tempDirs.push(baseDir); tempDirs.push(baseDir);
const startPort = await getFreePort(); 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_port.txt"), String(startPort), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8"); fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8"); fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
startDebugServer({} as unknown as DownloadManager, baseDir); 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" }); const restore = resolveRemoteDiagnosticsRestore({ allowlist: ["198.51.100.9"], port: 9100, hostMode: "network" });
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist }); 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_host.txt"), "utf8").trim()).toBe("127.0.0.1");
expect(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim()).toBe("9100"); 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(fs.readFileSync(path.join(baseDir, "debug_allowlist.txt"), "utf8")).toContain("198.51.100.9");
expect(getDebugServerRuntimeStatus().port).toBe(startPort); expect(getDebugServerRuntimeStatus().port).toBe(startPort);
}); });
}); });
describe("debug-server live diagnostics endpoints", () => { 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-")); const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-prov-"));
tempDirs.push(baseDir); tempDirs.push(baseDir);
const port = await getFreePort(); 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_port.txt"), String(port), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8"); fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8"); fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
startDebugServer({} as unknown as DownloadManager, baseDir); startDebugServer({} as unknown as DownloadManager, baseDir);
await waitForReady(`http://127.0.0.1:${port}/health?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?token=prov-secret`); const provRes = await fetch(`http://127.0.0.1:${port}/providers`, { headers: bearerHeaders("prov-secret") });
expect(provRes.status).toBe(200); expect(provRes.status).toBe(200);
const prov = await provRes.json(); const prov = await provRes.json();
expect(typeof prov.capturedAtMs).toBe("number"); 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`); const unauth = await fetch(`http://127.0.0.1:${port}/providers`);
expect(unauth.status).toBe(401); 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); expect(convRes.status).toBe(200);
const conv = await convRes.json(); const conv = await convRes.json();
expect(Array.isArray(conv.lines)).toBe(true); expect(Array.isArray(conv.lines)).toBe(true);
+67 -44
View File
@@ -32,35 +32,51 @@ async function getFreePort(): Promise<number> {
return address.port; return address.port;
} }
async function waitForReady(url: string): Promise<void> { async function waitForReady(url: string): Promise<void> {
const deadline = Date.now() + 5000; const deadline = Date.now() + 5000;
while (Date.now() < deadline) { while (Date.now() < deadline) {
try { try {
const res = await fetch(url); const res = await fetch(url, { headers: bearerHeaders(TOKEN) });
if (res.ok) { if (res.ok) {
return; return;
} }
} catch { } catch {
} }
await new Promise((resolve) => setTimeout(resolve, 40)); await new Promise((resolve) => setTimeout(resolve, 40));
} }
throw new Error(`debug server not ready: ${url}`); throw new Error(`debug server not ready: ${url}`);
} }
async function startWithAllowlist(allowlist: string[], host = "0.0.0.0"): Promise<{ baseUrl: string }> { function bearerHeaders(token: string): HeadersInit {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-allow-")); return { Authorization: `Bearer ${token}` };
tempDirs.push(baseDir); }
const port = await getFreePort();
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), TOKEN, "utf8"); function authedFetch(url: string, init: RequestInit = {}): Promise<Response> {
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8"); return fetch(url, {
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), host, "utf8"); ...init,
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), allowlist.join("\n"), "utf8"); headers: {
const manager = {} as unknown as DownloadManager; ...(init.headers || {}),
startDebugServer(manager, baseDir); ...bearerHeaders(TOKEN)
const baseUrl = `http://127.0.0.1:${port}`; }
await waitForReady(`${baseUrl}/health?token=${TOKEN}`); });
return { baseUrl }; }
}
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(() => { afterEach(() => {
stopDebugServer(); stopDebugServer();
@@ -116,14 +132,21 @@ describe("debug-server allowlist matcher (pure)", () => {
}); });
}); });
describe("debug-server allowlist enforcement (wired)", () => { describe("debug-server allowlist enforcement (wired)", () => {
it("allows a loopback connection and ignores a spoofed X-Forwarded-For", async () => { it("binds to loopback when no host file exists", async () => {
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]); await startWithAllowlist([], "0.0.0.0", false);
const plain = await fetch(`${baseUrl}/health?token=${TOKEN}`); const status = getDebugServerRuntimeStatus();
expect(plain.status).toBe(200); expect(status.host).toBe("127.0.0.1");
const spoofed = await fetch(`${baseUrl}/health?token=${TOKEN}`, { expect(status.localOnly).toBe(true);
headers: { "X-Forwarded-For": "203.0.113.9" } });
});
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); expect(spoofed.status).toBe(200);
}); });
@@ -133,14 +156,14 @@ describe("debug-server allowlist enforcement (wired)", () => {
expect(res.status).toBe(401); expect(res.status).toBe(401);
}); });
it("reloads the allowlist live via restartDebugServer", async () => { it("reloads the allowlist live via restartDebugServer", async () => {
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]); const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
expect(getDebugServerRuntimeStatus().allowlistCount).toBe(1); expect(getDebugServerRuntimeStatus().allowlistCount).toBe(1);
writeDebugServerConfig({ allowlist: ["9.9.9.9", "10.0.0.0/24"] }); writeDebugServerConfig({ allowlist: ["9.9.9.9", "10.0.0.0/24"] });
const status = await restartDebugServer(); const status = await restartDebugServer();
expect(status.running).toBe(true); expect(status.running).toBe(true);
expect(status.allowlistCount).toBe(2); expect(status.allowlistCount).toBe(2);
await waitForReady(`${baseUrl}/health?token=${TOKEN}`); await waitForReady(`${baseUrl}/health`);
expect((await fetch(`${baseUrl}/health?token=${TOKEN}`)).status).toBe(200); expect((await authedFetch(`${baseUrl}/health`)).status).toBe(200);
}); });
}); });
+150 -85
View File
@@ -94,20 +94,34 @@ async function getFreePort(): Promise<number> {
return address.port; return address.port;
} }
async function waitForReady(url: string): Promise<void> { async function waitForReady(url: string): Promise<void> {
const deadline = Date.now() + 5000; const deadline = Date.now() + 5000;
while (Date.now() < deadline) { while (Date.now() < deadline) {
try { try {
const response = await fetch(url); const response = await fetch(url, { headers: bearerHeaders("debug-secret") });
if (response.ok) { if (response.ok) {
return; return;
} }
} catch { } catch {
} }
await new Promise((resolve) => setTimeout(resolve, 50)); 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<Response> {
return fetch(url, {
...init,
headers: {
...(init.headers || {}),
...bearerHeaders(token)
}
});
}
function buildSnapshot(baseDir: string): UiSnapshot { function buildSnapshot(baseDir: string): UiSnapshot {
const settings = { const settings = {
@@ -225,10 +239,9 @@ async function createFixture() {
const snapshot = buildSnapshot(baseDir); const snapshot = buildSnapshot(baseDir);
const storagePaths = createStoragePaths(baseDir); const storagePaths = createStoragePaths(baseDir);
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), token, "utf8"); 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_port.txt"), String(port), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "0.0.0.0", "utf8"); const debridLinkApiKeys = "key-a\nkey-b";
const debridLinkApiKeys = "key-a\nkey-b";
const debridLinkKeyIds = getDebridLinkApiKeyIds(debridLinkApiKeys); const debridLinkKeyIds = getDebridLinkApiKeyIds(debridLinkApiKeys);
saveSettings(storagePaths, { saveSettings(storagePaths, {
@@ -313,10 +326,10 @@ async function createFixture() {
getItemLogPath: (itemId: string) => itemId === "item-2" ? itemLogPath : null getItemLogPath: (itemId: string) => itemId === "item-2" ? itemLogPath : null
} as unknown as DownloadManager; } as unknown as DownloadManager;
startDebugServer(manager, baseDir); startDebugServer(manager, baseDir);
const baseUrl = `http://127.0.0.1:${port}`; const baseUrl = `http://127.0.0.1:${port}`;
await waitForReady(`${baseUrl}/health?token=${token}`); await waitForReady(`${baseUrl}/health`);
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
return { return {
baseUrl, baseUrl,
@@ -345,15 +358,17 @@ afterEach(() => {
} }
}); });
describe("debug-server", () => { describe("debug-server", () => {
it("serves diagnostics with main, session, and package log tails", async () => { it("serves diagnostics with main, session, and package log tails", async () => {
const fixture = await createFixture(); const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/diagnostics?token=${fixture.token}&package=server-package&lines=20`); const response = await authedFetch(`${fixture.baseUrl}/diagnostics?package=server-package&lines=20`, fixture.token);
expect(response.ok).toBe(true); expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, any>; expect(response.headers.get("cache-control")).toContain("no-store");
expect(response.headers.get("access-control-allow-origin")).toBeNull();
expect(payload.meta?.appVersion).toBeTruthy(); const payload = await response.json() as Record<string, any>;
expect(payload.meta?.debugServer?.host).toBe("0.0.0.0");
expect(payload.meta?.appVersion).toBeTruthy();
expect(payload.meta?.debugServer?.host).toBe("127.0.0.1");
expect(payload.status?.running).toBe(true); expect(payload.status?.running).toBe(true);
expect(payload.host?.platform).toBe("win32"); expect(payload.host?.platform).toBe("win32");
expect(payload.host?.recentKernelPower?.[0]?.id).toBe(41); 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.toMatch(new RegExp(`\\b(?:${forbiddenSupportMarkers.join("|")})\\b`, "i"));
expect(JSON.stringify(manifest)).not.toContain(fixture.token); expect(JSON.stringify(manifest)).not.toContain(fixture.token);
expect(manifest.debugServer?.port).toBeGreaterThan(0); expect(manifest.debugServer?.port).toBeGreaterThan(0);
expect(manifest.debugServer?.remoteBaseUrlTemplate).toContain("<SERVER_IP_OR_DNS>"); expect(manifest.auth?.methods).toEqual(["Authorization: Bearer <token>"]);
expect(manifest.remoteAccessRequirements).toContain("A reachable server IP or DNS name."); 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.setupCheckEndpoint).toBe("/debug/setup");
expect(manifest.selfCheckEndpoint).toBe("/self-check"); expect(manifest.selfCheckEndpoint).toBe("/self-check");
expect(manifest.runtimeFiles?.tokenFile).toContain("debug_token.txt"); expect(manifest.runtimeFiles?.tokenFile).toContain("debug_token.txt");
expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/diagnostics")).toBe(true); expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/diagnostics")).toBe(true);
expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/logs/main")).toBe(true); expect(manifest.endpoints?.some((entry: Record<string, any>) => 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); expect(metaResponse.ok).toBe(true);
const metaPayload = await metaResponse.json() as Record<string, any>; const metaPayload = await metaResponse.json() as Record<string, any>;
expect(metaPayload.supportFiles?.supportManifest).toBe(manifestPath); expect(metaPayload.supportFiles?.supportManifest).toBe(manifestPath);
@@ -399,19 +416,19 @@ describe("debug-server", () => {
expect(metaPayload.supportChecks?.selfCheck).toBe("/self-check"); expect(metaPayload.supportChecks?.selfCheck).toBe("/self-check");
}); });
it("serves a debug setup check with trace expiry details", async () => { it("serves a debug setup check with trace expiry details", async () => {
const fixture = await createFixture(); const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/debug/setup?token=${fixture.token}`); const response = await authedFetch(`${fixture.baseUrl}/debug/setup`, fixture.token);
expect(response.ok).toBe(true); expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, any>; const payload = await response.json() as Record<string, any>;
expect(payload.enabled).toBe(true); expect(payload.enabled).toBe(true);
expect(Array.isArray(payload.warnings)).toBe(true); expect(Array.isArray(payload.warnings)).toBe(true);
expect(payload.status).toBe(payload.warnings.length > 0 ? "warn" : "ok"); expect(payload.status).toBe(payload.warnings.length > 0 ? "warn" : "ok");
expect(payload.runtimeBaseDir).toBe(fixture.baseDir); expect(payload.runtimeBaseDir).toBe(fixture.baseDir);
expect(payload.host).toBe("0.0.0.0"); expect(payload.host).toBe("127.0.0.1");
expect(payload.localOnly).toBe(false); expect(payload.localOnly).toBe(true);
expect(payload.tokenConfigured).toBe(true); expect(payload.tokenConfigured).toBe(true);
expect(payload.supportManifestPresent).toBe(true); expect(payload.supportManifestPresent).toBe(true);
expect(payload[`${legacyManifestField}Present`]).toBeUndefined(); expect(payload[`${legacyManifestField}Present`]).toBeUndefined();
expect(payload.supportManifestPath).toBe(path.join(fixture.baseDir, "debug_support_manifest.json")); 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?.totalBytes).toBeGreaterThan(0);
expect(payload.logSummary?.rename?.bytes).toBeGreaterThan(0); expect(payload.logSummary?.rename?.bytes).toBeGreaterThan(0);
expect(payload.logSummary?.packageLogs?.fileCount).toBe(1); expect(payload.logSummary?.packageLogs?.fileCount).toBe(1);
expect(payload.logSummary?.itemLogs?.fileCount).toBe(1); expect(payload.logSummary?.itemLogs?.fileCount).toBe(1);
expect(payload.supportBundle?.estimatedBytes).toBeGreaterThan(0); expect(payload.supportBundle?.estimatedBytes).toBeGreaterThan(0);
expect(payload.remoteUrlTemplates?.health).toContain("<SERVER_IP_OR_DNS>"); expect(JSON.stringify(payload.localUrls)).not.toContain(fixture.token);
expect(Array.isArray(payload.notes)).toBe(true); 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=");
it("serves the self-check alias", async () => { expect(payload.remoteUrlTemplates?.health).toContain("127.0.0.1");
const fixture = await createFixture(); expect(Array.isArray(payload.notes)).toBe(true);
const response = await fetch(`${fixture.baseUrl}/self-check?token=${fixture.token}`); });
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); expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, any>; const payload = await response.json() as Record<string, any>;
expect(Array.isArray(payload.warnings)).toBe(true); expect(Array.isArray(payload.warnings)).toBe(true);
@@ -439,12 +460,12 @@ describe("debug-server", () => {
expect(payload.supportBundle?.estimatedEntries).toBeGreaterThan(0); expect(payload.supportBundle?.estimatedEntries).toBeGreaterThan(0);
}); });
it("writes the client IP into the debug trace log", async () => { it("writes the client IP into the debug trace log", async () => {
const fixture = await createFixture(); const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/health?token=${fixture.token}`, { const response = await authedFetch(`${fixture.baseUrl}/health`, fixture.token, {
headers: { headers: {
"X-Forwarded-For": "203.0.113.46" "X-Forwarded-For": "203.0.113.46"
} }
}); });
expect(response.ok).toBe(true); expect(response.ok).toBe(true);
@@ -455,26 +476,26 @@ describe("debug-server", () => {
expect(traceText).toContain("clientIp=203.0.113.46"); expect(traceText).toContain("clientIp=203.0.113.46");
}); });
it("serves package details and package log by package query", async () => { it("serves package details and package log by package query", async () => {
const fixture = await createFixture(); const fixture = await createFixture();
const packagesResponse = await fetch(`${fixture.baseUrl}/packages?token=${fixture.token}&package=server&includeItems=1`); const packagesResponse = await authedFetch(`${fixture.baseUrl}/packages?package=server&includeItems=1`, fixture.token);
expect(packagesResponse.ok).toBe(true); expect(packagesResponse.ok).toBe(true);
const packagesPayload = await packagesResponse.json() as Record<string, any>; const packagesPayload = await packagesResponse.json() as Record<string, any>;
expect(packagesPayload.count).toBe(1); expect(packagesPayload.count).toBe(1);
expect(packagesPayload.packages?.[0]?.items?.length).toBe(2); 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); expect(logResponse.ok).toBe(true);
const logPayload = await logResponse.json() as Record<string, any>; const logPayload = await logResponse.json() as Record<string, any>;
expect(logPayload.package?.name).toBe("server-package"); expect(logPayload.package?.name).toBe("server-package");
expect((logPayload.lines || []).join("\n")).toContain("PACKAGE-LINE"); expect((logPayload.lines || []).join("\n")).toContain("PACKAGE-LINE");
}); });
it("serves item log by item query", async () => { it("serves item log by item query", async () => {
const fixture = await createFixture(); const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/logs/item?token=${fixture.token}&item=episode.part2.rar&lines=20`); const response = await authedFetch(`${fixture.baseUrl}/logs/item?item=episode.part2.rar&lines=20`, fixture.token);
expect(response.ok).toBe(true); expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, any>; const payload = await response.json() as Record<string, any>;
expect(payload.item?.id).toBe("item-2"); expect(payload.item?.id).toBe("item-2");
@@ -482,9 +503,9 @@ describe("debug-server", () => {
expect((payload.lines || []).join("\n")).toContain("ITEM-LINE"); expect((payload.lines || []).join("\n")).toContain("ITEM-LINE");
}); });
it("serves host diagnostics separately", async () => { it("serves host diagnostics separately", async () => {
const fixture = await createFixture(); const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/host/diagnostics?token=${fixture.token}`); const response = await authedFetch(`${fixture.baseUrl}/host/diagnostics`, fixture.token);
expect(response.ok).toBe(true); expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, any>; const payload = await response.json() as Record<string, any>;
expect(payload.platform).toBe("win32"); expect(payload.platform).toBe("win32");
@@ -493,30 +514,32 @@ describe("debug-server", () => {
}); });
it("serves audit log, settings, accounts, stats, and history", async () => { it("serves audit log, settings, accounts, stats, and history", async () => {
const fixture = await createFixture(); const fixture = await createFixture();
const auditResponse = await fetch(`${fixture.baseUrl}/logs/audit?token=${fixture.token}&lines=20`); const auditResponse = await authedFetch(`${fixture.baseUrl}/logs/audit?lines=20`, fixture.token);
expect(auditResponse.ok).toBe(true); expect(auditResponse.ok).toBe(true);
const auditPayload = await auditResponse.json() as Record<string, any>; const auditPayload = await auditResponse.json() as Record<string, any>;
expect((auditPayload.lines || []).join("\n")).toContain("AUDIT-LINE"); 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); expect(renameResponse.ok).toBe(true);
const renamePayload = await renameResponse.json() as Record<string, any>; const renamePayload = await renameResponse.json() as Record<string, any>;
expect((renamePayload.lines || []).join("\n")).toContain("RENAME-LINE"); 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); expect(traceResponse.ok).toBe(true);
const tracePayload = await traceResponse.json() as Record<string, any>; const tracePayload = await traceResponse.json() as Record<string, any>;
expect((tracePayload.lines || []).join("\n")).toContain("TRACE-EVENT"); expect((tracePayload.lines || []).join("\n")).toContain("TRACE-EVENT");
expect((tracePayload.lines || []).join("\n")).toContain("TRACE-MAIN-LINE"); expect((tracePayload.lines || []).join("\n")).toContain("TRACE-MAIN-LINE");
const traceConfigResponse = await fetch(`${fixture.baseUrl}/trace/config?token=${fixture.token}&enable=0&note=test`); const traceConfigGetResponse = await authedFetch(`${fixture.baseUrl}/trace/config?enable=0&note=test`, fixture.token);
expect(traceConfigGetResponse.status).toBe(405);
const traceConfigResponse = await authedFetch(`${fixture.baseUrl}/trace/config?enable=0&note=test`, fixture.token, { method: "POST" });
expect(traceConfigResponse.ok).toBe(true); expect(traceConfigResponse.ok).toBe(true);
const traceConfigPayload = await traceConfigResponse.json() as Record<string, any>; const traceConfigPayload = await traceConfigResponse.json() as Record<string, any>;
expect(traceConfigPayload.config?.enabled).toBe(false); 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); expect(settingsResponse.ok).toBe(true);
const settingsPayload = await settingsResponse.json() as Record<string, any>; const settingsPayload = await settingsResponse.json() as Record<string, any>;
expect(settingsPayload.accounts?.realDebrid?.configured).toBe(true); 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-a");
expect(JSON.stringify(settingsPayload)).not.toContain("key-b"); 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); expect(accountsResponse.ok).toBe(true);
const accountsPayload = await accountsResponse.json() as Record<string, any>; const accountsPayload = await accountsResponse.json() as Record<string, any>;
expect(accountsPayload.debridLink?.keyCount).toBe(2); expect(accountsPayload.debridLink?.keyCount).toBe(2);
expect(accountsPayload.debridLink?.disabledKeyCount).toBe(1); 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); expect(statsResponse.ok).toBe(true);
const statsPayload = await statsResponse.json() as Record<string, any>; const statsPayload = await statsResponse.json() as Record<string, any>;
expect(statsPayload.session?.totalDownloaded).toBeGreaterThan(0); expect(statsPayload.session?.totalDownloaded).toBeGreaterThan(0);
expect(statsPayload.allTime?.totalDownloadedAllTime).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); expect(historyResponse.ok).toBe(true);
const historyPayload = await historyResponse.json() as Record<string, any>; const historyPayload = await historyResponse.json() as Record<string, any>;
expect(historyPayload.total).toBe(1); expect(historyPayload.total).toBe(1);
@@ -548,9 +571,11 @@ describe("debug-server", () => {
it("downloads a support bundle zip", async () => { it("downloads a support bundle zip", async () => {
const fixture = await createFixture(); const fixture = await createFixture();
fs.writeFileSync(path.join(fixture.baseDir, legacyManifestFile), JSON.stringify({ purpose: "legacy" }), "utf8"); fs.writeFileSync(path.join(fixture.baseDir, legacyManifestFile), JSON.stringify({ purpose: "legacy" }), "utf8");
const response = await fetch(`${fixture.baseUrl}/support/bundle?token=${fixture.token}`); const response = await authedFetch(`${fixture.baseUrl}/support/bundle`, fixture.token);
expect(response.ok).toBe(true); expect(response.ok).toBe(true);
expect(response.headers.get("content-type")).toContain("application/zip"); 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 buffer = Buffer.from(await response.arrayBuffer());
const zip = new AdmZip(buffer); const zip = new AdmZip(buffer);
@@ -570,9 +595,49 @@ describe("debug-server", () => {
expect(entries).not.toContain("runtime/debug_token.txt"); expect(entries).not.toContain("runtime/debug_token.txt");
}); });
it("rejects unauthenticated requests", async () => { it("rejects unauthenticated requests", async () => {
const fixture = await createFixture(); const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/status`); const response = await fetch(`${fixture.baseUrl}/status`);
expect(response.status).toBe(401); 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<string, any>;
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<string, any>;
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<string, any>;
expect(payload.code).toBe("rate_limited");
});
});