Enhance security
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
52
src/lib/auth/loginLimiter.js
Normal file
52
src/lib/auth/loginLimiter.js
Normal 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";
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user