Frontend Labs
⋯

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
}

Generated from week13.md · 8 Oct 2026 16:36