From 7648c3412b403a29f04967c4b4e9725e228791d4 Mon Sep 17 00:00:00 2001 From: decolua Date: Mon, 8 Jun 2026 12:10:02 +0700 Subject: [PATCH] fix(auth): real client IP rate-limiting + remote default-password guard - Add custom-server.js: inject unspoofable socket IP, strip client XFF (wired into Docker CMD + CLI spawn + build-cli copy) - loginLimiter: key on trusted x-9r-real-ip, TRUST_PROXY opt-in, global fallback - Force password change on first remote login while default is in use - Add /api/auth/reset-password (local-only) so CLI reset writes live SQLite - CLI settings: reset via API instead of stale db.json - Fix OAuth modals opening duplicate browser tabs on add-connection - Add cli:pack / cli:publish scripts Co-authored-by: Cursor --- Dockerfile | 3 +- cli/cli.js | 8 ++- cli/scripts/build-cli.js | 9 +++ cli/src/cli/api/client.js | 9 +++ cli/src/cli/menus/settings.js | 33 ++--------- custom-server.js | 22 +++++++ package.json | 4 +- src/app/api/auth/login/route.js | 8 ++- src/app/api/auth/reset-password/route.js | 13 ++++ src/app/login/page.js | 59 ++++++++++++++++++- src/dashboardGuard.js | 3 +- src/lib/auth/loginLimiter.js | 14 ++++- src/lib/tunnel/index.js | 2 + src/lib/tunnel/tailscale/tailscale.js | 8 ++- src/shared/components/KiroSocialOAuthModal.js | 15 ++++- src/shared/components/OAuthModal.js | 5 ++ src/shared/services/initializeApp.js | 14 ++++- 17 files changed, 185 insertions(+), 44 deletions(-) create mode 100644 custom-server.js create mode 100644 src/app/api/auth/reset-password/route.js diff --git a/Dockerfile b/Dockerfile index 576176a2..5abe1f24 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,7 @@ ENV DATA_DIR=/app/data COPY --from=builder /app/public ./public COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/custom-server.js ./custom-server.js COPY --from=builder /app/open-sse ./open-sse # Next file tracing can omit sibling files; MITM runs server.js as a separate process. COPY --from=builder /app/src/mitm ./src/mitm @@ -49,4 +50,4 @@ RUN apk --no-cache upgrade && apk --no-cache add su-exec && \ EXPOSE 20128 ENTRYPOINT ["/entrypoint.sh"] -CMD ["node", "server.js"] +CMD ["node", "custom-server.js"] diff --git a/cli/cli.js b/cli/cli.js index 6e4bfe61..01300ca1 100755 --- a/cli/cli.js +++ b/cli/cli.js @@ -470,9 +470,13 @@ function openBrowser(url) { }); } -// Find standalone server (bundled in bin/app for published package) +// Find standalone server (bundled in bin/app for published package). +// Prefer custom-server.js (injects real socket IP) when present. const standaloneDir = path.join(__dirname, "app"); -const serverPath = path.join(standaloneDir, "server.js"); +const customServerPath = path.join(standaloneDir, "custom-server.js"); +const serverPath = fs.existsSync(customServerPath) + ? customServerPath + : path.join(standaloneDir, "server.js"); if (!fs.existsSync(serverPath)) { console.error("Error: Standalone build not found."); diff --git a/cli/scripts/build-cli.js b/cli/scripts/build-cli.js index 760e1e8c..be2e97c3 100644 --- a/cli/scripts/build-cli.js +++ b/cli/scripts/build-cli.js @@ -154,6 +154,15 @@ if (standaloneApp !== standaloneRootToUse && fs.existsSync(standaloneNodeModules } console.log("✅ Copied standalone build\n"); +// Step 3a: Copy custom server (injects real socket IP, strips spoofable XFF). +const customServerSrc = path.join(appDir, "custom-server.js"); +if (fs.existsSync(customServerSrc)) { + fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js")); + console.log("✅ Copied custom-server.js\n"); +} else { + console.warn("⚠️ custom-server.js not found — server will run without real-IP injection\n"); +} + // Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules. // Strip better-sqlite3 (native) — it lives in ~/.9router/runtime to avoid // Windows EBUSY during global CLI updates. node:sqlite (Node ≥22.5) is also diff --git a/cli/src/cli/api/client.js b/cli/src/cli/api/client.js index a8806e5b..257fcd22 100644 --- a/cli/src/cli/api/client.js +++ b/cli/src/cli/api/client.js @@ -410,6 +410,14 @@ async function updateSettings(data) { return makeRequest("PATCH", "/api/settings", data); } +/** + * Reset dashboard password to default (clears stored hash server-side) + * @returns {Promise} { success } + */ +async function resetPassword() { + return makeRequest("POST", "/api/auth/reset-password"); +} + // ============================================================================ // MODELS API // ============================================================================ @@ -528,6 +536,7 @@ module.exports = { // Settings getSettings, updateSettings, + resetPassword, // Tunnel getTunnelStatus, diff --git a/cli/src/cli/menus/settings.js b/cli/src/cli/menus/settings.js index e57490bd..a86a7c49 100644 --- a/cli/src/cli/menus/settings.js +++ b/cli/src/cli/menus/settings.js @@ -1,6 +1,3 @@ -const path = require("path"); -const fs = require("fs"); -const os = require("os"); const api = require("../api/client"); const { confirm, pause } = require("../utils/input"); const { showStatus } = require("../utils/display"); @@ -18,13 +15,6 @@ const COLORS = { const DEFAULT_PASSWORD = "123456"; -// Resolve db.json path (matches app/src/lib/dataDir.js convention) -function getDbPath() { - return process.platform === "win32" - ? path.join(process.env.APPDATA || "", "9router", "db.json") - : path.join(os.homedir(), ".9router", "db.json"); -} - /** * Show settings menu (tunnel + RTK + reset password) * @param {Array} breadcrumb - Breadcrumb path @@ -171,18 +161,10 @@ async function toggleRtk(currentlyOn) { } /** - * Reset dashboard password by clearing the hash in db.json (Phase B). + * Reset dashboard password to default via server API (writes the live SQLite DB). * After reset, user can log in with the default password "123456". */ async function resetPassword() { - const dbPath = getDbPath(); - - if (!fs.existsSync(dbPath)) { - showStatus(`db.json not found at ${dbPath}`, "error"); - await pause(); - return; - } - const ok = await confirm(`Reset dashboard password to default "${DEFAULT_PASSWORD}"?`); if (!ok) { showStatus("Cancelled", "info"); @@ -190,16 +172,11 @@ async function resetPassword() { return; } - try { - const raw = fs.readFileSync(dbPath, "utf-8"); - const db = JSON.parse(raw); - if (db.settings && Object.prototype.hasOwnProperty.call(db.settings, "password")) { - delete db.settings.password; - } - fs.writeFileSync(dbPath, JSON.stringify(db, null, 2)); + const result = await api.resetPassword(); + if (result.success) { showStatus(`Password reset. Default: ${DEFAULT_PASSWORD}`, "success"); - } catch (err) { - showStatus(`Failed to reset password: ${err.message}`, "error"); + } else { + showStatus(`Failed to reset password: ${result.error}`, "error"); } await pause(); } diff --git a/custom-server.js b/custom-server.js new file mode 100644 index 00000000..a12aa747 --- /dev/null +++ b/custom-server.js @@ -0,0 +1,22 @@ +const http = require("http"); + +const origCreate = http.createServer.bind(http); + +// Wrap Next standalone HTTP server: derive client IP from the TCP socket +// (unspoofable) and strip client-supplied forwarding headers so downstream +// rate-limiting keys on the real peer address instead of attacker-controlled XFF. +http.createServer = (...args) => { + const handler = args.find((a) => typeof a === "function"); + const rest = args.filter((a) => typeof a !== "function"); + if (!handler) return origCreate(...args); + const wrapped = (req, res) => { + const ip = req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : ""; + delete req.headers["x-9r-real-ip"]; + delete req.headers["x-forwarded-for"]; + req.headers["x-9r-real-ip"] = ip; + return handler(req, res); + }; + return origCreate(...rest, wrapped); +}; + +require("./server.js"); diff --git a/package.json b/package.json index e252a768..f8539086 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "start": "next start", "dev:bun": "bun --bun next dev --webpack --port 20128", "build:bun": "bun --bun next build --webpack", - "start:bun": "bun ./.next/standalone/server.js" + "start:bun": "bun ./.next/standalone/server.js", + "cli:pack": "npm --prefix cli run pack:cli", + "cli:publish": "npm --prefix cli run publish:cli" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/src/app/api/auth/login/route.js b/src/app/api/auth/login/route.js index 437732ae..e6b264dc 100644 --- a/src/app/api/auth/login/route.js +++ b/src/app/api/auth/login/route.js @@ -5,6 +5,7 @@ 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"; +import { isLocalRequest } from "@/dashboardGuard"; const RESET_HINT = "Forgot password? Reset to default via 9Router CLI → Settings → Reset Password to Default."; @@ -55,7 +56,12 @@ export async function POST(request) { const cookieStore = await cookies(); await setDashboardAuthCookie(cookieStore, request); - return NextResponse.json({ success: true }); + // Default password still in use on a remote client → force a password + // change before the dashboard is exposed remotely (keeps local UX intact). + const mustChangePassword = + !storedHash && !process.env.INITIAL_PASSWORD && !isLocalRequest(request); + + return NextResponse.json({ success: true, mustChangePassword }); } const { remainingBeforeLock } = recordFail(ip); diff --git a/src/app/api/auth/reset-password/route.js b/src/app/api/auth/reset-password/route.js new file mode 100644 index 00000000..49e6d724 --- /dev/null +++ b/src/app/api/auth/reset-password/route.js @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { updateSettings } from "@/lib/localDb"; + +// Reset dashboard password to default by clearing the stored hash. +// Local-only (enforced by dashboardGuard). Never returns the default literal. +export async function POST() { + try { + await updateSettings({ password: null }); + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/login/page.js b/src/app/login/page.js index dac8d4bf..38cf033d 100644 --- a/src/app/login/page.js +++ b/src/app/login/page.js @@ -14,6 +14,8 @@ export default function LoginPage() { const [authMode, setAuthMode] = useState("password"); const [oidcConfigured, setOidcConfigured] = useState(false); const [oidcLoginLabel, setOidcLoginLabel] = useState("Sign in with OIDC"); + const [mustChange, setMustChange] = useState(false); + const [newPassword, setNewPassword] = useState(""); const router = useRouter(); // Countdown for rate-limit @@ -72,6 +74,11 @@ export default function LoginPage() { }); if (res.ok) { + const data = await res.json(); + if (data.mustChangePassword) { + setMustChange(true); + return; + } router.push("/dashboard"); router.refresh(); } else { @@ -87,6 +94,31 @@ export default function LoginPage() { } }; + // Force a new password before entering the dashboard (default + remote). + const handleSetNewPassword = async (e) => { + e.preventDefault(); + setLoading(true); + setError(""); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ currentPassword: password, newPassword }), + }); + if (res.ok) { + router.push("/dashboard"); + router.refresh(); + } else { + const data = await res.json(); + setError(data.error || "Failed to set password"); + } + } catch (err) { + setError("An error occurred. Please try again."); + } finally { + setLoading(false); + } + }; + const handleOidcLogin = () => { window.location.href = "/api/auth/oidc/start"; }; @@ -121,6 +153,28 @@ export default function LoginPage() { + {mustChange ? ( +
+

