fix(security): scope /api/files and /api/batches to caller's tenant (#13882) (#14027)

/api/files, /api/files/[id]/content, /api/batches and /api/batches/[id]
only gated on requireManagementAuth(request), which returns null
unconditionally when settings.requireLogin===false, and never applied
any per-record ownership check. On an instance with login disabled, an
unauthenticated caller could enumerate/download every tenant's files
and batches — the hardened /api/v1/files and /api/v1/batches siblings
already scope via getApiKeyRequestScope()/resolveListScope()/
canAccessOwnedRecord() from the GHSA-2jm2-mpx8-6523 and
GHSA-m3hp-hq9g-fpmv fixes.

Port that exact scoping onto the 4 management routes: an API key sees
only its own files/batches, a dashboard session keeps instance-wide
access, and any other caller is rejected instead of falling through to
an unscoped read.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-18 11:56:24 -03:00
committed by GitHub
parent 5b61937f17
commit 25d35179fd
7 changed files with 308 additions and 23 deletions

View File

@@ -1,15 +1,19 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { getBatch } from "@/lib/db/batches";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getApiKeyRequestScope, canAccessOwnedRecord } from "@/app/api/v1/_helpers/apiKeyScope";
export async function GET(request: Request, { params }: { params: { id: string } }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const scope = await getApiKeyRequestScope(request);
if (scope.rejection) return scope.rejection;
try {
const batch = getBatch(params.id);
if (!batch) {
// Session = operator, key = own rows only, null owner = denied. Mirrors
// /api/v1/batches/[id] (GHSA-2jm2-mpx8-6523) so this management sibling
// cannot leak a foreign tenant's batch metadata to an unauthenticated
// caller (#13882).
if (!batch || !canAccessOwnedRecord(scope, batch.apiKeyId)) {
return NextResponse.json({ error: "Batch not found" }, { status: 404 });
}
return NextResponse.json({ batch });

View File

@@ -1,16 +1,24 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { listBatches } from "@/lib/db/batches";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getApiKeyRequestScope, resolveListScope } from "@/app/api/v1/_helpers/apiKeyScope";
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const scope = await getApiKeyRequestScope(request);
if (scope.rejection) return scope.rejection;
// Key → own batches only; dashboard session without a key → instance-wide;
// anonymous / unresolvable bearer → 401. Mirrors /api/v1/batches (GHSA-2jm2-
// mpx8-6523 / GHSA-m3hp-hq9g-fpmv) so this management sibling cannot leak
// every tenant's batches to an unauthenticated caller (#13882).
const listScope = resolveListScope(scope);
if (listScope.mode === "rejected") return listScope.response;
try {
const url = new URL(request.url);
const limit = Number.parseInt(url.searchParams.get("limit") || "100", 10);
const batches = listBatches(undefined, limit);
const ownerFilter = listScope.mode === "api_key" ? listScope.apiKeyId : undefined;
const batches = listBatches(ownerFilter, limit);
return NextResponse.json({ batches });
} catch (error) {
console.log("Error fetching batches:", error);

View File

@@ -1,15 +1,19 @@
import { NextResponse } from "next/server";
import { getFile, getFileContent } from "@/lib/db/files";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getApiKeyRequestScope, canAccessOwnedRecord } from "@/app/api/v1/_helpers/apiKeyScope";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const scope = await getApiKeyRequestScope(request);
if (scope.rejection) return scope.rejection;
const { id } = await params;
const file = getFile(id);
if (!file) {
// `getFileContent` has no ownership check of its own — this guard is the only
// thing between a caller and the raw bytes. Mirrors /api/v1/files/[id]/content
// (GHSA-2jm2-mpx8-6523) so this management sibling cannot leak a foreign
// tenant's file content to an unauthenticated caller (#13882).
if (!file || !canAccessOwnedRecord(scope, file.apiKeyId)) {
return NextResponse.json(
{ error: { message: "File not found", type: "invalid_request_error" } },
{ status: 404 }

View File

@@ -1,15 +1,25 @@
import { NextResponse } from "next/server";
import { listFiles } from "@/lib/db/files";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getApiKeyRequestScope, resolveListScope } from "@/app/api/v1/_helpers/apiKeyScope";
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const scope = await getApiKeyRequestScope(request);
if (scope.rejection) return scope.rejection;
// Key → own files only; dashboard session without a key → instance-wide;
// anonymous / unresolvable bearer → 401. Mirrors /api/v1/files (GHSA-2jm2-
// mpx8-6523 / GHSA-m3hp-hq9g-fpmv) so this management sibling cannot leak
// every tenant's files to an unauthenticated caller (#13882).
const listScope = resolveListScope(scope);
if (listScope.mode === "rejected") return listScope.response;
try {
const url = new URL(request.url);
const limit = Number.parseInt(url.searchParams.get("limit") || "100", 10);
const files = listFiles({ limit });
const files =
listScope.mode === "api_key"
? listFiles({ limit, apiKeyId: listScope.apiKeyId })
: listFiles({ limit });
return NextResponse.json({ files });
} catch (error) {
console.log("Error fetching files:", error);