fix(security): v3.8.15 hardening follow-ups (cookie maxAge, CHANGEME warn, token-in-URL warn, real rebuild path)

- Seg3: bound the auth_token cookie with maxAge=30d (matches the JWT 30d expiry)
- Seg2: warn loudly at boot when bootstrapping with INITIAL_PASSWORD=CHANGEME
- Seg4: warn once per process when a path-token VS Code/Ollama endpoint is used (API key in URL)
- Bug3: resolve the real global omniroute package dir for npm rebuild (was hardcoded /omniroute/app)

Bug1 (auto-update mode detection) deferred pending a concrete repro — high blast radius (controls prod self-update).

Regression guards: auth-login-route (maxAge), management-password-insecure-default,
vscode-token-in-url-warning, global-package-path.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-29 21:54:28 -03:00
parent 7c9cb7f47a
commit 6a5960676b
10 changed files with 280 additions and 6 deletions

View File

@@ -142,6 +142,9 @@ export async function POST(request) {
secure: useSecureCookie,
sameSite: "lax",
path: "/",
// 30 days — bound the cookie lifetime to the JWT's 30d expiry so the browser
// drops it on the same schedule the token stops being valid (Seg3 hardening).
maxAge: 60 * 60 * 24 * 30,
});
logAuditEvent({

View File

@@ -18,6 +18,7 @@ import {
} from "@/lib/system/autoUpdate";
import { NEWS_JSON_URL, parseActiveNewsPayload } from "@/shared/utils/releaseNotes";
import { isNewer, resolveLatestVersion } from "@/lib/system/versionCheck";
import { resolveGlobalOmniroutePath } from "@/lib/system/globalPackagePath";
const execFileAsync = promisify(execFile);
@@ -302,10 +303,7 @@ export async function POST(req: NextRequest) {
status: "running",
message: "Rebuilding native modules (better-sqlite3)...",
});
const globalRoot = (
await execFileAsync("npm", ["root", "-g"], { timeout: 10000, cwd: PROJECT_ROOT })
).stdout.trim();
const omniPath = `${globalRoot}/omniroute/app`;
const omniPath = await resolveGlobalOmniroutePath();
await execFileAsync(
"npm",
["rebuild", "better-sqlite3"],

View File

@@ -1 +1,5 @@
export { withPathTokenApiKey, withSanitizedPathTokenApiKey } from "@/lib/vscode/tokenizedRequest";
export {
withPathTokenApiKey,
withSanitizedPathTokenApiKey,
__vscodeRawInternals,
} from "@/lib/vscode/tokenizedRequest";

View File

@@ -4,13 +4,17 @@ import { getSettings, updateSettings } from "@/lib/db/settings";
const BCRYPT_HASH_PATTERN = /^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/;
const MANAGEMENT_PASSWORD_SALT_ROUNDS = 12;
// Well-known placeholder shipped in `.env.example` (INITIAL_PASSWORD=CHANGEME). Bootstrapping
// with it leaves the dashboard open to anyone, so we warn loudly on boot (Seg2 hardening).
const INSECURE_DEFAULT_PASSWORDS = new Set(["CHANGEME"]);
type JsonRecord = Record<string, unknown>;
type MigrationSource = "stored_hash" | "stored_plaintext" | "env" | "missing";
interface EnsureManagementPasswordOptions {
initialPassword?: string | null;
logger?: Pick<Console, "log">;
logger?: Pick<Console, "log"> & Partial<Pick<Console, "warn">>;
settings?: JsonRecord;
source?: string;
}
@@ -69,6 +73,15 @@ export async function ensurePersistentManagementPasswordHash(
storedPassword ||
getInitialPasswordValue(options.initialPassword ?? process.env.INITIAL_PASSWORD);
if (bootstrapPassword && INSECURE_DEFAULT_PASSWORDS.has(bootstrapPassword)) {
const warn = options.logger?.warn?.bind(options.logger) ?? console.warn;
warn(
'[AUTH][SECURITY] Management password is set to the well-known default "CHANGEME" ' +
"(INITIAL_PASSWORD in .env.example). Anyone can sign in to the dashboard with it — " +
"change it immediately via the dashboard or a strong INITIAL_PASSWORD."
);
}
if (!bootstrapPassword) {
return {
hash: null,

View File

@@ -0,0 +1,49 @@
import { execFile } from "child_process";
import { promisify } from "util";
import { existsSync } from "fs";
import path from "path";
import { PROJECT_ROOT } from "./autoUpdate";
const execFileAsync = promisify(execFile);
type ExecFileLike = (
file: string,
args: string[],
options: { timeout?: number; cwd?: string }
) => Promise<{ stdout: string | Buffer }>;
type ExistsLike = (target: string) => boolean;
/**
* Resolve the real install directory of the globally-installed `omniroute` package — the directory
* that owns `node_modules/better-sqlite3` and so is the correct cwd for `npm rebuild`.
*
* Replaces the hardcoded `${globalRoot}/omniroute/app` assumption (Bug 3, security-report v3.8.15):
* the global package root is `${npm root -g}/omniroute`, not `/omniroute/app`. We probe the real
* layout (current root first, then the legacy `app/` sub-dir) and fall back to the package root.
*
* `execImpl`/`fsExists` are injectable so the resolution logic is unit-testable without a real
* global install.
*/
export async function resolveGlobalOmniroutePath(
execImpl: ExecFileLike = execFileAsync,
fsExists: ExistsLike = existsSync
): Promise<string> {
const result = await execImpl("npm", ["root", "-g"], {
timeout: 10000,
cwd: PROJECT_ROOT,
});
const globalRoot = String(result.stdout).trim();
const packageRoot = path.join(globalRoot, "omniroute");
// [current layout, legacy layout] — first whose package.json exists wins.
const candidates = [packageRoot, path.join(packageRoot, "app")];
for (const candidate of candidates) {
if (fsExists(path.join(candidate, "package.json"))) {
return candidate;
}
}
return packageRoot;
}

View File

@@ -1,5 +1,26 @@
import { sanitizeVscodeRequest } from "@/app/api/v1/vscode/contextSanitizer";
// Path-token endpoints carry the API key in the URL (by design, for Ollama/VS Code clients that
// cannot send an Authorization header). URLs leak via access logs, proxies, and browser history,
// so we alert the operator once per process when such an endpoint is exercised (Seg4 hardening).
let hasWarnedTokenInUrl = false;
export const __vscodeRawInternals = {
resetTokenInUrlWarning() {
hasWarnedTokenInUrl = false;
},
};
function warnTokenInUrlOnce() {
if (hasWarnedTokenInUrl) return;
hasWarnedTokenInUrl = true;
console.warn(
"[VSCODE][SECURITY] A path-token endpoint (/api/v1/vscode/raw/[token] or /api/v1/vscode/[token]) " +
"was used. The API key travels in the request URL and can leak via access logs, proxies, and " +
"browser history. Prefer the Authorization header where the client supports it."
);
}
function inferTokenFromVscodePath(request: Request) {
try {
const url = new URL(request.url, "http://localhost");
@@ -27,6 +48,8 @@ export function withPathTokenApiKey(request: Request, token?: string) {
const resolvedToken = token || inferTokenFromVscodePath(request);
if (!resolvedToken) return request;
warnTokenInUrlOnce();
const headers = new Headers(request.headers);
if (!headers.has("x-api-key")) {

View File

@@ -107,3 +107,29 @@ test("auth login route lazily migrates INITIAL_PASSWORD to a persisted hash befo
true
);
});
test("auth login route sets a bounded maxAge on the auth_token cookie (Seg3)", async () => {
process.env.INITIAL_PASSWORD = "bootstrap-secret";
const setCalls: unknown[][] = [];
loginRoute.authRouteInternals.getCookieStore = async () => ({
set: (...args: unknown[]) => setCalls.push(args),
});
const response = await loginRoute.POST(
new Request("http://localhost/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ password: "bootstrap-secret" }),
})
);
assert.equal(response.status, 200);
assert.equal(setCalls.length, 1);
const [cookieName, , options] = setCalls[0] as [string, string, Record<string, unknown>];
assert.equal(cookieName, "auth_token");
// 30 days in seconds — must match the JWT 30d expiry so the cookie is not an open-ended
// session cookie outliving its token.
assert.equal(options.maxAge, 60 * 60 * 24 * 30);
assert.equal(options.httpOnly, true);
assert.equal(options.path, "/");
});

View File

@@ -0,0 +1,37 @@
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
const mod = await import("../../src/lib/system/globalPackagePath.ts");
const GLOBAL_ROOT = "/usr/lib/node_modules";
const PACKAGE_ROOT = path.join(GLOBAL_ROOT, "omniroute");
const LEGACY_ROOT = path.join(PACKAGE_ROOT, "app");
function execStub(stdout: string) {
return async () => ({ stdout });
}
test("resolves the package root when its package.json exists (Bug3)", async () => {
const exists = (target: string) => target === path.join(PACKAGE_ROOT, "package.json");
const result = await mod.resolveGlobalOmniroutePath(execStub(`${GLOBAL_ROOT}\n`), exists);
assert.equal(result, PACKAGE_ROOT);
});
test("falls back to the legacy app/ layout when only it has a package.json", async () => {
const exists = (target: string) => target === path.join(LEGACY_ROOT, "package.json");
const result = await mod.resolveGlobalOmniroutePath(execStub(GLOBAL_ROOT), exists);
assert.equal(result, LEGACY_ROOT);
});
test("defaults to the package root when neither layout is present", async () => {
const exists = () => false;
const result = await mod.resolveGlobalOmniroutePath(execStub(GLOBAL_ROOT), exists);
assert.equal(result, PACKAGE_ROOT);
});
test("trims whitespace from the npm root -g output", async () => {
const exists = (target: string) => target === path.join(PACKAGE_ROOT, "package.json");
const result = await mod.resolveGlobalOmniroutePath(execStub(` ${GLOBAL_ROOT} \n`), exists);
assert.equal(result, PACKAGE_ROOT);
});

View File

@@ -0,0 +1,64 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-mgmt-pwd-insecure-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const managementPassword = await import("../../src/lib/auth/managementPassword.ts");
function makeLogger() {
const warnings: string[] = [];
return {
warnings,
log() {},
warn: (...args: unknown[]) => {
warnings.push(args.map((a) => String(a)).join(" "));
},
};
}
test.afterEach(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("warns when bootstrapping the management password with the CHANGEME default (Seg2)", async () => {
const logger = makeLogger();
const result = await managementPassword.ensurePersistentManagementPasswordHash({
settings: {},
initialPassword: "CHANGEME",
logger,
});
// It still bootstraps (does not hard-reject — would break local dev), but it must warn loudly.
assert.equal(managementPassword.isBcryptHash(result.hash), true);
assert.equal(
logger.warnings.some((line) => line.includes("CHANGEME")),
true,
"expected a security warning mentioning the CHANGEME default"
);
});
test("does not warn when bootstrapping with a strong password", async () => {
const logger = makeLogger();
const result = await managementPassword.ensurePersistentManagementPasswordHash({
settings: {},
initialPassword: "a-strong-unique-password",
logger,
});
assert.equal(managementPassword.isBcryptHash(result.hash), true);
assert.equal(logger.warnings.length, 0, "did not expect any security warning for a strong password");
});

View File

@@ -0,0 +1,57 @@
import test from "node:test";
import assert from "node:assert/strict";
const tokenizedRequest = await import(
"../../src/app/api/v1/vscode/raw/[token]/tokenizedRequest.ts"
);
function captureWarn<T>(fn: () => T): { result: T; warnings: string[] } {
const warnings: string[] = [];
const original = console.warn;
console.warn = (...args: unknown[]) => {
warnings.push(args.map((a) => String(a)).join(" "));
};
try {
return { result: fn(), warnings };
} finally {
console.warn = original;
}
}
test.beforeEach(() => {
tokenizedRequest.__vscodeRawInternals.resetTokenInUrlWarning();
});
test("warns once when a path token is used, then stays quiet (Seg4)", () => {
const makeReq = () =>
new Request("http://localhost/api/v1/vscode/raw/sk-secret-token/models");
const first = captureWarn(() => tokenizedRequest.withPathTokenApiKey(makeReq()));
assert.equal(
first.warnings.some((line) => line.includes("[VSCODE][SECURITY]")),
true,
"expected a security warning on the first path-token request"
);
const second = captureWarn(() => tokenizedRequest.withPathTokenApiKey(makeReq()));
assert.equal(second.warnings.length, 0, "must not warn again within the same process");
});
test("propagates the path token into x-api-key / authorization headers", () => {
const request = new Request("http://localhost/api/v1/vscode/raw/sk-secret-token/models");
const { result } = captureWarn(() => tokenizedRequest.withPathTokenApiKey(request));
assert.equal(result.headers.get("x-api-key"), "sk-secret-token");
assert.equal(result.headers.get("authorization"), "Bearer sk-secret-token");
});
test("does not warn when there is no resolvable token", () => {
// No /vscode segment → inferTokenFromVscodePath returns null and the request passes through.
const request = new Request("http://localhost/api/v1/models");
const { result, warnings } = captureWarn(() =>
tokenizedRequest.withPathTokenApiKey(request)
);
assert.equal(warnings.length, 0);
assert.equal(result.headers.get("x-api-key"), null);
});