+ Set a new password before accessing the dashboard remotely. +

+
+ + setNewPassword(e.target.value)} + required + autoFocus + /> + {error &&

{error}

} +
+ +
+ ) : (
{oidcAvailable && (
+ )}
diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 989c6a1e..76275db4 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -78,6 +78,7 @@ const LOCAL_ONLY_PATHS = [ "/api/tunnel/disable", "/api/oauth/cursor/auto-import", "/api/oauth/kiro/auto-import", + "/api/auth/reset-password", ]; const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); @@ -88,7 +89,7 @@ function isLoopbackHostname(h) { return LOOPBACK_HOSTS.has(name); } -function isLocalRequest(request) { +export function isLocalRequest(request) { if (!isLoopbackHostname(request.headers.get("host"))) return false; const origin = request.headers.get("origin"); if (origin) { diff --git a/src/lib/auth/loginLimiter.js b/src/lib/auth/loginLimiter.js index a5412361..43dec55b 100644 --- a/src/lib/auth/loginLimiter.js +++ b/src/lib/auth/loginLimiter.js @@ -46,7 +46,15 @@ export function recordSuccess(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"; + // Trusted: set from TCP socket by custom-server.js (client cannot spoof). + const realIp = request.headers.get("x-9r-real-ip"); + if (realIp) return realIp; + // Behind a trusted reverse proxy that overwrites XFF with the real client IP. + if (process.env.TRUST_PROXY === "true") { + const xff = request.headers.get("x-forwarded-for"); + if (xff) return xff.split(",")[0].trim(); + } + // Direct exposure without custom-server: single bucket so spoofed XFF + // rotation cannot escape the limiter. + return "unknown"; } diff --git a/src/lib/tunnel/index.js b/src/lib/tunnel/index.js index 5c88b302..3ffab372 100644 --- a/src/lib/tunnel/index.js +++ b/src/lib/tunnel/index.js @@ -31,6 +31,8 @@ export { isTailscaleLoggedIn, isTailscaleLoggedInStrict, isSystemDaemonRunning, + isDaemonAlive, + startFunnel, getTailscaleBin, installTailscale, startLogin, diff --git a/src/lib/tunnel/tailscale/tailscale.js b/src/lib/tunnel/tailscale/tailscale.js index 44c069bc..467ad286 100644 --- a/src/lib/tunnel/tailscale/tailscale.js +++ b/src/lib/tunnel/tailscale/tailscale.js @@ -519,6 +519,11 @@ function isDaemonTunMode() { } catch { return null; } } +/** Daemon process alive (independent of funnel state) — mirrors cloudflared PID check semantic. */ +export function isDaemonAlive() { + return isDaemonTunMode() !== null; +} + /** * Start tailscaled. * - With sudoPassword: TUN mode (root) → Funnel TLS works @@ -550,8 +555,9 @@ export async function startDaemonWithPassword(sudoPassword) { return; } - const wantTun = !!sudoPassword; const currentMode = isDaemonTunMode(); // true=TUN, false=userspace, null=not running + // No password but a healthy TUN daemon already runs → keep TUN, never downgrade-kill it. + const wantTun = sudoPassword ? true : currentMode === true; // Daemon already running in correct mode → reuse if (currentMode !== null && currentMode === wantTun) { diff --git a/src/shared/components/KiroSocialOAuthModal.js b/src/shared/components/KiroSocialOAuthModal.js index 323d4b17..76cc95f9 100644 --- a/src/shared/components/KiroSocialOAuthModal.js +++ b/src/shared/components/KiroSocialOAuthModal.js @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import PropTypes from "prop-types"; import { Modal, Button, Input } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; @@ -16,6 +16,12 @@ export default function KiroSocialOAuthModal({ isOpen, provider, onSuccess, onCl const [callbackUrl, setCallbackUrl] = useState(""); const [error, setError] = useState(null); const { copied, copy } = useCopyToClipboard(); + const openedRef = useRef(false); + + // Reset auto-open guard when modal closes so it can re-open next session. + useEffect(() => { + if (!isOpen) openedRef.current = false; + }, [isOpen]); // Initialize auth flow useEffect(() => { @@ -37,8 +43,11 @@ export default function KiroSocialOAuthModal({ isOpen, provider, onSuccess, onCl setAuthUrl(data.authUrl); setStep("input"); - // Auto-open browser - window.open(data.authUrl, "_blank"); + // Auto-open browser once per modal session. + if (!openedRef.current) { + openedRef.current = true; + window.open(data.authUrl, "_blank"); + } } catch (err) { setError(err.message); setStep("error"); diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index 7bc0c976..28879c58 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -20,6 +20,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, const [polling, setPolling] = useState(false); const popupRef = useRef(null); const pollingAbortRef = useRef(false); + const openedRef = useRef(false); const { copied, copy } = useCopyToClipboard(); // State for client-only values to avoid hydration mismatch @@ -310,6 +311,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, // Reset state and start OAuth when modal opens useEffect(() => { if (isOpen && provider) { + // Guard against StrictMode/effect re-runs auto-opening multiple tabs. + if (openedRef.current) return; + openedRef.current = true; setAuthData(null); setCallbackUrl(""); setError(null); @@ -321,6 +325,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, } else if (!isOpen) { // Abort polling and cleanup proxy when modal closes pollingAbortRef.current = true; + openedRef.current = false; if (provider === "codex") { fetch("/api/oauth/codex/stop-proxy").catch(() => {}); } else if (provider === "xai") { diff --git a/src/shared/services/initializeApp.js b/src/shared/services/initializeApp.js index da12becf..3f79f66a 100644 --- a/src/shared/services/initializeApp.js +++ b/src/shared/services/initializeApp.js @@ -8,7 +8,7 @@ import { isTunnelManuallyDisabled, isTunnelReconnecting, isTailscaleReconnecting, getTunnelService, getTailscaleService, setTunnelUnexpectedExitCallback, killCloudflared, isCloudflaredRunning, ensureCloudflared, - isTailscaleRunning, isTailscaleRunningStrict, + isTailscaleRunning, isTailscaleRunningStrict, isDaemonAlive, startFunnel, checkInternet, RESTART_COOLDOWN_MS, NETWORK_SETTLE_MS, WATCHDOG_INTERVAL_MS, NETWORK_CHECK_INTERVAL_MS, VIRTUAL_IFACE_REGEX, @@ -176,6 +176,18 @@ async function safeRestartTailscale(reason) { const running = reason === "startup" ? await isTailscaleRunningStrict() : isTailscaleRunning(); if (running) return; + // Daemon alive but funnel dropped → recover funnel only; never full-restart (preserves login/daemon). + if (isDaemonAlive() && svc.activeLocalPort) { + try { + await startFunnel(svc.activeLocalPort); + svc.lastRestartAt = Date.now(); + console.log("[Tailscale] funnel re-established (daemon alive)"); + } catch (err) { + console.log("[Tailscale] funnel recovery failed:", err.message); + } + return; + } + const force = FORCE_RESTART_REASONS.test(reason); if (!force && Date.now() - svc.lastRestartAt < RESTART_COOLDOWN_MS) { console.log(`[Tailscale] degraded but cooldown active, skip (${reason})`);