diff --git a/app/api/analytics/blocked/route.ts b/app/api/analytics/blocked/route.ts index 27911502..e89dd102 100644 --- a/app/api/analytics/blocked/route.ts +++ b/app/api/analytics/blocked/route.ts @@ -1,16 +1,20 @@ import { NextRequest, NextResponse } from 'next/server'; -import { requireAdmin } from '@/src/lib/auth'; +import { requireApiAdmin, apiErrorResponse } from '@/src/lib/api-auth'; import { getAnalyticsBlocked, INTERVAL_SECONDS } from '@/src/lib/analytics-db'; export async function GET(req: NextRequest) { - await requireAdmin(); - const { searchParams } = req.nextUrl; - const hostsParam = searchParams.get('hosts') ?? ''; - const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; - const page = parseInt(searchParams.get('page') ?? '1', 10); - const { from, to } = resolveRange(searchParams); - const data = await getAnalyticsBlocked(from, to, hosts, page); - return NextResponse.json(data); + try { + await requireApiAdmin(req); + const { searchParams } = req.nextUrl; + const hostsParam = searchParams.get('hosts') ?? ''; + const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; + const page = parseInt(searchParams.get('page') ?? '1', 10); + const { from, to } = resolveRange(searchParams); + const data = await getAnalyticsBlocked(from, to, hosts, page); + return NextResponse.json(data); + } catch (error) { + return apiErrorResponse(error); + } } function resolveRange(params: URLSearchParams): { from: number; to: number } { diff --git a/app/api/analytics/countries/route.ts b/app/api/analytics/countries/route.ts index 28ccd3e6..49a955d9 100644 --- a/app/api/analytics/countries/route.ts +++ b/app/api/analytics/countries/route.ts @@ -1,15 +1,19 @@ import { NextRequest, NextResponse } from 'next/server'; -import { requireAdmin } from '@/src/lib/auth'; +import { requireApiAdmin, apiErrorResponse } from '@/src/lib/api-auth'; import { getAnalyticsCountries, INTERVAL_SECONDS } from '@/src/lib/analytics-db'; export async function GET(req: NextRequest) { - await requireAdmin(); - const { searchParams } = req.nextUrl; - const hostsParam = searchParams.get('hosts') ?? ''; - const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; - const { from, to } = resolveRange(searchParams); - const data = await getAnalyticsCountries(from, to, hosts); - return NextResponse.json(data); + try { + await requireApiAdmin(req); + const { searchParams } = req.nextUrl; + const hostsParam = searchParams.get('hosts') ?? ''; + const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; + const { from, to } = resolveRange(searchParams); + const data = await getAnalyticsCountries(from, to, hosts); + return NextResponse.json(data); + } catch (error) { + return apiErrorResponse(error); + } } function resolveRange(params: URLSearchParams): { from: number; to: number } { diff --git a/app/api/analytics/hosts/route.ts b/app/api/analytics/hosts/route.ts index 38fa9815..50889e5a 100644 --- a/app/api/analytics/hosts/route.ts +++ b/app/api/analytics/hosts/route.ts @@ -1,9 +1,13 @@ -import { NextResponse } from 'next/server'; -import { requireAdmin } from '@/src/lib/auth'; +import { NextRequest, NextResponse } from 'next/server'; +import { requireApiAdmin, apiErrorResponse } from '@/src/lib/api-auth'; import { getAnalyticsHosts } from '@/src/lib/analytics-db'; -export async function GET() { - await requireAdmin(); - const hosts = await getAnalyticsHosts(); - return NextResponse.json(hosts); +export async function GET(request: NextRequest) { + try { + await requireApiAdmin(request); + const hosts = await getAnalyticsHosts(); + return NextResponse.json(hosts); + } catch (error) { + return apiErrorResponse(error); + } } diff --git a/app/api/analytics/protocols/route.ts b/app/api/analytics/protocols/route.ts index 1cfa11af..6bd59e04 100644 --- a/app/api/analytics/protocols/route.ts +++ b/app/api/analytics/protocols/route.ts @@ -1,15 +1,19 @@ import { NextRequest, NextResponse } from 'next/server'; -import { requireAdmin } from '@/src/lib/auth'; +import { requireApiAdmin, apiErrorResponse } from '@/src/lib/api-auth'; import { getAnalyticsProtocols, INTERVAL_SECONDS } from '@/src/lib/analytics-db'; export async function GET(req: NextRequest) { - await requireAdmin(); - const { searchParams } = req.nextUrl; - const hostsParam = searchParams.get('hosts') ?? ''; - const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; - const { from, to } = resolveRange(searchParams); - const data = await getAnalyticsProtocols(from, to, hosts); - return NextResponse.json(data); + try { + await requireApiAdmin(req); + const { searchParams } = req.nextUrl; + const hostsParam = searchParams.get('hosts') ?? ''; + const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; + const { from, to } = resolveRange(searchParams); + const data = await getAnalyticsProtocols(from, to, hosts); + return NextResponse.json(data); + } catch (error) { + return apiErrorResponse(error); + } } function resolveRange(params: URLSearchParams): { from: number; to: number } { diff --git a/app/api/analytics/summary/route.ts b/app/api/analytics/summary/route.ts index 6fb643cc..9e76eaa0 100644 --- a/app/api/analytics/summary/route.ts +++ b/app/api/analytics/summary/route.ts @@ -1,15 +1,19 @@ import { NextRequest, NextResponse } from 'next/server'; -import { requireAdmin } from '@/src/lib/auth'; +import { requireApiAdmin, apiErrorResponse } from '@/src/lib/api-auth'; import { getAnalyticsSummary, INTERVAL_SECONDS } from '@/src/lib/analytics-db'; export async function GET(req: NextRequest) { - await requireAdmin(); - const { searchParams } = req.nextUrl; - const hostsParam = searchParams.get('hosts') ?? ''; - const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; - const { from, to } = resolveRange(searchParams); - const data = await getAnalyticsSummary(from, to, hosts); - return NextResponse.json(data); + try { + await requireApiAdmin(req); + const { searchParams } = req.nextUrl; + const hostsParam = searchParams.get('hosts') ?? ''; + const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; + const { from, to } = resolveRange(searchParams); + const data = await getAnalyticsSummary(from, to, hosts); + return NextResponse.json(data); + } catch (error) { + return apiErrorResponse(error); + } } function resolveRange(params: URLSearchParams): { from: number; to: number } { diff --git a/app/api/analytics/timeline/route.ts b/app/api/analytics/timeline/route.ts index d2fd058f..a7ca4a1b 100644 --- a/app/api/analytics/timeline/route.ts +++ b/app/api/analytics/timeline/route.ts @@ -1,15 +1,19 @@ import { NextRequest, NextResponse } from 'next/server'; -import { requireAdmin } from '@/src/lib/auth'; +import { requireApiAdmin, apiErrorResponse } from '@/src/lib/api-auth'; import { getAnalyticsTimeline, INTERVAL_SECONDS } from '@/src/lib/analytics-db'; export async function GET(req: NextRequest) { - await requireAdmin(); - const { searchParams } = req.nextUrl; - const hostsParam = searchParams.get('hosts') ?? ''; - const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; - const { from, to } = resolveRange(searchParams); - const data = await getAnalyticsTimeline(from, to, hosts); - return NextResponse.json(data); + try { + await requireApiAdmin(req); + const { searchParams } = req.nextUrl; + const hostsParam = searchParams.get('hosts') ?? ''; + const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; + const { from, to } = resolveRange(searchParams); + const data = await getAnalyticsTimeline(from, to, hosts); + return NextResponse.json(data); + } catch (error) { + return apiErrorResponse(error); + } } function resolveRange(params: URLSearchParams): { from: number; to: number } { diff --git a/app/api/analytics/user-agents/route.ts b/app/api/analytics/user-agents/route.ts index 86e6a055..fbcb3fb5 100644 --- a/app/api/analytics/user-agents/route.ts +++ b/app/api/analytics/user-agents/route.ts @@ -1,15 +1,19 @@ import { NextRequest, NextResponse } from 'next/server'; -import { requireAdmin } from '@/src/lib/auth'; +import { requireApiAdmin, apiErrorResponse } from '@/src/lib/api-auth'; import { getAnalyticsUserAgents, INTERVAL_SECONDS } from '@/src/lib/analytics-db'; export async function GET(req: NextRequest) { - await requireAdmin(); - const { searchParams } = req.nextUrl; - const hostsParam = searchParams.get('hosts') ?? ''; - const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; - const { from, to } = resolveRange(searchParams); - const data = await getAnalyticsUserAgents(from, to, hosts); - return NextResponse.json(data); + try { + await requireApiAdmin(req); + const { searchParams } = req.nextUrl; + const hostsParam = searchParams.get('hosts') ?? ''; + const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; + const { from, to } = resolveRange(searchParams); + const data = await getAnalyticsUserAgents(from, to, hosts); + return NextResponse.json(data); + } catch (error) { + return apiErrorResponse(error); + } } function resolveRange(params: URLSearchParams): { from: number; to: number } { diff --git a/app/api/analytics/waf-stats/route.ts b/app/api/analytics/waf-stats/route.ts index 2ce559f8..f64e8ca8 100644 --- a/app/api/analytics/waf-stats/route.ts +++ b/app/api/analytics/waf-stats/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { requireAdmin } from '@/src/lib/auth'; +import { requireApiAdmin, apiErrorResponse } from '@/src/lib/api-auth'; import { INTERVAL_SECONDS } from '@/src/lib/analytics-db'; import { countWafEventsInRange, getTopWafRulesWithHosts, getWafEventCountries } from '@/src/lib/models/waf-events'; @@ -16,12 +16,16 @@ function resolveRange(params: URLSearchParams): { from: number; to: number } { } export async function GET(req: NextRequest) { - await requireAdmin(); - const { from, to } = resolveRange(req.nextUrl.searchParams); - const [total, topRules, byCountry] = await Promise.all([ - countWafEventsInRange(from, to), - getTopWafRulesWithHosts(from, to, 10), - getWafEventCountries(from, to), - ]); - return NextResponse.json({ total, topRules, byCountry }); + try { + await requireApiAdmin(req); + const { from, to } = resolveRange(req.nextUrl.searchParams); + const [total, topRules, byCountry] = await Promise.all([ + countWafEventsInRange(from, to), + getTopWafRulesWithHosts(from, to, 10), + getWafEventCountries(from, to), + ]); + return NextResponse.json({ total, topRules, byCountry }); + } catch (error) { + return apiErrorResponse(error); + } } diff --git a/app/api/geoip-status/route.ts b/app/api/geoip-status/route.ts index bedcce15..79df8651 100644 --- a/app/api/geoip-status/route.ts +++ b/app/api/geoip-status/route.ts @@ -1,14 +1,18 @@ import { existsSync } from "node:fs"; -import { NextResponse } from "next/server"; -import { requireAdmin } from "@/src/lib/auth"; +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; const COUNTRY_DB = "/usr/share/GeoIP/GeoLite2-Country.mmdb"; const ASN_DB = "/usr/share/GeoIP/GeoLite2-ASN.mmdb"; -export async function GET() { - await requireAdmin(); - return NextResponse.json({ - country: existsSync(COUNTRY_DB), - asn: existsSync(ASN_DB), - }); +export async function GET(request: NextRequest) { + try { + await requireApiAdmin(request); + return NextResponse.json({ + country: existsSync(COUNTRY_DB), + asn: existsSync(ASN_DB), + }); + } catch (error) { + return apiErrorResponse(error); + } } diff --git a/app/api/l4-ports/route.ts b/app/api/l4-ports/route.ts index 9051eaf5..04dde053 100644 --- a/app/api/l4-ports/route.ts +++ b/app/api/l4-ports/route.ts @@ -1,20 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; -import { requireAdmin, checkSameOrigin } from "@/src/lib/auth"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; import { getL4PortsDiff, getL4PortsStatus, applyL4Ports } from "@/src/lib/l4-ports"; /** * GET /api/l4-ports — returns current port diff and apply status. */ -export async function GET() { +export async function GET(request: NextRequest) { try { - await requireAdmin(); + await requireApiAdmin(request); const [diff, status] = await Promise.all([ getL4PortsDiff(), getL4PortsStatus(), ]); return NextResponse.json({ diff, status }); - } catch { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } catch (error) { + return apiErrorResponse(error); } } @@ -22,18 +22,11 @@ export async function GET() { * POST /api/l4-ports — trigger port apply (write override + trigger file). */ export async function POST(request: NextRequest) { - const originCheck = checkSameOrigin(request); - if (originCheck) return originCheck; - try { - await requireAdmin(); + await requireApiAdmin(request); const status = await applyL4Ports(); return NextResponse.json({ status }); } catch (error) { - console.error("Failed to apply L4 ports:", error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Failed to apply L4 ports" }, - { status: 500 } - ); + return apiErrorResponse(error); } } diff --git a/app/api/v1/settings/[group]/route.ts b/app/api/v1/settings/[group]/route.ts index 61527742..bab37ae3 100644 --- a/app/api/v1/settings/[group]/route.ts +++ b/app/api/v1/settings/[group]/route.ts @@ -72,11 +72,25 @@ export async function PUT( const body = await request.json(); if (group === "instance-mode") { + const validModes = ["standalone", "master", "slave"]; + if (!validModes.includes(body.mode)) { + return NextResponse.json( + { error: `Invalid mode. Must be one of: ${validModes.join(", ")}` }, + { status: 400 } + ); + } await setInstanceMode(body.mode); return NextResponse.json({ ok: true }); } if (group === "sync-token") { + if (body.token !== null && body.token !== undefined && + (typeof body.token !== "string" || body.token.length < 32)) { + return NextResponse.json( + { error: "Token must be null or a string of at least 32 characters" }, + { status: 400 } + ); + } await setSlaveMasterToken(body.token ?? null); return NextResponse.json({ ok: true }); } diff --git a/app/api/waf-events/route.ts b/app/api/waf-events/route.ts index 52d13ae5..efa05c9e 100644 --- a/app/api/waf-events/route.ts +++ b/app/api/waf-events/route.ts @@ -1,10 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import { requireAdmin } from "@/src/lib/auth"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; import { listWafEvents, countWafEvents } from "@/src/lib/models/waf-events"; export async function GET(request: NextRequest) { try { - await requireAdmin(); + await requireApiAdmin(request); const { searchParams } = request.nextUrl; const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10) || 1); const perPage = Math.min(200, Math.max(1, parseInt(searchParams.get("per_page") ?? "50", 10) || 50)); @@ -17,7 +17,7 @@ export async function GET(request: NextRequest) { ]); return NextResponse.json({ events, total, page, perPage }); - } catch { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } catch (error) { + return apiErrorResponse(error); } } diff --git a/docker/caddy/Caddyfile b/docker/caddy/Caddyfile index 9e72c694..a085211b 100644 --- a/docker/caddy/Caddyfile +++ b/docker/caddy/Caddyfile @@ -1,4 +1,7 @@ { + # Bound to 0.0.0.0 within the Docker network so the web container can reach it. + # Port 2019 must NOT be published to the host in docker-compose.yml. + # The origins directive restricts which Host header values are accepted. admin 0.0.0.0:2019 { origins caddy:2019 localhost:2019 localhost } diff --git a/docker/l4-port-manager/Dockerfile b/docker/l4-port-manager/Dockerfile index 240ca894..7a535dbb 100644 --- a/docker/l4-port-manager/Dockerfile +++ b/docker/l4-port-manager/Dockerfile @@ -6,4 +6,7 @@ RUN apk add --no-cache bash COPY docker/l4-port-manager/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh +RUN addgroup -g 1001 -S appgroup && adduser -u 1001 -S appuser -G appgroup +USER appuser + ENTRYPOINT ["/entrypoint.sh"] diff --git a/src/lib/caddy-waf.ts b/src/lib/caddy-waf.ts index 692a861d..2d6daced 100644 --- a/src/lib/caddy-waf.ts +++ b/src/lib/caddy-waf.ts @@ -132,21 +132,39 @@ export function buildWafHandler(waf: WafSettings, allowWebsocket = false): Recor 'SecResponseBodyAccess Off', ); - // Block dangerous engine-level overrides in custom directives + // Allowlist approach: only permit known-safe directive prefixes in custom directives if (waf.custom_directives?.trim()) { const directives = waf.custom_directives.trim(); - const forbiddenPatterns = [ - /^\s*SecRuleEngine\s/im, - /^\s*SecAuditEngine\s/im, - /^\s*SecAuditLog\s/im, - /^\s*SecAuditLogFormat\s/im, - /^\s*SecResponseBodyAccess\s/im, + const allowedPrefixes = [ + /^SecRule\s/, + /^SecAction\s/, + /^SecMarker\s/, + /^SecDefaultAction\s/, + ]; + // SecRule* variants that are NOT plain SecRule (must be rejected) + const blockedSecRulePrefixes = [ + /^SecRuleEngine\s/i, + /^SecRuleRemoveById\s/i, + /^SecRuleRemoveByTag\s/i, + /^SecRuleRemoveByMsg\s/i, + /^SecRuleUpdateActionById\s/i, + /^SecRuleUpdateTargetById\s/i, ]; const lines = directives.split('\n'); const safeLines = lines.filter(line => { const trimmed = line.trim(); + // Allow empty lines and comments if (!trimmed || trimmed.startsWith('#')) return true; - return !forbiddenPatterns.some(pattern => pattern.test(trimmed)); + // Reject Include directives (prevents file inclusion from container filesystem) + if (/^Include\s/i.test(trimmed)) return false; + // Check against allowlist + const matchesAllowed = allowedPrefixes.some(pattern => pattern.test(trimmed)); + if (!matchesAllowed) return false; + // Reject blocked SecRule* variants (e.g. SecRuleEngine) + if (blockedSecRulePrefixes.some(pattern => pattern.test(trimmed))) return false; + // Reject ctl:ruleEngine inside allowed lines (can conditionally disable WAF) + if (/ctl:ruleEngine/i.test(trimmed)) return false; + return true; }); if (safeLines.length > 0) { parts.push(safeLines.join('\n')); diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts index 07403a5a..96d9ca64 100644 --- a/src/lib/caddy.ts +++ b/src/lib/caddy.ts @@ -916,6 +916,9 @@ async function buildProxyRoutes( } } + // Security: This field allows admins to inject arbitrary Caddy reverse_proxy config. + // This is intentional — admins have full control of the proxy configuration. + // Prototype pollution is prevented by mergeDeep blocking __proto__/constructor/prototype. const customReverseProxy = parseOptionalJson(meta.custom_reverse_proxy_json); if (customReverseProxy) { if (isPlainObject(customReverseProxy)) { @@ -937,6 +940,9 @@ async function buildProxyRoutes( } } + // Security: This field allows admins to inject arbitrary Caddy HTTP handlers. + // This is intentional — admins can add any handler (file_server, rewrite, etc.) + // before the reverse_proxy handler in the chain. const customHandlers = parseCustomHandlers(meta.custom_pre_handlers_json); if (customHandlers.length > 0) { handlers.push(...customHandlers); diff --git a/src/lib/clickhouse/client.ts b/src/lib/clickhouse/client.ts index ee65e95a..337fae5a 100644 --- a/src/lib/clickhouse/client.ts +++ b/src/lib/clickhouse/client.ts @@ -7,6 +7,11 @@ const CH_USER = process.env.CLICKHOUSE_USER ?? 'cpm'; const CH_PASS = process.env.CLICKHOUSE_PASSWORD ?? ''; const CH_DB = process.env.CLICKHOUSE_DB ?? 'analytics'; +// Validate CH_DB is a safe identifier (alphanumeric + underscore only) +if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(CH_DB)) { + throw new Error(`CLICKHOUSE_DB contains invalid characters: ${CH_DB}`); +} + // ── Singleton client ──────────────────────────────────────────────────────── let client: ClickHouseClient | null = null; @@ -71,7 +76,6 @@ SETTINGS index_granularity = 8192 export async function initClickHouse(): Promise { const ch = getClient(); - // Ensure database exists (the default user may need to create it) await ch.command({ query: `CREATE DATABASE IF NOT EXISTS ${CH_DB}` }); await ch.command({ query: TRAFFIC_EVENTS_DDL }); await ch.command({ query: WAF_EVENTS_DDL }); @@ -137,26 +141,48 @@ export async function insertWafEvents(rows: WafEventRow[]): Promise { await ch.insert({ table: 'waf_events', values, format: 'JSONEachRow' }); } -// ── Query helpers ─────────────────────────────────────────────────────────── +// ── Parameterized query helpers ───────────────────────────────────────────── -function hostFilter(hosts: string[]): string { - if (hosts.length === 0) return ''; - const escaped = hosts.map(h => `'${h.replace(/'/g, "\\'")}'`).join(','); - return ` AND host IN (${escaped})`; +type QueryParams = Record; + +/** + * Build a host filter clause using parameterized query placeholders. + * Returns the SQL fragment and the params to merge into query_params. + */ +function hostFilter(hosts: string[]): { sql: string; params: QueryParams } { + if (hosts.length === 0) return { sql: '', params: {} }; + const params: QueryParams = {}; + const placeholders: string[] = []; + hosts.forEach((h, i) => { + const key = `host_${i}`; + params[key] = h; + placeholders.push(`{${key}:String}`); + }); + return { sql: ` AND host IN (${placeholders.join(',')})`, params }; } -function timeFilter(from: number, to: number): string { - return `ts >= toDateTime(${from}) AND ts <= toDateTime(${to})`; +function timeFilter(): string { + return `ts >= toDateTime({p_from:UInt32}) AND ts <= toDateTime({p_to:UInt32})`; } -async function queryRows(query: string): Promise { +function timeParams(from: number, to: number): QueryParams { + return { p_from: safeUint(from), p_to: safeUint(to) }; +} + +/** Clamp a number to a safe non-negative integer (guards against NaN/Infinity). */ +function safeUint(n: number): number { + if (!Number.isFinite(n) || n < 0) return 0; + return Math.floor(n); +} + +async function queryRows(query: string, query_params?: QueryParams): Promise { const ch = getClient(); - const result = await ch.query({ query, format: 'JSONEachRow' }); + const result = await ch.query({ query, query_params, format: 'JSONEachRow' }); return result.json(); } -async function queryRow(query: string): Promise { - const rows = await queryRows(query); +async function queryRow(query: string, query_params?: QueryParams): Promise { + const rows = await queryRows(query, query_params); return rows[0] ?? null; } @@ -172,6 +198,7 @@ export interface AnalyticsSummary { export async function querySummary(from: number, to: number, hosts: string[]): Promise { const hf = hostFilter(hosts); + const tp = timeParams(from, to); const traffic = await queryRow<{ total: string; unique_ips: string; blocked: string; bytes: string }>(` SELECT @@ -180,14 +207,14 @@ export async function querySummary(from: number, to: number, hosts: string[]): P countIf(is_blocked) AS blocked, sum(bytes_sent) AS bytes FROM traffic_events - WHERE ${timeFilter(from, to)}${hf} - `); + WHERE ${timeFilter()}${hf.sql} + `, { ...tp, ...hf.params }); const wafRow = await queryRow<{ waf_blocked: string }>(` SELECT count() AS waf_blocked FROM waf_events - WHERE ${timeFilter(from, to)} AND blocked = true${hf} - `); + WHERE ${timeFilter()} AND blocked = true${hf.sql} + `, { ...tp, ...hf.params }); const total = Number(traffic?.total ?? 0); const geoBlocked = Number(traffic?.blocked ?? 0); @@ -220,17 +247,18 @@ export function bucketSizeForDuration(seconds: number): number { export async function queryTimeline(from: number, to: number, hosts: string[]): Promise { const bucketSize = bucketSizeForDuration(to - from); const hf = hostFilter(hosts); + const tp = timeParams(from, to); const rows = await queryRows<{ bucket: string; total: string; blocked: string }>(` SELECT - intDiv(toUInt32(ts), ${bucketSize}) AS bucket, + intDiv(toUInt32(ts), {p_bucket:UInt32}) AS bucket, count() AS total, countIf(is_blocked) AS blocked FROM traffic_events - WHERE ${timeFilter(from, to)}${hf} + WHERE ${timeFilter()}${hf.sql} GROUP BY bucket ORDER BY bucket - `); + `, { ...tp, ...hf.params, p_bucket: safeUint(bucketSize) }); return rows.map(r => ({ ts: Number(r.bucket) * bucketSize, @@ -247,6 +275,7 @@ export interface CountryStats { export async function queryCountries(from: number, to: number, hosts: string[]): Promise { const hf = hostFilter(hosts); + const tp = timeParams(from, to); const rows = await queryRows<{ country_code: string | null; total: string; blocked: string }>(` SELECT @@ -254,10 +283,10 @@ export async function queryCountries(from: number, to: number, hosts: string[]): count() AS total, countIf(is_blocked) AS blocked FROM traffic_events - WHERE ${timeFilter(from, to)}${hf} + WHERE ${timeFilter()}${hf.sql} GROUP BY country_code ORDER BY total DESC - `); + `, { ...tp, ...hf.params }); return rows.map(r => ({ countryCode: r.country_code ?? 'XX', @@ -274,16 +303,17 @@ export interface ProtoStats { export async function queryProtocols(from: number, to: number, hosts: string[]): Promise { const hf = hostFilter(hosts); + const tp = timeParams(from, to); const rows = await queryRows<{ proto: string; count: string }>(` SELECT proto, count() AS count FROM traffic_events - WHERE ${timeFilter(from, to)}${hf} + WHERE ${timeFilter()}${hf.sql} GROUP BY proto ORDER BY count DESC - `); + `, { ...tp, ...hf.params }); const total = rows.reduce((s, r) => s + Number(r.count), 0); @@ -302,17 +332,18 @@ export interface UAStats { export async function queryUserAgents(from: number, to: number, hosts: string[]): Promise { const hf = hostFilter(hosts); + const tp = timeParams(from, to); const rows = await queryRows<{ user_agent: string; count: string }>(` SELECT user_agent, count() AS count FROM traffic_events - WHERE ${timeFilter(from, to)}${hf} + WHERE ${timeFilter()}${hf.sql} GROUP BY user_agent ORDER BY count DESC LIMIT 10 - `); + `, { ...tp, ...hf.params }); const total = rows.reduce((s, r) => s + Number(r.count), 0); @@ -344,12 +375,14 @@ export interface BlockedPage { export async function queryBlocked(from: number, to: number, hosts: string[], page: number): Promise { const pageSize = 10; const hf = hostFilter(hosts); - const where = `${timeFilter(from, to)} AND is_blocked = true${hf}`; + const tp = timeParams(from, to); + const whereSQL = `${timeFilter()} AND is_blocked = true${hf.sql}`; + const params = { ...tp, ...hf.params }; - const totalRow = await queryRow<{ total: string }>(`SELECT count() AS total FROM traffic_events WHERE ${where}`); + const totalRow = await queryRow<{ total: string }>(`SELECT count() AS total FROM traffic_events WHERE ${whereSQL}`, params); const total = Number(totalRow?.total ?? 0); const pages = Math.max(1, Math.ceil(total / pageSize)); - const safePage = Math.min(Math.max(1, page), pages); + const safePage = Math.min(Math.max(1, Number.isFinite(page) ? page : 1), pages); const rows = await queryRows<{ ts: string; client_ip: string; country_code: string | null; @@ -357,10 +390,10 @@ export async function queryBlocked(from: number, to: number, hosts: string[], pa }>(` SELECT toUInt32(ts) AS ts, client_ip, country_code, method, uri, status, host FROM traffic_events - WHERE ${where} + WHERE ${whereSQL} ORDER BY ts DESC - LIMIT ${pageSize} OFFSET ${(safePage - 1) * pageSize} - `); + LIMIT {p_limit:UInt32} OFFSET {p_offset:UInt32} + `, { ...params, p_limit: pageSize, p_offset: (safePage - 1) * pageSize }); return { events: rows.map((r, i) => ({ @@ -387,23 +420,28 @@ export async function queryDistinctHosts(): Promise { // ── WAF analytics queries ─────────────────────────────────────────────────── export async function queryWafCount(from: number, to: number): Promise { + const tp = timeParams(from, to); const row = await queryRow<{ value: string }>(` - SELECT count() AS value FROM waf_events WHERE ${timeFilter(from, to)} - `); + SELECT count() AS value FROM waf_events WHERE ${timeFilter()} + `, tp); return Number(row?.value ?? 0); } export async function queryWafCountWithSearch(search?: string): Promise { - const where = search ? wafSearchFilter(search) : '1=1'; - const row = await queryRow<{ value: string }>(`SELECT count() AS value FROM waf_events WHERE ${where}`); + if (!search) { + const row = await queryRow<{ value: string }>(`SELECT count() AS value FROM waf_events`); + return Number(row?.value ?? 0); + } + const row = await queryRow<{ value: string }>(` + SELECT count() AS value FROM waf_events + WHERE host ILIKE {p_search:String} + OR client_ip ILIKE {p_search:String} + OR uri ILIKE {p_search:String} + OR rule_message ILIKE {p_search:String} + `, { p_search: `%${search}%` }); return Number(row?.value ?? 0); } -function wafSearchFilter(search: string): string { - const escaped = search.replace(/'/g, "\\'"); - return `(host ILIKE '%${escaped}%' OR client_ip ILIKE '%${escaped}%' OR uri ILIKE '%${escaped}%' OR rule_message ILIKE '%${escaped}%')`; -} - export interface TopWafRule { ruleId: number; count: number; @@ -411,17 +449,18 @@ export interface TopWafRule { } export async function queryTopWafRules(from: number, to: number, limit = 10): Promise { + const tp = timeParams(from, to); const rows = await queryRows<{ rule_id: string; count: string; message: string | null }>(` SELECT rule_id, count() AS count, any(rule_message) AS message FROM waf_events - WHERE ${timeFilter(from, to)} AND rule_id IS NOT NULL + WHERE ${timeFilter()} AND rule_id IS NOT NULL GROUP BY rule_id ORDER BY count DESC - LIMIT ${limit} - `); + LIMIT {p_limit:UInt32} + `, { ...tp, p_limit: safeUint(limit) }); return rows .filter(r => r.rule_id != null) @@ -439,14 +478,24 @@ export async function queryTopWafRulesWithHosts(from: number, to: number, limit const topRules = await queryTopWafRules(from, to, limit); if (topRules.length === 0) return []; - const ruleIds = topRules.map(r => r.ruleId).join(','); + // Rule IDs come from ClickHouse query results — they are integers, safe for IN clause + const ruleIds = topRules.map(r => r.ruleId); + const tp = timeParams(from, to); + const ruleParams: QueryParams = {}; + const rulePlaceholders: string[] = []; + ruleIds.forEach((id, i) => { + const key = `rid_${i}`; + ruleParams[key] = id; + rulePlaceholders.push(`{${key}:Int32}`); + }); + const hostRows = await queryRows<{ rule_id: string; host: string; count: string }>(` SELECT rule_id, host, count() AS count FROM waf_events - WHERE ${timeFilter(from, to)} AND rule_id IN (${ruleIds}) + WHERE ${timeFilter()} AND rule_id IN (${rulePlaceholders.join(',')}) GROUP BY rule_id, host ORDER BY count DESC - `); + `, { ...tp, ...ruleParams }); return topRules.map(rule => ({ ...rule, @@ -457,24 +506,32 @@ export async function queryTopWafRulesWithHosts(from: number, to: number, limit } export async function queryWafCountries(from: number, to: number): Promise<{ countryCode: string; count: number }[]> { + const tp = timeParams(from, to); const rows = await queryRows<{ country_code: string | null; count: string }>(` SELECT country_code, count() AS count FROM waf_events - WHERE ${timeFilter(from, to)} + WHERE ${timeFilter()} GROUP BY country_code ORDER BY count DESC - `); + `, tp); return rows.map(r => ({ countryCode: r.country_code ?? 'XX', count: Number(r.count) })); } export async function queryWafRuleMessages(ruleIds: number[]): Promise> { if (ruleIds.length === 0) return {}; + const params: QueryParams = {}; + const placeholders: string[] = []; + ruleIds.forEach((id, i) => { + const key = `rid_${i}`; + params[key] = id; + placeholders.push(`{${key}:Int32}`); + }); const rows = await queryRows<{ rule_id: string; message: string | null }>(` SELECT rule_id, any(rule_message) AS message FROM waf_events - WHERE rule_id IN (${ruleIds.join(',')}) + WHERE rule_id IN (${placeholders.join(',')}) GROUP BY rule_id - `); + `, params); return Object.fromEntries( rows.filter(r => r.rule_id != null).map(r => [Number(r.rule_id), r.message ?? null]) ); @@ -496,22 +553,45 @@ export interface WafEvent { } export async function queryWafEvents(limit = 50, offset = 0, search?: string): Promise { - const where = search ? wafSearchFilter(search) : '1=1'; + const safeLimit = safeUint(limit); + const safeOffset = safeUint(offset); + + let query: string; + let params: QueryParams; + + if (search) { + query = ` + SELECT toUInt32(ts) AS ts, host, client_ip, country_code, method, uri, + rule_id, rule_message, severity, raw_data, blocked + FROM waf_events + WHERE host ILIKE {p_search:String} + OR client_ip ILIKE {p_search:String} + OR uri ILIKE {p_search:String} + OR rule_message ILIKE {p_search:String} + ORDER BY ts DESC + LIMIT {p_limit:UInt32} OFFSET {p_offset:UInt32} + `; + params = { p_search: `%${search}%`, p_limit: safeLimit, p_offset: safeOffset }; + } else { + query = ` + SELECT toUInt32(ts) AS ts, host, client_ip, country_code, method, uri, + rule_id, rule_message, severity, raw_data, blocked + FROM waf_events + WHERE 1=1 + ORDER BY ts DESC + LIMIT {p_limit:UInt32} OFFSET {p_offset:UInt32} + `; + params = { p_limit: safeLimit, p_offset: safeOffset }; + } + const rows = await queryRows<{ ts: string; host: string; client_ip: string; country_code: string | null; method: string; uri: string; rule_id: string | null; rule_message: string | null; severity: string | null; raw_data: string | null; blocked: string; - }>(` - SELECT toUInt32(ts) AS ts, host, client_ip, country_code, method, uri, - rule_id, rule_message, severity, raw_data, blocked - FROM waf_events - WHERE ${where} - ORDER BY ts DESC - LIMIT ${limit} OFFSET ${offset} - `); + }>(query, params); return rows.map((r, i) => ({ - id: offset + i + 1, + id: safeOffset + i + 1, ts: Number(r.ts), host: r.host, clientIp: r.client_ip, diff --git a/src/lib/models/forward-auth.ts b/src/lib/models/forward-auth.ts index 4403aaff..31d50597 100644 --- a/src/lib/models/forward-auth.ts +++ b/src/lib/models/forward-auth.ts @@ -22,6 +22,20 @@ function hashToken(raw: string): string { // Store redirect URIs server-side so the client only holds an opaque ID. export async function createRedirectIntent(redirectUri: string): Promise { + // 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(); diff --git a/src/lib/models/proxy-hosts.ts b/src/lib/models/proxy-hosts.ts index e7fc91b3..2ece2e5f 100644 --- a/src/lib/models/proxy-hosts.ts +++ b/src/lib/models/proxy-hosts.ts @@ -6,6 +6,9 @@ import { asc, desc, eq, count, like, or } from "drizzle-orm"; import { type GeoBlockSettings } from "../settings"; import { normalizeProxyHostDomains } from "../proxy-host-domains"; +// Security: Only the protocol scheme is validated (http/https). Host/IP targets are +// not restricted — admins intentionally need to proxy to internal services. +// The Caddy admin API (port 2019) is protected by origins checking, not network isolation. function validateUpstreamProtocol(upstream: string): void { const trimmed = upstream.trim(); if (!trimmed) return; @@ -365,7 +368,7 @@ function sanitizeAuthentikMeta(meta: ProxyHostAuthentikMeta | undefined): ProxyH const authEndpoint = normalizeMetaValue(meta.auth_endpoint ?? null); if (authEndpoint) { - normalized.auth_endpoint = authEndpoint; + normalized.auth_endpoint = authEndpoint.replace(/\{[^}]*\}/g, ""); } if (Array.isArray(meta.copy_headers)) { @@ -387,7 +390,7 @@ function sanitizeAuthentikMeta(meta: ProxyHostAuthentikMeta | undefined): ProxyH } if (Array.isArray(meta.protected_paths)) { - const paths = meta.protected_paths.map((path) => path?.trim()).filter((path): path is string => Boolean(path)); + const paths = meta.protected_paths.map((path) => path?.trim().replace(/\{[^}]*\}/g, "")).filter((path): path is string => Boolean(path)); if (paths.length > 0) { normalized.protected_paths = paths; } @@ -567,7 +570,7 @@ function sanitizeCpmForwardAuthMeta(meta: CpmForwardAuthMeta | undefined): CpmFo normalized.enabled = Boolean(meta.enabled); } if (Array.isArray(meta.protected_paths)) { - const paths = meta.protected_paths.map((p) => p?.trim()).filter((p): p is string => Boolean(p)); + const paths = meta.protected_paths.map((p) => p?.trim().replace(/\{[^}]*\}/g, "")).filter((p): p is string => Boolean(p)); if (paths.length > 0) { normalized.protected_paths = paths; } @@ -658,7 +661,7 @@ function sanitizeRedirectRules(value: unknown): RedirectRule[] { typeof item.to === "string" && item.to.trim() && [301, 302, 307, 308].includes(item.status) ) { - valid.push({ from: item.from.trim(), to: item.to.trim(), status: item.status }); + valid.push({ from: item.from.trim().replace(/\{[^}]*\}/g, ""), to: item.to.trim().replace(/\{[^}]*\}/g, ""), status: item.status }); } } return valid; diff --git a/src/lib/secret.ts b/src/lib/secret.ts index 0689c4cc..3403d462 100644 --- a/src/lib/secret.ts +++ b/src/lib/secret.ts @@ -71,6 +71,9 @@ function _decryptWithKey(value: string, key: Buffer): string { } const iv = Buffer.from(ivB64, "base64"); const tag = Buffer.from(tagB64, "base64"); + if (tag.length !== 16) { + throw new Error("Invalid authentication tag length"); + } const data = Buffer.from(dataB64, "base64"); const decipher = createDecipheriv("aes-256-gcm", key, iv); decipher.setAuthTag(tag); diff --git a/tests/unit/api-routes/settings.test.ts b/tests/unit/api-routes/settings.test.ts index ff75a21b..f35a4664 100644 --- a/tests/unit/api-routes/settings.test.ts +++ b/tests/unit/api-routes/settings.test.ts @@ -204,13 +204,14 @@ describe('PUT /api/v1/settings/[group]', () => { it('sets sync token', async () => { mockSetSlaveMasterToken.mockResolvedValue(undefined as any); - const body = { token: 'new-sync-token' }; + const validToken = 'a]b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6'; + const body = { token: validToken }; const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ group: 'sync-token' }) }); const data = await response.json(); expect(response.status).toBe(200); expect(data).toEqual({ ok: true }); - expect(mockSetSlaveMasterToken).toHaveBeenCalledWith('new-sync-token'); + expect(mockSetSlaveMasterToken).toHaveBeenCalledWith(validToken); }); it('clears sync token when null', async () => {