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:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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¬e=test`);
|
||||
const traceConfigGetResponse = await authedFetch(`${fixture.baseUrl}/trace/config?enable=0¬e=test`, fixture.token);
|
||||
expect(traceConfigGetResponse.status).toBe(405);
|
||||
const traceConfigResponse = await authedFetch(`${fixture.baseUrl}/trace/config?enable=0¬e=test`, fixture.token, { method: "POST" });
|
||||
expect(traceConfigResponse.ok).toBe(true);
|
||||
const traceConfigPayload = await traceConfigResponse.json() as Record<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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user