mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
Enhance security
This commit is contained in:
@@ -105,6 +105,12 @@ export default function APIPageClient({ machineId }) {
|
|||||||
|
|
||||||
const { copied, copy } = useCopyToClipboard();
|
const { copied, copy } = useCopyToClipboard();
|
||||||
|
|
||||||
|
// Security gate: block remote exposure while dashboard uses default password or login is off.
|
||||||
|
const isLoginUnsafe = !requireLogin || !hasPassword;
|
||||||
|
const unsafeReason = !requireLogin
|
||||||
|
? "Enable \"Require login\" and set a custom password before activating the tunnel."
|
||||||
|
: "Change the default dashboard password before activating the tunnel.";
|
||||||
|
|
||||||
// Auto-scroll install log
|
// Auto-scroll install log
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tsLogRef.current) tsLogRef.current.scrollTop = tsLogRef.current.scrollHeight;
|
if (tsLogRef.current) tsLogRef.current.scrollTop = tsLogRef.current.scrollHeight;
|
||||||
@@ -846,6 +852,10 @@ export default function APIPageClient({ machineId }) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
icon="cloud_upload"
|
icon="cloud_upload"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (isLoginUnsafe) {
|
||||||
|
setTunnelStatus({ type: "error", message: `Security required: ${unsafeReason}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!requireApiKey) {
|
if (!requireApiKey) {
|
||||||
setTunnelStatus({ type: "error", message: "Security required: Enable \"Require API key\" before activating the tunnel." });
|
setTunnelStatus({ type: "error", message: "Security required: Enable \"Require API key\" before activating the tunnel." });
|
||||||
return;
|
return;
|
||||||
@@ -928,7 +938,13 @@ export default function APIPageClient({ machineId }) {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
icon="vpn_lock"
|
icon="vpn_lock"
|
||||||
onClick={handleOpenTsModal}
|
onClick={() => {
|
||||||
|
if (isLoginUnsafe) {
|
||||||
|
setTsStatus({ type: "error", message: `Security required: ${unsafeReason}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleOpenTsModal();
|
||||||
|
}}
|
||||||
className="bg-linear-to-r from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 text-white!"
|
className="bg-linear-to-r from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 text-white!"
|
||||||
>
|
>
|
||||||
Enable
|
Enable
|
||||||
@@ -937,6 +953,16 @@ export default function APIPageClient({ machineId }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pre-enable security gate banner */}
|
||||||
|
{isLoginUnsafe && !tunnelEnabled && !tsEnabled && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<SecurityWarning
|
||||||
|
message={unsafeReason}
|
||||||
|
action={{ label: "Open settings", href: "/dashboard/profile" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Security warnings when tunnel or tailscale is active */}
|
{/* Security warnings when tunnel or tailscale is active */}
|
||||||
{(tunnelEnabled || tsEnabled) && (
|
{(tunnelEnabled || tsEnabled) && (
|
||||||
<div className="mt-4 flex flex-col gap-2">
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import bcrypt from "bcryptjs";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { setDashboardAuthCookie } from "@/lib/auth/dashboardSession";
|
import { setDashboardAuthCookie } from "@/lib/auth/dashboardSession";
|
||||||
import { isOidcConfigured } from "@/lib/auth/oidc";
|
import { isOidcConfigured } from "@/lib/auth/oidc";
|
||||||
|
import { checkLock, recordFail, recordSuccess, getClientIp } from "@/lib/auth/loginLimiter";
|
||||||
|
|
||||||
|
const RESET_HINT = "Forgot password? Reset to default via 9Router CLI → Settings → Reset Password to Default.";
|
||||||
|
|
||||||
function isTunnelRequest(request, settings) {
|
function isTunnelRequest(request, settings) {
|
||||||
const host = (request.headers.get("host") || "").split(":")[0].toLowerCase();
|
const host = (request.headers.get("host") || "").split(":")[0].toLowerCase();
|
||||||
@@ -14,6 +17,15 @@ function isTunnelRequest(request, settings) {
|
|||||||
|
|
||||||
export async function POST(request) {
|
export async function POST(request) {
|
||||||
try {
|
try {
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
const lock = checkLock(ip);
|
||||||
|
if (lock.locked) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Too many failed attempts. Try again in ${lock.retryAfter}s. ${RESET_HINT}`, retryAfter: lock.retryAfter, resetHint: RESET_HINT },
|
||||||
|
{ status: 429, headers: { "Retry-After": String(lock.retryAfter) } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { password } = await request.json();
|
const { password } = await request.json();
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
|
|
||||||
@@ -39,13 +51,25 @@ export async function POST(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
|
recordSuccess(ip);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
await setDashboardAuthCookie(cookieStore, request);
|
await setDashboardAuthCookie(cookieStore, request);
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
|
const { remainingBeforeLock } = recordFail(ip);
|
||||||
|
const postLock = checkLock(ip);
|
||||||
|
if (postLock.locked) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Too many failed attempts. Try again in ${postLock.retryAfter}s. ${RESET_HINT}`, retryAfter: postLock.retryAfter, resetHint: RESET_HINT },
|
||||||
|
{ status: 429, headers: { "Retry-After": String(postLock.retryAfter) } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Invalid password. ${remainingBeforeLock} attempt(s) left before lockout.`, remainingBeforeLock },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-1
@@ -7,6 +7,8 @@ import { useRouter } from "next/navigation";
|
|||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [resetHint, setResetHint] = useState("");
|
||||||
|
const [retryAfter, setRetryAfter] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [hasPassword, setHasPassword] = useState(null);
|
const [hasPassword, setHasPassword] = useState(null);
|
||||||
const [authMode, setAuthMode] = useState("password");
|
const [authMode, setAuthMode] = useState("password");
|
||||||
@@ -14,6 +16,13 @@ export default function LoginPage() {
|
|||||||
const [oidcLoginLabel, setOidcLoginLabel] = useState("Sign in with OIDC");
|
const [oidcLoginLabel, setOidcLoginLabel] = useState("Sign in with OIDC");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Countdown for rate-limit
|
||||||
|
useEffect(() => {
|
||||||
|
if (retryAfter <= 0) return;
|
||||||
|
const id = setInterval(() => setRetryAfter((s) => (s > 0 ? s - 1 : 0)), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [retryAfter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -53,6 +62,7 @@ export default function LoginPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
setResetHint("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/login", {
|
const res = await fetch("/api/auth/login", {
|
||||||
@@ -67,6 +77,8 @@ export default function LoginPage() {
|
|||||||
} else {
|
} else {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setError(data.error || "Invalid password");
|
setError(data.error || "Invalid password");
|
||||||
|
if (data.resetHint) setResetHint(data.resetHint);
|
||||||
|
if (data.retryAfter) setRetryAfter(Number(data.retryAfter));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("An error occurred. Please try again.");
|
setError("An error occurred. Please try again.");
|
||||||
@@ -143,6 +155,16 @@ export default function LoginPage() {
|
|||||||
autoFocus={!oidcAvailable}
|
autoFocus={!oidcAvailable}
|
||||||
/>
|
/>
|
||||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
|
{retryAfter > 0 && (
|
||||||
|
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
Locked. Retry in <span className="font-mono">{retryAfter}s</span>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{resetHint && (
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
Forgot password? Open <code className="bg-sidebar px-1 rounded">9router</code> CLI on the host → <b>Settings</b> → <b>Reset Password to Default</b>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -150,8 +172,9 @@ export default function LoginPage() {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
disabled={retryAfter > 0}
|
||||||
>
|
>
|
||||||
Login
|
{retryAfter > 0 ? `Wait ${retryAfter}s` : "Login"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-xs text-center text-text-muted mt-2">
|
<p className="text-xs text-center text-text-muted mt-2">
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ async function canAccessPublicLlmApi(request) {
|
|||||||
|
|
||||||
async function canAccessLocalOnlyRoute(request) {
|
async function canAccessLocalOnlyRoute(request) {
|
||||||
if (await hasValidCliToken(request)) return true;
|
if (await hasValidCliToken(request)) return true;
|
||||||
// Browser on host: loopback Host + Origin (blocks tunnel/CSRF) + JWT cookie (blocks unauth raw clients)
|
// Browser on host: loopback Host + Origin (blocks tunnel/CSRF) + auth (JWT or requireLogin=false)
|
||||||
if (isLocalRequest(request) && await hasValidToken(request)) return true;
|
if (isLocalRequest(request) && await isAuthenticated(request)) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// In-memory progressive lockout for dashboard login. Resets on process restart.
|
||||||
|
|
||||||
|
const MAX_FAILS_BEFORE_LOCK = 5;
|
||||||
|
const LOCK_STEPS_MS = [30_000, 120_000, 600_000, 1_800_000]; // 30s, 2m, 10m, 30m
|
||||||
|
const FAIL_WINDOW_MS = 60 * 60 * 1000; // 1h since last fail → auto reset
|
||||||
|
|
||||||
|
const attempts = new Map(); // ip → { fails, lockUntil, lockLevel, lastFailAt }
|
||||||
|
|
||||||
|
function now() { return Date.now(); }
|
||||||
|
|
||||||
|
function getEntry(ip) {
|
||||||
|
const e = attempts.get(ip);
|
||||||
|
if (!e) return null;
|
||||||
|
// Auto reset if window expired and not currently locked
|
||||||
|
if (e.lastFailAt && now() - e.lastFailAt > FAIL_WINDOW_MS && (!e.lockUntil || now() >= e.lockUntil)) {
|
||||||
|
attempts.delete(ip);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkLock(ip) {
|
||||||
|
const e = getEntry(ip);
|
||||||
|
if (!e || !e.lockUntil) return { locked: false };
|
||||||
|
const remaining = e.lockUntil - now();
|
||||||
|
if (remaining <= 0) return { locked: false };
|
||||||
|
return { locked: true, retryAfter: Math.ceil(remaining / 1000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordFail(ip) {
|
||||||
|
const e = getEntry(ip) || { fails: 0, lockUntil: 0, lockLevel: 0, lastFailAt: 0 };
|
||||||
|
e.fails += 1;
|
||||||
|
e.lastFailAt = now();
|
||||||
|
if (e.fails >= MAX_FAILS_BEFORE_LOCK) {
|
||||||
|
const step = LOCK_STEPS_MS[Math.min(e.lockLevel, LOCK_STEPS_MS.length - 1)];
|
||||||
|
e.lockUntil = now() + step;
|
||||||
|
e.lockLevel += 1;
|
||||||
|
e.fails = 0;
|
||||||
|
}
|
||||||
|
attempts.set(ip, e);
|
||||||
|
return { remainingBeforeLock: Math.max(0, MAX_FAILS_BEFORE_LOCK - e.fails) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordSuccess(ip) {
|
||||||
|
attempts.delete(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClientIp(request) {
|
||||||
|
const xff = request.headers.get("x-forwarded-for");
|
||||||
|
if (xff) return xff.split(",")[0].trim();
|
||||||
|
return request.headers.get("x-real-ip") || "unknown";
|
||||||
|
}
|
||||||
@@ -129,13 +129,22 @@ describe("dashboard guard public LLM API access", () => {
|
|||||||
describe("dashboard guard local-only access", () => {
|
describe("dashboard guard local-only access", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mocks.getSettings.mockResolvedValue({ requireLogin: false });
|
mocks.getSettings.mockResolvedValue({ requireLogin: true });
|
||||||
mocks.validateApiKey.mockResolvedValue(false);
|
mocks.validateApiKey.mockResolvedValue(false);
|
||||||
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
|
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
|
||||||
mocks.verifyDashboardAuthToken.mockResolvedValue(false);
|
mocks.verifyDashboardAuthToken.mockResolvedValue(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects local-only route with spoofed loopback headers but no CLI token", async () => {
|
it("rejects local-only route from non-loopback host without CLI token", async () => {
|
||||||
|
const response = await proxy(request("/api/mcp/filesystem/sse", {
|
||||||
|
host: "router.example.com",
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body.error).toBe("Local only: CLI token required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects local-only route on loopback when requireLogin=true and no JWT", async () => {
|
||||||
const response = await proxy(request("/api/mcp/filesystem/sse", {
|
const response = await proxy(request("/api/mcp/filesystem/sse", {
|
||||||
host: "localhost:20128",
|
host: "localhost:20128",
|
||||||
origin: "http://localhost:20128",
|
origin: "http://localhost:20128",
|
||||||
@@ -145,6 +154,38 @@ describe("dashboard guard local-only access", () => {
|
|||||||
expect(response.body.error).toBe("Local only: CLI token required");
|
expect(response.body.error).toBe("Local only: CLI token required");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows local-only route on loopback when requireLogin=false", async () => {
|
||||||
|
mocks.getSettings.mockResolvedValue({ requireLogin: false });
|
||||||
|
|
||||||
|
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
|
||||||
|
host: "localhost:20128",
|
||||||
|
origin: "http://localhost:20128",
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(response).toBe(mocks.nextResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects local-only route from tunnel host even when requireLogin=false", async () => {
|
||||||
|
mocks.getSettings.mockResolvedValue({ requireLogin: false });
|
||||||
|
|
||||||
|
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
|
||||||
|
host: "router.example.com",
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects local-only route when Origin is non-loopback (CSRF block)", async () => {
|
||||||
|
mocks.getSettings.mockResolvedValue({ requireLogin: false });
|
||||||
|
|
||||||
|
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
|
||||||
|
host: "localhost:20128",
|
||||||
|
origin: "http://evil.example.com",
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
it("allows local-only route with valid CLI token", async () => {
|
it("allows local-only route with valid CLI token", async () => {
|
||||||
const response = await proxy(request("/api/mcp/filesystem/sse", {
|
const response = await proxy(request("/api/mcp/filesystem/sse", {
|
||||||
host: "router.example.com",
|
host: "router.example.com",
|
||||||
|
|||||||
Reference in New Issue
Block a user