Day 97 — IP Blocking & Proof-of-Work Challenges
| Learning objective | Implement tiered response: PoW challenge → block with auto-expiry |
| Key packages | crypto/sha256, crypto/rand, crypto/hmac, sync, net/http, encoding/hex |
| Security context | Hard blocks cause false positives; PoW imposes cost on bots while letting legitimate traffic through |
Key Concepts
Proof-of-Work: Server issues {challenge, difficulty}. Client must find nonce such that SHA256(challenge + nonce) has difficulty leading zero bits. Cost scales exponentially with difficulty — difficulty=16 requires ~65536 attempts on average.
Tiered response: Suspected (score 50-80) → 429 + PoW; Confirmed (score >80) → 403 block.
Exercise — PoW Challenge System + IP Blocklist
package ddos
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"net/http"
"sync"
"time"
)
// ExpirableBlockList is a thread-safe IP blocklist with auto-expiry
type ExpirableBlockList struct {
mu sync.RWMutex
entries map[string]time.Time
done chan struct{}
}
func NewBlockList() *ExpirableBlockList {
bl := &ExpirableBlockList{
entries: make(map[string]time.Time),
done: make(chan struct{}),
}
go bl.cleanup()
return bl
}
func (bl *ExpirableBlockList) Block(ip string, duration time.Duration) {
bl.mu.Lock()
bl.entries[ip] = time.Now().Add(duration)
bl.mu.Unlock()
}
func (bl *ExpirableBlockList) IsBlocked(ip string) bool {
bl.mu.RLock()
exp, ok := bl.entries[ip]
bl.mu.RUnlock()
return ok && time.Now().Before(exp)
}
func (bl *ExpirableBlockList) cleanup() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-bl.done:
return
case <-ticker.C:
now := time.Now()
bl.mu.Lock()
for ip, exp := range bl.entries {
if now.After(exp) {
delete(bl.entries, ip)
}
}
bl.mu.Unlock()
}
}
}
// PoW challenge system
type PowChallenger struct {
secret []byte
difficulty int // leading zero bits required
expiry time.Duration
}
func NewPowChallenger(secret []byte, difficulty int) *PowChallenger {
return &PowChallenger{secret: secret, difficulty: difficulty, expiry: 5 * time.Minute}
}
type Challenge struct {
Challenge string `json:"challenge"`
Difficulty int `json:"difficulty"`
Expires int64 `json:"expires"`
}
func (pc *PowChallenger) Issue() Challenge {
raw := make([]byte, 16)
rand.Read(raw)
expires := time.Now().Add(pc.expiry).Unix()
// Sign challenge + expiry with HMAC so server can verify it issued this challenge
mac := hmac.New(sha256.New, pc.secret)
mac.Write(raw)
binary.Write(mac, binary.BigEndian, expires)
sig := mac.Sum(nil)
payload := append(raw, sig[:8]...) // compact: 16 + 8 bytes
return Challenge{
Challenge: hex.EncodeToString(payload),
Difficulty: pc.difficulty,
Expires: expires,
}
}
type SolveRequest struct {
Challenge string `json:"challenge"`
Nonce string `json:"nonce"`
}
func (pc *PowChallenger) Verify(sr SolveRequest) bool {
payload, err := hex.DecodeString(sr.Challenge)
if err != nil || len(payload) < 24 {
return false
}
raw := payload[:16]
storedSig := payload[16:24]
// Reconstruct expires — in production, embed it in the challenge payload
// For simplicity here we skip expiry re-extraction (demonstrate concept)
mac := hmac.New(sha256.New, pc.secret)
mac.Write(raw)
expectedSig := mac.Sum(nil)[:8]
if !hmac.Equal(storedSig, expectedSig) {
return false
}
nonce, err := hex.DecodeString(sr.Nonce)
if err != nil {
return false
}
// Check SHA256(challenge_raw + nonce) has `difficulty` leading zero bits
h := sha256.New()
h.Write(raw)
h.Write(nonce)
hash := h.Sum(nil)
return leadingZeroBits(hash) >= pc.difficulty
}
func leadingZeroBits(b []byte) int {
count := 0
for _, by := range b {
if by == 0 {
count += 8
continue
}
for mask := byte(0x80); mask > 0; mask >>= 1 {
if by&mask != 0 {
return count
}
count++
}
break
}
return count
}
// TieredResponder applies challenge or block based on score
type TieredResponder struct {
blockList *ExpirableBlockList
challenger *PowChallenger
whitelist []string // trusted CIDR prefixes
}
func (tr *TieredResponder) Handle(score float64, ip string, w http.ResponseWriter, r *http.Request, next http.Handler) {
if tr.isTrusted(ip) {
next.ServeHTTP(w, r)
return
}
if tr.blockList.IsBlocked(ip) {
http.Error(w, `{"error":"blocked"}`, http.StatusForbidden)
return
}
switch {
case score > 80:
tr.blockList.Block(ip, 5*time.Minute)
http.Error(w, `{"error":"blocked"}`, http.StatusForbidden)
case score > 50:
// Issue PoW challenge
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
json.NewEncoder(w).Encode(tr.challenger.Issue())
default:
next.ServeHTTP(w, r)
}
}
func (tr *TieredResponder) isTrusted(ip string) bool {
for _, cidr := range tr.whitelist {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
continue
}
if network.Contains(net.ParseIP(ip)) {
return true
}
}
return false
}