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 <cursoragent@cursor.com>
This commit is contained in:
decolua
2026-06-13 11:40:35 +07:00
co-authored by Cursor
parent e6bac77696
commit 0c7c9de00a
5 changed files with 150 additions and 13 deletions
+56 -11
View File
@@ -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() {
<Button
variant="secondary"
icon="download"
onClick={handleExportDatabase}
onClick={() => setDbAuth({ open: true, mode: "export", password: "" })}
loading={dbLoading}
className="w-full sm:w-auto"
>
@@ -1135,6 +1151,35 @@ export default function ProfilePage() {
variant="danger"
loading={isShuttingDown}
/>
<Modal
isOpen={dbAuth.open}
onClose={() => setDbAuth({ open: false, mode: "", password: "" })}
title="Confirm Password"
size="sm"
footer={
<>
<Button variant="ghost" onClick={() => setDbAuth({ open: false, mode: "", password: "" })} disabled={dbLoading}>
Cancel
</Button>
<Button variant="primary" onClick={handleDbAuthConfirm} loading={dbLoading} disabled={!dbAuth.password}>
Confirm
</Button>
</>
}
>
<p className="text-text-muted mb-3 text-sm">
Enter your current password to {dbAuth.mode === "export" ? "export" : "import"} the database.
</p>
<Input
type="password"
value={dbAuth.password}
onChange={(e) => setDbAuth((s) => ({ ...s, password: e.target.value }))}
onKeyDown={(e) => { if (e.key === "Enter" && dbAuth.password) handleDbAuthConfirm(); }}
placeholder="Current password"
autoFocus
/>
</Modal>
</div>
);
}
+17 -2
View File
@@ -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.
+14
View File
@@ -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;
}
+54
View File
@@ -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");
}
+9
View File
@@ -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);