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:
+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