+ `;
+ }).join("");
+}
+
+async function handleBuildBlockClick(event) {
+ const btn = event.target.closest(".builds-action-btn");
+ if (!btn) return;
+ const buildId = btn.dataset.buildId;
+ const block = btn.dataset.block === "true";
+ if (!buildId) return;
+
+ const verb = block ? "Block" : "Unblock";
+ if (block) {
+ if (!confirm(`Block build ${buildId.substring(0, 8)}...?\n\nAny agent currently connected from this build will be disconnected, and future connection attempts with this build's tag will be rejected.`)) return;
+ }
+
+ btn.disabled = true;
+ let res;
+ try {
+ res = await fetch(`/api/build/${encodeURIComponent(buildId)}/block`, {
+ method: "POST",
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ blocked: block }),
+ });
+ } catch {
+ showBuildsMessage(`${verb} failed: network error`, "error");
+ btn.disabled = false;
+ return;
+ }
+
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok || data.success === false) {
+ showBuildsMessage(data.error || `${verb} failed`, "error");
+ btn.disabled = false;
+ return;
+ }
+
+ if (block && typeof data.disconnected === "number" && data.disconnected > 0) {
+ showBuildsMessage(`Build blocked. Disconnected ${data.disconnected} live agent${data.disconnected === 1 ? "" : "s"}.`);
+ } else {
+ showBuildsMessage(`Build ${block ? "blocked" : "unblocked"}.`);
+ }
+ await loadBuilds();
+}
+
+// ---- Sidebar nav ---------------------------------------------------------
+
+/**
+ * Update the --settings-scroll-mt CSS variable to match whatever the top nav
+ * is currently consuming at the top of the viewport. In topbar mode this is
+ * the topbar's rendered height + an 8px buffer so the section title isn't
+ * flush against the nav. In sidebar mode the nav doesn't obstruct the top, so
+ * we use a small visual margin. Called on init and on viewport changes.
+ */
+function updateSettingsScrollOffset() {
+ const root = document.documentElement;
+ const nav = document.getElementById("top-nav");
+ // Sidebar mode (left column): no top obstruction. Body gets `sb-ready` from
+ // the adaptive nav layout.
+ const isSidebarMode = document.body.classList.contains("sb-ready");
+ // Nav hidden via Ctrl+\: also no obstruction.
+ const isHidden = document.body.classList.contains("nav-hidden");
+
+ let offsetPx = 16; // matches the old scroll-mt-4 default
+ if (!isSidebarMode && !isHidden && nav) {
+ const rect = nav.getBoundingClientRect();
+ // Only treat as obstructing if the nav is actually pinned at the top and
+ // spans most of the width. (Defensive — covers mobile/dropdown variations.)
+ if (rect.top <= 0 && rect.height > 0 && rect.width >= window.innerWidth * 0.5) {
+ offsetPx = Math.round(rect.height) + 8;
+ }
+ }
+ root.style.setProperty("--settings-scroll-mt", `${offsetPx}px`);
+}
+
+function initSettingsSidebar() {
+ const sidebar = document.getElementById("settings-sidebar");
+ if (!sidebar) return;
+ const links = Array.from(sidebar.querySelectorAll(".settings-nav-link"));
+
+ // Hide nav links and group labels whose target sections are admin-only /
+ // admin-or-operator when the current user isn't authorized. We hide rather
+ // than disable so the sidebar stays uncluttered for non-admins.
+ const gated = sidebar.querySelectorAll("[data-admin-only], [data-admin-or-operator]");
+ for (const el of gated) {
+ if (el.dataset.adminOnly !== undefined && !isAdmin(currentUser?.role)) {
+ el.classList.add("hidden");
+ }
+ if (el.dataset.adminOrOperator !== undefined && !canManageBuilds(currentUser?.role)) {
+ el.classList.add("hidden");
+ }
+ }
+
+ // Build the section→link map up front so click + observer share it.
+ const sectionMap = new Map();
+ // sectionsInOrder preserves document order for "last visible at page bottom"
+ // tie-breaking (see updateActive below).
+ const sectionsInOrder = [];
+ for (const link of links) {
+ const id = link.dataset.target;
+ const section = id ? document.getElementById(id) : null;
+ if (section) {
+ sectionMap.set(section, link);
+ sectionsInOrder.push(section);
+ }
+ }
+ if (sectionMap.size === 0) return;
+
+ function setActive(link) {
+ links.forEach((l) => l.classList.remove("active"));
+ if (link) link.classList.add("active");
+ }
+
+ function isAtPageBottom() {
+ return window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 4;
+ }
+
+ // Click → smooth scroll to section. Mark the link active immediately so the
+ // bottom links (where scroll can't reach the section's top) still highlight.
+ sidebar.addEventListener("click", (event) => {
+ const link = event.target.closest(".settings-nav-link");
+ if (!link) return;
+ event.preventDefault();
+ const id = link.dataset.target;
+ if (!id) return;
+ const target = document.getElementById(id);
+ if (!target) return;
+ // Re-measure the topbar offset right before scrolling, so a layout shift
+ // (nav-hidden toggle, viewport resize, dropdown opened) doesn't leave the
+ // section partly hidden under the nav.
+ updateSettingsScrollOffset();
+ setActive(link);
+ target.scrollIntoView({ behavior: "smooth", block: "start" });
+ history.replaceState(null, "", `#${id}`);
+ });
+
+ // Active-section highlight via IntersectionObserver + page-bottom override.
+ const visible = new Set();
+ function updateActive() {
+ // Special case: if the user has scrolled as far as the page allows, the
+ // last visible section becomes active (otherwise the topmost-visible
+ // heuristic would leave shorter trailing sections — like Custom CSS or
+ // Export / Import — unable to ever become active because the page can't
+ // scroll their headings to the top of the viewport).
+ if (isAtPageBottom() && visible.size > 0) {
+ let lastEl = null;
+ for (const section of sectionsInOrder) {
+ if (visible.has(section)) lastEl = section;
+ }
+ if (lastEl) {
+ setActive(sectionMap.get(lastEl));
+ return;
+ }
+ }
+ // Default: topmost visible section.
+ let topEl = null;
+ let topY = Infinity;
+ for (const el of visible) {
+ const y = el.getBoundingClientRect().top;
+ if (y < topY) { topY = y; topEl = el; }
+ }
+ if (topEl) setActive(sectionMap.get(topEl));
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const e of entries) {
+ if (e.isIntersecting) visible.add(e.target);
+ else visible.delete(e.target);
+ }
+ updateActive();
+ },
+ { rootMargin: "-10% 0px -70% 0px", threshold: 0 },
+ );
+ for (const section of sectionMap.keys()) observer.observe(section);
+
+ // Re-run the picker on raw scroll too, so the page-bottom override fires
+ // even when no IntersectionObserver entry has changed (last sections may
+ // already be inside `visible` and the rootMargin won't re-trigger).
+ let scrollRaf = 0;
+ window.addEventListener("scroll", () => {
+ if (scrollRaf) return;
+ scrollRaf = requestAnimationFrame(() => {
+ scrollRaf = 0;
+ updateActive();
+ });
+ }, { passive: true });
+}
+
async function loadAppearanceSettings() {
if (!currentUser) return;
@@ -1340,6 +1619,15 @@ async function init() {
wipeOfflineSection.classList.remove("hidden");
}
+ if (canManageBuilds(currentUser?.role)) {
+ if (buildsSection) buildsSection.classList.remove("hidden");
+ // Hide the "show all" toggle for non-admins
+ if (buildsShowAllWrap && !isAdmin(currentUser?.role)) {
+ buildsShowAllWrap.classList.add("hidden");
+ }
+ await loadBuilds();
+ }
+
await loadSecurityPolicy();
await loadTlsSettings();
await loadAppearanceSettings();
@@ -1387,6 +1675,34 @@ async function init() {
bansTableBody.addEventListener("click", handleUnbanClick);
if (wipeOfflineBtn) wipeOfflineBtn.addEventListener("click", wipeOfflineClients);
+ if (refreshBuildsBtn) refreshBuildsBtn.addEventListener("click", loadBuilds);
+ if (buildsTableBody) buildsTableBody.addEventListener("click", handleBuildBlockClick);
+ if (buildsShowAllInput) buildsShowAllInput.addEventListener("change", loadBuilds);
+
+ initSettingsSidebar();
+
+ // Compute the scroll offset now, then again after the nav has had a moment
+ // to settle (the adaptive nav controller adjusts mode async based on
+ // viewport width and content overflow), and on every resize.
+ updateSettingsScrollOffset();
+ setTimeout(updateSettingsScrollOffset, 0);
+ setTimeout(updateSettingsScrollOffset, 200);
+ let resizeTimer = 0;
+ window.addEventListener("resize", () => {
+ clearTimeout(resizeTimer);
+ resizeTimer = setTimeout(updateSettingsScrollOffset, 100);
+ });
+
+ // Honor a ?#section-id deep-link on first load so we land on that section.
+ // Run AFTER the offset is set so the section lands under the topbar.
+ if (window.location.hash) {
+ const target = document.getElementById(window.location.hash.slice(1));
+ if (target) {
+ // Defer to the next frame so scroll-margin-top has been applied.
+ requestAnimationFrame(() => target.scrollIntoView({ behavior: "auto", block: "start" }));
+ }
+ }
+
await loadSessions();
if (refreshSessionsBtn) refreshSessionsBtn.addEventListener("click", loadSessions);
if (removeInactiveSessionsBtn) removeInactiveSessionsBtn.addEventListener("click", handleRemoveInactiveSessions);
diff --git a/Overlord-Server/public/settings.html b/Overlord-Server/public/settings.html
index 530fcf9..c02c569 100644
--- a/Overlord-Server/public/settings.html
+++ b/Overlord-Server/public/settings.html
@@ -9,236 +9,164 @@
+
-
-
+
+
Settings
- Manage your account preferences and review banned IP addresses.
+ Manage your account preferences, operational tools, and admin configuration.
-
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
News & Updates
-
Stay up to date with the newest features, releases, and announcements.
-
-
-
-
-
- Follow the official Overlord Telegram channel for the latest updates, changelogs, and feature announcements.
-
Review your builds, see how many agents have claimed each, and block a build to immediately disconnect any agent that connects with its tag.
+
+
+
+
+
+
+
+
+
+ Claim count is the number of unique agent fingerprints that have ever connected using a build's tag. Each unique host produces one claim. A claim count higher than the number of machines you actually deployed to suggests the build tag may have leaked.
+
+
+
+
+
+
+
Build ID
+
Tag
+
Status
+
Created
+
Claims
+
Action
+
+
+
+
+
Loading...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Wipe Offline Clients
+
Permanently remove all offline clients from the dashboard in one click.
+
+
+
+
+
+ Deletes every client currently marked as offline. If any of them reconnect later they will reappear automatically.
+
+
+
+
+
+
+
+
+
-
+
@@ -489,7 +578,7 @@
-
+
@@ -591,7 +680,7 @@
-
+
@@ -626,46 +715,8 @@
-
-
-
-
-
-
-
-
Custom CSS
-
Admin-only. Paste custom CSS to override the default theme across all pages.
-
-
-
-
-
-
-
+
@@ -766,7 +817,7 @@
-
+
@@ -799,6 +850,97 @@
+
+
+
+
+
+
+
+
Custom CSS
+
Admin-only. Paste custom CSS to override the default theme across all pages.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Export & Import Settings
+
Backup or restore your Overlord configuration (notifications, security, TLS, appearance, auth tokens).
+
+
+
+
+
+
+ Export — Downloads a JSON file with all current settings and auth tokens.
+
+
Keep this file secure — it contains sensitive tokens.
+
+
+
+
+
+
+
+ Import — Restore settings from a previously exported JSON file.
+
+
Auth token changes require a server restart. Environment variables will still take priority.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts
index 76f247e..3cb38e4 100644
--- a/Overlord-Server/src/db.ts
+++ b/Overlord-Server/src/db.ts
@@ -191,8 +191,19 @@ db.run(`
try { db.run(`ALTER TABLE builds ADD COLUMN build_tag TEXT`); } catch {}
try { db.run(`ALTER TABLE builds ADD COLUMN built_by_user_id INTEGER`); } catch {}
+try { db.run(`ALTER TABLE builds ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0`); } catch {}
db.run(`CREATE INDEX IF NOT EXISTS idx_builds_build_tag ON builds(build_tag);`);
+db.run(`
+ CREATE TABLE IF NOT EXISTS build_claims (
+ build_id TEXT NOT NULL,
+ key_fingerprint TEXT NOT NULL,
+ claimed_at INTEGER NOT NULL,
+ PRIMARY KEY (build_id, key_fingerprint)
+ );
+`);
+db.run(`CREATE INDEX IF NOT EXISTS idx_build_claims_build ON build_claims(build_id);`);
+
db.run(`
CREATE TABLE IF NOT EXISTS build_profiles (
user_id INTEGER NOT NULL,
@@ -1537,6 +1548,7 @@ export interface BuildRecord {
}>;
buildTag?: string;
builtByUserId?: number;
+ blocked?: boolean;
}
export function saveBuild(build: BuildRecord) {
@@ -1553,10 +1565,7 @@ export function saveBuild(build: BuildRecord) {
);
}
-export function getBuild(id: string): BuildRecord | null {
- const row = db.query(`SELECT * FROM builds WHERE id = ?`).get(id);
- if (!row) return null;
-
+function mapBuildRow(row: any): BuildRecord {
return {
id: row.id,
status: row.status,
@@ -1565,22 +1574,87 @@ export function getBuild(id: string): BuildRecord | null {
files: JSON.parse(row.files),
buildTag: row.build_tag || undefined,
builtByUserId: row.built_by_user_id || undefined,
+ blocked: row.blocked === 1,
};
}
+export function getBuild(id: string): BuildRecord | null {
+ const row = db.query(`SELECT * FROM builds WHERE id = ?`).get(id);
+ if (!row) return null;
+ return mapBuildRow(row);
+}
+
export function getBuildByTag(buildTag: string): BuildRecord | null {
const row = db.query(`SELECT * FROM builds WHERE build_tag = ?`).get(buildTag);
if (!row) return null;
+ return mapBuildRow(row);
+}
- return {
- id: row.id,
- status: row.status,
- startTime: row.start_time,
- expiresAt: row.expires_at,
- files: JSON.parse(row.files),
- buildTag: row.build_tag || undefined,
- builtByUserId: row.built_by_user_id || undefined,
- };
+export function isBuildTagBlocked(buildTag: string): boolean {
+ if (!buildTag) return false;
+ const row = db.query<{ blocked: number }>(
+ `SELECT blocked FROM builds WHERE build_tag = ?`,
+ ).get(buildTag);
+ return !!row && row.blocked === 1;
+}
+
+export function setBuildBlocked(buildId: string, blocked: boolean): boolean {
+ const result = db.run(
+ `UPDATE builds SET blocked = ? WHERE id = ?`,
+ blocked ? 1 : 0,
+ buildId,
+ );
+ return ((result as any)?.changes || 0) > 0;
+}
+
+export function listClientIdsByBuildTag(buildTag: string): string[] {
+ if (!buildTag) return [];
+ const rows = db.query<{ id: string }>(
+ `SELECT id FROM clients WHERE build_tag = ?`,
+ ).all(buildTag);
+ return rows.map((r) => r.id);
+}
+
+export function recordBuildClaim(buildId: string, keyFingerprint: string): boolean {
+ if (!buildId || !keyFingerprint) return false;
+ try {
+ const result = db.run(
+ `INSERT OR IGNORE INTO build_claims (build_id, key_fingerprint, claimed_at) VALUES (?, ?, ?)`,
+ buildId,
+ keyFingerprint,
+ Date.now(),
+ );
+ return ((result as any)?.changes || 0) > 0;
+ } catch {
+ return false;
+ }
+}
+
+/** Number of distinct agent fingerprints that have ever claimed this build's tag. */
+export function countBuildClaims(buildId: string): number {
+ if (!buildId) return 0;
+ const row = db.query<{ count: number }>(
+ `SELECT COUNT(*) as count FROM build_claims WHERE build_id = ?`,
+ ).get(buildId);
+ return row?.count ?? 0;
+}
+
+/** Batch helper — returns Map for the given build IDs. */
+export function countBuildClaimsBatch(buildIds: string[]): Map {
+ const out = new Map();
+ if (buildIds.length === 0) return out;
+ // SQLite has a host-parameter limit (~999 by default); chunk to be safe.
+ for (let i = 0; i < buildIds.length; i += 500) {
+ const chunk = buildIds.slice(i, i + 500);
+ const placeholders = chunk.map(() => "?").join(",");
+ const rows = db.query<{ build_id: string; count: number }>(
+ `SELECT build_id, COUNT(*) as count FROM build_claims WHERE build_id IN (${placeholders}) GROUP BY build_id`,
+ ).all(...chunk);
+ for (const r of rows) out.set(r.build_id, r.count);
+ }
+ // Ensure every requested ID is in the map (default 0).
+ for (const id of buildIds) if (!out.has(id)) out.set(id, 0);
+ return out;
}
export function getAllBuilds(userId?: number, role?: string): BuildRecord[] {
@@ -1591,28 +1665,12 @@ export function getAllBuilds(userId?: number, role?: string): BuildRecord[] {
const rows = db
.query(`SELECT * FROM builds WHERE built_by_user_id = ? ORDER BY start_time DESC`)
.all(userId);
- return rows.map((row) => ({
- id: row.id,
- status: row.status,
- startTime: row.start_time,
- expiresAt: row.expires_at,
- files: JSON.parse(row.files),
- buildTag: row.build_tag || undefined,
- builtByUserId: row.built_by_user_id || undefined,
- }));
+ return rows.map(mapBuildRow);
}
const rows = db
.query("SELECT * FROM builds ORDER BY start_time DESC")
.all();
- return rows.map((row) => ({
- id: row.id,
- status: row.status,
- startTime: row.start_time,
- expiresAt: row.expires_at,
- files: JSON.parse(row.files),
- buildTag: row.build_tag || undefined,
- builtByUserId: row.built_by_user_id || undefined,
- }));
+ return rows.map(mapBuildRow);
}
export function deleteExpiredBuilds() {
diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts
index ab15013..f2800de 100644
--- a/Overlord-Server/src/main-server.ts
+++ b/Overlord-Server/src/main-server.ts
@@ -5,7 +5,7 @@ import { fileURLToPath } from "url";
import path from "path";
import fs from "fs/promises";
import { existsSync } from "node:fs";
-import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getBuildByTag, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, clearNotificationScreenshots, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists, deleteExpiredSharedFiles, getChatHistory, insertChatMessage, getOnlineClientCountForUser, deleteExpiredChatMessages } from "./db";
+import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getBuildByTag, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, clearNotificationScreenshots, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists, deleteExpiredSharedFiles, getChatHistory, insertChatMessage, getOnlineClientCountForUser, deleteExpiredChatMessages, recordBuildClaim } from "./db";
import { handleFrame, handleHello, handlePing, handlePong } from "./wsHandlers";
import { getMessageByteLength, getMaxPayloadLimit, isAllowedClientMessageType } from "./wsValidation";
import { ClientInfo, ClientRole } from "./types";
@@ -482,11 +482,16 @@ async function startServer() {
registration: {},
};
- function handleBuildTagConnection(clientId: string, buildTagValue: string) {
+ function handleBuildTagConnection(clientId: string, buildTagValue: string, keyFingerprint: string) {
if (!buildTagValue) return;
const build = getBuildByTag(buildTagValue);
if (!build || !build.builtByUserId) return;
+ if (build.blocked) return;
+
+ const isFirstClaim = recordBuildClaim(build.id, keyFingerprint);
+ if (!isFirstClaim) return;
+
const userId = build.builtByUserId;
const user = getUserById(userId);
if (!user) return;
@@ -500,7 +505,7 @@ async function startServer() {
if (currentScope === "none" || currentScope === "allowlist") {
setUserClientAccessRule(userId, clientId, "allow");
- logger.info(`[build-tag] Auto-added client ${clientId} to user ${user.username}'s allowlist (build: ${build.id.substring(0, 8)})`);
+ logger.info(`[build-tag] Auto-added client ${clientId} to user ${user.username}'s allowlist (build: ${build.id.substring(0, 8)}, fp: ${keyFingerprint.substring(0, 12)}...)`);
}
}
diff --git a/Overlord-Server/src/server/build-process.ts b/Overlord-Server/src/server/build-process.ts
index aedfc09..d947c8c 100644
--- a/Overlord-Server/src/server/build-process.ts
+++ b/Overlord-Server/src/server/build-process.ts
@@ -46,10 +46,16 @@ function resolveClientBuildCacheRoot(): string {
}
function resolveAndroidNdkToolchainBin(): string | null {
- const ndkHome = (process.env.ANDROID_NDK_HOME || "/opt/android-ndk").trim();
+ const explicit = process.env.ANDROID_NDK_HOME?.trim();
+ const ndkHome = explicit || (process.platform === "win32" ? "" : "/opt/android-ndk");
+ if (!ndkHome) return null;
const hostArch = process.arch === "arm64" ? "linux-aarch64" : "linux-x86_64";
const toolchainBin = path.join(ndkHome, "toolchains", "llvm", "prebuilt", hostArch, "bin");
- return fs.existsSync(toolchainBin) ? toolchainBin : null;
+ try {
+ return fs.existsSync(toolchainBin) ? toolchainBin : null;
+ } catch {
+ return null;
+ }
}
type BoundFile = {
@@ -291,8 +297,8 @@ export async function startBuildProcess(
});
}
- const ndkBin = resolveAndroidNdkToolchainBin();
- if (!ndkBin && platformsToBuild.some((p) => p.startsWith("android-"))) {
+ const ndkBin = hasAndroidTargets ? resolveAndroidNdkToolchainBin() : null;
+ if (hasAndroidTargets && !ndkBin) {
sendToStream({
type: "output",
text: "Warning: Android NDK not found. Android builds require the NDK. Install it to /opt/android-ndk or set the ANDROID_NDK_HOME environment variable.\n",
diff --git a/Overlord-Server/src/server/plugin-state-bundle.ts b/Overlord-Server/src/server/plugin-state-bundle.ts
index 08a5701..0826c3a 100644
--- a/Overlord-Server/src/server/plugin-state-bundle.ts
+++ b/Overlord-Server/src/server/plugin-state-bundle.ts
@@ -77,6 +77,21 @@ export async function ensurePluginExtracted(
const zip = new AdmZip(zipPath);
const entries = zip.getEntries();
+
+ const MAX_PLUGIN_UNCOMPRESSED_BYTES = 200 * 1024 * 1024;
+ let totalUncompressed = 0;
+ for (const entry of entries as any[]) {
+ if (entry?.isDirectory) continue;
+ const sz = Number(entry?.header?.size ?? 0);
+ if (!Number.isFinite(sz) || sz < 0) {
+ throw new Error(`Invalid plugin bundle: ${safeId} (malformed zip)`);
+ }
+ totalUncompressed += sz;
+ if (totalUncompressed > MAX_PLUGIN_UNCOMPRESSED_BYTES) {
+ throw new Error(`Plugin bundle too large: ${safeId} (uncompressed > 200 MB)`);
+ }
+ }
+
let htmlEntry: Buffer | null = null;
let cssEntry: Buffer | null = null;
let jsEntry: Buffer | null = null;
diff --git a/Overlord-Server/src/server/routes/auto-scripts-routes.ts b/Overlord-Server/src/server/routes/auto-scripts-routes.ts
index cfe266f..7c0e0f7 100644
--- a/Overlord-Server/src/server/routes/auto-scripts-routes.ts
+++ b/Overlord-Server/src/server/routes/auto-scripts-routes.ts
@@ -40,11 +40,8 @@ export async function handleAutoScriptsRoutes(
headers: { "Content-Type": "application/json" },
});
}
- try {
- requirePermission(user, "clients:control");
- } catch (error) {
- if (error instanceof Response) return error;
- return new Response("Forbidden", { status: 403 });
+ if (user.role !== "admin") {
+ return new Response("Forbidden: Admin access required", { status: 403 });
}
let body: any = {};
@@ -98,11 +95,8 @@ export async function handleAutoScriptsRoutes(
headers: { "Content-Type": "application/json" },
});
}
- try {
- requirePermission(user, "clients:control");
- } catch (error) {
- if (error instanceof Response) return error;
- return new Response("Forbidden", { status: 403 });
+ if (user.role !== "admin") {
+ return new Response("Forbidden: Admin access required", { status: 403 });
}
let body: any = {};
@@ -153,11 +147,8 @@ export async function handleAutoScriptsRoutes(
headers: { "Content-Type": "application/json" },
});
}
- try {
- requirePermission(user, "clients:control");
- } catch (error) {
- if (error instanceof Response) return error;
- return new Response("Forbidden", { status: 403 });
+ if (user.role !== "admin") {
+ return new Response("Forbidden: Admin access required", { status: 403 });
}
deleteAutoScript(autoScriptMatch[1]);
diff --git a/Overlord-Server/src/server/routes/build-routes.ts b/Overlord-Server/src/server/routes/build-routes.ts
index 286b18c..0824bab 100644
--- a/Overlord-Server/src/server/routes/build-routes.ts
+++ b/Overlord-Server/src/server/routes/build-routes.ts
@@ -13,6 +13,9 @@ import {
listBuildProfilesForUser,
saveBuildProfileForUser,
deleteBuildProfileForUser,
+ setBuildBlocked,
+ listClientIdsByBuildTag,
+ countBuildClaimsBatch,
} from "../../db";
import { encodeMessage } from "../../protocol";
import { metrics } from "../../metrics";
@@ -386,7 +389,13 @@ export async function handleBuildRoutes(
const builds = showAll
? getAllBuilds(undefined, "admin")
: getAllBuilds(user.userId, user.role === "admin" ? "operator" : user.role);
- return Response.json({ builds });
+
+ const claimCounts = countBuildClaimsBatch(builds.map((b) => b.id));
+ const enriched = builds.map((b) => ({
+ ...b,
+ claimCount: claimCounts.get(b.id) ?? 0,
+ }));
+ return Response.json({ builds: enriched });
}
if (req.method === "GET" && url.pathname === "/api/build/profiles") {
@@ -518,6 +527,66 @@ export async function handleBuildRoutes(
return Response.json({ success: true });
}
+ if (req.method === "POST" && url.pathname.match(/^\/api\/build\/(.+)\/block$/)) {
+ requirePermission(user, "clients:build");
+
+ const buildId = decodeURIComponent(url.pathname.split("/")[3]);
+ const build = getBuild(buildId);
+ if (!build) {
+ return new Response("Not Found", { status: 404 });
+ }
+ if (build.builtByUserId && build.builtByUserId !== user.userId && user.role !== "admin") {
+ return new Response("Forbidden", { status: 403 });
+ }
+
+ let body: any = {};
+ try {
+ body = await req.json();
+ } catch {
+ // empty body = default to block (true)
+ }
+ const blocked = body && typeof body.blocked === "boolean" ? body.blocked : true;
+
+ const ok = setBuildBlocked(buildId, blocked);
+ if (!ok) {
+ return Response.json({ error: "Build not found" }, { status: 404 });
+ }
+
+ let disconnected = 0;
+ if (blocked && build.buildTag) {
+ const clientIds = listClientIdsByBuildTag(build.buildTag);
+ for (const cid of clientIds) {
+ const target = clientManager.getClient(cid);
+ if (!target?.ws) continue;
+ try {
+ target.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "disconnect",
+ id: uuidv4(),
+ payload: { reason: "build_blocked" },
+ }),
+ );
+ } catch {}
+ try { target.ws.close(4007, "build_blocked"); } catch {}
+ disconnected++;
+ }
+ }
+
+ const ip = server.requestIP(req)?.address || "unknown";
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip,
+ action: AuditAction.COMMAND,
+ details: `${blocked ? "Blocked" : "Unblocked"} build ${buildId}${blocked && disconnected > 0 ? ` (kicked ${disconnected} live agents)` : ""}`,
+ success: true,
+ });
+
+ logger.info(`[build:block] Build ${buildId.substring(0, 8)} ${blocked ? "blocked" : "unblocked"} by ${user.username}${blocked && disconnected > 0 ? ` (${disconnected} live agents kicked)` : ""}`);
+ return Response.json({ success: true, blocked, disconnected });
+ }
+
if (req.method === "GET" && url.pathname.match(/^\/api\/build\/(.+)\/stream$/)) {
requirePermission(user, "clients:build");
@@ -625,11 +694,30 @@ export async function handleBuildRoutes(
!fileName ||
fileName.includes("\u0000") ||
fileName.includes("/") ||
- fileName.includes("\\")
+ fileName.includes("\\") ||
+ fileName.includes("\"") ||
+ fileName.includes("\r") ||
+ fileName.includes("\n") ||
+ fileName.includes(";")
) {
return Response.json({ error: "File not found" }, { status: 404 });
}
+ const allBuilds = getAllBuilds(undefined, "admin");
+ const owningBuild = allBuilds.find((b) =>
+ Array.isArray(b.files) && b.files.some((f: any) => f && f.filename === fileName),
+ );
+ if (!owningBuild) {
+ return Response.json({ error: "File not found" }, { status: 404 });
+ }
+ if (
+ owningBuild.builtByUserId &&
+ owningBuild.builtByUserId !== user.userId &&
+ user.role !== "admin"
+ ) {
+ return new Response("Forbidden", { status: 403 });
+ }
+
const rootDir = resolveRuntimeRoot();
const distRoot = path.resolve(rootDir, "dist-clients");
const filePath = path.resolve(distRoot, fileName);
diff --git a/Overlord-Server/src/server/routes/misc-routes.ts b/Overlord-Server/src/server/routes/misc-routes.ts
index 90fc156..70bb0cd 100644
--- a/Overlord-Server/src/server/routes/misc-routes.ts
+++ b/Overlord-Server/src/server/routes/misc-routes.ts
@@ -4,7 +4,7 @@ import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfi
import { getClientMetricsSummary, getClientMetricsSummaryForUser } from "../../db";
import { metrics } from "../../metrics";
import { requirePermission } from "../../rbac";
-import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess } from "../../users";
+import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess, canUserAccessClient } from "../../users";
import { runCertbotSetup } from "../certbot-setup";
import {
getActiveProxies,
@@ -351,7 +351,10 @@ export async function handleMiscRoutes(
if (user.role === "viewer") {
return new Response("Forbidden", { status: 403 });
}
- return Response.json({ proxies: getActiveProxies() }, { headers: deps.CORS_HEADERS });
+ const proxies = getActiveProxies().filter((p) =>
+ canUserAccessClient(user.userId, user.role, p.clientId),
+ );
+ return Response.json({ proxies }, { headers: deps.CORS_HEADERS });
}
if (req.method === "POST" && url.pathname === "/api/proxy/start") {
@@ -373,10 +376,22 @@ export async function handleMiscRoutes(
if (!clientId) {
return Response.json({ ok: false, message: "clientId is required" }, { status: 400 });
}
+ if (!canUserAccessClient(user.userId, user.role, clientId)) {
+ return Response.json({ ok: false, message: "Forbidden: Client access denied" }, { status: 403 });
+ }
if (port < 1 || port > 65535) {
return Response.json({ ok: false, message: "port must be 1-65535" }, { status: 400 });
}
const result = startProxy(clientId, port);
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip: deps.requestIP?.(req)?.address || "unknown",
+ action: AuditAction.COMMAND,
+ details: `Started SOCKS5 proxy on port ${port} for client ${clientId}`,
+ success: result.ok,
+ errorMessage: result.ok ? undefined : result.message,
+ });
return Response.json(result, {
status: result.ok ? 200 : 400,
headers: deps.CORS_HEADERS,
@@ -401,7 +416,20 @@ export async function handleMiscRoutes(
if (port < 1 || port > 65535) {
return Response.json({ ok: false, message: "port must be 1-65535" }, { status: 400 });
}
+ const owner = getActiveProxies().find((p) => p.port === port);
+ if (owner && !canUserAccessClient(user.userId, user.role, owner.clientId)) {
+ return Response.json({ ok: false, message: "Forbidden: Client access denied" }, { status: 403 });
+ }
const result = stopProxy(port);
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip: deps.requestIP?.(req)?.address || "unknown",
+ action: AuditAction.COMMAND,
+ details: `Stopped SOCKS5 proxy on port ${port}${owner ? ` (client ${owner.clientId})` : ""}`,
+ success: result.ok,
+ errorMessage: result.ok ? undefined : result.message,
+ });
return Response.json(result, {
status: result.ok ? 200 : 400,
headers: deps.CORS_HEADERS,
@@ -577,6 +605,10 @@ export async function handleMiscRoutes(
}
if (req.method === "GET" && url.pathname === "/api/cert/download" && deps.tlsCertPath) {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response("Unauthorized", { status: 401 });
+ }
try {
const file = Bun.file(deps.tlsCertPath);
if (await file.exists()) {
diff --git a/Overlord-Server/src/server/routes/notifications-config-routes.ts b/Overlord-Server/src/server/routes/notifications-config-routes.ts
index 6de815e..90a57bf 100644
--- a/Overlord-Server/src/server/routes/notifications-config-routes.ts
+++ b/Overlord-Server/src/server/routes/notifications-config-routes.ts
@@ -81,8 +81,8 @@ export async function handleNotificationsConfigRoutes(
headers: { "Content-Type": "application/json" },
});
}
- if (user.role !== "admin" && user.role !== "operator") {
- return new Response("Forbidden: Admin or operator access required", {
+ if (user.role !== "admin") {
+ return new Response("Forbidden: Admin access required", {
status: 403,
});
}
diff --git a/Overlord-Server/src/server/routes/plugin-routes.ts b/Overlord-Server/src/server/routes/plugin-routes.ts
index 71db1c2..cb44e26 100644
--- a/Overlord-Server/src/server/routes/plugin-routes.ts
+++ b/Overlord-Server/src/server/routes/plugin-routes.ts
@@ -1,5 +1,7 @@
import fs from "fs/promises";
+import { constants as fsConstants } from "fs";
import path from "path";
+import AdmZip from "adm-zip";
import { v4 as uuidv4 } from "uuid";
import { authenticateRequest } from "../../auth";
import { requirePermission } from "../../rbac";
@@ -189,6 +191,12 @@ export async function handlePluginRoutes(
return new Response("Only .zip files are supported", { status: 400 });
}
+ const MAX_PLUGIN_ZIP_BYTES = 50 * 1024 * 1024; // 50 MB compressed
+ const MAX_PLUGIN_UNCOMPRESSED_BYTES = 200 * 1024 * 1024; // 200 MB total uncompressed
+ if (typeof file.size === "number" && file.size > MAX_PLUGIN_ZIP_BYTES) {
+ return new Response("Plugin zip exceeds size limit", { status: 413 });
+ }
+
const base = path.basename(filename, path.extname(filename));
let pluginId = "";
try {
@@ -197,9 +205,30 @@ export async function handlePluginRoutes(
return new Response("Invalid plugin name", { status: 400 });
}
+ const data = new Uint8Array(await file.arrayBuffer());
+ if (data.byteLength > MAX_PLUGIN_ZIP_BYTES) {
+ return new Response("Plugin zip exceeds size limit", { status: 413 });
+ }
+
+ try {
+ const probe = new AdmZip(Buffer.from(data));
+ let totalUncompressed = 0;
+ for (const entry of probe.getEntries() as any[]) {
+ const sz = Number(entry?.header?.size ?? 0);
+ if (!Number.isFinite(sz) || sz < 0) {
+ return new Response("Invalid plugin zip", { status: 400 });
+ }
+ totalUncompressed += sz;
+ if (totalUncompressed > MAX_PLUGIN_UNCOMPRESSED_BYTES) {
+ return new Response("Plugin uncompressed size exceeds limit", { status: 413 });
+ }
+ }
+ } catch {
+ return new Response("Invalid plugin zip", { status: 400 });
+ }
+
await fs.mkdir(deps.PLUGIN_ROOT, { recursive: true });
const zipPath = path.join(deps.PLUGIN_ROOT, `${pluginId}.zip`);
- const data = new Uint8Array(await file.arrayBuffer());
await fs.writeFile(zipPath, data);
try {
@@ -262,8 +291,8 @@ export async function handlePluginRoutes(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
- if (user.role !== "admin" && user.role !== "operator") {
- return new Response("Forbidden: Admin or operator access required", { status: 403 });
+ if (user.role !== "admin") {
+ return new Response("Forbidden: Admin access required", { status: 403 });
}
let pluginId = "";
try {
@@ -325,6 +354,28 @@ export async function handlePluginRoutes(
return target;
}
+ async function isPathSafe(target: string, dataDir: string): Promise {
+ const root = path.resolve(dataDir);
+ let current = path.resolve(target);
+ while (current !== root) {
+ try {
+ const st = await fs.lstat(current);
+ if (st.isSymbolicLink()) return false;
+ } catch {
+ }
+ const parent = path.dirname(current);
+ if (parent === current) return false;
+ current = parent;
+ }
+ try {
+ const st = await fs.lstat(root);
+ if (st.isSymbolicLink() || !st.isDirectory()) return false;
+ } catch {
+ return false;
+ }
+ return true;
+ }
+
const pluginDataListMatch = url.pathname.match(/^\/api\/plugins\/([^/]+)\/data$/);
if (req.method === "GET" && pluginDataListMatch) {
const user = await authenticateRequest(req);
@@ -366,10 +417,13 @@ export async function handlePluginRoutes(
if (relPath.includes("\u0000")) return new Response("Bad request", { status: 400 });
const target = resolveDataPath(pluginId, relPath);
if (!target) return new Response("Forbidden", { status: 403 });
+ const dataDir = path.join(deps.PLUGIN_ROOT, pluginId, "data");
+ if (!(await isPathSafe(target, dataDir))) return new Response("Forbidden", { status: 403 });
const file = Bun.file(target);
if (!(await file.exists())) return new Response("Not found", { status: 404 });
- const st = await fs.stat(target);
+ const st = await fs.lstat(target);
if (st.isDirectory()) return new Response("Is a directory", { status: 400 });
+ if (st.isSymbolicLink()) return new Response("Forbidden", { status: 403 });
return new Response(file, { headers: deps.secureHeaders(deps.mimeType(relPath)) });
}
@@ -387,9 +441,27 @@ export async function handlePluginRoutes(
}
const target = resolveDataPath(pluginId, relPath);
if (!target) return new Response("Forbidden", { status: 403 });
+ const dataDir = path.join(deps.PLUGIN_ROOT, pluginId, "data");
+ await fs.mkdir(dataDir, { recursive: true });
await fs.mkdir(path.dirname(target), { recursive: true });
+ if (!(await isPathSafe(target, dataDir))) return new Response("Forbidden", { status: 403 });
+ const existingSt = await fs.lstat(target).catch(() => null);
+ if (existingSt && existingSt.isSymbolicLink()) {
+ return new Response("Forbidden", { status: 403 });
+ }
const body = await req.arrayBuffer();
- await fs.writeFile(target, new Uint8Array(body));
+ let fh: import("fs/promises").FileHandle | null = null;
+ try {
+ fh = await fs.open(target, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | (fsConstants.O_NOFOLLOW || 0));
+ await fh.writeFile(new Uint8Array(body));
+ } catch (err: any) {
+ if (err && (err.code === "ELOOP" || err.code === "EISDIR")) {
+ return new Response("Forbidden", { status: 403 });
+ }
+ throw err;
+ } finally {
+ if (fh) await fh.close().catch(() => {});
+ }
return Response.json({ ok: true, path: relPath, size: body.byteLength });
}
@@ -405,9 +477,14 @@ export async function handlePluginRoutes(
if (relPath.includes("\u0000")) return new Response("Bad request", { status: 400 });
const target = resolveDataPath(pluginId, relPath);
if (!target) return new Response("Forbidden", { status: 403 });
+ const dataDir = path.join(deps.PLUGIN_ROOT, pluginId, "data");
+ if (!(await isPathSafe(target, dataDir))) return new Response("Forbidden", { status: 403 });
try {
- const st = await fs.stat(target);
- if (st.isDirectory()) {
+ const st = await fs.lstat(target);
+ if (st.isSymbolicLink()) {
+ // Just unlink the link itself; never traverse it.
+ await fs.unlink(target);
+ } else if (st.isDirectory()) {
await fs.rm(target, { recursive: true, force: true });
} else {
await fs.unlink(target);
@@ -437,19 +514,35 @@ export async function handlePluginRoutes(
if (decodedFile.includes("\u0000")) return new Response("Bad request", { status: 400 });
const target = resolveDataPath(pluginId, decodedFile);
if (!target) return new Response("Forbidden", { status: 403 });
+ const dataDir = path.join(deps.PLUGIN_ROOT, pluginId, "data");
+ if (!(await isPathSafe(target, dataDir))) return new Response("Forbidden", { status: 403 });
try {
- const st = await fs.stat(target);
+ const st = await fs.lstat(target);
+ if (st.isSymbolicLink()) return new Response("Forbidden", { status: 403 });
if (st.isDirectory()) return new Response("Is a directory", { status: 400 });
} catch {
return new Response("Not found", { status: 404 });
}
+ // Resolve via realpath to defeat any symlink raced into place after the lstat
+ // above; re-verify the resolved path is still under the data dir.
+ let realTarget: string;
+ try {
+ realTarget = await fs.realpath(target);
+ } catch {
+ return new Response("Forbidden", { status: 403 });
+ }
+ const dataDirPrefix = dataDir.endsWith(path.sep) ? dataDir : `${dataDir}${path.sep}`;
+ if (realTarget !== dataDir && !realTarget.startsWith(dataDirPrefix)) {
+ return new Response("Forbidden", { status: 403 });
+ }
const args: string[] = Array.isArray(body.args) ? body.args.filter((a: any) => typeof a === "string") : [];
const stdinData: string = typeof body.stdin === "string" ? body.stdin : "";
const timeoutMs: number = typeof body.timeoutMs === "number" && body.timeoutMs > 0 ? Math.min(body.timeoutMs, 60_000) : 30_000;
- // Ensure the binary is executable
- try { await fs.chmod(target, 0o755); } catch {}
- const proc = Bun.spawn([target, ...args], {
- cwd: path.dirname(target),
+ // Ensure the binary is executable. chmod follows symlinks, but we've verified
+ // the realpath stays inside dataDir, so this is safe.
+ try { await fs.chmod(realTarget, 0o755); } catch {}
+ const proc = Bun.spawn([realTarget, ...args], {
+ cwd: path.dirname(realTarget),
stdin: stdinData ? Buffer.from(stdinData) : "ignore",
stdout: "pipe",
stderr: "pipe",
diff --git a/Overlord-Server/src/server/routes/registration-routes.ts b/Overlord-Server/src/server/routes/registration-routes.ts
index d19f6b0..13986ba 100644
--- a/Overlord-Server/src/server/routes/registration-routes.ts
+++ b/Overlord-Server/src/server/routes/registration-routes.ts
@@ -65,11 +65,7 @@ type RegistrationRouteDeps = {
};
function getClientIp(req: Request, deps: RegistrationRouteDeps): string {
- const fromDeps = deps.requestIP?.(req)?.address;
- if (fromDeps) return fromDeps;
- return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
- || req.headers.get("x-real-ip")
- || "unknown";
+ return deps.requestIP?.(req)?.address || "unknown";
}
export async function handleRegistrationRoutes(
diff --git a/Overlord-Server/src/server/routes/users-routes.ts b/Overlord-Server/src/server/routes/users-routes.ts
index 06a8ff1..58b05ab 100644
--- a/Overlord-Server/src/server/routes/users-routes.ts
+++ b/Overlord-Server/src/server/routes/users-routes.ts
@@ -2,6 +2,11 @@ import { generateToken, authenticateRequest, getSessionTtlSeconds } from "../../
import { AuditAction, logAudit } from "../../auditLog";
import { logger } from "../../logger";
import { requirePermission } from "../../rbac";
+import {
+ listUserSessions,
+ persistRevokedTokenHash,
+ revokeAllUserSessions,
+} from "../../db";
import {
type ClientAccessRuleKind,
type ClientAccessScope,
@@ -80,6 +85,9 @@ export async function handleUsersRoutes(
}
if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/password$/)) {
+ if (!user) {
+ return Response.json({ error: "Not authenticated" }, { status: 401 });
+ }
const userId = parseInt(url.pathname.split("/")[3]);
const body = await req.json();
const { password, newPassword, currentPassword } = body;
@@ -90,12 +98,19 @@ export async function handleUsersRoutes(
return Response.json({ error: "Permission denied" }, { status: 403 });
}
- if (user.userId === userId && currentPassword) {
+ if (user.userId === userId) {
const targetUser = getUserById(userId);
if (!targetUser) {
return Response.json({ error: "User not found" }, { status: 404 });
}
+ if (typeof currentPassword !== "string" || currentPassword.length === 0) {
+ return Response.json(
+ { error: "Current password is required" },
+ { status: 400 },
+ );
+ }
+
const isValid = await Bun.password.verify(
currentPassword,
targetUser.password_hash,
@@ -117,6 +132,13 @@ export async function handleUsersRoutes(
if (result.success) {
const targetUser = getUserById(userId);
+
+ const existingSessions = listUserSessions(userId).filter((s) => !s.revoked);
+ for (const s of existingSessions) {
+ persistRevokedTokenHash(s.tokenHash, s.expiresAt);
+ }
+ revokeAllUserSessions(userId);
+
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
@@ -168,6 +190,13 @@ export async function handleUsersRoutes(
if (result.success) {
const targetUser = getUserById(userId);
+
+ const existingSessions = listUserSessions(userId).filter((s) => !s.revoked);
+ for (const s of existingSessions) {
+ persistRevokedTokenHash(s.tokenHash, s.expiresAt);
+ }
+ revokeAllUserSessions(userId);
+
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts
index 5f54f31..8d3fd26 100644
--- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts
+++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts
@@ -103,7 +103,7 @@ type WsLifecycleDeps = {
clearPendingNotificationScreenshots: (clientId: string) => void;
clearClientPluginState: (clientId: string) => void;
notifyRemoteDesktopStatus: (clientId: string, status: string, reason?: string) => void;
- handleBuildTagConnection: (clientId: string, buildTag: string) => void;
+ handleBuildTagConnection: (clientId: string, buildTag: string, keyFingerprint: string) => void;
notifyDashboard: () => void;
notifyDashboardClientEvent: (
event: "client_online" | "client_offline" | "client_purgatory",
@@ -302,6 +302,22 @@ export async function handleWebSocketMessage(
const build = buildTag ? getBuildByTag(buildTag) : null;
const builtByUserId = build?.builtByUserId;
+ if (buildTag && build?.blocked) {
+ logger.info(`[build-block] rejecting agent ${resolvedId} from blocked build ${build.id.substring(0, 8)}`);
+ try {
+ ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "disconnect",
+ id: crypto.randomUUID(),
+ payload: { reason: "build_blocked" },
+ }),
+ );
+ } catch {}
+ try { ws.close(4007, "build_blocked"); } catch {}
+ return;
+ }
+
if (enrollmentStatus === "denied") {
logger.info(`[purgatory] denied client ${resolvedId} tried to connect`);
ws.send(encodeMessage({ type: "enrollment_status", status: "denied" }));
@@ -478,7 +494,7 @@ export async function handleWebSocketMessage(
(ws as any).data.wasKnown = true;
if (buildTag) {
- deps.handleBuildTagConnection(infoObj.id, buildTag);
+ deps.handleBuildTagConnection(infoObj.id, buildTag, keyFingerprint);
}
}
break;
diff --git a/Overlord-Server/src/users.ts b/Overlord-Server/src/users.ts
index 5d6c9b6..66ddc1e 100644
--- a/Overlord-Server/src/users.ts
+++ b/Overlord-Server/src/users.ts
@@ -280,10 +280,7 @@ if (userCount.count === 0) {
logger.info(`[users] Initial admin account created (username: ${initialUsername})`);
logger.warn(
- `[users] Bootstrap login credentials -> username: ${initialUsername} | password: ${initialPassword}`,
- );
- logger.warn(
- "[users] SECURITY WARNING: Rotate the bootstrap password after first login. Default bootstrap credentials are admin/admin unless overridden by configuration.",
+ "[users] SECURITY WARNING: A default admin account has been created. Sign in and rotate the password immediately. Bootstrap credentials default to admin/admin unless overridden by configuration; the password is not logged.",
);
}
@@ -742,12 +739,30 @@ export function updateLastLogin(userId: number): void {
);
}
+let _dummyHashPromise: Promise | null = null;
+function getDummyPasswordHash(): Promise {
+ if (!_dummyHashPromise) {
+ _dummyHashPromise = Bun.password.hash("__overlord_dummy_hash_for_timing__", {
+ algorithm: "bcrypt",
+ cost: 10,
+ });
+ }
+ return _dummyHashPromise;
+}
+
export async function verifyPassword(
username: string,
password: string,
): Promise {
const user = getUserByUsername(username);
- if (!user) return null;
+ if (!user) {
+ try {
+ const dummy = await getDummyPasswordHash();
+ await Bun.password.verify(password, dummy);
+ } catch {
+ }
+ return null;
+ }
const isValid = await Bun.password.verify(password, user.password_hash);
if (!isValid) return null;
diff --git a/Overlord-Server/src/wsHandlers.ts b/Overlord-Server/src/wsHandlers.ts
index 86f7a92..d222508 100644
--- a/Overlord-Server/src/wsHandlers.ts
+++ b/Overlord-Server/src/wsHandlers.ts
@@ -24,6 +24,37 @@ function sanitizeInfoString(val: unknown, maxLen = 256): string | undefined {
return val.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").slice(0, maxLen);
}
+function sanitizeMonitorCount(val: unknown): number | undefined {
+ if (typeof val !== "number" || !Number.isFinite(val)) return undefined;
+ const n = Math.floor(val);
+ if (n < 0) return 0;
+ if (n > 32) return 32;
+ return n;
+}
+
+function sanitizeJsonField(
+ val: unknown,
+ opts: { maxJsonBytes: number; maxArrayLen?: number; maxKeys?: number },
+): unknown | undefined {
+ if (val === null || val === undefined) return undefined;
+ if (typeof val !== "object") return undefined;
+ if (Array.isArray(val) && opts.maxArrayLen !== undefined && val.length > opts.maxArrayLen) {
+ return undefined;
+ }
+ if (!Array.isArray(val) && opts.maxKeys !== undefined) {
+ const keys = Object.keys(val as Record);
+ if (keys.length > opts.maxKeys) return undefined;
+ }
+ let serialized: string;
+ try {
+ serialized = JSON.stringify(val);
+ } catch {
+ return undefined;
+ }
+ if (serialized.length > opts.maxJsonBytes) return undefined;
+ return val;
+}
+
const MAX_PING_RTT_MS = 15_000;
const CLIENT_DB_SYNC_INTERVAL_MS = Number(process.env.OVERLORD_CLIENT_DB_SYNC_MS || 5000);
const lastClientDbSync = new Map();
@@ -99,13 +130,23 @@ export async function handleHello(
info.arch = sanitizeInfoString(payload.arch, 32);
info.version = sanitizeInfoString(payload.version, 64);
info.user = sanitizeInfoString(payload.user);
- info.monitors = payload.monitors;
- info.monitorInfo = (payload as any).monitorInfo || info.monitorInfo;
+ info.monitors = sanitizeMonitorCount(payload.monitors) ?? info.monitors;
+ const cleanMonitorInfo = sanitizeJsonField((payload as any).monitorInfo, {
+ maxJsonBytes: 8 * 1024,
+ maxArrayLen: 32,
+ });
+ if (cleanMonitorInfo !== undefined) {
+ info.monitorInfo = cleanMonitorInfo as any;
+ }
info.inMemory = !!(payload as any).inMemory;
info.isAdmin = !!(payload as any).isAdmin;
info.elevation = sanitizeInfoString((payload as any).elevation, 32) ?? info.elevation;
- if ((payload as any).permissions && typeof (payload as any).permissions === "object") {
- info.permissions = (payload as any).permissions;
+ const cleanPermissions = sanitizeJsonField((payload as any).permissions, {
+ maxJsonBytes: 4 * 1024,
+ maxKeys: 64,
+ });
+ if (cleanPermissions !== undefined && !Array.isArray(cleanPermissions)) {
+ info.permissions = cleanPermissions as any;
}
info.cpu = sanitizeInfoString((payload as any).cpu) || info.cpu;
info.gpu = sanitizeInfoString((payload as any).gpu) || info.gpu;