diff --git a/src/app/api/model-combo-mappings/[id]/route.ts b/src/app/api/model-combo-mappings/[id]/route.ts index 57d0cd6d74..0b23e41d64 100644 --- a/src/app/api/model-combo-mappings/[id]/route.ts +++ b/src/app/api/model-combo-mappings/[id]/route.ts @@ -34,7 +34,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: } return NextResponse.json({ mapping }); } catch (error: any) { - return NextResponse.json({ error: error.message || "Failed to get mapping" }, { status: 500 }); + console.error("Failed to get mapping:", error); + return NextResponse.json({ error: "Failed to get mapping" }, { status: 500 }); } } @@ -58,8 +59,9 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: return NextResponse.json({ mapping }); } catch (error: any) { + console.error("Failed to update mapping:", error); return NextResponse.json( - { error: error.message || "Failed to update mapping" }, + { error: "Failed to update mapping" }, { status: 500 } ); } @@ -79,8 +81,9 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i return NextResponse.json({ success: true }); } catch (error: any) { + console.error("Failed to delete mapping:", error); return NextResponse.json( - { error: error.message || "Failed to delete mapping" }, + { error: "Failed to delete mapping" }, { status: 500 } ); } diff --git a/src/app/api/model-combo-mappings/route.ts b/src/app/api/model-combo-mappings/route.ts index 5b5e54dc62..0a55fa0875 100644 --- a/src/app/api/model-combo-mappings/route.ts +++ b/src/app/api/model-combo-mappings/route.ts @@ -26,8 +26,9 @@ export async function GET(request: Request) { const mappings = await getModelComboMappings(); return NextResponse.json({ mappings }); } catch (error: any) { + console.error("Failed to list model-combo mappings:", error); return NextResponse.json( - { error: error.message || "Failed to list model-combo mappings" }, + { error: "Failed to list model-combo mappings" }, { status: 500 } ); } @@ -55,8 +56,9 @@ export async function POST(request: Request) { return NextResponse.json({ mapping }, { status: 201 }); } catch (error: any) { + console.error("Failed to create model-combo mapping:", error); return NextResponse.json( - { error: error.message || "Failed to create model-combo mapping" }, + { error: "Failed to create model-combo mapping" }, { status: 500 } ); } diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index 8bd8b902db..4db7573a5b 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -155,8 +155,8 @@ export async function GET( return NextResponse.json({ error: "Unknown action" }, { status: 400 }); } catch (error) { - console.log("OAuth GET error:", error); - return NextResponse.json({ error: (error as any).message }, { status: 500 }); + console.error("OAuth GET error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } @@ -227,7 +227,8 @@ async function handleStartCallbackServer(provider: string, searchParams: URLSear serverPort: port, }); } catch (error) { - return NextResponse.json({ error: (error as any).message }, { status: 500 }); + console.error("OAuth start-callback-server error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } @@ -593,7 +594,8 @@ export async function POST( }, }); } catch (exchangeErr: any) { - return NextResponse.json({ success: false, error: exchangeErr.message }, { status: 500 }); + console.error("OAuth exchange error:", exchangeErr); + return NextResponse.json({ success: false, error: "Internal server error" }, { status: 500 }); } } @@ -669,8 +671,8 @@ export async function POST( return NextResponse.json({ error: "Unknown action" }, { status: 400 }); } catch (error) { - console.log("OAuth POST error:", error); - return NextResponse.json({ error: (error as any).message }, { status: 500 }); + console.error("OAuth POST error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/src/app/api/oauth/cursor/auto-import/route.ts b/src/app/api/oauth/cursor/auto-import/route.ts index aae3925059..937e0fd6c3 100755 --- a/src/app/api/oauth/cursor/auto-import/route.ts +++ b/src/app/api/oauth/cursor/auto-import/route.ts @@ -86,7 +86,8 @@ function tryIdeAuth(): { }; } catch (error) { db?.close(); - return { found: false, error: `Failed to read database: ${(error as any).message}` }; + console.error("Failed to read Cursor IDE database:", error); + return { found: false, error: "Failed to read database" }; } } @@ -132,7 +133,7 @@ export async function GET(request: Request) { error: "No Cursor credentials found. Install Cursor IDE or login with cursor-agent.", }); } catch (error) { - console.log("Cursor auto-import error:", error); - return NextResponse.json({ found: false, error: (error as any).message }, { status: 500 }); + console.error("Cursor auto-import error:", error); + return NextResponse.json({ found: false, error: "Internal server error" }, { status: 500 }); } } diff --git a/src/app/api/oauth/cursor/import/route.ts b/src/app/api/oauth/cursor/import/route.ts index 8e747c8041..026cfc51cd 100755 --- a/src/app/api/oauth/cursor/import/route.ts +++ b/src/app/api/oauth/cursor/import/route.ts @@ -101,8 +101,8 @@ export async function POST(request: Request) { }, }); } catch (error: any) { - console.log("Cursor import token error:", error); - return NextResponse.json({ error: error.message }, { status: 500 }); + console.error("Cursor import token error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/src/app/api/oauth/kiro/auto-import/route.ts b/src/app/api/oauth/kiro/auto-import/route.ts index 29a0cf6a64..56e43835b7 100755 --- a/src/app/api/oauth/kiro/auto-import/route.ts +++ b/src/app/api/oauth/kiro/auto-import/route.ts @@ -265,9 +265,6 @@ async function saveAndRespond( }); } catch (error: any) { console.error("[kiro auto-import] save error:", error); - return NextResponse.json( - { found: false, error: `Import failed: ${error.message}` }, - { status: 500 } - ); + return NextResponse.json({ found: false, error: "Internal server error" }, { status: 500 }); } } diff --git a/src/app/api/oauth/kiro/import/route.ts b/src/app/api/oauth/kiro/import/route.ts index 44803229e5..ac65ed0ce6 100755 --- a/src/app/api/oauth/kiro/import/route.ts +++ b/src/app/api/oauth/kiro/import/route.ts @@ -87,8 +87,8 @@ export async function POST(request: Request) { }, }); } catch (error: any) { - console.log("Kiro-compatible import token error:", error); - return NextResponse.json({ error: error.message }, { status: 500 }); + console.error("Kiro-compatible import token error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/src/app/api/oauth/kiro/social-authorize/route.ts b/src/app/api/oauth/kiro/social-authorize/route.ts index 61df8219af..6a557abb43 100755 --- a/src/app/api/oauth/kiro/social-authorize/route.ts +++ b/src/app/api/oauth/kiro/social-authorize/route.ts @@ -38,7 +38,7 @@ export async function GET(request) { provider, }); } catch (error) { - console.log("Kiro social authorize error:", error); - return NextResponse.json({ error: error.message }, { status: 500 }); + console.error("Kiro social authorize error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/src/app/api/oauth/kiro/social-exchange/route.ts b/src/app/api/oauth/kiro/social-exchange/route.ts index 950c59e8db..10bafd4cf4 100755 --- a/src/app/api/oauth/kiro/social-exchange/route.ts +++ b/src/app/api/oauth/kiro/social-exchange/route.ts @@ -75,8 +75,8 @@ export async function POST(request: Request) { }, }); } catch (error: any) { - console.log("Kiro social exchange error:", error); - return NextResponse.json({ error: error.message }, { status: 500 }); + console.error("Kiro social exchange error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/src/lib/sync/tokens.ts b/src/lib/sync/tokens.ts index 4b39db7ccd..f5b12499c7 100644 --- a/src/lib/sync/tokens.ts +++ b/src/lib/sync/tokens.ts @@ -16,7 +16,10 @@ function normalizeToken(rawToken: string | null | undefined) { } export function hashSyncToken(rawToken: string) { - return createHash("sha256").update(rawToken).digest("hex"); + // CodeQL: Intentionally SHA-256, NOT password hashing. Sync tokens are + // high-entropy random values (osync_ + 32 random bytes) — not user passwords. + // codeql[js/insufficient-password-hash] + return createHash("sha256").update(rawToken).digest("hex"); // nosemgrep: insufficient-password-hash } export function generatePlaintextSyncToken() { diff --git a/tests/unit/error-message-sanitization.test.ts b/tests/unit/error-message-sanitization.test.ts new file mode 100644 index 0000000000..ac2496fc47 --- /dev/null +++ b/tests/unit/error-message-sanitization.test.ts @@ -0,0 +1,204 @@ +/** + * Verifies that API routes sanitize error messages (CodeQL js/stack-trace-exposure) + * and that security-critical helpers behave correctly. + */ +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-err-sanitize-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-api-key-secret-32chars-long!!"; + +const core = await import("../../src/lib/db/core.ts"); +const combosDb = await import("../../src/lib/db/combos.ts"); +const mappingsRoute = await import( + "../../src/app/api/model-combo-mappings/route.ts" +); +const mappingsIdRoute = await import( + "../../src/app/api/model-combo-mappings/[id]/route.ts" +); +const syncTokens = await import("../../src/lib/sync/tokens.ts"); + +function makeRequest(url: string, options: { method?: string; body?: unknown } = {}) { + const { method = "GET", body } = options; + return new Request(url, { + method, + headers: body !== undefined ? { "content-type": "application/json" } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +async function createCombo(name: string, model: string) { + return combosDb.createCombo({ + name, + models: [{ provider: "openai", model }], + strategy: "priority", + config: {}, + }); +} + +// ── model-combo-mappings routes ────────────────────────────────────────────── + +test("GET /model-combo-mappings returns empty list on fresh DB", async () => { + const res = await mappingsRoute.GET(); + assert.equal(res.status, 200); + const body = await res.json() as any; + assert.ok(Array.isArray(body.mappings), "body.mappings must be an array"); + assert.equal(body.mappings.length, 0); + assert.ok(!("error" in body), "success response must not contain error field"); +}); + +test("GET /model-combo-mappings error response never leaks raw error.message", async () => { + const res = await mappingsRoute.GET(); + // In the success case, there is no error field at all + const body = await res.json() as any; + if (res.status >= 500) { + assert.equal(body.error, "Failed to list model-combo mappings"); + assert.ok(!("stack" in body), "stack trace must not be present in response"); + } +}); + +test("POST /model-combo-mappings returns 400 for empty pattern", async () => { + const res = await mappingsRoute.POST( + makeRequest("http://localhost/api/model-combo-mappings", { + method: "POST", + body: { pattern: "", comboId: "combo-1" }, + }) + ); + assert.equal(res.status, 400); + const body = await res.json() as any; + assert.ok("error" in body); + assert.ok(!("stack" in body), "400 response must not contain stack trace"); +}); + +test("POST /model-combo-mappings returns 400 for missing comboId", async () => { + const res = await mappingsRoute.POST( + makeRequest("http://localhost/api/model-combo-mappings", { + method: "POST", + body: { pattern: "gpt-*" }, + }) + ); + assert.equal(res.status, 400); +}); + +test("POST /model-combo-mappings creates a mapping and response has no error field", async () => { + const combo = await createCombo("test-combo", "gpt-4o"); + const res = await mappingsRoute.POST( + makeRequest("http://localhost/api/model-combo-mappings", { + method: "POST", + body: { pattern: "gpt-*", comboId: combo.id }, + }) + ); + assert.equal(res.status, 201); + const body = await res.json() as any; + assert.ok("mapping" in body, "response must have mapping field"); + assert.ok(!("error" in body), "success response must not contain error field"); + assert.ok(!("stack" in body)); + assert.equal(body.mapping.pattern, "gpt-*"); +}); + +test("GET /model-combo-mappings/[id] returns 404 for non-existent id", async () => { + const res = await mappingsIdRoute.GET( + makeRequest("http://localhost/api/model-combo-mappings/nonexistent"), + { params: Promise.resolve({ id: "nonexistent" }) } + ); + assert.equal(res.status, 404); + const body = await res.json() as any; + assert.equal(body.error, "Mapping not found"); + assert.ok(!("stack" in body), "404 response must not contain stack trace"); +}); + +test("GET /model-combo-mappings/[id] error response never leaks internal details", async () => { + const res = await mappingsIdRoute.GET( + makeRequest("http://localhost/api/model-combo-mappings/some-id"), + { params: Promise.resolve({ id: "some-id" }) } + ); + const body = await res.json() as any; + if (res.status >= 500) { + assert.equal(body.error, "Failed to get mapping"); + assert.ok(!body.error.includes("SQLITE"), "SQLite internals must not be exposed"); + assert.ok(!("stack" in body)); + } +}); + +test("DELETE /model-combo-mappings/[id] returns 404 for non-existent mapping", async () => { + const res = await mappingsIdRoute.DELETE( + makeRequest("http://localhost/api/model-combo-mappings/nonexistent", { method: "DELETE" }), + { params: Promise.resolve({ id: "nonexistent" }) } + ); + assert.equal(res.status, 404); + const body = await res.json() as any; + assert.equal(body.error, "Mapping not found"); + assert.ok(!("stack" in body)); +}); + +test("PUT /model-combo-mappings/[id] returns 404 for non-existent mapping", async () => { + const res = await mappingsIdRoute.PUT( + makeRequest("http://localhost/api/model-combo-mappings/nonexistent", { + method: "PUT", + body: { pattern: "new-*" }, + }), + { params: Promise.resolve({ id: "nonexistent" }) } + ); + assert.equal(res.status, 404); + const body = await res.json() as any; + assert.equal(body.error, "Mapping not found"); + assert.ok(!("stack" in body)); +}); + +// ── sync token hashing (src/lib/sync/tokens.ts) ────────────────────────────── + +test("hashSyncToken returns a 64-character hex string (SHA-256 output)", () => { + const token = syncTokens.generatePlaintextSyncToken(); + const hash = syncTokens.hashSyncToken(token); + assert.match(hash, /^[0-9a-f]{64}$/, "hash must be 64 lowercase hex chars"); +}); + +test("hashSyncToken is deterministic — same input always produces same output", () => { + const token = syncTokens.generatePlaintextSyncToken(); + assert.equal( + syncTokens.hashSyncToken(token), + syncTokens.hashSyncToken(token), + "hashing the same token twice must yield the same result" + ); +}); + +test("hashSyncToken produces different hashes for different tokens", () => { + const a = syncTokens.generatePlaintextSyncToken(); + const b = syncTokens.generatePlaintextSyncToken(); + assert.notEqual( + syncTokens.hashSyncToken(a), + syncTokens.hashSyncToken(b), + "different tokens must produce different hashes" + ); +}); + +test("generatePlaintextSyncToken starts with osync_ prefix", () => { + const token = syncTokens.generatePlaintextSyncToken(); + assert.ok(token.startsWith("osync_"), `token must start with 'osync_', got: ${token.slice(0, 10)}`); +}); + +test("hashSyncToken output is never the plain token (not stored in clear text)", () => { + const token = syncTokens.generatePlaintextSyncToken(); + const hash = syncTokens.hashSyncToken(token); + assert.notEqual(hash, token, "hash must differ from plaintext token"); + assert.ok(!hash.startsWith("osync_"), "hash must not start with the token prefix"); +});