Files
caddy-proxy-manager/src/lib/models/forward-auth.ts
fuomag9 5d0b4837d8 Security hardening: fix SQL injection, WAF bypass, placeholder injection, and more
- C1: Replace all ClickHouse string interpolation with parameterized queries
  (query_params) to eliminate SQL injection in analytics endpoints
- C3: Strip Caddy placeholder patterns from redirect rules, protected paths,
  and Authentik auth endpoint to prevent config injection
- C4: Replace WAF custom directive blocklist with allowlist approach — only
  SecRule/SecAction/SecMarker/SecDefaultAction permitted; block ctl:ruleEngine
  and Include directives
- H2: Validate GCM authentication tag is exactly 16 bytes before decryption
- H3: Validate forward auth redirect URIs (scheme, no credentials) to prevent
  open redirects
- H4: Switch 11 analytics/WAF/geoip endpoints from session-only requireAdmin
  to requireApiAdmin supporting both Bearer token and session auth
- H5: Add input validation for instance-mode (whitelist) and sync-token
  (32-char minimum) in settings API
- M1: Add non-root user to l4-port-manager Dockerfile
- M5: Document Caddy admin API binding security rationale
- Document C2 (custom config injection) and H1 (SSRF via upstreams) as
  intentional admin features

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 12:13:50 +02:00

410 lines
12 KiB
TypeScript

