diff --git a/app/api/v1/tokens/route.ts b/app/api/v1/tokens/route.ts index c3a6111f..651b671f 100644 --- a/app/api/v1/tokens/route.ts +++ b/app/api/v1/tokens/route.ts @@ -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; diff --git a/next.config.mjs b/next.config.mjs index 0cbd1dfb..843d443b 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -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. }; diff --git a/src/lib/api-auth.ts b/src/lib/api-auth.ts index 30ee7c91..7551f5b0 100644 --- a/src/lib/api-auth.ts +++ b/src/lib/api-auth.ts @@ -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 } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 32cd1566..bbeb03fd 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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, diff --git a/src/lib/models/api-tokens.ts b/src/lib/models/api-tokens.ts index 04715aea..782c47b4 100644 --- a/src/lib/models/api-tokens.ts +++ b/src/lib/models/api-tokens.ts @@ -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 }); 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 diff --git a/src/lib/models/proxy-hosts.ts b/src/lib/models/proxy-hosts.ts index 46f5c87c..e7fc91b3 100644 --- a/src/lib/models/proxy-hosts.ts +++ b/src/lib/models/proxy-hosts.ts @@ -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 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,