fix(security): require auth for db-backups export/import under requireLogin=false

/api/db-backups/export and /import sat outside ALWAYS_PROTECTED_API_PATHS, so with
requireLogin=false an anonymous caller could stream the full SQLite database
(api_keys, provider credentials, OAuth tokens) or replace it wholesale. Adding
/api/db-backups to the Tier-2 allowlist requires a credential for all three
sibling routes, matching the trade-off /api/settings/database already makes.

Reported by @ntdat812 via GHSA-mghq-58h3-qcqj.
This commit is contained in:
Xiangzhe
2026-08-21 13:05:05 -03:00
parent 64b8ffffc1
commit 49a4ad31e4
2 changed files with 14 additions and 0 deletions

View File

@@ -119,6 +119,11 @@ export const ALWAYS_PROTECTED_API_PATHS: ReadonlyArray<string> = [
"/api/shutdown",
"/api/providers/health-autopilot/actions",
"/api/settings/database",
// Full-database export/import: a credential dump and an irreversible replace.
// Must stay authenticated even under requireLogin=false, for the same reason
// /api/settings/database already does. isAlwaysProtectedPath matches on a path
// boundary, so this covers export, exportAll and import. (GHSA-mghq-58h3-qcqj)
"/api/db-backups",
];
export function isLoopbackHost(hostHeader: string | null): boolean {

View File

@@ -58,6 +58,15 @@ test("isAlwaysProtectedPath: /api/settings/database is always protected", () =>
assert.equal(isAlwaysProtectedPath("/api/settings/database"), true);
});
test("isAlwaysProtectedPath: /api/db-backups is always protected (GHSA-mghq-58h3-qcqj)", () => {
// Full-database read/replace must require auth even when requireLogin=false —
// the same Tier-2 trade-off /api/settings/database already makes. The single
// prefix entry covers export, exportAll, import and future siblings.
assert.equal(isAlwaysProtectedPath("/api/db-backups/export"), true);
assert.equal(isAlwaysProtectedPath("/api/db-backups/exportAll"), true);
assert.equal(isAlwaysProtectedPath("/api/db-backups/import"), true);
});
test("isAlwaysProtectedPath: ordinary settings routes are not always protected", () => {
assert.equal(isAlwaysProtectedPath("/api/settings"), false);
assert.equal(isAlwaysProtectedPath("/api/settings/proxy"), false);