import { createHash, randomBytes } from "node:crypto";
import db, { nowIso, toIso } from "../db";
import { logAuditEvent } from "../audit";
import {
forwardAuthSessions,
forwardAuthExchanges,
forwardAuthAccess,
forwardAuthRedirectIntents,
groupMembers,
} from "../db/schema";
import { and, eq, gt, inArray, lt } from "drizzle-orm";
const DEFAULT_SESSION_TTL = 7 * 24 * 60 * 60; // 7 days in seconds
const EXCHANGE_CODE_TTL = 60; // 60 seconds
const REDIRECT_INTENT_TTL = 10 * 60; // 10 minutes — covers login + OAuth flow time
function hashToken(raw: string): string {
return createHash("sha256").update(raw).digest("hex");
}
// ── Redirect Intents ────────────────────────────────────────────────
// Store redirect URIs server-side so the client only holds an opaque ID.
export async function createRedirectIntent(redirectUri: string): Promise<string> {
// Validate redirect URI to prevent open redirects
let parsed: URL;
try {
parsed = new URL(redirectUri);
} catch {
throw new Error("Invalid redirect URI");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Redirect URI must use http or https scheme");
}
if (parsed.username || parsed.password) {
throw new Error("Redirect URI must not contain credentials");
}
const rid = randomBytes(16).toString("hex");
const ridHash = hashToken(rid);
const now = nowIso();
const expiresAt = new Date(Date.now() + REDIRECT_INTENT_TTL * 1000).toISOString();
await db.insert(forwardAuthRedirectIntents).values({
ridHash,
redirectUri,
expiresAt,
consumed: false,
createdAt: now
});
// Opportunistic cleanup of expired intents
await db
.delete(forwardAuthRedirectIntents)
.where(lt(forwardAuthRedirectIntents.expiresAt, now));
return rid;
}
export async function consumeRedirectIntent(
rid: string
): Promise<string | null> {
const ridHash = hashToken(rid);
const now = nowIso();
// Atomic claim: only succeeds if the intent exists, is unconsumed, and not expired
const claimed = await db
.update(forwardAuthRedirectIntents)
.set({ consumed: true })
.where(
and(
eq(forwardAuthRedirectIntents.ridHash, ridHash),
eq(forwardAuthRedirectIntents.consumed, false),
gt(forwardAuthRedirectIntents.expiresAt, now)
)
)
.returning();
if (claimed.length === 0) return null;
const redirectUri = claimed[0].redirectUri;
// Delete immediately after consumption
await db
.delete(forwardAuthRedirectIntents)
.where(eq(forwardAuthRedirectIntents.id, claimed[0].id));
return redirectUri;
}
// ── Sessions ─────────────────────────────────────────────────────────
export type ForwardAuthSession = {
id: number;
user_id: number;
expires_at: string;
created_at: string;
};
export async function createForwardAuthSession(
userId: number,
ttlSeconds?: number
): Promise<{ rawToken: string; session: ForwardAuthSession }> {
const rawToken = randomBytes(32).toString("hex");
const tokenHash = hashToken(rawToken);
const now = nowIso();
const ttl = ttlSeconds ?? DEFAULT_SESSION_TTL;
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
const [row] = await db
.insert(forwardAuthSessions)
.values({ userId, tokenHash, expiresAt, createdAt: now })
.returning();
if (!row) throw new Error("Failed to create forward auth session");
return {
rawToken,
session: {
id: row.id,
user_id: row.userId,
expires_at: toIso(row.expiresAt)!,
created_at: toIso(row.createdAt)!
}
};
}
export async function validateForwardAuthSession(
rawToken: string
): Promise<{ sessionId: number; userId: number } | null> {
const tokenHash = hashToken(rawToken);
const session = await db.query.forwardAuthSessions.findFirst({
where: (table, operators) => operators.eq(table.tokenHash, tokenHash)
});
if (!session) return null;
if (new Date(session.expiresAt) <= new Date()) return null;
return { sessionId: session.id, userId: session.userId };
}
export async function listForwardAuthSessions(): Promise<ForwardAuthSession[]> {
const rows = await db.query.forwardAuthSessions.findMany({
where: (table, operators) => operators.gt(table.expiresAt, nowIso())
});
return rows.map((r) => ({
id: r.id,
user_id: r.userId,
expires_at: toIso(r.expiresAt)!,
created_at: toIso(r.createdAt)!
}));
}
export async function deleteForwardAuthSession(id: number): Promise<void> {
await db.delete(forwardAuthSessions).where(eq(forwardAuthSessions.id, id));
}
export async function deleteUserForwardAuthSessions(userId: number): Promise<void> {
await db
.delete(forwardAuthSessions)
.where(eq(forwardAuthSessions.userId, userId));
}
// ── Exchange Codes ───────────────────────────────────────────────────
export async function createExchangeCode(
sessionId: number,
redirectUri: string
): Promise<{ rawCode: string }> {
const rawCode = randomBytes(32).toString("hex");
const codeHash = hashToken(rawCode);
const now = nowIso();
const expiresAt = new Date(Date.now() + EXCHANGE_CODE_TTL * 1000).toISOString();
await db.insert(forwardAuthExchanges).values({
sessionId,
codeHash,
sessionToken: "[pending]", // placeholder — fresh token generated at redemption
redirectUri,
expiresAt,
used: false,
createdAt: now
});
return { rawCode };
}
export async function redeemExchangeCode(
rawCode: string
): Promise<{ sessionId: number; redirectUri: string; rawSessionToken: string } | null> {
const codeHash = hashToken(rawCode);
const now = nowIso();
// Atomic claim: only succeeds if the exchange exists, is unused, and not expired
const claimed = await db
.update(forwardAuthExchanges)
.set({ used: true })
.where(
and(
eq(forwardAuthExchanges.codeHash, codeHash),
eq(forwardAuthExchanges.used, false),
gt(forwardAuthExchanges.expiresAt, now)
)
)
.returning();
if (claimed.length === 0) return null;
const exchange = claimed[0];
// Generate a fresh session token (never stored in the exchange table)
const rawToken = randomBytes(32).toString("hex");
const tokenHash = hashToken(rawToken);
await db
.update(forwardAuthSessions)
.set({ tokenHash })
.where(eq(forwardAuthSessions.id, exchange.sessionId));
// Delete the redeemed exchange immediately
await db
.delete(forwardAuthExchanges)
.where(eq(forwardAuthExchanges.id, exchange.id));
return {
sessionId: exchange.sessionId,
redirectUri: exchange.redirectUri,
rawSessionToken: rawToken
};
}
// ── Host Access Control ──────────────────────────────────────────────
export type ForwardAuthAccessEntry = {
id: number;
proxy_host_id: number;
user_id: number | null;
group_id: number | null;
created_at: string;
};
export async function checkHostAccess(
userId: number,
proxyHostId: number
): Promise<boolean> {
const user = await db.query.users.findFirst({
where: (table, operators) => operators.eq(table.id, userId)
});
if (!user) return false;
// Check direct user access
const directAccess = await db.query.forwardAuthAccess.findFirst({
where: (table, operators) =>
operators.and(
operators.eq(table.proxyHostId, proxyHostId),
operators.eq(table.userId, userId)
)
});
if (directAccess) return true;
// Check group-based access
const userGroupIds = await db
.select({ groupId: groupMembers.groupId })
.from(groupMembers)
.where(eq(groupMembers.userId, userId));
if (userGroupIds.length === 0) return false;
const groupIds = userGroupIds.map((r) => r.groupId);
const groupAccess = await db.query.forwardAuthAccess.findFirst({
where: (table, operators) =>
operators.and(
operators.eq(table.proxyHostId, proxyHostId),
inArray(table.groupId, groupIds)
)
});
return !!groupAccess;
}
export async function checkHostAccessByDomain(
userId: number,
host: string
): Promise<{ hasAccess: boolean; proxyHostId: number | null }> {
// Find proxy host(s) that contain this domain
const allHosts = await db.query.proxyHosts.findMany({
where: (table, operators) => operators.eq(table.enabled, true)
});
for (const ph of allHosts) {
let parsed: string[];
try {
parsed = JSON.parse(ph.domains);
} catch {
continue;
}
if (parsed.some((d) => d.toLowerCase() === host.toLowerCase())) {
const hasAccess = await checkHostAccess(userId, ph.id);
return { hasAccess, proxyHostId: ph.id };
}
}
// Host not found in any proxy host — deny by default
return { hasAccess: false, proxyHostId: null };
}
export async function getForwardAuthAccessForHost(
proxyHostId: number
): Promise<ForwardAuthAccessEntry[]> {
const rows = await db
.select()
.from(forwardAuthAccess)
.where(eq(forwardAuthAccess.proxyHostId, proxyHostId));
return rows.map((r) => ({
id: r.id,
proxy_host_id: r.proxyHostId,
user_id: r.userId,
group_id: r.groupId,
created_at: toIso(r.createdAt)!
}));
}
export async function setForwardAuthAccess(
proxyHostId: number,
access: { userIds?: number[]; groupIds?: number[] },
actorUserId: number
): Promise<ForwardAuthAccessEntry[]> {
// Delete existing access for this host
await db
.delete(forwardAuthAccess)
.where(eq(forwardAuthAccess.proxyHostId, proxyHostId));
const now = nowIso();
const values: Array<{
proxyHostId: number;
userId: number | null;
groupId: number | null;
createdAt: string;
}> = [];
for (const uid of access.userIds ?? []) {
values.push({ proxyHostId, userId: uid, groupId: null, createdAt: now });
}
for (const gid of access.groupIds ?? []) {
values.push({ proxyHostId, userId: null, groupId: gid, createdAt: now });
}
if (values.length > 0) {
await db.insert(forwardAuthAccess).values(values);
}
logAuditEvent({
userId: actorUserId,
action: "update",
entityType: "forward_auth_access",
entityId: proxyHostId,
summary: `Updated forward auth access for proxy host ${proxyHostId}`
});
return getForwardAuthAccessForHost(proxyHostId);
}
// ── Domain Validation ────────────────────────────────────────────────
export async function isForwardAuthDomain(host: string): Promise<boolean> {
const allHosts = await db.query.proxyHosts.findMany({
where: (table, operators) => operators.eq(table.enabled, true)
});
for (const ph of allHosts) {
let parsed: string[];
try {
parsed = JSON.parse(ph.domains);
} catch {
continue;
}
if (parsed.some((d) => d.toLowerCase() === host.toLowerCase())) {
// Check that this host actually has forward auth enabled
let parsedMeta: Record<string, unknown>;
try {
parsedMeta = ph.meta ? JSON.parse(ph.meta) : {};
} catch {
continue;
}
const fa = parsedMeta.cpm_forward_auth as Record<string, unknown> | undefined;
if (fa?.enabled) return true;
}
}
return false;
}
// ── Cleanup ──────────────────────────────────────────────────────────
export async function cleanupExpiredSessions(): Promise<number> {
const now = nowIso();
// Delete expired exchanges first (FK constraint)
await db
.delete(forwardAuthExchanges)
.where(lt(forwardAuthExchanges.expiresAt, now));
// Delete expired sessions
const result = await db
.delete(forwardAuthSessions)
.where(lt(forwardAuthSessions.expiresAt, now))
.returning();
return result.length;
}