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
This commit is contained in:
diegosouzapw
2026-04-12 19:08:06 -03:00
parent b65caf82b4
commit c286fdc96a
9 changed files with 51 additions and 15 deletions

View File

@@ -28,6 +28,8 @@ scripts/
.vscode/
.agents/
.env*
app/.env
app/.env*
eslint.config.mjs
prettier.config.mjs
postcss.config.mjs

View File

@@ -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(

View File

@@ -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();

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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 () => {

View File

@@ -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", () => {

View File

@@ -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", () => {

View File

@@ -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 });