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);
|
||||
|
||||
Reference in New Issue
Block a user