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 <cursoragent@cursor.com>
This commit is contained in:
decolua
2026-06-08 12:10:02 +07:00
co-authored by Cursor
parent c572c68717
commit 7648c3412b
17 changed files with 185 additions and 44 deletions
+6 -2
View File
@@ -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.");
+9
View File
@@ -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
+9
View File
@@ -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<Object>} { 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,
+5 -28
View File
@@ -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<string>} 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();
}