From 1532a9de3ead0d55376c7f2044ef89942b5d4317 Mon Sep 17 00:00:00 2001 From: m4 Date: Tue, 11 Aug 2026 10:08:45 +0800 Subject: [PATCH] feat(webui): honor security overrides in auth, captcha, login, and proxy Co-Authored-By: Claude Opus 4.7 --- src/app/api/auth/captcha/route.ts | 4 +- src/app/api/auth/login/route.ts | 9 ++-- src/app/api/auth/me/route.ts | 5 +- src/app/api/auth/password/route.ts | 12 ++--- src/app/api/auth/status/route.ts | 4 +- src/lib/server/actor.ts | 5 +- src/lib/server/auth.ts | 4 +- src/lib/server/loginFailures.test.ts | 2 + src/lib/server/loginFailures.ts | 5 +- src/lib/server/securityOverrides.test.ts | 65 ++++++++++++++++++++++++ src/proxy.ts | 6 ++- 11 files changed, 98 insertions(+), 23 deletions(-) create mode 100644 src/lib/server/securityOverrides.test.ts diff --git a/src/app/api/auth/captcha/route.ts b/src/app/api/auth/captcha/route.ts index aec0775..eb9ec11 100644 --- a/src/app/api/auth/captcha/route.ts +++ b/src/app/api/auth/captcha/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from "next/server"; -import { isAuthenticationEnabled } from "@/lib/auth"; import { createCaptcha } from "@/lib/server/captcha"; +import { effectiveSecurity } from "@/lib/server/systemConfig"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET() { - if (!isAuthenticationEnabled()) { + if (!effectiveSecurity().authEnabled) { return NextResponse.json( { error: "WebUI authentication is disabled." }, { status: 404, headers: { "Cache-Control": "no-store" } } diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 5966a64..88df77f 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,9 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { - isAuthenticationEnabled, isSafeReturnPath, rememberTtlSeconds, - sessionTtlSeconds, } from "@/lib/auth"; import { AuthConfigurationError, @@ -13,6 +11,7 @@ import { verifyCredentials, } from "@/lib/server/auth"; import { verifyCaptcha } from "@/lib/server/captcha"; +import { effectiveSecurity } from "@/lib/server/systemConfig"; import { clearFailures, clientIp, @@ -26,7 +25,7 @@ export const dynamic = "force-dynamic"; const NO_STORE = { "Cache-Control": "no-store" }; export async function POST(request: NextRequest) { - if (!isAuthenticationEnabled()) { + if (!effectiveSecurity().authEnabled) { return NextResponse.json( { error: "WebUI authentication is disabled." }, { status: 404, headers: NO_STORE } @@ -87,7 +86,9 @@ export async function POST(request: NextRequest) { } clearFailures(ip); const rememberMe = body?.rememberMe === true; - const ttlSeconds = rememberMe ? rememberTtlSeconds() : sessionTtlSeconds(); + const ttlSeconds = rememberMe + ? rememberTtlSeconds() + : effectiveSecurity().sessionTtlSeconds; const { token, payload } = createSession(user.username, user.role, ttlSeconds); await recordLogin(payload); const response = NextResponse.json( diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index de9b7bc..7d95a82 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; -import { AUTH_COOKIE_NAME, isAuthenticationEnabled } from "@/lib/auth"; +import { AUTH_COOKIE_NAME } from "@/lib/auth"; import { decodeSessionPayload, roleForUsername } from "@/lib/server/auth"; +import { effectiveSecurity } from "@/lib/server/systemConfig"; import { ensureBootstrapAdmin } from "@/lib/server/userStore"; export const runtime = "nodejs"; @@ -9,7 +10,7 @@ export const dynamic = "force-dynamic"; const NO_STORE = { "Cache-Control": "no-store" }; export function GET(request: NextRequest) { - if (!isAuthenticationEnabled()) { + if (!effectiveSecurity().authEnabled) { return NextResponse.json( { error: "WebUI authentication is disabled." }, { status: 404, headers: NO_STORE } diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index bb02d10..c4f2de2 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -1,8 +1,5 @@ import { type NextRequest, NextResponse } from "next/server"; -import { - AUTH_COOKIE_NAME, - isAuthenticationEnabled, -} from "@/lib/auth"; +import { AUTH_COOKIE_NAME } from "@/lib/auth"; import { AuthConfigurationError, authCookieOptions, @@ -13,6 +10,7 @@ import { updateAuthPassword, verifyCredentials, } from "@/lib/server/auth"; +import { effectiveSecurity } from "@/lib/server/systemConfig"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -28,7 +26,9 @@ function hasControlCharacter(value: string): boolean { } export function passwordValidationError(password: string): string | null { - if (password.length < 8) return "New password must contain at least 8 characters."; + const minLength = effectiveSecurity().passwordMinLength; + if (password.length < minLength) + return `New password must contain at least ${minLength} characters.`; if (password.length > 256) return "New password is too long."; if (hasControlCharacter(password)) { return "New password contains unsupported control characters."; @@ -37,7 +37,7 @@ export function passwordValidationError(password: string): string | null { } export async function POST(request: NextRequest) { - if (!isAuthenticationEnabled()) { + if (!effectiveSecurity().authEnabled) { return NextResponse.json( { error: "WebUI authentication is disabled." }, { status: 404, headers: NO_STORE } diff --git a/src/app/api/auth/status/route.ts b/src/app/api/auth/status/route.ts index f4a8a74..3bdea12 100644 --- a/src/app/api/auth/status/route.ts +++ b/src/app/api/auth/status/route.ts @@ -1,11 +1,11 @@ import { NextResponse } from "next/server"; -import { isAuthenticationEnabled } from "@/lib/auth"; +import { effectiveSecurity } from "@/lib/server/systemConfig"; export const dynamic = "force-dynamic"; export function GET() { return NextResponse.json( - { enabled: isAuthenticationEnabled() }, + { enabled: effectiveSecurity().authEnabled }, { headers: { "Cache-Control": "no-store" } } ); } diff --git a/src/lib/server/actor.ts b/src/lib/server/actor.ts index e23e157..34de40f 100644 --- a/src/lib/server/actor.ts +++ b/src/lib/server/actor.ts @@ -1,8 +1,9 @@ import "server-only"; import type { NextRequest } from "next/server"; -import { AUTH_COOKIE_NAME, isAuthenticationEnabled } from "@/lib/auth"; +import { AUTH_COOKIE_NAME } from "@/lib/auth"; import { decodeSessionPayload, roleForUsername } from "./auth"; +import { effectiveSecurity } from "./systemConfig"; import { ensureBootstrapAdmin, type UserRole } from "./userStore"; export interface Actor { @@ -26,7 +27,7 @@ export class ActorError extends Error { * callers that need it. */ export function requireActor(request: NextRequest): Actor { - if (!isAuthenticationEnabled()) { + if (!effectiveSecurity().authEnabled) { return { sub: "local-admin", role: "admin" }; } const session = decodeSessionPayload( diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 89a31f9..81884eb 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -5,9 +5,9 @@ import { join } from "path"; import { AUTH_COOKIE_NAME, authSecret, - isAuthenticationEnabled, } from "@/lib/auth"; import { webuiDataDir } from "./dataDir"; +import { effectiveSecurity } from "./systemConfig"; import { ensureBootstrapAdmin, getUser, @@ -34,7 +34,7 @@ interface LoginAudit { export class AuthConfigurationError extends Error {} function requireSecret(): string { - if (!isAuthenticationEnabled()) { + if (!effectiveSecurity().authEnabled) { throw new AuthConfigurationError("WebUI authentication is disabled."); } const secret = authSecret(); diff --git a/src/lib/server/loginFailures.test.ts b/src/lib/server/loginFailures.test.ts index 2f39982..2a3fb91 100644 --- a/src/lib/server/loginFailures.test.ts +++ b/src/lib/server/loginFailures.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; + +vi.mock("server-only", () => ({})); import { clearAllFailuresForTests, clearFailures, diff --git a/src/lib/server/loginFailures.ts b/src/lib/server/loginFailures.ts index b36263c..dd73d6f 100644 --- a/src/lib/server/loginFailures.ts +++ b/src/lib/server/loginFailures.ts @@ -1,4 +1,5 @@ import type { NextRequest } from "next/server"; +import { effectiveSecurity } from "./systemConfig"; export const FAILURE_THRESHOLD = 3; const ENTRY_TTL_MS = 15 * 60 * 1000; @@ -26,13 +27,15 @@ export function recordFailure(ip: string): void { } export function shouldRequireCaptcha(ip: string): boolean { + const { captchaEnabled, captchaThreshold } = effectiveSecurity(); + if (!captchaEnabled) return false; const entry = entries.get(ip); if (!entry) return false; if (Date.now() - entry.touchedAt > ENTRY_TTL_MS) { entries.delete(ip); return false; } - return entry.count >= FAILURE_THRESHOLD; + return entry.count >= captchaThreshold; } export function clearFailures(ip: string): void { diff --git a/src/lib/server/securityOverrides.test.ts b/src/lib/server/securityOverrides.test.ts new file mode 100644 index 0000000..72f6800 --- /dev/null +++ b/src/lib/server/securityOverrides.test.ts @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-sec-override-")); +const originalDataDir = process.env.EVOSCIENTIST_DATA_DIR; +process.env.EVOSCIENTIST_DATA_DIR = dataDir; + +const { + getSystemConfig, + saveSystemConfig, + resetSystemConfigCacheForTests, +} = await import("./systemConfig"); +const loginFailures = await import("./loginFailures"); + +describe("security overrides", () => { + beforeEach(() => { + const config = getSystemConfig(); + config.security = { + authEnabled: null, + sessionTtlHours: null, + passwordMinLength: null, + captchaEnabled: null, + captchaThreshold: null, + }; + saveSystemConfig(config); + resetSystemConfigCacheForTests(); + loginFailures.clearAllFailuresForTests(); + }); + + afterAll(() => { + if (originalDataDir === undefined) delete process.env.EVOSCIENTIST_DATA_DIR; + else process.env.EVOSCIENTIST_DATA_DIR = originalDataDir; + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it("requires captcha after 3 failures by default", () => { + loginFailures.recordFailure("1.1.1.1"); + loginFailures.recordFailure("1.1.1.1"); + expect(loginFailures.shouldRequireCaptcha("1.1.1.1")).toBe(false); + loginFailures.recordFailure("1.1.1.1"); + expect(loginFailures.shouldRequireCaptcha("1.1.1.1")).toBe(true); + }); + + it("honors a captcha threshold override", () => { + const config = getSystemConfig(); + config.security.captchaThreshold = 1; + saveSystemConfig(config); + resetSystemConfigCacheForTests(); + loginFailures.recordFailure("2.2.2.2"); + expect(loginFailures.shouldRequireCaptcha("2.2.2.2")).toBe(true); + }); + + it("never requires captcha when disabled", () => { + const config = getSystemConfig(); + config.security.captchaEnabled = false; + saveSystemConfig(config); + resetSystemConfigCacheForTests(); + for (let i = 0; i < 10; i += 1) loginFailures.recordFailure("3.3.3.3"); + expect(loginFailures.shouldRequireCaptcha("3.3.3.3")).toBe(false); + }); +}); diff --git a/src/proxy.ts b/src/proxy.ts index e79b388..04bd592 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -3,8 +3,8 @@ import { AUTH_COOKIE_NAME, AUTH_LOGIN_PATH, authSecret, - isAuthenticationEnabled, } from "@/lib/auth"; +import { effectiveSecurity } from "@/lib/server/systemConfig"; interface SessionPayload { username: string; @@ -81,6 +81,8 @@ function isPublicPath(pathname: string): boolean { pathname === "/api/usage/events" || pathname === "/api/usage/sources/heartbeat" || pathname === "/api/usage/capabilities" || + pathname === "/api/system/config/public" || + pathname.startsWith("/api/system/branding/asset/") || pathname.startsWith("/api/workspace/render/") || pathname.startsWith("/_next/") || pathname === "/favicon.ico" || @@ -101,7 +103,7 @@ function configurationError(request: NextRequest) { } export async function proxy(request: NextRequest) { - if (!isAuthenticationEnabled() || isPublicPath(request.nextUrl.pathname)) { + if (!effectiveSecurity().authEnabled || isPublicPath(request.nextUrl.pathname)) { return NextResponse.next(); } if (!authSecret() || authSecret()!.length < 32) {