Files
caddy-proxy-manager/src/lib/api-auth.ts
fuomag9 23bc2a0476 Fix security issues found during pentest
- Add per-user API token limit (max 10) and name length validation (max 100 chars)
- Return 404 instead of 500 for "not found" errors in API responses
- Disable X-Powered-By header to prevent framework fingerprinting
- Enforce http/https protocol on proxy host upstream URLs
- Remove stale comment about OAuth users defaulting to admin role

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:09:21 +02:00

112 lines
3.1 KiB
TypeScript

import { type NextRequest, NextResponse } from "next/server";
import { auth, checkSameOrigin } from "./auth";
import { validateToken } from "./models/api-tokens";
export class ApiAuthError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ApiAuthError";
this.status = status;
}
}
export class NotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = "NotFoundError";
}
}
export type ApiAuthResult = {
userId: number;
role: string;
authMethod: "bearer" | "session";
};
export async function authenticateApiRequest(
request: NextRequest
): Promise<ApiAuthResult> {
// Try Bearer token first
const authHeader = request.headers.get("authorization") ?? "";
if (authHeader.startsWith("Bearer ")) {
const rawToken = authHeader.slice(7);
if (!rawToken) {
throw new ApiAuthError("Invalid Bearer token", 401);
}
const result = await validateToken(rawToken);
if (!result) {
throw new ApiAuthError("Invalid or expired API token", 401);
}
return {
userId: result.user.id,
role: result.user.role,
authMethod: "bearer",
};
}
// Fall back to session auth
const session = await auth();
if (!session?.user?.id) {
throw new ApiAuthError("Unauthorized", 401);
}
// Deny access when role is missing rather than defaulting to "user"
const role = session.user.role;
if (!role) {
throw new ApiAuthError("Session missing role claim", 401);
}
return {
userId: Number(session.user.id),
role,
authMethod: "session",
};
}
export async function requireApiUser(request: NextRequest): Promise<ApiAuthResult> {
const result = await authenticateApiRequest(request);
// CSRF check for session-authenticated mutating requests
if (result.authMethod === "session") {
const method = request.method.toUpperCase();
if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
const csrfResponse = checkSameOrigin(request);
if (csrfResponse) {
throw new ApiAuthError("Forbidden", 403);
}
}
}
return result;
}
export async function requireApiAdmin(request: NextRequest): Promise<ApiAuthResult> {
const result = await requireApiUser(request);
if (result.role !== "admin") {
throw new ApiAuthError("Administrator privileges required", 403);
}
return result;
}
/**
* Helper to build an error response from an ApiAuthError or generic error.
*/
export function apiErrorResponse(error: unknown): NextResponse {
if (error instanceof ApiAuthError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
if (error instanceof NotFoundError) {
return NextResponse.json({ error: error.message }, { status: 404 });
}
if (error instanceof Error && error.message.toLowerCase().includes("not found")) {
return NextResponse.json({ error: error.message }, { status: 404 });
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Internal server error" },
{ status: 500 }
);
}