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
+94 -70
View File
@@ -45,20 +45,24 @@ async function getFreePort(): Promise<number> {
return address.port;
}
async function waitForReady(url: string): Promise<void> {
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<void> {
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);
+67 -44
View File
@@ -32,35 +32,51 @@ async function getFreePort(): Promise<number> {
return address.port;
}
async function waitForReady(url: string): Promise<void> {
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<void> {
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<Response> {
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);
});
});
+150 -85
View File
@@ -94,20 +94,34 @@ async function getFreePort(): Promise<number> {
return address.port;
}
async function waitForReady(url: string): Promise<void> {
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<void> {
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<Response> {
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<string, any>;
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<string, any>;
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("<SERVER_IP_OR_DNS>");
expect(manifest.remoteAccessRequirements).toContain("A reachable server IP or DNS name.");
expect(manifest.auth?.methods).toEqual(["Authorization: Bearer <token>"]);
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<string, any>) => entry.path === "/diagnostics")).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);
const metaPayload = await metaResponse.json() as Record<string, any>;
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<string, any>;
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<string, any>;
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("<SERVER_IP_OR_DNS>");
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<string, any>;
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<string, any>;
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<string, any>;
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<string, any>;
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<string, any>;
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<string, any>;
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<string, any>;
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<string, any>;
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&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);
const traceConfigPayload = await traceConfigResponse.json() as Record<string, any>;
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<string, any>;
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<string, any>;
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<string, any>;
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<string, any>;
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<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");
});
});