Enhance security

This commit is contained in:
decolua
2026-05-20 10:05:22 +07:00
parent e3cab135ef
commit 026a7c9b85
6 changed files with 173 additions and 7 deletions

View File

@@ -105,6 +105,12 @@ export default function APIPageClient({ machineId }) {
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
useEffect(() => {
if (tsLogRef.current) tsLogRef.current.scrollTop = tsLogRef.current.scrollHeight;
@@ -846,6 +852,10 @@ export default function APIPageClient({ machineId }) {
size="sm"
icon="cloud_upload"
onClick={() => {
if (isLoginUnsafe) {
setTunnelStatus({ type: "error", message: `Security required: ${unsafeReason}` });
return;
}
if (!requireApiKey) {
setTunnelStatus({ type: "error", message: "Security required: Enable \"Require API key\" before activating the tunnel." });
return;
@@ -928,7 +938,13 @@ export default function APIPageClient({ machineId }) {
<Button
size="sm"
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!"
>
Enable
@@ -937,6 +953,16 @@ export default function APIPageClient({ machineId }) {
</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 */}
{(tunnelEnabled || tsEnabled) && (
<div className="mt-4 flex flex-col gap-2">

View File

@@ -4,6 +4,9 @@ import bcrypt from "bcryptjs";
import { cookies } from "next/headers";
import { setDashboardAuthCookie } from "@/lib/auth/dashboardSession";
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) {
const host = (request.headers.get("host") || "").split(":")[0].toLowerCase();
@@ -14,6 +17,15 @@ function isTunnelRequest(request, settings) {
export async function POST(request) {
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 settings = await getSettings();
@@ -39,13 +51,25 @@ export async function POST(request) {
}
if (isValid) {
recordSuccess(ip);
const cookieStore = await cookies();
await setDashboardAuthCookie(cookieStore, request);
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) {
return NextResponse.json({ error: error.message }, { status: 500 });
}

View File

@@ -7,6 +7,8 @@ import { useRouter } from "next/navigation";
export default function LoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [resetHint, setResetHint] = useState("");
const [retryAfter, setRetryAfter] = useState(0);
const [loading, setLoading] = useState(false);
const [hasPassword, setHasPassword] = useState(null);
const [authMode, setAuthMode] = useState("password");
@@ -14,6 +16,13 @@ export default function LoginPage() {
const [oidcLoginLabel, setOidcLoginLabel] = useState("Sign in with OIDC");
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(() => {
async function checkAuth() {
const controller = new AbortController();
@@ -53,6 +62,7 @@ export default function LoginPage() {
e.preventDefault();
setLoading(true);
setError("");
setResetHint("");
try {
const res = await fetch("/api/auth/login", {
@@ -67,6 +77,8 @@ export default function LoginPage() {
} else {
const data = await res.json();
setError(data.error || "Invalid password");
if (data.resetHint) setResetHint(data.resetHint);
if (data.retryAfter) setRetryAfter(Number(data.retryAfter));
}
} catch (err) {
setError("An error occurred. Please try again.");
@@ -143,6 +155,16 @@ export default function LoginPage() {
autoFocus={!oidcAvailable}
/>
{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>
<Button
@@ -150,8 +172,9 @@ export default function LoginPage() {
variant="primary"
className="w-full"
loading={loading}
disabled={retryAfter > 0}
>
Login
{retryAfter > 0 ? `Wait ${retryAfter}s` : "Login"}
</Button>
<p className="text-xs text-center text-text-muted mt-2">

View File

@@ -125,8 +125,8 @@ async function canAccessPublicLlmApi(request) {
async function canAccessLocalOnlyRoute(request) {
if (await hasValidCliToken(request)) return true;
// Browser on host: loopback Host + Origin (blocks tunnel/CSRF) + JWT cookie (blocks unauth raw clients)
if (isLocalRequest(request) && await hasValidToken(request)) return true;
// Browser on host: loopback Host + Origin (blocks tunnel/CSRF) + auth (JWT or requireLogin=false)
if (isLocalRequest(request) && await isAuthenticated(request)) return true;
return false;
}

View File

@@ -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";
}

View File

@@ -129,13 +129,22 @@ describe("dashboard guard public LLM API access", () => {
describe("dashboard guard local-only access", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getSettings.mockResolvedValue({ requireLogin: false });
mocks.getSettings.mockResolvedValue({ requireLogin: true });
mocks.validateApiKey.mockResolvedValue(false);
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
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", {
host: "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");
});
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 () => {
const response = await proxy(request("/api/mcp/filesystem/sse", {
host: "router.example.com",