web / bot prevention

Automated bots probe web servers for sensitive paths:

GET /.env
GET /docker-compose.yml
GET /proc/self/environ
GET /.git/config

Those probes return 404 responses that consume origin worker processes and connection slots.

Edge path filtering

Cloudflare Managed Rulesets target exploit payloads like SQL injection, but permit path probes to reach the origin.

I block path scans at the edge with custom WAF rules:

(http.request.uri.path contains "/." and not http.request.uri.path contains "/.well-known/") or
http.request.uri.path matches "(?i)\.(env|yml|yaml|tfstate|sql|pem|key|bak|conf)$" or
http.request.uri.path contains "proc/self"

I exclude /.well-known/ paths so OAuth discovery and RFC metadata resolve.

WAF attack score mitigation

Cloudflare assigns an attack score from 1 to 99, where lower scores indicate attacks. I block requests with an attack score of 20 or lower:

(cf.waf.score ge 1 and cf.waf.score le 20)

The rule leaves out my CI server's host. A failed check's output holds HTML, SQL, and request log lines, and the attack score reads that as an attack. The rule would refuse the runner's result with a 403 that the origin never sees.

Response-based rate limiting

Scanners send many invalid requests in seconds. I configure a rate limit rule that tracks origin HTTP 404 responses:

http.response.code eq 404

When an IP address triggers 15 404 responses in one minute, Cloudflare blocks that IP address for 10 minutes. Normal requests receive 200 responses and bypass the limit.

Non-browser traffic

CLI tools, Git over HTTPS, and webhooks cannot solve JavaScript bot challenges. I skip bot challenges for /api/ and /git/ paths:

http.request.uri.path startswith "/api/" or
http.request.uri.path startswith "/git/"

Origin limits

My CI server also limits the routes that anyone can call: a login challenge and an error post. One limiter holds a token bucket for each client address or key, built on golang.org/x/time/rate:

func (l *keyLimiter) take(key int64, n int, now time.Time) bool {
	l.mu.Lock()
	defer l.mu.Unlock()
	if l.m == nil {
		l.m = map[int64]*rate.Limiter{}
	}
	// A bucket that has refilled is full, so it holds no state. Sweep at
	// most once a second so the scan is not paid per request.
	if len(l.m) >= keyLimiterMaxKeys && now.Sub(l.swept) >= time.Second {
		l.swept = now
		for k, v := range l.m {
			if v.TokensAt(now) >= float64(l.burst) {
				delete(l.m, k)
			}
		}
	}
	if l.m[key] == nil && len(l.m) >= keyLimiterMaxKeys {
		key = keyLimiterOverflowKey
	}
	b := l.m[key]
	if b == nil {
		b = rate.NewLimiter(l.rate, l.burst)
		l.m[key] = b
	}
	return b.AllowN(now, n)
}

The map holds at most 4,096 keys. Past that bound, a key not yet seen shares one overflow bucket, so a flood of new addresses cannot grow the map. The limiter keeps its state in memory, not in Postgres, so it spends no database work to protect the database. The limit is per process and not exact: two processes allow twice the rate, and a restart starts each bucket full.

← All articles