Merge branch 'release/v3.8.50' into fix/minimax-openai-vision

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-05 16:23:59 -03:00
committed by GitHub
12 changed files with 143 additions and 27 deletions

View File

@@ -0,0 +1 @@
- **docs:** add management authentication terminology guide ([#7786](https://github.com/diegosouzapw/OmniRoute/issues/7786))

View File

@@ -0,0 +1 @@
- fix(auto-update): skip synthetic Next.js standalone package.json without `name` field in resolveProjectRoot (#8956)

View File

@@ -0,0 +1 @@
- fix(auth): IP blacklist now blocks on direct connections via trusted peer stamp and re-reads config without restart (#9033)

View File

@@ -1 +0,0 @@
- fix(management): authorize mcp:connect-only keys on loopback/LAN when requireLogin is enabled (#9159)

View File

@@ -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_<random-hex>`. 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 <standard-api-key-with-manage-scope>
Cookie: omniroute_session=<jwt-token>
```
See `docs/reference/API_REFERENCE.md` for endpoint-specific auth requirements.

View File

@@ -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",

View File

@@ -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;

View File

@@ -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" },

View File

@@ -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),

View File

@@ -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)}`
);
});
});

View File

@@ -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.

View File

@@ -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"));
});
});