The Problem
Luca leads security at a 25-person API company. They get hit by credential stuffing attacks from rotating IP ranges, scrapers from data center IPs, and DDoS attempts from botnets. Their cloud WAF costs $500/month and still lets through targeted attacks because rules are too generic. They can't block entire countries during attacks without calling support. They need an application-level firewall: real-time IP blocking, CIDR range support, country-level rules, automatic threat detection with auto-blocking, and tiered rate limiting for different API consumers.
Step 1: Build the IP Firewall
typescript
// src/security/firewall.ts — Application-level IP firewall with auto-blocking and analytics
import { Redis } from "ioredis";
import { pool } from "../db";
const redis = new Redis(process.env.REDIS_URL!);
interface FirewallRule {
id: string;
type: "ip" | "cidr" | "country" | "asn";
value: string;
action: "allow" | "block" | "challenge" | "rate_limit";
rateLimitConfig?: { requests: number; windowSeconds: number };
reason: string;
expiresAt: string | null;
autoGenerated: boolean;
createdAt: string;
}
interface FirewallDecision {
action: "allow" | "block" | "challenge" | "rate_limit";
rule: string | null;
reason: string;
}
// Check IP against firewall rules
export async function checkIP(ip: string, country?: string, asn?: number): Promise<FirewallDecision> {
// 1. Check allowlist first (always takes priority)
const allowed = await redis.sismember("fw:allowlist", ip);
if (allowed) return { action: "allow", rule: "allowlist", reason: "Allowlisted IP" };
// 2. Check exact IP blocklist
const blocked = await redis.sismember("fw:blocklist", ip);
if (blocked) return { action: "block", rule: "blocklist", reason: "Blocked IP" };
// 3. Check temporary blocks (auto-generated)
const tempBlock = await redis.get(`fw:temp:${ip}`);
if (tempBlock) {
const data = JSON.parse(tempBlock);
return { action: "block", rule: "auto-block", reason: data.reason };
}
// 4. Check CIDR ranges
const cidrBlock = await checkCIDR(ip);
if (cidrBlock) return cidrBlock;
// 5. Check country blocks
if (country) {
const countryBlocked = await redis.sismember("fw:blocked_countries", country);
if (countryBlocked) return { action: "block", rule: `country:${country}`, reason: `Country blocked: ${country}` };
}
// 6. Check ASN blocks
if (asn) {
const asnBlocked = await redis.sismember("fw:blocked_asns", String(asn));
if (asnBlocked) return { action: "block", rule: `asn:${asn}`, reason: `ASN blocked: ${asn}` };
}
// 7. Rate limiting
const rateResult = await checkRateLimit(ip);
if (rateResult) return rateResult;
return { action: "allow", rule: null, reason: "No matching rules" };
}
// Add rule
export async function addRule(rule: Omit<FirewallRule, "id" | "createdAt">): Promise<void> {
switch (rule.type) {
case "ip":
if (rule.action === "allow") await redis.sadd("fw:allowlist", rule.value);
else if (rule.action === "block") await redis.sadd("fw:blocklist", rule.value);
break;
case "cidr":
await redis.sadd(`fw:cidr:${rule.action}`, rule.value);
break;
case "country":
if (rule.action === "block") await redis.sadd("fw:blocked_countries", rule.value);
break;
case "asn":
if (rule.action === "block") await redis.sadd("fw:blocked_asns", rule.value);
break;
}
if (rule.expiresAt) {
const ttl = Math.ceil((new Date(rule.expiresAt).getTime() - Date.now()) / 1000);
if (ttl > 0 && rule.type === "ip") {
await redis.setex(`fw:temp:${rule.value}`, ttl, JSON.stringify({ reason: rule.reason }));
}
}
await pool.query(
`INSERT INTO firewall_rules (type, value, action, reason, expires_at, auto_generated, created_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())`,
[rule.type, rule.value, rule.action, rule.reason, rule.expiresAt, rule.autoGenerated]
);
}
// Auto-detect and block threats
export async function detectThreats(ip: string, context: {
path: string;
method: string;
statusCode: number;
userAgent: string;
}): Promise<void> {
const now = Date.now();
const minute = Math.floor(now / 60000);
// Track failed auth attempts
if (context.statusCode === 401 || context.statusCode === 403) {
const failKey = `fw:fails:${ip}:${minute}`;
const fails = await redis.incr(failKey);
await redis.expire(failKey, 300);
if (fails > 20) {
await autoBlock(ip, 3600, "Credential stuffing: 20+ auth failures/minute");
return;
}
}
// Track 4xx errors (scanning/fuzzing)
if (context.statusCode >= 400 && context.statusCode < 500) {
const errorKey = `fw:errors:${ip}:${minute}`;
const errors = await redis.incr(errorKey);
await redis.expire(errorKey, 300);
if (errors > 50) {
await autoBlock(ip, 1800, "Path scanning: 50+ 4xx errors/minute");
return;
}
}
// Track request volume (DDoS)
const volumeKey = `fw:volume:${ip}:${minute}`;
const volume = await redis.incr(volumeKey);
await redis.expire(volumeKey, 120);
if (volume > 500) {
await autoBlock(ip, 7200, "DDoS: 500+ requests/minute");
}
// Detect SQL injection / XSS attempts
const suspicious = [
"UNION SELECT", "DROP TABLE", "<script>", "../../", "%00",
"' OR '1'='1", "eval(", "exec(", ".env", "wp-admin",
];
const fullPath = context.path.toLowerCase();
if (suspicious.some((s) => fullPath.includes(s.toLowerCase()))) {
await autoBlock(ip, 86400, `Attack pattern detected in path: ${context.path.slice(0, 100)}`);
}
}
async function autoBlock(ip: string, durationSeconds: number, reason: string): Promise<void> {
await redis.setex(`fw:temp:${ip}`, durationSeconds, JSON.stringify({ reason, blockedAt: Date.now() }));
await pool.query(
`INSERT INTO firewall_rules (type, value, action, reason, expires_at, auto_generated, created_at)
VALUES ('ip', $1, 'block', $2, $3, true, NOW())`,
[ip, reason, new Date(Date.now() + durationSeconds * 1000).toISOString()]
);
await redis.rpush("notification:queue", JSON.stringify({
type: "firewall_block", ip, reason, duration: durationSeconds,
}));
}
// CIDR range check
async function checkCIDR(ip: string): Promise<FirewallDecision | null> {
const blockedCIDRs = await redis.smembers("fw:cidr:block");
for (const cidr of blockedCIDRs) {
if (ipInCIDR(ip, cidr)) {
return { action: "block", rule: `cidr:${cidr}`, reason: `IP in blocked CIDR range ${cidr}` };
}
}
return null;
}
function ipInCIDR(ip: string, cidr: string): boolean {
const [range, bits] = cidr.split("/");
const mask = ~(2 ** (32 - parseInt(bits)) - 1);
const ipNum = ipToNum(ip);
const rangeNum = ipToNum(range);
return (ipNum & mask) === (rangeNum & mask);
}
function ipToNum(ip: string): number {
return ip.split(".").reduce((acc, octet) => (acc << 8) + parseInt(octet), 0) >>> 0;
}
// Tiered rate limiting
async function checkRateLimit(ip: string): Promise<FirewallDecision | null> {
const minute = Math.floor(Date.now() / 60000);
const key = `fw:rl:${ip}:${minute}`;
const count = await redis.incr(key);
await redis.expire(key, 120);
// Default: 100 req/min
if (count > 100) {
return { action: "rate_limit", rule: "default_rate_limit", reason: `Rate limited: ${count} req/min` };
}
return null;
}
// Analytics
export async function getFirewallStats(): Promise<{
totalBlocked24h: number;
topBlockedIPs: Array<{ ip: string; count: number; reason: string }>;
blockedCountries: string[];
autoBlocksActive: number;
}> {
const { rows: [{ count: blocked24h }] } = await pool.query(
"SELECT COUNT(*) as count FROM firewall_rules WHERE auto_generated = true AND created_at > NOW() - INTERVAL '24 hours'"
);
const { rows: topIPs } = await pool.query(
`SELECT value as ip, COUNT(*) as count, reason FROM firewall_rules
WHERE auto_generated = true AND created_at > NOW() - INTERVAL '24 hours'
GROUP BY value, reason ORDER BY count DESC LIMIT 10`
);
const blockedCountries = await redis.smembers("fw:blocked_countries");
// Count active auto-blocks
const keys = await redis.keys("fw:temp:*");
return {
totalBlocked24h: parseInt(blocked24h),
topBlockedIPs: topIPs,
blockedCountries,
autoBlocksActive: keys.length,
};
}
// Middleware
export async function firewallMiddleware(c: any, next: any): Promise<void> {
const ip = c.req.header("CF-Connecting-IP") || c.req.header("X-Forwarded-For")?.split(",")[0]?.trim() || "unknown";
const decision = await checkIP(ip);
if (decision.action === "block") {
return c.json({ error: "Access denied" }, 403);
}
if (decision.action === "rate_limit") {
c.header("Retry-After", "60");
return c.json({ error: "Too many requests" }, 429);
}
await next();
// Post-response threat detection
detectThreats(ip, {
path: c.req.path,
method: c.req.method,
statusCode: c.res.status,
userAgent: c.req.header("User-Agent") || "",
}).catch(() => {});
}
Results
- WAF cost: $500/month → $0 — application-level firewall handles what the cloud WAF couldn't; custom rules for their specific threat profile
- Credential stuffing auto-blocked — 20+ auth failures/minute → IP blocked for 1 hour; attack surface reduced without manual intervention
- Country blocking in 1 command — during attacks from specific regions, block entire countries in Redis set operation; takes effect in milliseconds
- CIDR range blocking — data center IP ranges (AWS, DigitalOcean) blocked as CIDR; stops proxy rotation attacks that change individual IPs
- SQL injection/XSS caught at firewall — suspicious patterns in URLs blocked before reaching application code; auto-blocked for 24 hours with evidence logged