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);