Skip to content
Closed
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ MACHINE_ID_SALT=endpoint-proxy-salt
ENABLE_REQUEST_LOGS=false
AUTH_COOKIE_SECURE=false
REQUIRE_API_KEY=false
ALLOW_API_KEY_REVEAL=false

# Input Sanitizer (FASE-01 — prompt injection & PII protection)
# INPUT_SANITIZER_ENABLED=true
Expand Down
35 changes: 18 additions & 17 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -507,23 +507,24 @@ post_install() {

### Environment Variables

| Variable | Default | Description |
| ------------------------- | ------------------------------------ | ------------------------------------------------------- |
| `JWT_SECRET` | `omniroute-default-secret-change-me` | JWT signing secret (**change in production**) |
| `INITIAL_PASSWORD` | `123456` | First login password |
| `DATA_DIR` | `~/.omniroute` | Data directory (db, usage, logs) |
| `PORT` | framework default | Service port (`20128` in examples) |
| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) |
| `NODE_ENV` | runtime default | Set `production` for deploy |
| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL |
| `CLOUD_URL` | `https://omniroute.dev` | Cloud sync endpoint base URL |
| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys |
| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` |
| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs |
| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (behind HTTPS reverse proxy) |
| `OMNIROUTE_MEMORY_MB` | `512` | Node.js heap limit in MB |
| `PROMPT_CACHE_MAX_SIZE` | `50` | Max prompt cache entries |
| `SEMANTIC_CACHE_MAX_SIZE` | `100` | Max semantic cache entries |
| Variable | Default | Description |
| ------------------------- | ------------------------------------ | -------------------------------------------------------------- |
| `JWT_SECRET` | `omniroute-default-secret-change-me` | JWT signing secret (**change in production**) |
| `INITIAL_PASSWORD` | `123456` | First login password |
| `DATA_DIR` | `~/.omniroute` | Data directory (db, usage, logs) |
| `PORT` | framework default | Service port (`20128` in examples) |
| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) |
| `NODE_ENV` | runtime default | Set `production` for deploy |
| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL |
| `CLOUD_URL` | `https://omniroute.dev` | Cloud sync endpoint base URL |
| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys |
| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` |
| `ALLOW_API_KEY_REVEAL` | `false` | Allow full API keys to be copied from `/dashboard/api-manager` |
| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs |
| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (behind HTTPS reverse proxy) |
| `OMNIROUTE_MEMORY_MB` | `512` | Node.js heap limit in MB |
| `PROMPT_CACHE_MAX_SIZE` | `50` | Max prompt cache entries |
| `SEMANTIC_CACHE_MAX_SIZE` | `100` | Max semantic cache entries |

For the full environment variable reference, see the [README](../README.md).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export default function ApiManagerPageClient() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [usageStats, setUsageStats] = useState<Record<string, KeyUsageStats>>({});
const [sessionCounts, setSessionCounts] = useState<Record<string, number>>({});
const [allowKeyReveal, setAllowKeyReveal] = useState(false);

const { copied, copy } = useCopyToClipboard();

Expand Down Expand Up @@ -150,6 +151,7 @@ export default function ApiManagerPageClient() {
if (res.ok) {
const data = await res.json();
setKeys(data.keys || []);
setAllowKeyReveal(data.allowKeyReveal === true);
// Fetch usage stats after keys are loaded
fetchUsageStats(data.keys || []);
fetchSessionCounts(data.keys || []);
Expand Down Expand Up @@ -506,7 +508,7 @@ export default function ApiManagerPageClient() {
</div>
</div>

<p className="text-sm text-text-muted mb-4">{t("keysSecurityNote")}</p>
{!allowKeyReveal && <p className="text-sm text-text-muted mb-4">{t("keysSecurityNote")}</p>}

{keys.length === 0 ? (
<div className="text-center py-12 border border-dashed border-border rounded-lg">
Expand Down Expand Up @@ -560,12 +562,25 @@ export default function ApiManagerPageClient() {
</div>
<div className="col-span-3 flex items-center gap-1.5">
<code className="text-sm text-text-muted font-mono truncate">{key.key}</code>
<span
className="p-1 text-text-muted/40 opacity-0 group-hover:opacity-100 transition-all shrink-0 cursor-help"
title={t("keyOnlyAvailableAtCreation")}
>
<span className="material-symbols-outlined text-[14px]">lock</span>
</span>
{allowKeyReveal ? (
<button
onClick={() => copy(key.key, `existing_key_${key.id}`)}
className="p-1 text-text-muted/60 hover:text-primary transition-colors shrink-0"
title={t("copyKey")}
aria-label={t("copyKey")}
>
<span className="material-symbols-outlined text-[14px]">
{copied === `existing_key_${key.id}` ? "check" : "content_copy"}
</span>
</button>
) : (
<span
className="p-1 text-text-muted/40 opacity-0 group-hover:opacity-100 transition-all shrink-0 cursor-help"
title={t("keyOnlyAvailableAtCreation")}
>
<span className="material-symbols-outlined text-[14px]">lock</span>
</span>
)}
</div>
<div className="col-span-2 flex items-center">
<div className="flex flex-col items-start gap-1">
Expand Down Expand Up @@ -752,7 +767,11 @@ export default function ApiManagerPageClient() {
<p className="text-sm text-green-800 dark:text-green-200 font-medium mb-1">
{t("keyCreatedSuccess")}
</p>
<p className="text-sm text-green-700 dark:text-green-300">{t("keyCreatedNote")}</p>
{!allowKeyReveal && (
<p className="text-sm text-green-700 dark:text-green-300">
{t("keyCreatedNote")}
</p>
)}
</div>
</div>
</div>
Expand Down
6 changes: 3 additions & 3 deletions src/app/api/cli-tools/claude-settings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,9 @@ export async function POST(request: Request) {
const { env } = validation.data;

// (#523/#526) If a keyId was provided, resolve the real API key from DB.
// The /api/keys list endpoint returns masked key strings — sending those to
// disk would save an unusable half-hidden token. Resolving by ID guarantees
// we always write the full key value to the config file.
// The /api/keys list endpoint may return masked key strings depending on
// ALLOW_API_KEY_REVEAL, so resolving by ID guarantees we always write the
// full key value to the config file.
const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
if (keyId) {
try {
Expand Down
5 changes: 3 additions & 2 deletions src/app/api/cli-tools/codex-settings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,9 @@ export async function POST(request: Request) {
}

// (#549) Resolve real key from DB if keyId was provided.
// The dashboard sends masked key strings — resolving by ID guarantees
// we always write the full key value to the config file.
// The dashboard may send masked key strings depending on
// ALLOW_API_KEY_REVEAL, so resolving by ID guarantees we always write the
// full key value to the config file.
const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
if (keyId) {
try {
Expand Down
7 changes: 4 additions & 3 deletions src/app/api/keys/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { updateKeyPermissionsSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { isApiKeyRevealEnabled, presentStoredApiKey } from "@/lib/apiKeyExposure";

// GET /api/keys/[id] - Get single API key
export async function GET(request, { params }) {
Expand All @@ -20,11 +21,11 @@ export async function GET(request, { params }) {
return NextResponse.json({ error: "Key not found" }, { status: 404 });
}

// Mask the key value
const keyValue = typeof key.key === "string" ? key.key : null;
const allowKeyReveal = isApiKeyRevealEnabled();
return NextResponse.json({
...key,
key: keyValue ? keyValue.slice(0, 8) + "****" + keyValue.slice(-4) : null,
key: presentStoredApiKey(key.key),
allowKeyReveal,
});
Comment on lines +24 to 29

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as the list endpoint: with ALLOW_API_KEY_REVEAL enabled this route will return the full stored key to any caller that reaches it, including scenarios where management auth is skipped (requireLogin=false) or where the caller is authenticated via Bearer API key. To reduce blast radius, consider only revealing when the request is authenticated via a verified dashboard JWT session (and/or only when auth is required), otherwise always mask.

Copilot uses AI. Check for mistakes.
} catch (error) {
console.log("Error fetching key:", error);
Expand Down
9 changes: 5 additions & 4 deletions src/app/api/keys/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,18 @@ import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { createKeySchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { isApiKeyRevealEnabled, presentStoredApiKey } from "@/lib/apiKeyExposure";

// GET /api/keys - List API keys
export async function GET() {
try {
const keys = await getApiKeys();
// Mask key values — users should never see full keys after creation
const maskedKeys = keys.map((k) => ({
const allowKeyReveal = isApiKeyRevealEnabled();
const presentedKeys = keys.map((k) => ({
...k,
key: typeof k.key === "string" ? k.key.slice(0, 8) + "****" + k.key.slice(-4) : null,
key: presentStoredApiKey(k.key),
}));
Comment on lines +13 to 17

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allowKeyReveal is computed once but presentStoredApiKey() recomputes the env flag on every item. This is redundant work and can theoretically create inconsistencies if the env were mutated between calls. Consider passing allowKeyReveal into the presentation logic (e.g., choose between maskStoredApiKey and the raw key based on the already-computed boolean).

Copilot uses AI. Check for mistakes.
return NextResponse.json({ keys: maskedKeys });
return NextResponse.json({ keys: presentedKeys, allowKeyReveal });
Comment on lines 12 to +18

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When ALLOW_API_KEY_REVEAL is enabled this endpoint will return full stored API keys even in cases where the management API is effectively public (e.g. requireLogin=false makes /api/* skip auth in src/proxy.ts) and also to any request authenticated via Bearer API key. That creates a high-impact key exfiltration path (a single leaked/limited key can be used to retrieve all keys, or keys can be revealed without auth). Consider restricting key revealing to verified dashboard JWT sessions only (and/or only when auth is required) and otherwise always returning masked keys even if the env flag is set.

Copilot uses AI. Check for mistakes.
} catch (error) {
console.log("Error fetching keys:", error);
return NextResponse.json({ error: "Failed to fetch keys" }, { status: 500 });
Expand Down
18 changes: 18 additions & 0 deletions src/lib/apiKeyExposure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]);

export function isApiKeyRevealEnabled(): boolean {
const raw = String(process.env.ALLOW_API_KEY_REVEAL || "")
.trim()
.toLowerCase();
return ENABLED_VALUES.has(raw);
}

export function maskStoredApiKey(key: unknown): string | null {
if (typeof key !== "string") return null;
return key.slice(0, 8) + "****" + key.slice(-4);
Comment on lines +11 to +12

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current key masking logic can produce incorrect results for keys shorter than 12 characters because the prefix and suffix slices can overlap. For example, a 10-character key 1234567890 would be masked as 12345678****7890, which is not the intended behavior.

To make this function more robust, we should handle short keys as a special case. It's also good practice to handle empty strings.

Suggested change
if (typeof key !== "string") return null;
return key.slice(0, 8) + "****" + key.slice(-4);
if (typeof key !== "string" || !key) return null;
if (key.length < 12) {
return "****" + key.slice(-4);
}
return key.slice(0, 8) + "****" + key.slice(-4);

}

export function presentStoredApiKey(key: unknown): string | null {
if (typeof key !== "string") return null;
return isApiKeyRevealEnabled() ? key : maskStoredApiKey(key);
}
94 changes: 94 additions & 0 deletions tests/unit/api-key-visibility-route.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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-api-key-visibility-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-api-key-secret";

const core = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const listRoute = await import("../../src/app/api/keys/route.ts");
const detailRoute = await import("../../src/app/api/keys/[id]/route.ts");

const MACHINE_ID = "1234567890abcdef";

async function resetStorage() {
delete process.env.ALLOW_API_KEY_REVEAL;
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}

function maskKey(key) {
return key.slice(0, 8) + "****" + key.slice(-4);
}
Comment on lines +26 to +28

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test helper maskKey() duplicates the production masking algorithm, which makes the test less effective (a bug in masking logic could be mirrored here and still pass). Prefer asserting the expected shape/properties (e.g., starts with the first 8 chars, ends with the last 4, contains "****", and differs from the original) without re-implementing the same function.

Copilot uses AI. Check for mistakes.

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

test.after(async () => {
delete process.env.ALLOW_API_KEY_REVEAL;
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});

test("GET /api/keys masks stored keys when reveal is disabled", async () => {
const created = await apiKeysDb.createApiKey("Primary Key", MACHINE_ID);

const response = await listRoute.GET();
const body = await response.json();

assert.equal(response.status, 200);
assert.equal(body.allowKeyReveal, false);
assert.equal(Array.isArray(body.keys), true);
assert.equal(body.keys.length, 1);
assert.equal(body.keys[0].id, created.id);
assert.equal(body.keys[0].key, maskKey(created.key));
assert.notEqual(body.keys[0].key, created.key);
});

test("GET /api/keys returns full keys when reveal is enabled", async () => {
process.env.ALLOW_API_KEY_REVEAL = "true";
const created = await apiKeysDb.createApiKey("Primary Key", MACHINE_ID);

const response = await listRoute.GET();
const body = await response.json();

assert.equal(response.status, 200);
assert.equal(body.allowKeyReveal, true);
assert.equal(Array.isArray(body.keys), true);
assert.equal(body.keys.length, 1);
assert.equal(body.keys[0].id, created.id);
assert.equal(body.keys[0].key, created.key);
});

test("GET /api/keys/[id] mirrors the reveal toggle", async () => {
const created = await apiKeysDb.createApiKey("Primary Key", MACHINE_ID);
const request = new Request(`http://localhost/api/keys/${created.id}`);

const maskedResponse = await detailRoute.GET(request, {
params: Promise.resolve({ id: created.id }),
});
const maskedBody = await maskedResponse.json();

assert.equal(maskedResponse.status, 200);
assert.equal(maskedBody.allowKeyReveal, false);
assert.equal(maskedBody.key, maskKey(created.key));

process.env.ALLOW_API_KEY_REVEAL = "true";

const revealedResponse = await detailRoute.GET(request, {
params: Promise.resolve({ id: created.id }),
});
const revealedBody = await revealedResponse.json();

assert.equal(revealedResponse.status, 200);
assert.equal(revealedBody.allowKeyReveal, true);
assert.equal(revealedBody.key, created.key);
});