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>
This commit is contained in:
fuomag9
2026-04-06 15:09:21 +02:00
parent d9fdaba031
commit 23bc2a0476
6 changed files with 56 additions and 4 deletions

View File

@@ -30,7 +30,10 @@ export async function POST(request: NextRequest) {
try {
result = await createApiToken(body.name, userId, body.expires_at ?? undefined);
} catch (e) {
if (e instanceof Error && (e.message.includes("expires_at") || e.message.includes("ISO 8601"))) {
if (e instanceof Error && (
e.message.includes("expires_at") || e.message.includes("ISO 8601") ||
e.message.includes("characters or fewer") || e.message.includes("Maximum of")
)) {
return NextResponse.json({ error: e.message }, { status: 400 });
}
throw e;

View File

@@ -20,6 +20,7 @@ const nextConfig = {
}
},
output: 'standalone',
poweredByHeader: false,
// Security headers (CSP, etc.) are set per-request in proxy.ts middleware
// with a unique nonce, so they are NOT defined here as static headers.
};

View File

@@ -11,6 +11,13 @@ export class ApiAuthError extends Error {
}
}
export class NotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = "NotFoundError";
}
}
export type ApiAuthResult = {
userId: number;
role: string;
@@ -91,6 +98,12 @@ 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 }

View File

@@ -322,7 +322,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
throw new Error(`LINKING_REQUIRED:${linkingId}`);
}
// New OAuth user - create account (defaults to admin role)
// New OAuth user - create account
const newUser = await createUser({
email: user.email,
name: user.name,

View File

@@ -1,7 +1,8 @@
import { createHash, randomBytes } from "node:crypto";
import db, { nowIso, toIso } from "../db";
import { apiTokens } from "../db/schema";
import { eq } from "drizzle-orm";
import { count, eq } from "drizzle-orm";
import { NotFoundError } from "../api-auth";
export type ApiToken = {
id: number;
@@ -29,11 +30,28 @@ function hashToken(rawToken: string): string {
return createHash("sha256").update(rawToken).digest("hex");
}
const MAX_TOKENS_PER_USER = 10;
const MAX_TOKEN_NAME_LENGTH = 100;
export async function createApiToken(
name: string,
createdBy: number,
expiresAt?: string
): Promise<{ token: ApiToken; rawToken: string }> {
const trimmedName = name.trim();
if (trimmedName.length > MAX_TOKEN_NAME_LENGTH) {
throw new Error(`Token name must be ${MAX_TOKEN_NAME_LENGTH} characters or fewer`);
}
// Enforce per-user token limit
const existingCount = await db
.select({ value: count() })
.from(apiTokens)
.where(eq(apiTokens.createdBy, createdBy));
if (existingCount[0] && existingCount[0].value >= MAX_TOKENS_PER_USER) {
throw new Error(`Maximum of ${MAX_TOKENS_PER_USER} API tokens per user`);
}
// Validate expires_at is a valid ISO 8601 date in the future
let validatedExpiresAt: string | null = null;
if (expiresAt) {
@@ -91,7 +109,7 @@ export async function deleteApiToken(id: number, userId: number): Promise<void>
});
if (!token) {
throw new Error("Token not found");
throw new NotFoundError("Token not found");
}
// Check if the user owns the token or is an admin

View File

@@ -6,6 +6,19 @@ import { asc, desc, eq, count, like, or } from "drizzle-orm";
import { type GeoBlockSettings } from "../settings";
import { normalizeProxyHostDomains } from "../proxy-host-domains";
function validateUpstreamProtocol(upstream: string): void {
const trimmed = upstream.trim();
if (!trimmed) return;
// If upstream contains "://", enforce http or https scheme
const schemeMatch = trimmed.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//);
if (schemeMatch) {
const scheme = schemeMatch[1].toLowerCase();
if (scheme !== "http" && scheme !== "https") {
throw new Error(`Invalid upstream protocol "${scheme}://". Only http:// and https:// are allowed`);
}
}
}
const DEFAULT_AUTHENTIK_HEADERS = [
"X-Authentik-Username",
"X-Authentik-Groups",
@@ -1608,6 +1621,7 @@ export async function createProxyHost(input: ProxyHostInput, actorUserId: number
if (!input.upstreams || input.upstreams.length === 0) {
throw new Error("At least one upstream must be specified");
}
input.upstreams.forEach(validateUpstreamProtocol);
const now = nowIso();
const meta = buildMeta({}, input);
@@ -1666,6 +1680,9 @@ export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>
const domains = JSON.stringify(
input.domains ? normalizeProxyHostDomains(input.domains) : existing.domains
);
if (input.upstreams) {
input.upstreams.forEach(validateUpstreamProtocol);
}
const upstreams = input.upstreams ? JSON.stringify(Array.from(new Set(input.upstreams))) : JSON.stringify(existing.upstreams);
const existingMeta: ProxyHostMeta = {
custom_reverse_proxy_json: existing.custom_reverse_proxy_json ?? undefined,