feat(webui): honor security overrides in auth, captcha, login, and proxy

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-08-11 10:08:45 +08:00
parent 74516fd7c8
commit 1532a9de3e
11 changed files with 98 additions and 23 deletions
+2 -2
View File
@@ -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" } }
+5 -4
View File
@@ -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(
+3 -2
View File
@@ -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 }
+6 -6
View File
@@ -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 }
+2 -2
View File
@@ -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" } }
);
}
+3 -2
View File
@@ -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(
+2 -2
View File
@@ -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();
+2
View File
@@ -1,5 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("server-only", () => ({}));
import {
clearAllFailuresForTests,
clearFailures,
+4 -1
View File
@@ -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 {
+65
View File
@@ -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);
});
});
+4 -2
View File
@@ -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) {