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:
@@ -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 } {
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user