fix(webui): harden branding path validation, auth-on warning, scheduler retry

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-08-11 12:33:58 +08:00
parent 6d0251a673
commit 79cfeae8d8
5 changed files with 58 additions and 1 deletions
@@ -8,11 +8,13 @@ function TriState({
value,
onChange,
confirmOff,
confirmOn,
}: {
label: string;
value: boolean | null;
onChange: (value: boolean | null) => void;
confirmOff?: string;
confirmOn?: string;
}) {
return (
<div className="flex items-center justify-between gap-3">
@@ -24,6 +26,7 @@ function TriState({
const next =
event.target.value === "default" ? null : event.target.value === "on";
if (next === false && confirmOff && !window.confirm(confirmOff)) return;
if (next === true && confirmOn && !window.confirm(confirmOn)) return;
onChange(next);
}}
>
@@ -87,6 +90,7 @@ export function SecurityTab({ draft, setDraft }: TabProps) {
label="WebUI authentication"
value={draft.security.authEnabled}
confirmOff="Disabling authentication opens the UI to anyone who can reach it. Continue?"
confirmOn="Enabling authentication requires WEBUI_AUTH_SECRET to be set in the server environment. Without it, every page (including this dialog) will return 503 and recovery requires hand-editing ~/.evoscientist/system-config.json. Continue?"
onChange={(authEnabled) => set({ authEnabled })}
/>
<NullableNumber
+20
View File
@@ -57,4 +57,24 @@ describe("backupScheduler.tickBackupSchedule", () => {
await scheduler.tickBackupSchedule(now + 10 * HOUR);
expect(listBackups()).toHaveLength(0);
});
it("records a failed attempt so the next tick does not retry", async () => {
const backendDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-backend-"));
const config = getSystemConfig();
config.backup.backendDataDir = backendDir;
saveSystemConfig(config);
resetSystemConfigCacheForTests();
fs.rmSync(backendDir, { recursive: true, force: true }); // createBackup now fails
const { listErrorLogs, clearErrorLogs } = await import("./errorLogStore");
clearErrorLogs("system");
await scheduler.tickBackupSchedule(now);
expect(listBackups()).toHaveLength(0);
expect(listErrorLogs("system")).toHaveLength(1);
await scheduler.tickBackupSchedule(now + 60_000); // 1 min later: no retry
expect(listBackups()).toHaveLength(0);
expect(listErrorLogs("system")).toHaveLength(1);
});
});
+3 -1
View File
@@ -19,10 +19,12 @@ export async function tickBackupSchedule(now = Date.now()): Promise<void> {
if (!schedule.enabled) return;
const lastRunAt = globalState.__evoscientistBackupLastRunAt ?? 0;
if (now - lastRunAt < schedule.intervalHours * 3_600_000) return;
// Record the attempt up front so a persistent failure retries on the next
// configured interval instead of on every tick, flooding the error log.
globalState.__evoscientistBackupLastRunAt = now;
try {
await createBackup("auto");
pruneBackups(schedule.keepCount);
globalState.__evoscientistBackupLastRunAt = now;
} catch (error) {
try {
appendErrorLog("system", {
+17
View File
@@ -95,6 +95,23 @@ describe("systemConfig", () => {
).toThrow(/backend data dir/i);
});
it("rejects branding file names outside the fixed allowlist", () => {
expect(() =>
validateSystemConfig({ branding: { logoFile: "../../../tmp/x.svg" } })
).toThrow(/logoFile/);
expect(() =>
validateSystemConfig({ branding: { logoFile: "favicon.png" } })
).toThrow(/logoFile/);
expect(() =>
validateSystemConfig({ branding: { faviconFile: "../favicon.ico" } })
).toThrow(/faviconFile/);
const valid = validateSystemConfig({
branding: { logoFile: "logo.svg", faviconFile: "favicon.ico" },
});
expect(valid.branding.logoFile).toBe("logo.svg");
expect(valid.branding.faviconFile).toBe("favicon.ico");
});
it("derives effective security with env/code defaults", () => {
const sec = effectiveSecurity();
expect(sec.authEnabled).toBe(false); // WEBUI_AUTH_ENABLED unset
+14
View File
@@ -131,6 +131,13 @@ function optInt(
return value as number;
}
// Branding file fields feed an unauthenticated asset route, so only the
// exact fixed names the upload route produces are acceptable.
const BRANDING_FILE_RE: Record<"logoFile" | "faviconFile", RegExp> = {
logoFile: /^logo\.(png|jpe?g|svg)$/,
faviconFile: /^favicon\.(png|jpe?g|svg|ico)$/,
};
export function validateSystemConfig(input: unknown): SystemConfig {
const source = isRecord(input) ? input : {};
const merged = merge(source);
@@ -149,6 +156,13 @@ export function validateSystemConfig(input: unknown): SystemConfig {
if (value !== null && typeof value !== "string") {
throw new SystemConfigError(`branding.${key} must be a string or null.`);
}
if (typeof value === "string" && !BRANDING_FILE_RE[key].test(value)) {
throw new SystemConfigError(
`branding.${key} must be a fixed asset file name (e.g. ${
key === "logoFile" ? "logo.svg" : "favicon.ico"
}).`
);
}
}
merged.loginTerms.enabled = merged.loginTerms.enabled === true;