Terminal.skills
Use Cases/Build an IP Blocking Firewall

Build an IP Blocking Firewall

Build an application-level IP firewall with allowlists, blocklists, CIDR range support, country blocking, automatic threat detection, rate limiting tiers, and real-time analytics.

#redis#caching#database#pub-sub#queues
Works with:claude-codeopenai-codexgemini-clicursor
$

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