Rate-Limit & Brute-Force Shield
1. System Architecture & Prerequisites
- Python 3.10+ (stdlib only for the scanner:
re,os,json,argparse,pathlib) - Generated Node.js middleware requires:
express,express-rate-limit,rate-limit-redis(orioredis),redis - Generated test client is a single Node script (stdlib
http) - No external Python dependencies
2. Input/Output Data Contracts
Input (CLI args):
{
"type": "object",
"properties": {
"source": { "type": "string", "description": "Source root containing route definitions" },
"report_dir": { "type": "string", "description": "Output directory", "default": "./rate-shield-reports" }
},
"required": ["source"]
}
Output artifacts:
{report_dir}/rate_limit_report.json—endpoints: [{path, protected, store}]{report_dir}/express_rate_limit.js— hardened Express middleware (Redis store, per-IP+per-account, lockout){report_dir}/rate_limit_client_test.js— burst test client asserting 429 on the 101st request
3. Production Reference Implementation
#!/usr/bin/env python3
"""Rate-Limit & Brute-Force Shield — auth-endpoint scanner + hardened Express/Redis middleware generator."""
import re
import os
import json
import argparse
from pathlib import Path
SENSITIVE_ROUTE_PATTERNS = [
(re.compile(r"""(?:['"`][^'"`]*|\s)(/(?:api/)?(?:login|signin|sign-in|sign_up|signup|auth/token|reset-password|forgot-password|verify-otp|verify_otp|otp|2fa|mfa|activate)(?:/[^'"`]*)?['"`])""", re.IGNORECASE | re.MULTILINE),
"high-risk auth route (login / password reset / OTP / MFA)"),
(re.compile(r"""(?:app\.(?:post|get)\(|router\.(?:post|get)\(|@app\.(?:post|get)\(|@router\.(?:post|get)\s*\(\s*["'])""", re.IGNORECASE),
"route mount (context window applied)"),
]
RATE_LIMIT_PATTERNS = [
re.compile(r"""(?:rateLimit|rate-limit|express-rate-limit|RateLimiter|limiter|throttle|slowDown|isRateLimited|rate_limit)""", re.IGNORECASE),
re.compile(r"""(?:tooManyRequests|429|Retry-After|retrySeconds|windowMs|max:\s*\d+)""", re.IGNORECASE),
]
SKIP_DIRS = {"node_modules", ".git", "__pycache__", "venv", ".venv", "dist", "build", ".next"}
SOURCE_EXTS = {".js", ".ts", ".jsx", ".tsx"}
def scan_source(source: Path) -> list:
"""Find auth endpoints and whether they are rate-limited. Returns endpoint records."""
endpoints = []
for dirpath, dirnames, filenames in os.walk(source):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for fname in filenames:
if Path(fname).suffix not in SOURCE_EXTS:
continue
fpath = Path(dirpath) / fname
try:
lines = fpath.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
continue
for i, line in enumerate(lines):
path_match = SENSITIVE_ROUTE_PATTERNS[0][0].search(line)
if not path_match:
continue
route = path_match.group(1).strip("'\"`")
window_start = max(0, i - 20)
window_end = min(len(lines), i + 20)
context = "\n".join(lines[window_start:window_end])
has_limit = any(p.search(context) for p in RATE_LIMIT_PATTERNS)
method_match = re.search(r"\.(get|post|put|patch|delete)\s*\(", line, re.IGNORECASE)
methods = method_match.group(1).upper() if method_match else "ANY"
endpoints.append({
"file": str(fpath),
"line": i + 1,
"path": route,
"methods": methods,
"protected": has_limit,
"store": "redis" if re.search(r"redis|ioredis|rate-limit-redis", context, re.IGNORECASE) else
("memory" if has_limit else "none"),
"snippet": line.strip()[:180],
})
return endpoints
EXPRESS_MIDDLEWARE = r'''// express_rate_limit.js — Redis-backed rate limiting + account lockout for auth routes.
// Generated by rate-limit-bruteforce-shield.
// Requires: npm i express express-rate-limit rate-limit-redis ioredis
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const Redis = require('ioredis');
const redisClient = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379');
redisClient.on('error', (err) => console.error('[redis]', err.message));
// Per-IP + per-account sliding window. Key combines client IP and the account
// identifier so credential stuffing across many IPs toward one account is caught.
const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
const MAX_PER_IP = 5; // 5 attempts per IP per window
const MAX_PER_ACCOUNT = 10; // 10 attempts per account per window
const LOCKOUT_AFTER = 10; // successes-independent failure count
const LOCKOUT_MS = 30 * 60 * 1000; // 30 minutes
const ipLimiter = rateLimit({
windowMs: WINDOW_MS,
limit: MAX_PER_IP,
standardHeaders: 'draft-7', // RateLimit-* headers
legacyHeaders: false,
handler: (req, res) => {
res.setHeader('Retry-After', Math.ceil(WINDOW_MS / 1000));
return res.status(429).json({ error: 'Too many requests', retryAfterSeconds: WINDOW_MS / 1000 });
},
store: new RedisStore({
sendCommand: (...args) => redisClient.call(...args),
prefix: 'rl:ip:',
}),
keyGenerator: (req) => req.ip,
skip: (req) => req.path.toLowerCase().endsWith('/favicon.ico'),
});
// Sliding-window account limiter keyed by SHA-256(account|route|ip-prefix).
// Prevents one attacker rotating IPs from hammering a single account.
const crypto = require('crypto');
function accountKey(req) {
const account = (req.body && (req.body.username || req.body.email || req.body.account)) || 'anon';
const bucket = crypto.createHash('sha256')
.update(`${account}|${req.path}`)
.digest('hex')
.slice(0, 24);
return bucket;
}
const accountLimiter = rateLimit({
windowMs: WINDOW_MS,
limit: MAX_PER_ACCOUNT,
standardHeaders: 'draft-7',
legacyHeaders: false,
handler: (req, res) => {
const retryAfter = Math.ceil(WINDOW_MS / 1000);
res.setHeader('Retry-After', String(retryAfter));
return res.status(429).json({ error: 'Too many attempts for this account', retryAfterSeconds: retryAfter });
},
store: new RedisStore({
sendCommand: (...args) => redisClient.call(...args),
prefix: 'rl:acct:',
}),
keyGenerator: accountKey,
});
// Login route: per-IP limiter, per-account limiter, then lockout counter.
const LOCK_PREFIX = 'lock:acct:';
async function isLocked(req) {
const key = LOCK_PREFIX + accountKey(req);
const ttl = await redisClient.ttl(key);
return ttl > 0;
}
function lockAccount(req) {
const key = LOCK_PREFIX + accountKey(req);
return redisClient.multi()
.set(key, 'locked')
.expire(key, LOCKOUT_MS / 1000)
.exec();
}
async function onLoginSuccess(req) {
const key = LOCK_PREFIX + accountKey(req);
await redisClient.del(key);
}
async function onLoginFailure(req) {
const key = `fail:acct:${accountKey(req)}`;
const failures = await redisClient.incr(key);
if (failures === 1) await redisClient.expire(key, LOCKOUT_MS / 1000);
if (failures >= LOCKOUT_AFTER) {
await redisClient.multi()
.set(`${LOCK_PREFIX}${accountKey(req)}`, 'locked')
.expire(`${LOCK_PREFIX}${accountKey(req)}`, LOCKOUT_MS / 1000)
.del(key)
.exec();
}
return failures;
}
// Account-lockout middleware: run together with the two limiters on /login.
async function lockoutGuard(req, res, next) {
try {
const locked = await isLocked(req);
if (locked) {
return res.status(429).json({
error: 'Account temporarily locked',
retryAfterSeconds: LOCKOUT_MS / 1000,
});
}
next();
} catch (err) {
// Fail open on Redis errors to avoid locking out the whole app — log loudly.
console.error('[lockout]', err.message);
next();
}
}
function wireAuthRateLimits(app, loginPath = '/api/auth/login') {
app.post(loginPath, lockoutGuard, ipLimiter, accountLimiter, async (req, res) => {
// Your real login handler goes here. Example with fake credential check:
const ok = req.body && req.body.password === 'letmein';
if (ok) {
await onLoginSuccess(req);
return res.json({ ok: true });
}
const failures = await onLoginFailure(req);
if (failures >= LOCKOUT_AFTER) {
return res.status(429).json({ error: 'Account locked', retryAfterSeconds: LOCKOUT_MS / 1000 });
}
return res.status(401).json({ error: 'Invalid credentials' });
});
['/api/forgot-password', '/api/reset-password', '/api/verify-otp'].forEach((p) => {
app.post(p, ipLimiter, accountLimiter, (req, res) => {
res.json({ ok: true });
});
});
}
module.exports = {
ipLimiter,
accountLimiter,
lockoutGuard,
onLoginSuccess,
onLoginFailure,
isLocked,
lockAccount,
wireAuthRateLimits,
};
// Usage:
// app.use(require('express').json());
// require('./express_rate_limit').wireAuthRateLimits(app);
'''
TEST_CLIENT = r'''// rate_limit_client_test.js — burst test: 100 rapid attempts then assert 429.
// Run: node rate_limit_client_test.js [baseUrl]
const http = require('http');
const BASE = process.argv[2] || 'http://127.0.0.1:3000';
const BURST_COUNT = 100;
const TARGET = '/api/auth/login';
function attempt(i) {
return new Promise((resolve) => {
const body = JSON.stringify({ username: 'alice', password: 'wrong', account: 'alice' });
const req = http.request(BASE + TARGET, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
}, (res) => {
res.resume();
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, index: i }));
});
req.on('error', (err) => resolve({ error: err.message, index: i }));
req.write(body);
req.end();
});
}
(async () => {
const statuses = {};
let hit429 = false;
let retryAfter = null;
for (let i = 1; i <= BURST_COUNT + 5; i++) {
const r = await attempt(i);
statuses[r.status] = (statuses[r.status] || 0) + 1;
if (r.status === 429) hit429 = true;
if (r.headers && r.headers['retry-after']) retryAfter = r.headers['retry-after'];
await new Promise((res) => setTimeout(res, 10));
if (hit429 && i >= BURST_COUNT + 1) break;
}
console.log('Rate-limit shield test result');
console.log(' responses:', statuses);
const pass = hit429 && statuses['200'] <= BURST_COUNT;
console.log(pass ? 'PASS Got 429 after burst' : 'FAIL No 429 observed (shield not active?)');
if (retryAfter) console.log(' Retry-After header:', retryAfter);
process.exit(pass ? 0 : 1);
})();
'''
def main():
parser = argparse.ArgumentParser(
description="Rate-Limit & Brute-Force Shield — auth-endpoint scan + Redis-backed hardening assets.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--source", required=True, help="Source root containing route definitions")
parser.add_argument("--report-dir", default="./rate-shield-reports", help="Output directory")
args = parser.parse_args()
source = Path(args.source).resolve()
if not source.is_dir():
print(f"[ERROR] Source directory does not exist: {source}")
raise SystemExit(1)
report_dir = Path(args.report_dir).resolve()
report_dir.mkdir(parents=True, exist_ok=True)
print(f"[INFO] Scanning auth endpoints in: {source}")
endpoints = scan_source(source)
unprotected = [e for e in endpoints if not e["protected"]]
report = {
"target": str(source),
"endpoints": endpoints,
"summary": {
"auth_endpoints_found": len(endpoints),
"unprotected_count": len(unprotected),
"protected_count": len(endpoints) - len(unprotected),
},
}
mgr_path = report_dir / "express_rate_limit.js"
mgr_path.write_text(EXPRESS_MIDDLEWARE, encoding="utf-8")
test_path = report_dir / "rate_limit_client_test.js"
test_path.write_text(TEST_CLIENT, encoding="utf-8")
report["generated"] = {
"express_middleware": str(mgr_path),
"test_client": str(test_path),
}
report_path = report_dir / "rate_limit_report.json"
report_path.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8")
print(f"[INFO] Found {len(endpoints)} auth endpoint(s), {len(unprotected)} unprotected")
for e in endpoints:
flag = "UNPROTECTED" if not e["protected"] else f"protected ({e['store']})"
print(f" [{flag}] {e['methods']} {e['path']} ({e['file']}:{e['line']})")
print(f"[INFO] Middleware written: {mgr_path}")
print(f"[INFO] Test client written: {test_path}")
print(f"[INFO] Report written: {report_path}")
if __name__ == "__main__":
main()
4. Execution Protocol & Step-by-Step Workflow
- Scan the auth endpoints in your source tree:
python rate-limit-bruteforce-shield.md --source ./api --report-dir ./rate-shield-reports - Read
rate_limit_report.json→ identifyUNPROTECTEDendpoints (no limiter detected in the ±20-line context). - Install middleware dependencies in the target Express app:
npm install express-rate-limit rate-limit-redis ioredis - Copy
express_rate_limit.jsinto the app and wire it afterapp.use(express.json()):require('./express_rate_limit').wireAuthRateLimits(app); - Ensure Redis is running (
redis-server), pointingREDIS_URLat it if non-default. - Run the burst test client against the live server:
node rate_limit_client_test.js http://127.0.0.1:3000 - Assert PASS (429 on request 101+, Retry-After present); fix wiring if FAIL.
- Re-scan; all sensitive endpoints must now report
protectedwithstore: "redis".
5. Edge Cases & Error Handling
- Redis failures fail open (requests proceed, error logged): prevents a Redis outage from DoS-ing the whole app; acceptable trade-off for auth, monitored loudly.
keyGeneratorfalls back to'anon'when the account identifier is missing, so anonymous bursts are still IP-limited.- Account keys are SHA-256 hashed before use as Redis keys — avoids user-controlled characters breaking key structure.
- Sliding window uses IP AND per-account counters via two chained limiter instances, defeating single-account multi-IP stuffing.
Retry-Afteris always emitted (asserted by the test client) so clients can back off gracefully.- The lockout counter resets on success via
onLoginSuccess; failures increment viaonLoginFailurewith a TTL equal to the lockout window. - The scanner deliberately uses a ±20-line context window for limiter detection — a limiter imported from another module far away is a known false-positive source;
confidenceis implied by snippet inspection. skipfavicon check prevents polluting the IP bucket with preflight/favicon traffic.