From c286fdc96a06dc254b903b72e53532cbe14ea324 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 12 Apr 2026 19:08:06 -0300 Subject: [PATCH] fix(auth): require admin auth for backup and translator routes Protect database backup, export, restore, and translator save endpoints with authentication checks to block unauthenticated data access and state changes. Also remove the insecure API key secret fallback, ignore nested app env files from package publishes, and align tests with explicit application/json Accept headers for non-stream requests --- .npmignore | 2 ++ src/app/api/db-backups/exportAll/route.ts | 10 ++++++++-- src/app/api/db-backups/route.ts | 24 +++++++++++++++++++---- src/app/api/translator/save/route.ts | 9 +++++++-- src/shared/utils/apiKey.ts | 7 +++++-- tests/unit/chatcore-sanitization.test.mjs | 4 ++-- tests/unit/executor-codex.test.mjs | 2 +- tests/unit/plan3-p0.test.mjs | 2 +- tests/unit/qoder-executor.test.mjs | 6 +++++- 9 files changed, 51 insertions(+), 15 deletions(-) diff --git a/.npmignore b/.npmignore index 3a1109429e..69b962ff51 100644 --- a/.npmignore +++ b/.npmignore @@ -28,6 +28,8 @@ scripts/ .vscode/ .agents/ .env* +app/.env +app/.env* eslint.config.mjs prettier.config.mjs postcss.config.mjs diff --git a/src/app/api/db-backups/exportAll/route.ts b/src/app/api/db-backups/exportAll/route.ts index 64fd705a82..3a786ae6a9 100644 --- a/src/app/api/db-backups/exportAll/route.ts +++ b/src/app/api/db-backups/exportAll/route.ts @@ -1,14 +1,20 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { getDbInstance, SQLITE_FILE } from "@/lib/db/core"; import fs from "fs"; import path from "path"; import os from "os"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; /** * GET /api/db-backups/exportAll * Exports the entire database + settings as a ZIP archive + * Security: Requires admin authentication. */ -export async function GET() { +export async function GET(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { if (!SQLITE_FILE) { return NextResponse.json( diff --git a/src/app/api/db-backups/route.ts b/src/app/api/db-backups/route.ts index c816d4b8db..167a081742 100644 --- a/src/app/api/db-backups/route.ts +++ b/src/app/api/db-backups/route.ts @@ -1,12 +1,18 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { listDbBackups, restoreDbBackup, backupDbFile } from "@/lib/localDb"; import { dbBackupRestoreSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; /** * PUT /api/db-backups — Trigger a manual backup snapshot. + * Security: Requires admin authentication. */ -export async function PUT() { +export async function PUT(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { const result = backupDbFile("manual"); if (!result) { @@ -21,8 +27,13 @@ export async function PUT() { /** * GET /api/db-backups — List available database backups. + * Security: Requires admin authentication. */ -export async function GET() { +export async function GET(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { const backups = await listDbBackups(); return NextResponse.json({ backups }); @@ -35,8 +46,13 @@ export async function GET() { /** * POST /api/db-backups — Restore a specific backup. * Body: { backupId: "db_2026-02-11T14-00-00-000Z_pre-write.json" } + * Security: Requires admin authentication. */ -export async function POST(request) { +export async function POST(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/translator/save/route.ts b/src/app/api/translator/save/route.ts index c5ab45af66..337eb84ade 100644 --- a/src/app/api/translator/save/route.ts +++ b/src/app/api/translator/save/route.ts @@ -1,10 +1,15 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import fs from "fs"; import path from "path"; import { translatorSaveSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; + +export async function POST(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } -export async function POST(request) { let rawBody; try { rawBody = await request.json(); diff --git a/src/shared/utils/apiKey.ts b/src/shared/utils/apiKey.ts index b0969cd857..1b60814bb4 100644 --- a/src/shared/utils/apiKey.ts +++ b/src/shared/utils/apiKey.ts @@ -6,9 +6,12 @@ if (!process.env.API_KEY_SECRET) { } function getApiKeySecret(): string { - const secret = process.env.API_KEY_SECRET || "omniroute-default-insecure-api-key-secret"; + const secret = process.env.API_KEY_SECRET; if (!secret || secret.trim() === "") { - throw new Error("API_KEY_SECRET is required for API key CRC operations"); + throw new Error( + "API_KEY_SECRET is required for API key CRC operations. " + + "The startup validator (instrumentation-node.ts) should have set this automatically." + ); } return secret; } diff --git a/tests/unit/chatcore-sanitization.test.mjs b/tests/unit/chatcore-sanitization.test.mjs index 87214231b3..d25c924d45 100644 --- a/tests/unit/chatcore-sanitization.test.mjs +++ b/tests/unit/chatcore-sanitization.test.mjs @@ -365,9 +365,9 @@ test("chatCore resolves stream mode from body.stream and Accept header", async ( }); assert.equal(explicitTrue.call.headers.Accept, "text/event-stream"); - assert.equal(explicitFalse.call.headers.Accept, undefined); + assert.equal(explicitFalse.call.headers.Accept, "application/json"); assert.equal(acceptDriven.call.headers.Accept, "text/event-stream"); - assert.equal(jsonDefault.call.headers.Accept, undefined); + assert.equal(jsonDefault.call.headers.Accept, "application/json"); }); test("chatCore injects memories when enabled and memories are found", async () => { diff --git a/tests/unit/executor-codex.test.mjs b/tests/unit/executor-codex.test.mjs index 20fee2d4ad..a4002382f2 100644 --- a/tests/unit/executor-codex.test.mjs +++ b/tests/unit/executor-codex.test.mjs @@ -67,7 +67,7 @@ test("CodexExecutor.buildHeaders binds workspace ids and disables SSE accept for assert.equal(standardHeaders.Authorization, "Bearer codex-token"); assert.equal(standardHeaders.Accept, "text/event-stream"); assert.equal(standardHeaders["chatgpt-account-id"], "workspace-1"); - assert.equal(compactHeaders.Accept, undefined); + assert.equal(compactHeaders.Accept, "application/json"); }); test("CodexExecutor.transformRequest injects default instructions, clamps reasoning and strips unsupported fields", () => { diff --git a/tests/unit/plan3-p0.test.mjs b/tests/unit/plan3-p0.test.mjs index b4b6a0347a..8abba76620 100644 --- a/tests/unit/plan3-p0.test.mjs +++ b/tests/unit/plan3-p0.test.mjs @@ -249,7 +249,7 @@ test("CodexExecutor does not request SSE accept header for compact requests", () }, false ); - assert.equal(headers.Accept, undefined); + assert.equal(headers.Accept, "application/json"); }); test("CodexExecutor preserves native responses payloads for Codex passthrough", () => { diff --git a/tests/unit/qoder-executor.test.mjs b/tests/unit/qoder-executor.test.mjs index 9cbbeb73ba..e13f879852 100644 --- a/tests/unit/qoder-executor.test.mjs +++ b/tests/unit/qoder-executor.test.mjs @@ -29,6 +29,7 @@ test("QoderExecutor: buildHeaders inherits configured user agent, auth and strea "Content-Type": "application/json", "User-Agent": "Qoder-Cli", Authorization: "Bearer token", + Accept: "application/json", }); }); @@ -115,7 +116,10 @@ test("validateQoderCliPat succeeds when the validation endpoint returns OK", asy if (urlStr.includes("/ping")) { return new Response("pong", { status: 200 }); } - assert.match(urlStr, /api1\.qoder\.sh\/algo\/api\/v2\/service\/pro\/sse\/agent_chat_generation/); + assert.match( + urlStr, + /api1\.qoder\.sh\/algo\/api\/v2\/service\/pro\/sse\/agent_chat_generation/ + ); assert.equal(options.method, "POST"); assert.match(String(options.headers.Authorization), /^Bearer COSY\./); return new Response("{}", { status: 200 });