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:
parent
e6bac77696
commit
0c7c9de00a
5 changed files with 150 additions and 13 deletions
|
|
@ -3,7 +3,7 @@
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Card, Button, Toggle, Input } from "@/shared/components";
|
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 LanguageSwitcher from "@/shared/components/LanguageSwitcher";
|
||||||
import { useTheme } from "@/shared/hooks/useTheme";
|
import { useTheme } from "@/shared/hooks/useTheme";
|
||||||
import { cn } from "@/shared/utils/cn";
|
import { cn } from "@/shared/utils/cn";
|
||||||
|
|
@ -34,6 +34,8 @@ export default function ProfilePage() {
|
||||||
const [passLoading, setPassLoading] = useState(false);
|
const [passLoading, setPassLoading] = useState(false);
|
||||||
const [dbLoading, setDbLoading] = useState(false);
|
const [dbLoading, setDbLoading] = useState(false);
|
||||||
const [dbStatus, setDbStatus] = useState({ type: "", message: "" });
|
const [dbStatus, setDbStatus] = useState({ type: "", message: "" });
|
||||||
|
const [dbAuth, setDbAuth] = useState({ open: false, mode: "", password: "" });
|
||||||
|
const pendingImportRef = useRef(null);
|
||||||
const [oidcForm, setOidcForm] = useState({
|
const [oidcForm, setOidcForm] = useState({
|
||||||
authMode: "password",
|
authMode: "password",
|
||||||
oidcIssuerUrl: "",
|
oidcIssuerUrl: "",
|
||||||
|
|
@ -471,11 +473,13 @@ export default function ProfilePage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportDatabase = async () => {
|
const handleExportDatabase = async (password) => {
|
||||||
setDbLoading(true);
|
setDbLoading(true);
|
||||||
setDbStatus({ type: "", message: "" });
|
setDbStatus({ type: "", message: "" });
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/settings/database");
|
const res = await fetch("/api/settings/database", {
|
||||||
|
headers: { "x-9r-password": password },
|
||||||
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
throw new Error(data.error || "Failed to export database");
|
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];
|
const file = event.target.files?.[0];
|
||||||
|
if (importFileRef.current) importFileRef.current.value = "";
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
pendingImportRef.current = file;
|
||||||
setDbLoading(true);
|
|
||||||
setDbStatus({ type: "", message: "" });
|
setDbStatus({ type: "", message: "" });
|
||||||
|
setDbAuth({ open: true, mode: "import", password: "" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const runImportDatabase = async (password) => {
|
||||||
|
const file = pendingImportRef.current;
|
||||||
|
if (!file) return;
|
||||||
|
setDbLoading(true);
|
||||||
try {
|
try {
|
||||||
const raw = await file.text();
|
const raw = await file.text();
|
||||||
const payload = JSON.parse(raw);
|
const payload = JSON.parse(raw);
|
||||||
|
|
@ -516,7 +526,7 @@ export default function ProfilePage() {
|
||||||
const res = await fetch("/api/settings/database", {
|
const res = await fetch("/api/settings/database", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify({ ...payload, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|
@ -529,13 +539,19 @@ export default function ProfilePage() {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDbStatus({ type: "error", message: err.message || "Invalid backup file" });
|
setDbStatus({ type: "error", message: err.message || "Invalid backup file" });
|
||||||
} finally {
|
} finally {
|
||||||
if (importFileRef.current) {
|
pendingImportRef.current = null;
|
||||||
importFileRef.current.value = "";
|
|
||||||
}
|
|
||||||
setDbLoading(false);
|
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 observabilityEnabled = settings.enableObservability === true;
|
||||||
|
|
||||||
const handleShutdown = async () => {
|
const handleShutdown = async () => {
|
||||||
|
|
@ -608,7 +624,7 @@ export default function ProfilePage() {
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
icon="download"
|
icon="download"
|
||||||
onClick={handleExportDatabase}
|
onClick={() => setDbAuth({ open: true, mode: "export", password: "" })}
|
||||||
loading={dbLoading}
|
loading={dbLoading}
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
|
|
@ -1135,6 +1151,35 @@ export default function ProfilePage() {
|
||||||
variant="danger"
|
variant="danger"
|
||||||
loading={isShuttingDown}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,21 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { exportDb, getSettings, importDb } from "@/lib/localDb";
|
import { exportDb, getSettings, importDb } from "@/lib/localDb";
|
||||||
import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";
|
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 {
|
try {
|
||||||
|
if (!isCliRequest(request) && !(await verifyDashboardPassword(request.headers.get(PASSWORD_HEADER)))) {
|
||||||
|
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
|
||||||
|
}
|
||||||
const payload = await exportDb();
|
const payload = await exportDb();
|
||||||
return NextResponse.json(payload);
|
return NextResponse.json(payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -14,7 +26,10 @@ export async function GET() {
|
||||||
|
|
||||||
export async function POST(request) {
|
export async function POST(request) {
|
||||||
try {
|
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);
|
await importDb(payload);
|
||||||
|
|
||||||
// Ensure proxy settings take effect immediately after a DB import.
|
// Ensure proxy settings take effect immediately after a DB import.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { SignJWT, jwtVerify } from "jose";
|
import { SignJWT, jwtVerify } from "jose";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { DATA_DIR } from "@/lib/dataDir";
|
import { DATA_DIR } from "@/lib/dataDir";
|
||||||
|
import { getSettings } from "@/lib/localDb";
|
||||||
|
|
||||||
|
const DEFAULT_PASSWORD = "123456";
|
||||||
|
|
||||||
function loadJwtSecret() {
|
function loadJwtSecret() {
|
||||||
if (process.env.JWT_SECRET) return process.env.JWT_SECRET;
|
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) {
|
export function clearDashboardAuthCookie(cookieStore) {
|
||||||
cookieStore.delete("auth_token");
|
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
src/shared/utils/ssrfGuard.js
Normal file
54
src/shared/utils/ssrfGuard.js
Normal 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");
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
||||||
import * as log from "../utils/logger.js";
|
import * as log from "../utils/logger.js";
|
||||||
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
|
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
|
||||||
import { handleComboChat, getComboModelsFromData } from "open-sse/services/combo.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.
|
* 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");
|
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
|
// Combo expansion: providerInput may be a combo name → run fallback/round-robin across providers
|
||||||
const combos = await getCombos();
|
const combos = await getCombos();
|
||||||
const comboModels = getComboModelsFromData(providerInput, combos);
|
const comboModels = getComboModelsFromData(providerInput, combos);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue