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:
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user