Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **Security — `POST /api/cli/connect` (#14486):** the well-known `INITIAL_PASSWORD=CHANGEME` placeholder can no longer be exchanged for an `admin`-scoped `oma_` access token from off-loopback. #13679 added that gate to `/api/auth/login`, but this route verifies the same management password, mints a token on success and is not loopback-only — so a fresh install (where `sync-env.mjs` copies the placeholder from `.env.example`) handed admin to anyone who could reach the port. Loopback pairing and any rotated password are unaffected.
33 changes: 33 additions & 0 deletions src/app/api/cli/connect/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
import { classifyIpScope } from "@/lib/ipUtils";
import { getCachedSettings } from "@/lib/db/readCache";
import {
ensurePersistentManagementPasswordHash,
getStoredManagementPassword,
isKnownInsecureManagementPassword,
verifyManagementPassword,
} from "@/lib/auth/managementPassword";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
Expand Down Expand Up @@ -115,6 +117,37 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
}

// #14486: /api/auth/login refuses the well-known INITIAL_PASSWORD placeholder
// from off-loopback since #13679, but this route verifies the SAME password and
// mints an admin-scoped `oma_` token, and it is not loopback-only. A fresh
// install carries `INITIAL_PASSWORD=CHANGEME` (scripts/dev/sync-env.mjs copies
// .env.example), so without this gate the public default is exchangeable for
// admin from anywhere the port is reachable. Pair from a local console first,
// then rotate.
if (isKnownInsecureManagementPassword(password) && classifyIpScope(clientIp) !== "loopback") {
logAuditEvent({
action: "cli.connect.insecure_default_blocked",
actor: "anonymous",
target: "cli-access-token",
resourceType: "auth_session",
status: "failed",
ipAddress: clientIp || undefined,
requestId: auditContext.requestId,
metadata: {
reason: "well_known_default_password_non_loopback",
sourceScope: classifyIpScope(clientIp),
},
});
return NextResponse.json(
{
error:
"The management password is still set to the well-known default. " +
"Pair the CLI from the host itself (loopback) and rotate the password first.",
},
{ status: 403 }
);
}

clearLoginAttempts(clientIp);

const tokenScope = scope ?? "admin";
Expand Down
9 changes: 8 additions & 1 deletion stryker.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,14 @@
"tests/unit/rate-limit-learned-cap-13594.test.ts",
"tests/unit/native-codex-auto-resume.test.ts",
"tests/unit/translator-openai-to-gemini-turn-pairing-13848.test.ts",
"tests/unit/obsidian-ssrf-guard.test.ts"
"tests/unit/obsidian-ssrf-guard.test.ts",
"tests/unit/14486-cli-connect-insecure-default-gate.test.ts",
"tests/unit/bare-403-neutral.test.ts",
"tests/unit/chat-noauth-model-cooldown.test.ts",
"tests/unit/combo-ref-capabilities-14232.test.ts",
"tests/unit/intelligent-routing-weight-edit.test.ts",
"tests/unit/noauth-model-lockout-retryable.test.ts",
"tests/unit/noauth-refresh-guard.test.ts"
],
"nodeArgs": [
"--import",
Expand Down
111 changes: 111 additions & 0 deletions tests/unit/14486-cli-connect-insecure-default-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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";

/**
* Regression test for #14486 (item 1).
*
* #13679 (PR D, item #5) refuses a login that matches the well-known
* `INITIAL_PASSWORD=CHANGEME` placeholder unless the request comes from
* loopback — but that gate lives in `/api/auth/login` only.
*
* `POST /api/cli/connect` verifies the same management password
* (`verifyManagementPassword`) and, on success, mints an `admin`-scoped `oma_`
* access token. It is not in `LOCAL_ONLY_API_PREFIXES`, so on a fresh install
* — where `scripts/dev/sync-env.mjs` copies `INITIAL_PASSWORD=CHANGEME` from
* `.env.example` into `.env` — anyone who can reach the port could exchange the
* public default for an admin token. The insecure-default gate has to hold on
* every path that accepts that password, not just the dashboard one.
*/

const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-14486-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.JWT_SECRET = "test-jwt-secret-14486";

const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;

const core = await import("../../src/lib/db/core.ts");
const compliance = await import("../../src/lib/compliance/index.ts");
const connectRoute = await import("../../src/app/api/cli/connect/route.ts");

async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
process.env.INITIAL_PASSWORD = "CHANGEME";
}

test.beforeEach(async () => {
await resetStorage();
});

test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
if (ORIGINAL_INITIAL_PASSWORD === undefined) {
delete process.env.INITIAL_PASSWORD;
} else {
process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
}
});

function postConnect(password: string, forwardedFor: string) {
return connectRoute.POST(
new Request("http://localhost/api/cli/connect", {
method: "POST",
headers: {
"content-type": "application/json",
"x-forwarded-for": forwardedFor,
},
body: JSON.stringify({ password, name: "test-cli" }),
}) as never
);
}

test("a public-IP cli/connect with the well-known default password is refused an admin token", async () => {
const response = await postConnect("CHANGEME", "203.0.113.77");

assert.equal(
response.status,
403,
"the well-known default must not be exchangeable for an admin-scoped oma_ token " +
"from off-loopback — the same control /api/auth/login applies since #13679"
);

const body = (await response.json()) as { token?: string; error?: string };
assert.equal(body.token, undefined, "no access token may be minted for the blocked attempt");
assert.ok(!String(body.error ?? "").includes("at /"), "no stack trace in the error body");

const [entry] = compliance.getAuditLog({
action: "cli.connect.insecure_default_blocked",
limit: 1,
});
assert.ok(entry, "expected a cli.connect.insecure_default_blocked audit entry");
});

test("a loopback cli/connect with the well-known default password still works", async () => {
const response = await postConnect("CHANGEME", "127.0.0.1");

assert.equal(
response.status,
200,
"the operator must still be able to pair a local CLI before rotating the password"
);
const body = (await response.json()) as { token?: string };
assert.ok(body.token, "a loopback pairing must still receive its token");
});

test("a public-IP cli/connect with a rotated password still works", async () => {
process.env.INITIAL_PASSWORD = "a-real-rotated-password-14486";
const response = await postConnect("a-real-rotated-password-14486", "203.0.113.77");

assert.equal(
response.status,
200,
"the gate must only bite on the well-known default, never on a rotated password"
);
const body = (await response.json()) as { token?: string };
assert.ok(body.token, "a remote pairing with a real password must still receive its token");
});
Loading