DefendX
A self-hosted WAF + SIEM + IDS with 32 threat detectors, escalating auto-bans, and a real-time security dashboard built in TypeScript with Hono, Drizzle ORM, PostgreSQL, and React.

32-Detector Threat Analysis Pipeline
A WAF that relies on a single monolithic detection function becomes impossible to extend adding a new attack class means editing existing logic and risking regressions on the detections that already work. The system needs to evaluate every request against dozens of independent heuristics without coupling them.
Built a pluggable detector architecture where each of the 32 detectors is a standalone function that accepts a NormalizedRequest and returns an array of DetectorResult objects. A central runAllDetectors() orchestrator iterates over all registered detector functions in a fixed order, catches and silently swallows any individual detector that throws, and concatenates the results. Every detector is imported from its own file in engine/detectors/ and registered in a single array adding a new detector means writing one function and adding one import line.
export function runAllDetectors(req: NormalizedRequest): DetectorResult[] {
const allResults: DetectorResult[] = [];
const detectors = [
detectInvalidHttpMethod, detectLargeRequestSize,
detectMissingUserAgent, detectSuspiciousUserAgent,
detectRateLimit, detectAuthAttacks,
detectPathTraversal, detectSqlInjection, detectXss,
detectCommandInjection, detectSsrf, detectXxe,
];
for (const detector of detectors) {
try {
const results = detector(req);
allResults.push(...results);
} catch { }
}
return allResults;
}export function detectSqlInjection(req: NormalizedRequest): DetectorResult[] {
const results: DetectorResult[] = [];
const targets = getInjectionTargets(req);
for (const target of targets) {
for (const { pattern, name } of SQL_PATTERNS) {
if (pattern.test(target.value)) {
results.push({
detector_name: "SQL Injection", severity: "critical",
score: 50, source: target.source,
field_name: target.field, matched_value: name,
});
break;
}
}
}
return results;
}The alternative was a single large function with regex banks organized by attack type. That approach is faster to prototype but degrades quickly: a regex change intended for SQL injection can accidentally match XSS patterns, and the function grows linearly with every new detection rule. The per-detector file structure trades a small amount of import boilerplate for complete isolation each detector owns its own regex patterns, thresholds, and target-scanning logic, and a failure in one detector never prevents the others from running. The try/catch wrapper around each detector invocation ensures that a malformed regex or unexpected null in one detector cannot crash the entire pipeline.
32 independent detectors covering 8 attack categories (request-layer, rate limiting, authentication, path analysis, injection, redirect, session, and bot scanning) run on every ingested request with zero cross-contamination between detection rules. Adding a new detector requires exactly two changes: one new file and one new import line in the orchestrator.
Escalating Ban System with Accumulated Score Thresholds
A flat block/unblock model creates a binary outcome an IP is either blocked or not with no concept of repeated offenses. Aggressive first-time bans anger legitimate users who trigger a single false positive; lenient thresholds let persistent attackers probe indefinitely before any consequence. The system needs a progression that gets harsher with each offense while still giving first-time offenders a short cooldown.
Implemented a three-tier escalating ban system: the first offense earns a 10-minute temporary ban, the second escalates to 1 hour, the third to 24 hours, and the fourth offense triggers a permanent ban with no expiry. Ban durations are stored in a BAN_DURATIONS array indexed by offense count, capped at MAX_BAN_DURATION_MS (30 days). Separately, an accumulated score threshold of 100 triggers an auto-ban regardless of the single-request action, and if the IP's lifetime score reaches 300 (3x the threshold), the ban is immediate permanent. A background cleanup interval runs every 60 seconds to delete expired temporary blocks.
const BAN_DURATIONS = [
10 * 60 * 1000, // 1st offense: 10 minutes
60 * 60 * 1000, // 2nd offense: 1 hour
24 * 60 * 60 * 1000, // 3rd offense: 24 hours
];
const isPermanent = offenseCount >= 4;
if (isPermanent) {
await db.insert(schema.blockedIps).values({
ip, reason, blockType: "permanent",
totalOffenses: offenseCount, expiresAt: null,
}).onConflictDoUpdate({ target: schema.blockedIps.ip, ... });
}if (ACCUMULATED_SCORE_BAN_THRESHOLD > 0 && newTotalScore >= ACCUMULATED_SCORE_BAN_THRESHOLD) {
const alreadyBlocked = await isBlocked(ip);
if (!alreadyBlocked) {
const isPermanent = newTotalScore >= ACCUMULATED_SCORE_BAN_THRESHOLD
* ACCUMULATED_PERMANENT_MULTIPLIER;
await applyBan(ip, isPermanent ? "permanent_ban" : "temporary_ban",
newTotalScore, "Repeat offender");
}
}A static block duration (e.g., always 24 hours) is the simplest approach but creates a lose-lose: it's too short for repeat offenders who return immediately, and too long for a one-time scanner that briefly probed a sensitive path. The escalating model uses the IP's own behavior to determine punishment severity a single low-score event gets a brief cooldown, while an IP that accumulates repeated high-score detections proves itself worthy of permanent removal. The separate accumulated-score threshold handles a different failure mode: an IP that fires many individual low-score requests (each under the single-request ban threshold) but whose lifetime total crosses 100 gets auto-banned, preventing the "death by a thousand cuts" pattern where an attacker stays just below the per-request threshold.
Repeat offenders are permanently removed after 4 offenses, while first-time scanners get a brief 10-minute cooldown that rarely impacts legitimate traffic. The 60-second cleanup interval ensures expired temporary bans are automatically lifted without manual intervention.
Multi-Vector Injection Detection Across 9 Attack Classes
Injection attacks (SQL, XSS, command, SSRF, LDAP, NoSQL, template, XXE, CRLF) can target any user-controlled input URL path, query string, request body, form data, HTTP headers, or cookies. A detector that only scans the URL misses body-based payloads; one that only scans headers misses query-based payloads. The attacker surface is the union of every field the server parses.
Each of the 9 injection detectors defines a getTargets() helper that collects all injectable values from the NormalizedRequest: the full URL (path + query string), every key-value pair in the body, query_params, and form_data, and in some cases (XSS, CRLF, request-layer) also HTTP headers and cookies. The detector then runs its regex bank against every target, reporting the source and field name alongside the matched pattern. For example, the SQL injection detector tests 27 regex patterns (UNION SELECT, OR 1=1, xp_cmdshell, BENCHMARK, FLOOR(RAND), etc.) against each target, while the XSS detector tests 29 patterns across script tags, event handlers, and DOM manipulation APIs.
function getXssTargets(req: NormalizedRequest): Array<{ value: string; field: string; source: string }> {
const targets = [];
targets.push({ value: `${req.path}?${req.query_string || ""}`, field: "url", source: "query" });
for (const [key, val] of Object.entries(req.headers))
targets.push({ value: val, field: key, source: "header" });
if (req.body) for (const [key, val] of Object.entries(req.body))
targets.push({ value: val, field: key, source: "body" });
for (const [key, val] of Object.entries(req.cookies))
targets.push({ value: val, field: key, source: "cookie" });
return targets;
}function getSsrfTargets(req: NormalizedRequest) {
// ...URL always scanned...
if (req.body) for (const [key, val] of Object.entries(req.body)) {
if (/url|link|href|redirect|callback|webhook|feed|src|dest|target|proxy/i.test(key))
targets.push({ value: val, field: key, source: "body" });
}
}Centralizing all input into a single NormalizedRequest type at the ingestion layer means every detector receives the same shape regardless of where the payload arrived. The per-detector getTargets() function is a deliberate choice over a shared target collector because different detectors need different input surfaces: SSRF detection only cares about fields whose names match URL/link/href/redirect patterns, while XXE detection focuses on body fields when the Content-Type header is XML. Pushing target selection into each detector avoids scanning irrelevant fields and keeps each detector's logic self-contained.
| Approach | Why rejected |
|---|---|
| Shared target collector (scan all fields for all detectors) | Wastes cycles scanning cookies for SSRF patterns and headers for NoSQL operators; couples target selection to a shared module |
| Per-detector getTargets() with NormalizedRequest | Chosen each detector defines exactly which fields it inspects, keeping scan surface tight and logic isolated |
Every user-controlled input surface (URL, query, body, form, headers, cookies) is covered by the detectors that are relevant to it. The SSRF detector targets only URL-like field names; the XXE detector triggers on XML Content-Type bodies; the XSS detector sweeps every string field including cookies. Total regex patterns across all injection detectors: 150+.
Sliding-Window Rate Limiting with Burst Detection
Basic fixed-window rate limiting (e.g., 100 requests per minute) has a well-known boundary problem: an attacker can send 100 requests at 11:59:59 and 100 more at 12:00:00, technically staying under the limit while actually sustaining 200 requests in a 2-second window. Fixed windows also fail to detect short-duration burst traffic that exceeds any reasonable per-second rate.
Implemented a sliding-window rate limiter using in-memory Maps keyed by IP, with three independent checks per request: requests-per-second (RPS, threshold 20), requests-per-minute (RPM, threshold 100), and burst detection (more than 50 requests within a 1-second sliding window). Each window is maintained by filtering stored timestamps to only those within the window's time bounds on every request. A periodic cleanup interval runs every 60 seconds and evicts entries whose timestamp arrays are empty, preventing unbounded memory growth from IPs that stop sending traffic.
entry.rps = cleanupOldEntries(entry.rps, 1000);
entry.rps.push(now);
if (entry.rps.length > RATE_LIMIT_RPS) {
results.push({
detector_name: "Rate Limit - RPS", severity: "medium",
score: 20, source: "rate_limiter", field_name: "ip",
matched_value: `${entry.rps.length} req/s`,
});
}const recentTimestamps = cleanupOldEntries(entry.timestamps, BURST_WINDOW_MS);
if (recentTimestamps.length > BURST_THRESHOLD) {
results.push({
detector_name: "Burst Traffic", severity: "high",
score: 25, source: "rate_limiter", field_name: "ip",
matched_value: `${recentTimestamps.length} requests in ${BURST_WINDOW_MS}ms`,
});
}A Redis-backed sliding window would survive process restarts and work across multiple backend instances, but adds a runtime dependency that a self-hosted WAF should not require. The in-memory approach is correct for a single-instance deployment and keeps the operational footprint to just Node.js and PostgreSQL. The burst detector is separate from the RPM/RPS check because it detects a qualitatively different pattern: a flood of 50+ requests in a single second is almost always automated, even if the per-minute total stays under 100. The three thresholds (RPM=100, RPS=20, burst=50/1s) were chosen to allow normal browser traffic (typically 2-5 RPS during page loads) while catching script-based scanning.
Three independent sliding-window checks detect sustained high-volume traffic (RPM), sustained high-frequency requests (RPS), and short-duration flood attacks (burst) without false-positiving on normal browser behavior. The 60-second cleanup prevents memory leaks from long-dormant IPs.
JWT and Session Token Abuse Detection
Session hijacking and token manipulation are among the most impactful attacks because a valid session token grants full access to an authenticated account. Common attack vectors include forging JWTs with alg:none (bypassing signature verification), injecting admin role claims into JWT payloads, and substituting weak or trivially guessable session tokens. A WAF that only inspects URL and body parameters misses these attacks entirely because the payload lives inside a cookie.
Built a session abuse detector that inspects every cookie whose name matches known session patterns (session, sid, token, jwt, auth, PHPSESSID, JSESSIONID, connect.sid, laravel_session, wordpress_logged_in, and others). For JWT-formatted tokens (eyJ...header.eyJ...payload.signature), the detector base64url-decodes the header and payload without performing cryptographic verification it checks for the string "alg":"none" in the header (the classic alg:none bypass) and "role":"admin" or "admin":true in the payload (privilege escalation). For non-JWT session tokens, it flags tokens shorter than 8 characters, tokens matching trivially weak values (admin, root, test, 12345, aaaaaa, all-zeros), tokens exceeding 2048 characters, and tokens containing injection characters (<>"'\;()&|).
if (JWT_PATTERN.test(value)) {
const parts = value.split(".");
let decodedPayload = "";
try { decodedPayload = Buffer.from(parts[1], "base64url").toString("utf-8"); } catch { }
if (decodedPayload.includes('"role":"admin"') || decodedPayload.includes('"admin":true')) {
results.push({ detector_name: "Session Abuse", severity: "critical",
score: 60, matched_value: "JWT privilege escalation attempt" });
}
const header = Buffer.from(parts[0], "base64url").toString("utf-8");
if (header.includes('"alg":"none"') || header.includes('"alg":"None"')) {
results.push({ detector_name: "Session Abuse", severity: "critical",
score: 60, matched_value: "JWT none-algorithm attack" });
}
}const SUSPICIOUS_TOKEN_PATTERNS = [
/^admin$/i, /^root$/i, /^test$/i, /^12345/,
/^abcde/, /^aaaaaa/, /^(0+)$/,
];
for (const pattern of SUSPICIOUS_TOKEN_PATTERNS) {
if (pattern.test(value)) {
results.push({ detector_name: "Session Abuse", severity: "high",
score: 40, matched_value: `Weak session token: ${value.substring(0, 20)}` });
}
}Cryptographic JWT verification belongs to the application layer, not the WAF the WAF cannot know the signing key. But structural analysis of the JWT header and payload is still valuable because it catches the most common misconfigurations before the request reaches the application. The alg:none attack is detectable by string matching on the decoded header alone. The privilege escalation check (role:admin in payload) is a heuristic that may produce false positives on legitimately admin-scoped tokens, but in the context of a WAF this is acceptable a high-severity detection with a score of 60 triggers a temporary ban, not an immediate block, giving the operator time to investigate.
Detects JWT alg:none attacks, JWT privilege escalation attempts, weak session tokens, and injection characters in session values all without performing cryptographic verification or requiring access to the application's signing key. The detector inspects 14 known session cookie name patterns across all major frameworks.
API-Key Gated Log Ingestion with GeoIP Enrichment
A WAF that requires modifying reverse proxy configuration or deploying sidecar containers has a high adoption barrier. The simplest integration model is a log ingestion endpoint: the existing infrastructure sends request metadata as JSON, and the WAF processes it asynchronously. But a raw ingestion endpoint is itself an attack surface without authentication, anyone can flood it with garbage data, and without IP validation, the WAF cannot accurately attribute detections to real source IPs.
Designed POST /api/logs as the single ingestion endpoint. Every request (except /health) passes through a requireApiKey middleware that checks the defendx-api-key header. The ingestion handler normalizes the incoming JSON into a NormalizedRequest, validates the source IP against a denylist of empty/malformed values (", "unknown", "none", "null", "undefined"), skips analysis for localhost IPs, whitelisted IPs, excluded paths (/health, /metrics, /docs), static file requests (15 extensions), and OPTIONS/HEAD methods, and then runs the full detection pipeline. GeoIP lookup via ip-api.com is performed asynchronously with a 3-second timeout, skipping private IPs (10.x, 172.16-31.x, 192.168.x) entirely. Known bots (Googlebot, Bingbot, DuckDuckBot, and 5 others) are logged but not analyzed, reducing false positives on legitimate crawler traffic.
// 1. Validate required fields
const missing = REQUIRED_LOG_FIELDS.filter((f) => !body[f]);
// 2. Normalize and validate IP
const cleanIp = normalizeIp(rawIp);
if (isLocalhost(cleanIp)) return storeRequestOnly(cleanIp, body, 200);
if (await isWhitelisted(cleanIp)) return { whitelisted: true };
if (await isBlocked(cleanIp)) return c.json({ error: "IP is blocked" }, 403);
// 3. Skip analysis for non-threatening traffic
if (EXCLUDED_PATHS.some(p => body.path.startsWith(p)) || isStaticFile(body.path))
return storeRequestOnly(cleanIp, body, 200);
// 4. Run full detection pipeline
const result = await processRequest(normalized);if (IGNORE_KNOWN_BOTS && req.user_agent) {
const isKnownBot = KNOWN_BOT_PATTERNS.some((p) => p.test(req.user_agent));
if (isKnownBot) {
await storeRequest(req, requestId, 0, "log");
return { request_id: requestId, detections: [], total_score: 0, action_taken: "log" };
}
}The API-key middleware is intentionally simple a single header comparison because the WAF sits behind an existing reverse proxy that should already handle TLS termination and rate limiting at the network layer. Adding OAuth or JWT verification to the ingestion endpoint would be over-engineering for a self-hosted tool where the operator controls both the key and the clients. The skip-analysis short-circuit for static files, health checks, and OPTIONS requests is a performance optimization that avoids running 32 detectors on traffic that is definitionally not attack traffic. The GeoIP lookup uses a free API with a hard timeout because geographic enrichment is useful for dashboard display but must never block the detection pipeline a slow or down GeoIP service should degrade gracefully, not stall request processing.
A single POST endpoint with API-key authentication handles all request ingestion. Localhost, whitelisted IPs, known bots, static files, and health checks are fast-pathed around the 32-detector pipeline. GeoIP enrichment adds country and city metadata to every request without introducing a blocking dependency.
Defense-in-Depth Middleware Stack
A WAF that only analyzes incoming requests but does not harden its own response headers leaves the management interface vulnerable to the same attacks it is designed to detect. Missing security headers like HSTS, CSP, and X-Frame-Options on the WAF's own API responses create an inconsistency where the WAF detects clickjacking in upstream traffic while being susceptible to it itself.
Implemented a four-middleware security stack that applies to every response: (1) requireApiKey validates the defendx-api-key header on all routes except /health, (2) securityHeaders sets 10 response headers including Strict-Transport-Security (max-age=31536000, includeSubDomains, preload), Content-Security-Policy (default-src 'self'; object-src 'none'; frame-ancestors 'none'), X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy (disabling camera, microphone, geolocation, payment, USB, accelerometer, gyroscope), Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Resource-Policy: same-origin, and Origin-Agent-Cluster: ?1, and (3) a logger middleware that records method, path, status code, and response time for every request. The Server and X-Powered-By headers are actively deleted from every response.
export function securityHeaders(): MiddlewareHandler {
return async (c, next) => {
await next();
c.res.headers.set("Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload");
c.res.headers.set("X-Content-Type-Options", "nosniff");
c.res.headers.set("X-Frame-Options", "DENY");
c.res.headers.set("Content-Security-Policy",
"default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'");
c.res.headers.delete("Server");
c.res.headers.delete("X-Powered-By");
};
}export const requireApiKey: MiddlewareHandler = async (c, next) => {
if (c.req.path === "/health") return next();
const apiKey = c.req.header("defendx-api-key");
if (!apiKey || apiKey !== env.DEFENDX_API_KEY) {
return c.json({ error: "Unauthorized", message: "Missing or invalid API key" }, 401);
}
await next();
};Stacking the middleware in Hono's use() chain CORS, security headers, logger, API key means every response gets hardened before it leaves the process, regardless of which route handler produced it. The security headers middleware runs after next() so it can inspect and modify the response headers set by the route handler, including deleting the Server and X-Powered-By headers that frameworks inject by default. The CSP header uses a restrictive default-src 'self' policy because the WAF dashboard serves only its own bundled assets there are no third-party scripts, fonts, or analytics that would require relaxing the policy. The Permissions-Policy header disables every browser feature API that a WAF management interface has no reason to access.
Every API response includes 10 security headers, strips framework-identifying headers, and enforces a strict CSP. The management interface is hardened against the same attack classes (clickjacking, MIME sniffing, code injection via third-party scripts) that the WAF is designed to detect in upstream traffic.
Key Technical Achievements
32 Independent Detectors
A pluggable detector pipeline running 32 threat detection functions across 8 attack categories, with per-detector error isolation ensuring a single failure never crashes the analysis engine.
Escalating Auto-Ban
A three-tier ban escalation system (10min → 1hr → 24hr → permanent) triggered by repeat offenses, with a secondary accumulated-score threshold (100 points) that catches low-and-slow attackers.
9 Injection Classes
Multi-vector injection detection covering SQL, XSS, command injection, SSRF, LDAP, NoSQL, template injection, XXE, and CRLF scanning URL, query, body, form, headers, and cookies.
JWT Structural Analysis
Header and payload inspection of JWT-formatted session tokens detecting alg:none bypasses and privilege escalation attempts without performing cryptographic verification.
Sliding-Window Rate Limiting
Three independent sliding-window checks (RPM, RPS, burst) with automatic memory cleanup, detecting sustained high-volume traffic and short-duration flood attacks simultaneously.
10 Security Response Headers
HSTS, CSP, X-Frame-Options, Permissions-Policy, COOP, CORP, and more applied to every API response, with Server and X-Powered-By actively stripped.
Full-Stack TypeScript Dashboard
React + Vite frontend with GSAP page transitions, Recharts traffic visualizations, paginated data tables, CSV/JSON export, and mobile-responsive layout across 6 dashboard views.