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