From 5e344a3a99377f6c463d0ac0ebb9200735000e03 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 5 Aug 2026 16:07:21 -0300 Subject: [PATCH 1/3] fix(auth): IP blacklist now blocks on direct connections via trusted peer stamp and re-reads config without restart (#9033) (#9385) Co-authored-by: diegosouzapw --- changelog.d/fixes/9033-fix.plan.md | 1 + open-sse/services/ipFilter.ts | 22 +++++++++++++++------- src/server/authz/pipeline.ts | 23 +++++++++++++++++++++-- src/shared/validation/schemas/misc.ts | 2 +- tests/unit/authz/probe-9033-repro.test.ts | 16 ++++------------ 5 files changed, 42 insertions(+), 22 deletions(-) create mode 100644 changelog.d/fixes/9033-fix.plan.md diff --git a/changelog.d/fixes/9033-fix.plan.md b/changelog.d/fixes/9033-fix.plan.md new file mode 100644 index 0000000000..b5d8a37ca3 --- /dev/null +++ b/changelog.d/fixes/9033-fix.plan.md @@ -0,0 +1 @@ +- fix(auth): IP blacklist now blocks on direct connections via trusted peer stamp and re-reads config without restart (#9033) diff --git a/open-sse/services/ipFilter.ts b/open-sse/services/ipFilter.ts index c023397891..e54930c5b1 100644 --- a/open-sse/services/ipFilter.ts +++ b/open-sse/services/ipFilter.ts @@ -22,15 +22,16 @@ let _config = { // lazily loaded on first access. better-sqlite3 is synchronous, so both the load // and the save stay in the sync hot path without extra startup wiring. tempBans // are intentionally NOT persisted — they are ephemeral, TTL-swept runtime state. +// +// D2 (#9033): the _loaded one-shot gate was removed so a config persisted by the +// dashboard settings route (a separate module instance, since @omniroute/open-sse +// is bundled per-entry via transpilePackages) propagates to the proxy runtime +// without a restart. A DB failure still degrades to the in-memory defaults, and +// tempBans remain in-memory-only as before. const IP_FILTER_NAMESPACE = "ipFilter"; const IP_FILTER_KEY = "config"; -let _loaded = false; function ensureLoaded() { - if (_loaded) return; - // Mark loaded up-front so a DB failure (build phase / cloud / migration not yet - // run) degrades to in-memory only instead of retrying on every request. - _loaded = true; try { const row = getDbInstance() .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?") @@ -235,9 +236,17 @@ export function createIPFilterMiddleware() { /** * For Next.js App Router — check IP from request object + * + * D1 (#9033): accepts an optional trustedPeerIp (resolved from the authenticated + * peer stamp, available on direct connections where the proxy runtime has no + * socket). When provided, it is checked FIRST before falling through to the + * forwarding headers, so a blacklisted IP on a direct connection (no XFF, no + * socket) is blocked. When behind a reverse proxy (via-proxy marker set), the + * caller passes null so the XFF path continues to work. */ -export function checkRequestIP(request) { +export function checkRequestIP(request, trustedPeerIp) { const ip = + pickFirstValidIp(trustedPeerIp || null) || pickFirstValidIp(request.headers?.get?.("cf-connecting-ip")) || pickFirstValidIp(request.headers?.get?.("x-forwarded-for")) || pickFirstValidIp(request.headers?.get?.("x-real-ip")) || @@ -329,7 +338,6 @@ function extractClientIP(req) { * Reset config (for testing) */ export function resetIPFilter() { - _loaded = false; _config = { enabled: false, mode: "blacklist", diff --git a/src/server/authz/pipeline.ts b/src/server/authz/pipeline.ts index b440bad49f..f2619a189b 100644 --- a/src/server/authz/pipeline.ts +++ b/src/server/authz/pipeline.ts @@ -8,7 +8,11 @@ import { applyCorsHeaders } from "../cors/origins"; import { validateBrowserMutationOrigin } from "../origin/publicOrigin"; import { classifyRoute } from "./classify"; import { validateDashboardCsrfToken } from "./csrf"; -import { classifyStampedPeerLocality } from "./peerStamp"; +import { + classifyStampedPeerLocality, + resolveStampedPeer, + resolveStampedViaProxy, +} from "./peerStamp"; import { checkRequestIP } from "@omniroute/open-sse/services/ipFilter.ts"; import { clientApiPolicy } from "./policies/clientApi"; import { managementPolicy } from "./policies/management"; @@ -347,8 +351,23 @@ export async function runAuthzPipeline( // external surface. Loopback is exempt so the local operator can never lock // themselves out of the dashboard (they can always fix the list from // localhost). checkIP is a no-op when the filter is disabled. + // + // D1 (#9033): on a direct connection the proxy runtime has no socket, so + // checkRequestIP reads only forwarding headers + undefined request.ip and + // falls to "unknown", never blocking the blacklisted client. Resolve the + // trusted peer IP from the authenticated stamp and pass it to checkRequestIP, + // but only when NOT behind a reverse proxy (the via-proxy marker means the + // peer IP is the proxy hop, e.g. 127.0.0.1, and the real client is in XFF). if (peerLocality !== "loopback") { - const ipVerdict = checkRequestIP(request); + const trustedPeerIp = resolveStampedPeer( + request.headers.get(PEER_IP_HEADER), + process.env.OMNIROUTE_PEER_STAMP_TOKEN + ); + const viaProxy = resolveStampedViaProxy( + request.headers.get(VIA_PROXY_HEADER), + process.env.OMNIROUTE_PEER_STAMP_TOKEN + ); + const ipVerdict = checkRequestIP(request, viaProxy ? null : trustedPeerIp); if (!ipVerdict.allowed) { const blocked = NextResponse.json( { error: ipVerdict.reason || "Access denied" }, diff --git a/src/shared/validation/schemas/misc.ts b/src/shared/validation/schemas/misc.ts index a7cecbd012..3ed8f5cf87 100644 --- a/src/shared/validation/schemas/misc.ts +++ b/src/shared/validation/schemas/misc.ts @@ -108,7 +108,7 @@ export const resetStatsActionSchema = z.object({ action: z.literal("reset-stats"), }); -export const ipFilterModeSchema = z.enum(["blacklist", "whitelist"]); +export const ipFilterModeSchema = z.enum(["blacklist", "whitelist", "whitelist-priority"]); export const tempBanSchema = z.object({ ip: z.string().trim().min(1), diff --git a/tests/unit/authz/probe-9033-repro.test.ts b/tests/unit/authz/probe-9033-repro.test.ts index 563739d34f..6044de755e 100644 --- a/tests/unit/authz/probe-9033-repro.test.ts +++ b/tests/unit/authz/probe-9033-repro.test.ts @@ -55,11 +55,7 @@ test("D1: blacklisted IP is blocked on a DIRECT connection (trusted peer stamp, { enforce: true } ); - assert.equal( - res.status, - 403, - `direct blacklisted IP must be blocked, got status=${res.status}` - ); + assert.equal(res.status, 403, `direct blacklisted IP must be blocked, got status=${res.status}`); }); test("D2: persisted config written after first load is honored WITHOUT restart", async () => { @@ -74,9 +70,7 @@ test("D2: persisted config written after first load is honored WITHOUT restart", // Now simulate a "settings route" write: write directly to the DB key_value table // with a DIFFERENT config (e.g. empty blacklist, effectively "allow all"). const db = core.getDbInstance(); - db.prepare( - "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)" - ).run( + db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run( "ipFilter", "config", JSON.stringify({ enabled: true, mode: "blacklist", blacklist: [], whitelist: [] }) @@ -116,13 +110,11 @@ test("D3: behind reverse proxy (peer stamp=loopback + via-proxy marker + XFF=bla }); test("Bonus: ipFilterModeSchema accepts whitelist-priority", async () => { - const { ipFilterModeSchema } = await import( - "../../../src/shared/validation/schemas/misc.ts" - ); + const { ipFilterModeSchema } = await import("../../../src/shared/validation/schemas/misc.ts"); const result = ipFilterModeSchema.safeParse("whitelist-priority"); assert.equal( result.success, true, `ipFilterModeSchema must accept "whitelist-priority", got: ${JSON.stringify(result)}` ); -}); \ No newline at end of file +}); From 9e3126828e8a3bac113e8a375e592ef940ab551b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 5 Aug 2026 16:07:31 -0300 Subject: [PATCH 2/3] fix(auto-update): skip synthetic Next.js standalone package.json without name field in resolveProjectRoot (#8956) (#9354) A Next.js standalone build writes a synthetic .build/next/package.json ({"type":"commonjs"}) that lacks a "name" field. The resolveProjectRoot() walk-up was stopping at this marker instead of continuing to the real repo root, making PROJECT_ROOT point at .build/next where no .git exists, which caused the source-mode validation to report "Not a git repository." Fix: only accept a package.json as a project-root marker when its parsed content has a non-empty "name" field. Keep .git as a hard marker. Add isValidPackageMarker() helper for testability. Co-authored-by: diegosouzapw --- changelog.d/fixes/8956-fix.plan.md | 1 + src/lib/system/autoUpdate.ts | 27 ++++++++++++++++++++++++--- tests/unit/auto-update.test.ts | 2 +- 3 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fixes/8956-fix.plan.md diff --git a/changelog.d/fixes/8956-fix.plan.md b/changelog.d/fixes/8956-fix.plan.md new file mode 100644 index 0000000000..a5e4892c00 --- /dev/null +++ b/changelog.d/fixes/8956-fix.plan.md @@ -0,0 +1 @@ +- fix(auto-update): skip synthetic Next.js standalone package.json without `name` field in resolveProjectRoot (#8956) \ No newline at end of file diff --git a/src/lib/system/autoUpdate.ts b/src/lib/system/autoUpdate.ts index cd10b6edcb..582a04a0b6 100644 --- a/src/lib/system/autoUpdate.ts +++ b/src/lib/system/autoUpdate.ts @@ -1,5 +1,5 @@ import { execFile, spawn } from "node:child_process"; -import { closeSync, mkdirSync, openSync, existsSync } from "node:fs"; +import { closeSync, mkdirSync, openSync, existsSync, readFileSync } from "node:fs"; import { access } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; @@ -7,15 +7,36 @@ import { homedir } from "node:os"; const execFileAsync = promisify(execFile); +/** + * Check whether a directory's package.json is a valid project-root marker by + * requiring a non-empty `name` field. The Next.js standalone build writes a + * synthetic `.build/next/package.json` = `{"type":"commonjs"}` that should not + * be mistaken for the real project root. + * + * Swallows read / parse errors (missing file, invalid JSON) and returns false + * so the walk-up continues. + * + * @internal — exported for testability. + */ +export function isValidPackageMarker(dir: string): boolean { + try { + const content = readFileSync(path.join(dir, "package.json"), "utf-8"); + const pkg = JSON.parse(content); + return typeof pkg.name === "string" && pkg.name.length > 0; + } catch { + return false; + } +} + /** @internal — exported for testability. */ export function resolveProjectRoot( fallback: string, startDir: string = typeof __dirname !== "undefined" ? __dirname : process.cwd() ): string { - const markers = ["package.json", ".git"] as const; let dir = path.resolve(startDir); while (true) { - if (markers.some((m) => existsSync(path.join(dir, m)))) return dir; + if (existsSync(path.join(dir, ".git"))) return dir; + if (existsSync(path.join(dir, "package.json")) && isValidPackageMarker(dir)) return dir; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; diff --git a/tests/unit/auto-update.test.ts b/tests/unit/auto-update.test.ts index a6714cb765..55112bae62 100644 --- a/tests/unit/auto-update.test.ts +++ b/tests/unit/auto-update.test.ts @@ -404,7 +404,7 @@ test("resolveProjectRoot walks up from start dir to nearest package.json or .git const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-root-")); const subDir = path.join(tempRoot, "sub", "deep"); fs.mkdirSync(subDir, { recursive: true }); - fs.writeFileSync(path.join(tempRoot, "package.json"), "{}"); + fs.writeFileSync(path.join(tempRoot, "package.json"), JSON.stringify({ name: "omniroute" })); try { // Walking up from a deep subdir that does not have markers must find the real root. From 7589c9f71ca8cc09bf87da84261f812705ffe48d Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 5 Aug 2026 16:15:34 -0300 Subject: [PATCH 3/3] fix(docs): repair the #7786 squash contamination on release/v3.8.50 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #7786 squash accidentally committed its worktree copy (.claude/worktrees/feat-7786/**, since untracked) and leaked probe tests (repro-8522/probe-9033/repro-8956 — each now green via #9355/#9385/#9354) plus a stray changelog.d/fixes/9159-fix.plan.md describing an UNMERGED fix (would fabricate a changelog entry at release time — removed; #9159's own PR ships its fragment). This restores the PR's actual deliverable at the right paths: the management-auth terminology guide (now with the required MDX frontmatter), its docs test (3/3 green) and its changelog fragment. --- .../7786-management-auth-terminology-docs.md | 1 + changelog.d/fixes/9159-fix.plan.md | 1 - docs/guides/MANAGEMENT-AUTH.md | 47 +++++++++++++++++++ tests/unit/management-auth-docs.test.ts | 27 +++++++++++ 4 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 changelog.d/features/7786-management-auth-terminology-docs.md delete mode 100644 changelog.d/fixes/9159-fix.plan.md create mode 100644 docs/guides/MANAGEMENT-AUTH.md create mode 100644 tests/unit/management-auth-docs.test.ts diff --git a/changelog.d/features/7786-management-auth-terminology-docs.md b/changelog.d/features/7786-management-auth-terminology-docs.md new file mode 100644 index 0000000000..4a5fca7f9d --- /dev/null +++ b/changelog.d/features/7786-management-auth-terminology-docs.md @@ -0,0 +1 @@ +- **docs:** add management authentication terminology guide ([#7786](https://github.com/diegosouzapw/OmniRoute/issues/7786)) diff --git a/changelog.d/fixes/9159-fix.plan.md b/changelog.d/fixes/9159-fix.plan.md deleted file mode 100644 index 22d84fba2a..0000000000 --- a/changelog.d/fixes/9159-fix.plan.md +++ /dev/null @@ -1 +0,0 @@ -- fix(management): authorize mcp:connect-only keys on loopback/LAN when requireLogin is enabled (#9159) \ No newline at end of file diff --git a/docs/guides/MANAGEMENT-AUTH.md b/docs/guides/MANAGEMENT-AUTH.md new file mode 100644 index 0000000000..25e0d7ae59 --- /dev/null +++ b/docs/guides/MANAGEMENT-AUTH.md @@ -0,0 +1,47 @@ +--- +title: "Management Authentication" +version: 3.8.50 +lastUpdated: 2026-08-05 +--- + +# Management Authentication + +OmniRoute uses four distinct credential families for management access. This guide +distinguishes them by purpose, scope, and locality. + +| Credential | Scope | Locality | Use Case | +|-------------------------|--------------------|---------------|-----------------------------------| +| Dashboard JWT session | Full management | Localhost | Web dashboard login | +| CLI machine-id token | Full management | Per-machine | `omniroute` CLI commands | +| Scoped `oma_` token | Configurable scope | External | Automation / CI / API access | +| Manage-scope API key | `manage` scope | External | Management API calls | + +## Dashboard JWT Session + +Generated on dashboard login (`/api/auth/login`). Stored in HTTP-only cookie. +Valid for the session duration. Cannot be used from external hosts. + +## CLI Machine-ID Token + +Created by `omniroute auth login` on first use. Stored in `~/.omniroute/auth.json`. +Used by the CLI for all management operations. Tied to the machine identity. + +## Scoped `oma_` Access Token + +Created via dashboard or CLI with configurable scopes (e.g., `manage`, `read`). +Format: `oma_`. Used for programmatic access from external systems. + +## Manage-Scope API Key + +Standard API key with the `manage` scope enabled. Created in dashboard API Keys page. +Used for management API calls from external hosts. + +## Header Examples + +``` +Authorization: Bearer oma_abc123def456 +Authorization: Bearer +Cookie: omniroute_session= +``` + +See `docs/reference/API_REFERENCE.md` for endpoint-specific auth requirements. diff --git a/tests/unit/management-auth-docs.test.ts b/tests/unit/management-auth-docs.test.ts new file mode 100644 index 0000000000..35410e81c3 --- /dev/null +++ b/tests/unit/management-auth-docs.test.ts @@ -0,0 +1,27 @@ +import { describe, it } from "node:test"; +import { ok } from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +describe("Management auth documentation (#7786)", () => { + const docPath = "docs/guides/MANAGEMENT-AUTH.md"; + const content = readFileSync(docPath, "utf-8"); + + it("exists and has content", () => { + ok(content.length > 500, "should have substantial content"); + ok(content.includes("Dashboard JWT session")); + ok(content.includes("CLI machine-id token")); + ok(content.includes("oma_")); + }); + + it("documents all four credential families", () => { + const families = ["Dashboard JWT", "CLI machine-id", "oma_", "Manage-scope"]; + for (const f of families) { + ok(content.includes(f), `should document ${f}`); + } + }); + + it("mentions relevant auth header examples", () => { + ok(content.includes("Authorization")); + ok(content.includes("Bearer")); + }); +});