diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index 3959a9a9..4b2534dd 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -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 }) {

diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index c48b0182..61af0939 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -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; } diff --git a/src/lib/auth/loginLimiter.js b/src/lib/auth/loginLimiter.js new file mode 100644 index 00000000..a5412361 --- /dev/null +++ b/src/lib/auth/loginLimiter.js @@ -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"; +} diff --git a/tests/unit/dashboard-guard.test.js b/tests/unit/dashboard-guard.test.js index 55784fc7..a2eb318f 100644 --- a/tests/unit/dashboard-guard.test.js +++ b/tests/unit/dashboard-guard.test.js @@ -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",