-
-
Notifications
You must be signed in to change notification settings - Fork 10.3k
[codex] Add optional API key reveal toggle #737
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
|
||
| return NextResponse.json({ keys: maskedKeys }); | ||
| return NextResponse.json({ keys: presentedKeys, allowKeyReveal }); | ||
|
Comment on lines
12
to
+18
|
||
| } catch (error) { | ||
| console.log("Error fetching keys:", error); | ||
| return NextResponse.json({ error: "Failed to fetch keys" }, { status: 500 }); | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 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
|
||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| export function presentStoredApiKey(key: unknown): string | null { | ||||||||||||||||||
| if (typeof key !== "string") return null; | ||||||||||||||||||
| return isApiKeyRevealEnabled() ? key : maskStoredApiKey(key); | ||||||||||||||||||
| } | ||||||||||||||||||
| 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
|
||
|
|
||
| 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); | ||
| }); | ||
There was a problem hiding this comment.
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_REVEALenabled 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.