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>
This commit is contained in:
fuomag9
2026-04-10 12:13:50 +02:00
parent e1c97038d4
commit 5d0b4837d8
21 changed files with 338 additions and 164 deletions

View File

@@ -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 } {

View File

@@ -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 } {

View File

@@ -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);
}
}

View File

@@ -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 } {

View File

@@ -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 } {

View File

@@ -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 } {

View File

@@ -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 } {

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 });
}

View File

@@ -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);
}
}

View File

@@ -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
}

View File

@@ -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"]

View File

@@ -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'));

View File

@@ -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);

View File

@@ -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<void> {
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<void> {
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<string, unknown>;
/**
* 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<T>(query: string): Promise<T[]> {
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<T>(query: string, query_params?: QueryParams): Promise<T[]> {
const ch = getClient();
const result = await ch.query({ query, format: 'JSONEachRow' });
const result = await ch.query({ query, query_params, format: 'JSONEachRow' });
return result.json<T>();
}
async function queryRow<T>(query: string): Promise<T | null> {
const rows = await queryRows<T>(query);
async function queryRow<T>(query: string, query_params?: QueryParams): Promise<T | null> {
const rows = await queryRows<T>(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<AnalyticsSummary> {
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<TimelineBucket[]> {
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<CountryStats[]> {
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<ProtoStats[]> {
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<UAStats[]> {
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<BlockedPage> {
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<string[]> {
// ── WAF analytics queries ───────────────────────────────────────────────────
export async function queryWafCount(from: number, to: number): Promise<number> {
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<number> {
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<TopWafRule[]> {
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<Record<number, string | null>> {
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<WafEvent[]> {
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,

View File

@@ -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<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();

View File

@@ -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;

View File

@@ -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);

View File

@@ -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 () => {