mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 { 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);
|
||||
|
||||
Reference in New Issue
Block a user