From 0c7c9de00ae3ab81d6580e3cc368483c4c03f6fd Mon Sep 17 00:00:00 2001 From: decolua Date: Sat, 13 Jun 2026 11:40:35 +0700 Subject: [PATCH] fix(security): re-auth on DB export/import + SSRF guard on web fetch - /api/settings/database now requires current password (header for GET, body for POST) in addition to session; CLI-token requests exempt - add verifyDashboardPassword helper reusing login bcrypt check - profile UI prompts password via modal before export/import - /v1/web/fetch rejects internal/private/metadata targets via assertPublicUrl Refs GHSA-qvfm-67h2-2qfx, GHSA-qj3v-64wj-q825 Co-authored-by: Cursor --- src/app/(dashboard)/dashboard/profile/page.js | 67 ++++++++++++++++--- src/app/api/settings/database/route.js | 19 +++++- src/lib/auth/dashboardSession.js | 14 ++++ src/shared/utils/ssrfGuard.js | 54 +++++++++++++++ src/sse/handlers/fetch.js | 9 +++ 5 files changed, 150 insertions(+), 13 deletions(-) create mode 100644 src/shared/utils/ssrfGuard.js diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index e5e40642..2851bc0c 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -3,7 +3,7 @@ import { useState, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import { Card, Button, Toggle, Input } from "@/shared/components"; -import { ConfirmModal } from "@/shared/components/Modal"; +import Modal, { ConfirmModal } from "@/shared/components/Modal"; import LanguageSwitcher from "@/shared/components/LanguageSwitcher"; import { useTheme } from "@/shared/hooks/useTheme"; import { cn } from "@/shared/utils/cn"; @@ -34,6 +34,8 @@ export default function ProfilePage() { const [passLoading, setPassLoading] = useState(false); const [dbLoading, setDbLoading] = useState(false); const [dbStatus, setDbStatus] = useState({ type: "", message: "" }); + const [dbAuth, setDbAuth] = useState({ open: false, mode: "", password: "" }); + const pendingImportRef = useRef(null); const [oidcForm, setOidcForm] = useState({ authMode: "password", oidcIssuerUrl: "", @@ -471,11 +473,13 @@ export default function ProfilePage() { } }; - const handleExportDatabase = async () => { + const handleExportDatabase = async (password) => { setDbLoading(true); setDbStatus({ type: "", message: "" }); try { - const res = await fetch("/api/settings/database"); + const res = await fetch("/api/settings/database", { + headers: { "x-9r-password": password }, + }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "Failed to export database"); @@ -502,13 +506,19 @@ export default function ProfilePage() { } }; - const handleImportDatabase = async (event) => { + const handleImportDatabase = (event) => { const file = event.target.files?.[0]; + if (importFileRef.current) importFileRef.current.value = ""; if (!file) return; - - setDbLoading(true); + pendingImportRef.current = file; setDbStatus({ type: "", message: "" }); + setDbAuth({ open: true, mode: "import", password: "" }); + }; + const runImportDatabase = async (password) => { + const file = pendingImportRef.current; + if (!file) return; + setDbLoading(true); try { const raw = await file.text(); const payload = JSON.parse(raw); @@ -516,7 +526,7 @@ export default function ProfilePage() { const res = await fetch("/api/settings/database", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), + body: JSON.stringify({ ...payload, password }), }); const data = await res.json().catch(() => ({})); @@ -529,13 +539,19 @@ export default function ProfilePage() { } catch (err) { setDbStatus({ type: "error", message: err.message || "Invalid backup file" }); } finally { - if (importFileRef.current) { - importFileRef.current.value = ""; - } + pendingImportRef.current = null; setDbLoading(false); } }; + // Confirm password modal, then run export or import. + const handleDbAuthConfirm = async () => { + const { mode, password } = dbAuth; + setDbAuth({ open: false, mode: "", password: "" }); + if (mode === "export") await handleExportDatabase(password); + else if (mode === "import") await runImportDatabase(password); + }; + const observabilityEnabled = settings.enableObservability === true; const handleShutdown = async () => { @@ -608,7 +624,7 @@ export default function ProfilePage() { + + + } + > +

+ Enter your current password to {dbAuth.mode === "export" ? "export" : "import"} the database. +

+ setDbAuth((s) => ({ ...s, password: e.target.value }))} + onKeyDown={(e) => { if (e.key === "Enter" && dbAuth.password) handleDbAuthConfirm(); }} + placeholder="Current password" + autoFocus + /> + ); } diff --git a/src/app/api/settings/database/route.js b/src/app/api/settings/database/route.js index 5d696054..a20e27bf 100644 --- a/src/app/api/settings/database/route.js +++ b/src/app/api/settings/database/route.js @@ -1,9 +1,21 @@ import { NextResponse } from "next/server"; import { exportDb, getSettings, importDb } from "@/lib/localDb"; import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy"; +import { verifyDashboardPassword } from "@/lib/auth/dashboardSession"; -export async function GET() { +const CLI_TOKEN_HEADER = "x-9r-cli-token"; +const PASSWORD_HEADER = "x-9r-password"; + +// CLI token requests are already trusted (local machine); skip password re-auth. +function isCliRequest(request) { + return Boolean(request.headers.get(CLI_TOKEN_HEADER)); +} + +export async function GET(request) { try { + if (!isCliRequest(request) && !(await verifyDashboardPassword(request.headers.get(PASSWORD_HEADER)))) { + return NextResponse.json({ error: "Invalid password" }, { status: 401 }); + } const payload = await exportDb(); return NextResponse.json(payload); } catch (error) { @@ -14,7 +26,10 @@ export async function GET() { export async function POST(request) { try { - const payload = await request.json(); + const { password, ...payload } = await request.json(); + if (!isCliRequest(request) && !(await verifyDashboardPassword(password))) { + return NextResponse.json({ error: "Invalid password" }, { status: 401 }); + } await importDb(payload); // Ensure proxy settings take effect immediately after a DB import. diff --git a/src/lib/auth/dashboardSession.js b/src/lib/auth/dashboardSession.js index dfa88823..b4c65aae 100644 --- a/src/lib/auth/dashboardSession.js +++ b/src/lib/auth/dashboardSession.js @@ -1,8 +1,12 @@ import { SignJWT, jwtVerify } from "jose"; +import bcrypt from "bcryptjs"; import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import { DATA_DIR } from "@/lib/dataDir"; +import { getSettings } from "@/lib/localDb"; + +const DEFAULT_PASSWORD = "123456"; function loadJwtSecret() { if (process.env.JWT_SECRET) return process.env.JWT_SECRET; @@ -66,3 +70,13 @@ export async function setDashboardAuthCookie(cookieStore, request, claims = {}) export function clearDashboardAuthCookie(cookieStore) { cookieStore.delete("auth_token"); } + +// Verify the current dashboard password (re-auth for sensitive actions). +export async function verifyDashboardPassword(password) { + if (typeof password !== "string" || !password) return false; + const settings = await getSettings(); + const storedHash = settings?.password; + if (storedHash) return bcrypt.compare(password, storedHash); + const initialPassword = process.env.INITIAL_PASSWORD || DEFAULT_PASSWORD; + return password === initialPassword; +} diff --git a/src/shared/utils/ssrfGuard.js b/src/shared/utils/ssrfGuard.js new file mode 100644 index 00000000..1176d7b6 --- /dev/null +++ b/src/shared/utils/ssrfGuard.js @@ -0,0 +1,54 @@ +// SSRF guard: block internal/private/metadata targets for server-side fetch. + +const BLOCKED_HOSTNAMES = new Set(["localhost", "ip6-localhost", "ip6-loopback"]); +const BLOCKED_SUFFIXES = [".internal", ".local", ".localhost"]; + +// Parse dotted IPv4 to 32-bit integer, or null if not a valid IPv4 literal. +function ipv4ToInt(host) { + const parts = host.split("."); + if (parts.length !== 4) return null; + let value = 0; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return null; + const octet = Number(part); + if (octet > 255) return null; + value = value * 256 + octet; + } + return value >>> 0; +} + +// Private/reserved IPv4 ranges as [startInt, maskBits]. +const BLOCKED_V4_RANGES = [ + [ipv4ToInt("0.0.0.0"), 8], + [ipv4ToInt("10.0.0.0"), 8], + [ipv4ToInt("127.0.0.0"), 8], + [ipv4ToInt("169.254.0.0"), 16], + [ipv4ToInt("172.16.0.0"), 12], + [ipv4ToInt("192.168.0.0"), 16], +]; + +function isBlockedIpv4(host) { + const ip = ipv4ToInt(host); + if (ip === null) return false; + return BLOCKED_V4_RANGES.some(([base, bits]) => { + const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0; + return (ip & mask) === (base & mask); + }); +} + +function isBlockedIpv6(host) { + const h = host.replace(/^\[|\]$/g, "").toLowerCase(); + if (h === "::1" || h === "::") return true; + return h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd"); +} + +// Throw if URL targets a non-public host. Caller should map to 400. +export function assertPublicUrl(rawUrl) { + const parsed = new URL(rawUrl); + const host = parsed.hostname.toLowerCase(); + + if (BLOCKED_HOSTNAMES.has(host)) throw new Error("Blocked URL: internal host"); + if (BLOCKED_SUFFIXES.some((s) => host.endsWith(s))) throw new Error("Blocked URL: internal host"); + if (isBlockedIpv4(host)) throw new Error("Blocked URL: private IP"); + if (host.includes(":") && isBlockedIpv6(host)) throw new Error("Blocked URL: private IP"); +} diff --git a/src/sse/handlers/fetch.js b/src/sse/handlers/fetch.js index 6d096d9a..db62a8c7 100644 --- a/src/sse/handlers/fetch.js +++ b/src/sse/handlers/fetch.js @@ -13,6 +13,7 @@ import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js"; import * as log from "../utils/logger.js"; import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js"; import { handleComboChat, getComboModelsFromData } from "open-sse/services/combo.js"; +import { assertPublicUrl } from "@/shared/utils/ssrfGuard.js"; /** * Handle web fetch (URL extraction) request for the SSE/Next.js server. @@ -78,6 +79,14 @@ export async function handleFetch(request) { return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid URL format"); } + // SSRF guard: reject internal/private/metadata targets + try { + assertPublicUrl(targetUrl); + } catch (err) { + log.warn("FETCH", "Blocked URL", { url: targetUrl }); + return errorResponse(HTTP_STATUS.BAD_REQUEST, err.message); + } + // Combo expansion: providerInput may be a combo name → run fallback/round-robin across providers const combos = await getCombos(); const comboModels = getComboModelsFromData(providerInput, combos);