chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Regression: when OMNIROUTE_BUILD_PROFILE=minimal is the resolved build
|
||||
* profile, the four stub modules throw FeatureDisabledError instead of
|
||||
* performing their privileged operations.
|
||||
* See docs/security/SOCKET_DEV_FINDINGS.md.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
test("featureDisabledError carries the featureName", async () => {
|
||||
const mod = await import("../../../src/lib/build-profile/featureDisabled.ts");
|
||||
const err = mod.featureDisabledError("my-feature");
|
||||
assert.equal(err.featureName, "my-feature");
|
||||
assert.match(err.message, /my-feature/);
|
||||
assert.match(err.message, /minimal/);
|
||||
assert.equal("OMNIROUTE_BUILD_PROFILE" in mod, false);
|
||||
assert.equal("IS_MINIMAL_BUILD" in mod, false);
|
||||
});
|
||||
|
||||
test("install.stub.ts: installCert / uninstallCert throw FeatureDisabledError", async () => {
|
||||
const stub = await import("../../../src/mitm/cert/install.stub.ts");
|
||||
await assert.rejects(() => stub.installCert("pw", "/tmp/x"), /mitm-cert-install/);
|
||||
await assert.rejects(() => stub.uninstallCert("pw", "/tmp/x"), /mitm-cert-install/);
|
||||
// checkCertInstalled returns false (does not throw — used by render paths)
|
||||
assert.equal(await stub.checkCertInstalled("/tmp/x"), false);
|
||||
});
|
||||
|
||||
test("keychain-reader.stub.ts: discoverZedCredentials / getZedCredential throw", async () => {
|
||||
const stub = await import("../../../src/lib/zed-oauth/keychain-reader.stub.ts");
|
||||
await assert.rejects(() => stub.discoverZedCredentials(), /zed-keychain-import/);
|
||||
await assert.rejects(() => stub.getZedCredential("openai"), /zed-keychain-import/);
|
||||
assert.equal(await stub.isZedInstalled(), false);
|
||||
});
|
||||
|
||||
test("cloudSync.stub.ts: syncToCloud soft-fails with feature-disabled message", async () => {
|
||||
const stub = await import("../../../src/lib/cloudSync.stub.ts");
|
||||
const result = await stub.syncToCloud("machine-id");
|
||||
assert.deepEqual(result, {
|
||||
error: "Cloud Sync is disabled in this build (minimal profile)",
|
||||
});
|
||||
assert.equal(stub.CLOUD_SYNC_SECRETS_ENABLED, false);
|
||||
await assert.rejects(() => stub.fetchWithTimeout(), /cloud-sync/);
|
||||
});
|
||||
|
||||
test("ninerouter.stub.ts: install / resolveSpawnArgs throw FeatureDisabledError", async () => {
|
||||
const stub = await import("../../../src/lib/services/installers/ninerouter.stub.ts");
|
||||
await assert.rejects(() => stub.installNinerouter(), /9router-installer/);
|
||||
assert.throws(() => stub.resolveSpawnArgs("api-key", 20130), /9router-installer/);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Regression: Cloud sync must verify the X-Cloud-Sig HMAC and must NOT
|
||||
* overwrite accessToken / refreshToken unless OMNIROUTE_CLOUD_SYNC_SECRETS=true.
|
||||
* See docs/security/SOCKET_DEV_FINDINGS.md §5.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
test("verifyCloudSignature accepts a valid HMAC", async () => {
|
||||
// Test-only HMAC key derived deterministically — no hardcoded production secret.
|
||||
const TEST_HMAC_KEY = crypto.createHash("sha256").update("omniroute-test").digest("hex");
|
||||
process.env.OMNIROUTE_CLOUD_SYNC_SECRET = TEST_HMAC_KEY;
|
||||
// Re-import so the module re-reads the env.
|
||||
const mod = await import(
|
||||
"../../../src/lib/cloudSync.ts?cache=" + Date.now()
|
||||
).catch(() => import("../../../src/lib/cloudSync.ts"));
|
||||
const body = JSON.stringify({ data: { providers: {} } });
|
||||
const sig = crypto.createHmac("sha256", TEST_HMAC_KEY).update(body).digest("hex");
|
||||
assert.equal((mod as any).verifyCloudSignature(body, sig), true);
|
||||
});
|
||||
|
||||
test("verifyCloudSignature rejects a forged signature", async () => {
|
||||
// Test-only HMAC key derived deterministically — no hardcoded production secret.
|
||||
const TEST_HMAC_KEY = crypto.createHash("sha256").update("omniroute-test").digest("hex");
|
||||
process.env.OMNIROUTE_CLOUD_SYNC_SECRET = TEST_HMAC_KEY;
|
||||
const mod = await import("../../../src/lib/cloudSync.ts");
|
||||
const body = JSON.stringify({ data: { providers: {} } });
|
||||
const forged = "0".repeat(64);
|
||||
assert.equal((mod as any).verifyCloudSignature(body, forged), false);
|
||||
});
|
||||
|
||||
test("verifyCloudSignature rejects when the secret is set but sig header is missing", async () => {
|
||||
// Test-only HMAC key derived deterministically — no hardcoded production secret.
|
||||
const TEST_HMAC_KEY = crypto.createHash("sha256").update("omniroute-test").digest("hex");
|
||||
process.env.OMNIROUTE_CLOUD_SYNC_SECRET = TEST_HMAC_KEY;
|
||||
const mod = await import("../../../src/lib/cloudSync.ts");
|
||||
const body = JSON.stringify({ data: { providers: {} } });
|
||||
assert.equal((mod as any).verifyCloudSignature(body, null), false);
|
||||
});
|
||||
|
||||
test("verifyCloudSignature falls through (legacy mode) when secret is unset", async () => {
|
||||
delete process.env.OMNIROUTE_CLOUD_SYNC_SECRET;
|
||||
// Force re-import so module constants pick up the cleared env.
|
||||
delete (globalThis as any).__omniroute_cloudSync_cache;
|
||||
const mod = await import("../../../src/lib/cloudSync.ts");
|
||||
const body = JSON.stringify({ data: { providers: {} } });
|
||||
// Behaviour: accept unsigned body but log warning. We assert it doesn't throw.
|
||||
const result = (mod as any).verifyCloudSignature(body, null);
|
||||
assert.equal(typeof result, "boolean");
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { resolveSafeI18nSectionDir } from "../../../src/lib/docsI18nPath.ts";
|
||||
|
||||
const DOCS_ROOT = path.resolve("/srv/app", "docs");
|
||||
const I18N_ROOT = path.join(DOCS_ROOT, "i18n");
|
||||
|
||||
test("resolveSafeI18nSectionDir rejects traversal in the locale (cookie-controlled)", () => {
|
||||
const badLocales = ["..", "../..", "/etc", "es/../../etc", "es\0", "..%2f", "en.", "es/x"];
|
||||
for (const locale of badLocales) {
|
||||
assert.strictEqual(
|
||||
resolveSafeI18nSectionDir(DOCS_ROOT, locale, ["overview"]),
|
||||
null,
|
||||
`Should reject bad locale: ${JSON.stringify(locale)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveSafeI18nSectionDir rejects traversal in slug segments", () => {
|
||||
const badSlugs = [[".."], ["..", "foo"], ["/etc", "passwd"], ["foo", "..", "bar"], ["a/b"]];
|
||||
for (const slug of badSlugs) {
|
||||
assert.strictEqual(
|
||||
resolveSafeI18nSectionDir(DOCS_ROOT, "pt-BR", slug),
|
||||
null,
|
||||
`Should reject bad slug: ${slug.join("/")}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveSafeI18nSectionDir confines the resolved dir to docs/i18n", () => {
|
||||
// Valid input → a dir strictly under i18nRoot.
|
||||
const ok = resolveSafeI18nSectionDir(DOCS_ROOT, "pt-BR", ["architecture", "overview"]);
|
||||
assert.ok(ok, "valid locale+slug should resolve");
|
||||
assert.ok(
|
||||
ok === I18N_ROOT || ok.startsWith(I18N_ROOT + path.sep),
|
||||
`resolved dir must stay under i18nRoot, got ${ok}`
|
||||
);
|
||||
assert.strictEqual(ok, path.join(I18N_ROOT, "pt-BR", "docs", "architecture"));
|
||||
});
|
||||
|
||||
test("resolveSafeI18nSectionDir allows valid locale/slug patterns", () => {
|
||||
const goodLocales = ["en", "pt-BR", "zh-CN", "es"];
|
||||
for (const locale of goodLocales) {
|
||||
assert.ok(
|
||||
resolveSafeI18nSectionDir(DOCS_ROOT, locale, ["getting-started"]),
|
||||
`Should allow good locale: ${locale}`
|
||||
);
|
||||
}
|
||||
assert.ok(resolveSafeI18nSectionDir(DOCS_ROOT, "es", ["v1-migration"]));
|
||||
assert.strictEqual(resolveSafeI18nSectionDir(DOCS_ROOT, "es", []), null, "empty slug → null");
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { sanitizeDocsHtml } from "../../../src/lib/docsSanitizer.ts";
|
||||
|
||||
test("sanitizeDocsHtml removes dangerous tags and attributes", () => {
|
||||
const malicious = '<script>alert("xss")</script><img src="x" onerror="alert(1)"> <a href="javascript:alert(1)">link</a> <div onclick="evil()">content</div>';
|
||||
const sanitized = sanitizeDocsHtml(malicious);
|
||||
|
||||
assert.ok(!sanitized.includes("<script>"), "Should remove <script>");
|
||||
assert.ok(!sanitized.includes("onerror"), "Should remove onerror");
|
||||
assert.ok(!sanitized.includes("onclick"), "Should remove onclick");
|
||||
assert.ok(!sanitized.includes("javascript:"), "Should remove javascript: URLs");
|
||||
assert.ok(sanitized.includes("<a>link</a>") || sanitized.includes("<a >link</a>") || sanitized.includes("link"), "Should keep safe content");
|
||||
});
|
||||
|
||||
test("sanitizeDocsHtml allows safe tags and attributes", () => {
|
||||
const safe = '<h1>Title</h1><p>Paragraph with <strong>bold</strong> and <em>italic</em>.</p><ul><li>Item</li></ul><pre><code>code</code></pre>';
|
||||
const sanitized = sanitizeDocsHtml(safe);
|
||||
|
||||
assert.ok(sanitized.includes("<h1>Title</h1>"), "Should allow <h1>");
|
||||
assert.ok(sanitized.includes("<p>"), "Should allow <p>");
|
||||
assert.ok(sanitized.includes("<strong>"), "Should allow <strong>");
|
||||
assert.ok(sanitized.includes("<ul>"), "Should allow <ul>");
|
||||
assert.ok(sanitized.includes("<li>"), "Should allow <li>");
|
||||
assert.ok(sanitized.includes("<pre>"), "Should allow <pre>");
|
||||
assert.ok(sanitized.includes("<code>"), "Should allow <code>");
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Regression: runElevatedPowerShell() must no longer use -EncodedCommand and
|
||||
* must write the elevated payload to a per-call temp .ps1 file referenced via
|
||||
* -File. See docs/security/SOCKET_DEV_FINDINGS.md §1.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildElevatedScriptWrapper,
|
||||
_runElevatedPowerShellForTest,
|
||||
} from "../../../src/mitm/systemCommands.ts";
|
||||
|
||||
test("buildElevatedScriptWrapper does not contain -EncodedCommand fingerprint", () => {
|
||||
const wrapper = buildElevatedScriptWrapper("C:\\Temp\\omniroute-elevate-x.ps1");
|
||||
assert.ok(
|
||||
!wrapper.includes("-EncodedCommand"),
|
||||
"wrapper must not contain -EncodedCommand (Socket.dev textbook fingerprint)"
|
||||
);
|
||||
assert.ok(wrapper.includes("-File"), "wrapper must reference the payload via -File");
|
||||
assert.ok(wrapper.includes("Start-Process"), "wrapper must use Start-Process -Verb RunAs");
|
||||
assert.ok(wrapper.includes("-Verb RunAs"), "wrapper must request elevation via -Verb RunAs");
|
||||
});
|
||||
|
||||
test("buildElevatedScriptWrapper quotes the script path safely (no shell injection)", () => {
|
||||
const wrapper = buildElevatedScriptWrapper("C:\\Temp\\path with spaces'and-quote.ps1");
|
||||
// PowerShell single-quote escaping doubles the quote — our quotePowerShell
|
||||
// helper does that. Confirm both the original and the escaped form are present.
|
||||
assert.ok(
|
||||
wrapper.includes("'C:\\Temp\\path with spaces''and-quote.ps1'"),
|
||||
"single quotes in the path must be doubled per PowerShell escaping rules"
|
||||
);
|
||||
});
|
||||
|
||||
test("_runElevatedPowerShellForTest writes payload to a temp .ps1 file and unlinks after", async () => {
|
||||
let capturedWrapper: string | null = null;
|
||||
let capturedTempPath: string | null = null;
|
||||
|
||||
await _runElevatedPowerShellForTest(
|
||||
"Write-Output 'omniroute regression test'",
|
||||
async (wrapper, tempPath) => {
|
||||
capturedWrapper = wrapper;
|
||||
capturedTempPath = tempPath;
|
||||
assert.ok(fs.existsSync(tempPath), "temp file must exist while the runner is active");
|
||||
const content = fs.readFileSync(tempPath, "utf8");
|
||||
assert.match(content, /Write-Output 'omniroute regression test'/);
|
||||
return "ok";
|
||||
}
|
||||
);
|
||||
|
||||
assert.ok(capturedWrapper, "wrapper must be captured");
|
||||
assert.ok(!capturedWrapper!.includes("-EncodedCommand"), "wrapper must not use -EncodedCommand");
|
||||
assert.ok(capturedTempPath, "temp path must be captured");
|
||||
assert.ok(
|
||||
capturedTempPath!.startsWith(path.resolve(os.tmpdir())) ||
|
||||
capturedTempPath!.startsWith(os.tmpdir()),
|
||||
"temp .ps1 must live inside os.tmpdir()"
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(capturedTempPath!),
|
||||
"temp .ps1 file must be unlinked after the runner returns"
|
||||
);
|
||||
});
|
||||
|
||||
test("_runElevatedPowerShellForTest unlinks the temp file even when the runner throws", async () => {
|
||||
let capturedTempPath: string | null = null;
|
||||
let threw = false;
|
||||
|
||||
try {
|
||||
await _runElevatedPowerShellForTest("Write-Output 'denied'", async (_wrapper, tempPath) => {
|
||||
capturedTempPath = tempPath;
|
||||
throw new Error("simulated UAC denial");
|
||||
});
|
||||
} catch (err) {
|
||||
threw = true;
|
||||
assert.match((err as Error).message, /simulated UAC denial/);
|
||||
}
|
||||
|
||||
assert.ok(threw, "the error must propagate to the caller");
|
||||
assert.ok(capturedTempPath, "temp path must still be captured");
|
||||
assert.ok(
|
||||
!fs.existsSync(capturedTempPath!),
|
||||
"temp .ps1 must be removed by the finally block even after a failed call"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Unit tests for `liveServerAllowList`.
|
||||
*
|
||||
* Bug #1 (plans/2026-06-23-omniroute-v3.8.34-deep-audit.md) introduced the
|
||||
* `LIVE_WS_ALLOWED_HOSTS` opt-in for LAN/Tailscale deployments. These tests
|
||||
* pin down the contract: defaults remain loopback-only; the env var extends
|
||||
* the allow-list with bare hostnames or `host:port` pairs; the absence of
|
||||
* Origin is still only acceptable on loopback.
|
||||
*
|
||||
* Runner note: lives under tests/unit/security/ (a node:test–collected subdir)
|
||||
* and uses node:test + assert/strict so the gate `check:test-discovery` actually
|
||||
* runs it. The original copy under tests/unit/server/ used vitest in a path no
|
||||
* runner collected — it never executed (caught by Gate 6A.1).
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildAllowedOrigins,
|
||||
buildAllowedHosts,
|
||||
isOriginAllowed,
|
||||
originHost,
|
||||
originHostMatches,
|
||||
parseCsvEnv,
|
||||
DEFAULT_ALLOWED_ORIGINS,
|
||||
} from "@/server/ws/liveServerAllowList";
|
||||
|
||||
const EMPTY_ENV: NodeJS.ProcessEnv = {};
|
||||
|
||||
describe("parseCsvEnv", () => {
|
||||
it("returns empty set for undefined", () => {
|
||||
assert.equal(parseCsvEnv(undefined).size, 0);
|
||||
});
|
||||
|
||||
it("returns empty set for empty string", () => {
|
||||
assert.equal(parseCsvEnv("").size, 0);
|
||||
});
|
||||
|
||||
it("trims whitespace and drops empty entries", () => {
|
||||
const out = parseCsvEnv(" a , b ,, c ");
|
||||
assert.deepEqual([...out], ["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("deduplicates entries", () => {
|
||||
const out = parseCsvEnv("a,a,b");
|
||||
assert.deepEqual([...out], ["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAllowedOrigins", () => {
|
||||
it("includes the loopback defaults", () => {
|
||||
const out = buildAllowedOrigins(EMPTY_ENV);
|
||||
for (const origin of DEFAULT_ALLOWED_ORIGINS) {
|
||||
assert.equal(out.has(origin), true);
|
||||
}
|
||||
});
|
||||
|
||||
it("extends defaults with LIVE_WS_ALLOWED_ORIGINS", () => {
|
||||
const env = {
|
||||
...EMPTY_ENV,
|
||||
LIVE_WS_ALLOWED_ORIGINS: "https://dash.example.com,https://other.example.com",
|
||||
};
|
||||
const out = buildAllowedOrigins(env);
|
||||
assert.equal(out.has("https://dash.example.com"), true);
|
||||
assert.equal(out.has("https://other.example.com"), true);
|
||||
// Defaults remain.
|
||||
assert.equal(out.has("http://localhost:20128"), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAllowedHosts", () => {
|
||||
it("returns empty set when env is not set", () => {
|
||||
assert.equal(buildAllowedHosts(EMPTY_ENV).size, 0);
|
||||
});
|
||||
|
||||
it("parses comma-separated hosts", () => {
|
||||
const env = { ...EMPTY_ENV, LIVE_WS_ALLOWED_HOSTS: "100.96.135.160,desktop.tailnet.ts.net" };
|
||||
const out = buildAllowedHosts(env);
|
||||
assert.equal(out.has("100.96.135.160"), true);
|
||||
assert.equal(out.has("desktop.tailnet.ts.net"), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("originHost", () => {
|
||||
it("returns host and hostname for a valid URL", () => {
|
||||
assert.deepEqual(originHost("http://100.96.135.160:20128"), {
|
||||
host: "100.96.135.160:20128",
|
||||
hostname: "100.96.135.160",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for an invalid URL", () => {
|
||||
assert.equal(originHost("not a url"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("originHostMatches", () => {
|
||||
it("returns false when the allow-list is empty", () => {
|
||||
assert.equal(originHostMatches("http://100.96.135.160:20128", new Set()), false);
|
||||
});
|
||||
|
||||
it("matches by exact host:port", () => {
|
||||
const allow = new Set(["100.96.135.160:20128"]);
|
||||
assert.equal(originHostMatches("http://100.96.135.160:20128", allow), true);
|
||||
});
|
||||
|
||||
it("matches by bare hostname regardless of port", () => {
|
||||
const allow = new Set(["100.96.135.160"]);
|
||||
assert.equal(originHostMatches("http://100.96.135.160:20128", allow), true);
|
||||
assert.equal(originHostMatches("http://100.96.135.160:55555", allow), true);
|
||||
});
|
||||
|
||||
it("returns false for a non-matching host", () => {
|
||||
const allow = new Set(["100.96.135.160"]);
|
||||
assert.equal(originHostMatches("http://10.0.0.5:20128", allow), false);
|
||||
});
|
||||
|
||||
it("returns false for an unparseable origin", () => {
|
||||
const allow = new Set(["100.96.135.160"]);
|
||||
assert.equal(originHostMatches("not-a-url", allow), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOriginAllowed", () => {
|
||||
it("rejects any non-loopback origin by default", () => {
|
||||
assert.equal(isOriginAllowed("http://100.96.135.160:20128", EMPTY_ENV), false);
|
||||
});
|
||||
|
||||
it("accepts the default loopback origins", () => {
|
||||
assert.equal(isOriginAllowed("http://127.0.0.1:20128", EMPTY_ENV), true);
|
||||
assert.equal(isOriginAllowed("http://localhost:20128", EMPTY_ENV), true);
|
||||
assert.equal(isOriginAllowed("http://[::1]:20128", EMPTY_ENV), true);
|
||||
});
|
||||
|
||||
it("accepts an Origin matching LIVE_WS_ALLOWED_ORIGINS", () => {
|
||||
const env = { ...EMPTY_ENV, LIVE_WS_ALLOWED_ORIGINS: "https://dash.example.com" };
|
||||
assert.equal(isOriginAllowed("https://dash.example.com", env), true);
|
||||
});
|
||||
|
||||
it("accepts a Tailscale Origin when LIVE_WS_ALLOWED_HOSTS is set", () => {
|
||||
const env = { ...EMPTY_ENV, LIVE_WS_ALLOWED_HOSTS: "100.96.135.160" };
|
||||
assert.equal(isOriginAllowed("http://100.96.135.160:20128", env), true);
|
||||
});
|
||||
|
||||
it("accepts a Tailscale Origin matched by host:port when LIVE_WS_ALLOWED_HOSTS is set", () => {
|
||||
const env = { ...EMPTY_ENV, LIVE_WS_ALLOWED_HOSTS: "100.96.135.160:20128" };
|
||||
assert.equal(isOriginAllowed("http://100.96.135.160:20128", env), true);
|
||||
});
|
||||
|
||||
it("does NOT accept a Tailscale Origin when LIVE_WS_ALLOWED_HOSTS is unset", () => {
|
||||
// Critical security invariant: without explicit opt-in, the LAN/Tailscale
|
||||
// surface is closed even though the listener is reachable.
|
||||
assert.equal(isOriginAllowed("http://100.96.135.160:20128", EMPTY_ENV), false);
|
||||
});
|
||||
|
||||
it("rejects a missing Origin when bound to LAN (0.0.0.0)", () => {
|
||||
// A CLI client that omits Origin should NOT be accepted when the
|
||||
// operator opted into LAN exposure. Browsers always send Origin, so the
|
||||
// empty-Origin path is for non-browser callers; the security stance is
|
||||
// "refuse unless loopback".
|
||||
const env = { ...EMPTY_ENV, LIVE_WS_HOST: "0.0.0.0" };
|
||||
assert.equal(isOriginAllowed(undefined, env), false);
|
||||
});
|
||||
|
||||
it("accepts a missing Origin on loopback (CLI/MCP)", () => {
|
||||
// CLI/MCP clients running on the same host omit Origin. The default
|
||||
// listener (127.0.0.1) accepts them.
|
||||
assert.equal(isOriginAllowed(undefined, EMPTY_ENV), true);
|
||||
});
|
||||
|
||||
it("accepts a missing Origin on ::1 / localhost hosts", () => {
|
||||
const env1 = { ...EMPTY_ENV, LIVE_WS_HOST: "::1" };
|
||||
assert.equal(isOriginAllowed(undefined, env1), true);
|
||||
const env2 = { ...EMPTY_ENV, LIVE_WS_HOST: "localhost" };
|
||||
assert.equal(isOriginAllowed(undefined, env2), true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isLocalRequestAllowed } from "../../../src/lib/security/localEndpoints.ts";
|
||||
|
||||
/**
|
||||
* Tests for the /api/local/* security guard. The guard reads from
|
||||
* `globalThis.__omniRequestHeaders` (set by the Next.js middleware shim) and
|
||||
* from `process.env` (production opt-in and bearer token). Each test cleans
|
||||
* up both before and after running so the order doesn't matter.
|
||||
*/
|
||||
|
||||
type OmniGlobals = { __omniRequestHeaders?: Headers };
|
||||
const G = globalThis as OmniGlobals;
|
||||
|
||||
function reset() {
|
||||
delete G.__omniRequestHeaders;
|
||||
}
|
||||
|
||||
function setHeaders(headers: Record<string, string>) {
|
||||
G.__omniRequestHeaders = new Headers(headers);
|
||||
}
|
||||
|
||||
test("isLocalRequestAllowed: allows when no headers injected and not production", () => {
|
||||
const prevNodeEnv = process.env.NODE_ENV;
|
||||
const prevEnabled = process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED;
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED;
|
||||
reset();
|
||||
try {
|
||||
const out = isLocalRequestAllowed();
|
||||
assert.equal(out.allowed, true, `expected allowed, got: ${JSON.stringify(out)}`);
|
||||
} finally {
|
||||
reset();
|
||||
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
|
||||
if (prevEnabled !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED = prevEnabled;
|
||||
}
|
||||
});
|
||||
|
||||
test("isLocalRequestAllowed: allows loopback host + empty xff (browser dev path)", () => {
|
||||
const prevNodeEnv = process.env.NODE_ENV;
|
||||
const prevToken = process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
|
||||
setHeaders({ host: "localhost:20128" });
|
||||
try {
|
||||
const out = isLocalRequestAllowed();
|
||||
assert.equal(out.allowed, true, `expected allowed, got: ${JSON.stringify(out)}`);
|
||||
} finally {
|
||||
reset();
|
||||
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
|
||||
if (prevToken !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("isLocalRequestAllowed: allows IPv4 loopback host 127.0.0.1", () => {
|
||||
const prevNodeEnv = process.env.NODE_ENV;
|
||||
delete process.env.NODE_ENV;
|
||||
setHeaders({ host: "127.0.0.1:20128" });
|
||||
try {
|
||||
const out = isLocalRequestAllowed();
|
||||
assert.equal(out.allowed, true);
|
||||
} finally {
|
||||
reset();
|
||||
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test("isLocalRequestAllowed: allows IPv6 loopback host [::1]", () => {
|
||||
const prevNodeEnv = process.env.NODE_ENV;
|
||||
delete process.env.NODE_ENV;
|
||||
setHeaders({ host: "[::1]:20128" });
|
||||
try {
|
||||
const out = isLocalRequestAllowed();
|
||||
assert.equal(out.allowed, true);
|
||||
} finally {
|
||||
reset();
|
||||
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test("isLocalRequestAllowed: rejects public host even with loopback x-forwarded-for", () => {
|
||||
const prevNodeEnv = process.env.NODE_ENV;
|
||||
const prevToken = process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
|
||||
// Public Host with loopback XFF is rejected — Host header is the
|
||||
// authoritative loopback check (defence against Host header injection
|
||||
// from a tunneled dev URL).
|
||||
setHeaders({ host: "example.com", "x-forwarded-for": "127.0.0.1" });
|
||||
try {
|
||||
const out = isLocalRequestAllowed();
|
||||
assert.equal(out.allowed, false);
|
||||
assert.equal(out.reason, "non-local origin");
|
||||
} finally {
|
||||
reset();
|
||||
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
|
||||
if (prevToken !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("isLocalRequestAllowed: rejects non-loopback origin (no bearer token)", () => {
|
||||
const prevNodeEnv = process.env.NODE_ENV;
|
||||
const prevToken = process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
|
||||
setHeaders({ host: "example.com", "x-forwarded-for": "203.0.113.5" });
|
||||
try {
|
||||
const out = isLocalRequestAllowed();
|
||||
assert.equal(out.allowed, false);
|
||||
assert.equal(out.reason, "non-local origin");
|
||||
} finally {
|
||||
reset();
|
||||
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
|
||||
if (prevToken !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("isLocalRequestAllowed: bearer token takes precedence over host check (desktop app)", () => {
|
||||
const prevNodeEnv = process.env.NODE_ENV;
|
||||
const prevToken = process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
|
||||
process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = "s3cret-token-abc";
|
||||
delete process.env.NODE_ENV;
|
||||
// Non-loopback origin BUT valid bearer token → allowed (desktop app)
|
||||
setHeaders({
|
||||
host: "example.com",
|
||||
"x-forwarded-for": "203.0.113.5",
|
||||
authorization: "Bearer s3cret-token-abc",
|
||||
});
|
||||
try {
|
||||
const out = isLocalRequestAllowed();
|
||||
assert.equal(out.allowed, true, `expected allowed, got: ${JSON.stringify(out)}`);
|
||||
} finally {
|
||||
reset();
|
||||
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
|
||||
if (prevToken !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("isLocalRequestAllowed: rejects wrong bearer token even with token configured", () => {
|
||||
const prevNodeEnv = process.env.NODE_ENV;
|
||||
const prevToken = process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
|
||||
process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = "s3cret-token-abc";
|
||||
delete process.env.NODE_ENV;
|
||||
setHeaders({
|
||||
host: "example.com",
|
||||
"x-forwarded-for": "203.0.113.5",
|
||||
authorization: "Bearer wrong-token",
|
||||
});
|
||||
try {
|
||||
const out = isLocalRequestAllowed();
|
||||
assert.equal(out.allowed, false);
|
||||
// Falls through to the host check, which rejects.
|
||||
assert.equal(out.reason, "non-local origin");
|
||||
} finally {
|
||||
reset();
|
||||
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
|
||||
if (prevToken !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("isLocalRequestAllowed: production without opt-in rejects (no headers path)", () => {
|
||||
const prevNodeEnv = process.env.NODE_ENV;
|
||||
const prevEnabled = process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED;
|
||||
process.env.NODE_ENV = "production";
|
||||
delete process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED;
|
||||
reset();
|
||||
try {
|
||||
const out = isLocalRequestAllowed();
|
||||
assert.equal(out.allowed, false);
|
||||
assert.equal(out.reason, "disabled in production");
|
||||
} finally {
|
||||
reset();
|
||||
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
|
||||
if (prevEnabled !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED = prevEnabled;
|
||||
}
|
||||
});
|
||||
|
||||
test("isLocalRequestAllowed: production WITH opt-in allows (no headers path)", () => {
|
||||
// The "no headers" path bypasses the host check entirely; it's the catch-all
|
||||
// for non-Next contexts (cron jobs, scripts). In production with opt-in,
|
||||
// the function returns { allowed: true } for that path. This is by design:
|
||||
// the production gate is at the route handler, not the guard.
|
||||
const prevNodeEnv = process.env.NODE_ENV;
|
||||
const prevEnabled = process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED;
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED = "1";
|
||||
reset();
|
||||
try {
|
||||
const out = isLocalRequestAllowed();
|
||||
assert.equal(out.allowed, true);
|
||||
} finally {
|
||||
reset();
|
||||
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
|
||||
if (prevEnabled !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED = prevEnabled;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Regression: fingerprintZedCredential is deterministic, 16 hex chars, and
|
||||
* different inputs produce different fingerprints (collision sanity).
|
||||
* See docs/security/SOCKET_DEV_FINDINGS.md §2.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { fingerprintZedCredential } from "../../../src/lib/zed-oauth/credentialFingerprint.ts";
|
||||
|
||||
test("fingerprint is 16 lowercase hex chars", () => {
|
||||
const fp = fingerprintZedCredential("zed-openai", "default", "sk-test-token-1234");
|
||||
assert.match(fp, /^[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
test("fingerprint is deterministic across calls", () => {
|
||||
const a = fingerprintZedCredential("zed-openai", "default", "sk-test-token-1234");
|
||||
const b = fingerprintZedCredential("zed-openai", "default", "sk-test-token-1234");
|
||||
assert.equal(a, b);
|
||||
});
|
||||
|
||||
test("different service produces different fingerprint", () => {
|
||||
const a = fingerprintZedCredential("zed-openai", "default", "sk-test-token-1234");
|
||||
const b = fingerprintZedCredential("zed-anthropic", "default", "sk-test-token-1234");
|
||||
assert.notEqual(a, b);
|
||||
});
|
||||
|
||||
test("different account produces different fingerprint", () => {
|
||||
const a = fingerprintZedCredential("zed-openai", "default", "sk-test-token-1234");
|
||||
const b = fingerprintZedCredential("zed-openai", "alt-account", "sk-test-token-1234");
|
||||
assert.notEqual(a, b);
|
||||
});
|
||||
|
||||
test("different token produces different fingerprint", () => {
|
||||
const a = fingerprintZedCredential("zed-openai", "default", "sk-test-token-1234");
|
||||
const b = fingerprintZedCredential("zed-openai", "default", "sk-test-token-other");
|
||||
assert.notEqual(a, b);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Regression: confirmedAccounts validation helpers reject malformed bodies and
|
||||
* filterCredentialsByConfirmation only returns credentials whose fingerprint
|
||||
* matches a confirmed entry. See docs/security/SOCKET_DEV_FINDINGS.md §2.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
isConfirmedAccount,
|
||||
parseConfirmedAccounts,
|
||||
filterCredentialsByConfirmation,
|
||||
} from "../../../src/lib/zed-oauth/confirmedAccounts.ts";
|
||||
import { fingerprintZedCredential } from "../../../src/lib/zed-oauth/credentialFingerprint.ts";
|
||||
|
||||
test("isConfirmedAccount rejects null / wrong-typed entries", () => {
|
||||
assert.equal(isConfirmedAccount(null), false);
|
||||
assert.equal(isConfirmedAccount("string"), false);
|
||||
assert.equal(isConfirmedAccount({}), false);
|
||||
assert.equal(isConfirmedAccount({ service: "zed-openai" }), false);
|
||||
assert.equal(
|
||||
isConfirmedAccount({ service: "zed-openai", account: "default", fingerprint: "" }),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
isConfirmedAccount({ service: 123, account: "default", fingerprint: "abc" }),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("isConfirmedAccount accepts a fully-formed entry", () => {
|
||||
assert.equal(
|
||||
isConfirmedAccount({ service: "zed-openai", account: "default", fingerprint: "abc123" }),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("parseConfirmedAccounts returns null for missing / malformed bodies", () => {
|
||||
assert.equal(parseConfirmedAccounts(null), null);
|
||||
assert.equal(parseConfirmedAccounts({}), null);
|
||||
assert.equal(parseConfirmedAccounts({ confirmedAccounts: "not-an-array" }), null);
|
||||
assert.equal(
|
||||
parseConfirmedAccounts({ confirmedAccounts: [{ service: 1 }] }),
|
||||
null,
|
||||
"an array with a malformed entry should fail validation"
|
||||
);
|
||||
});
|
||||
|
||||
test("parseConfirmedAccounts returns the list when every entry is valid", () => {
|
||||
const result = parseConfirmedAccounts({
|
||||
confirmedAccounts: [
|
||||
{ service: "zed-openai", account: "default", fingerprint: "abc123" },
|
||||
{ service: "zed-anthropic", account: "default", fingerprint: "def456" },
|
||||
],
|
||||
});
|
||||
assert.ok(result);
|
||||
assert.equal(result!.length, 2);
|
||||
});
|
||||
|
||||
test("filterCredentialsByConfirmation only returns credentials matching the fingerprint set", () => {
|
||||
const credentials = [
|
||||
{ provider: "openai", service: "zed-openai", account: "default", token: "sk-real-1" },
|
||||
{ provider: "anthropic", service: "zed-anthropic", account: "default", token: "sk-real-2" },
|
||||
{ provider: "google", service: "zed-google", account: "default", token: "sk-real-3" },
|
||||
];
|
||||
// Operator confirmed only openai and anthropic; the matching fingerprints
|
||||
// come from the same algorithm used by /discover.
|
||||
const confirmed = [
|
||||
{
|
||||
service: "zed-openai",
|
||||
account: "default",
|
||||
fingerprint: fingerprintZedCredential("zed-openai", "default", "sk-real-1"),
|
||||
},
|
||||
{
|
||||
service: "zed-anthropic",
|
||||
account: "default",
|
||||
fingerprint: fingerprintZedCredential("zed-anthropic", "default", "sk-real-2"),
|
||||
},
|
||||
];
|
||||
|
||||
const result = filterCredentialsByConfirmation(credentials, confirmed);
|
||||
assert.equal(result.length, 2);
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.provider).sort(),
|
||||
["anthropic", "openai"]
|
||||
);
|
||||
});
|
||||
|
||||
test("filterCredentialsByConfirmation rejects entries with mismatched fingerprint (token rotation)", () => {
|
||||
const credentials = [
|
||||
{ provider: "openai", service: "zed-openai", account: "default", token: "sk-NEW-rotated" },
|
||||
];
|
||||
// Operator's confirmation references the OLD token's fingerprint.
|
||||
const confirmed = [
|
||||
{
|
||||
service: "zed-openai",
|
||||
account: "default",
|
||||
fingerprint: fingerprintZedCredential("zed-openai", "default", "sk-old-token"),
|
||||
},
|
||||
];
|
||||
|
||||
const result = filterCredentialsByConfirmation(credentials, confirmed);
|
||||
assert.equal(
|
||||
result.length,
|
||||
0,
|
||||
"fingerprint mismatch (token rotated since discover) must skip the credential"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user