|
1 | 1 | #!/usr/bin/env bash |
2 | 2 | # WAF Smoke Tests — validates Cloudflare WAF rules for gh.gordoncode.dev |
| 3 | +# Requires: GNU parallel (brew install parallel / apt install parallel) |
3 | 4 | # |
4 | 5 | # Usage: pnpm test:waf |
5 | 6 | # |
|
10 | 11 |
|
11 | 12 | set -euo pipefail |
12 | 13 |
|
| 14 | +if ! command -v parallel &>/dev/null; then |
| 15 | + printf 'Error: GNU parallel is required (brew install parallel / apt install parallel)\n' >&2 |
| 16 | + exit 1 |
| 17 | +fi |
| 18 | + |
13 | 19 | BASE="https://gh.gordoncode.dev" |
14 | | -PASS=0 |
15 | | -FAIL=0 |
16 | 20 |
|
17 | | -assert_status() { |
18 | | - local expected="$1" actual="$2" label="$3" |
| 21 | +# --- Test runner (exported for GNU parallel) --- |
| 22 | +run_test() { |
| 23 | + local expected="$1" label="$2" |
| 24 | + shift 2 |
| 25 | + local actual |
| 26 | + actual=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 --max-time 10 "$@") |
19 | 27 | if [[ "$actual" == "$expected" ]]; then |
20 | | - echo " PASS [${actual}] ${label}" |
21 | | - PASS=$((PASS + 1)) |
| 28 | + printf ' PASS [%s] %s\n' "$actual" "$label" |
22 | 29 | else |
23 | | - echo " FAIL [${actual}] ${label} (expected ${expected})" |
24 | | - FAIL=$((FAIL + 1)) |
| 30 | + printf ' FAIL [%s] %s (expected %s)\n' "$actual" "$label" "$expected" |
| 31 | + return 1 |
25 | 32 | fi |
26 | 33 | } |
27 | | - |
28 | | -fetch() { |
29 | | - curl -s -o /dev/null -w "%{http_code}" "$@" |
| 34 | +export -f run_test |
| 35 | + |
| 36 | +# --- Test spec parser (exported for GNU parallel) --- |
| 37 | +# Splits pipe-delimited spec into: expected_status | label | curl_args... |
| 38 | +run_spec() { |
| 39 | + local expected label |
| 40 | + IFS='|' read -ra parts <<< "$1" |
| 41 | + expected="${parts[0]}" |
| 42 | + label="${parts[1]}" |
| 43 | + run_test "$expected" "$label" "${parts[@]:2}" |
30 | 44 | } |
| 45 | +export -f run_spec |
| 46 | + |
| 47 | +# --- Test specs: expected_status | label | curl args ... --- |
| 48 | +# Pipe-delimited. Fields after label are passed directly to curl. |
| 49 | +TESTS=( |
| 50 | + # Rule 1: Path Allowlist — allowed paths |
| 51 | + "200|GET /|${BASE}/" |
| 52 | + "200|GET /login|${BASE}/login" |
| 53 | + "200|GET /oauth/callback|${BASE}/oauth/callback" |
| 54 | + "200|GET /onboarding|${BASE}/onboarding" |
| 55 | + "200|GET /dashboard|${BASE}/dashboard" |
| 56 | + "200|GET /settings|${BASE}/settings" |
| 57 | + "200|GET /privacy|${BASE}/privacy" |
| 58 | + "307|GET /index.html (html_handling redirect)|${BASE}/index.html" |
| 59 | + "200|GET /assets/nonexistent.js|${BASE}/assets/nonexistent.js" |
| 60 | + "200|GET /api/health|${BASE}/api/health" |
| 61 | + "400|POST /api/oauth/token (no body)|-X|POST|${BASE}/api/oauth/token" |
| 62 | + "404|GET /api/nonexistent|${BASE}/api/nonexistent" |
| 63 | + # Rule 1: Path Allowlist — blocked paths |
| 64 | + "403|GET /wp-admin|${BASE}/wp-admin" |
| 65 | + "403|GET /wp-login.php|${BASE}/wp-login.php" |
| 66 | + "403|GET /.env|${BASE}/.env" |
| 67 | + "403|GET /.env.production|${BASE}/.env.production" |
| 68 | + "403|GET /.git/config|${BASE}/.git/config" |
| 69 | + "403|GET /.git/HEAD|${BASE}/.git/HEAD" |
| 70 | + "403|GET /xmlrpc.php|${BASE}/xmlrpc.php" |
| 71 | + "403|GET /phpmyadmin/|${BASE}/phpmyadmin/" |
| 72 | + "403|GET /phpMyAdmin/|${BASE}/phpMyAdmin/" |
| 73 | + "403|GET /.htaccess|${BASE}/.htaccess" |
| 74 | + "403|GET /.htpasswd|${BASE}/.htpasswd" |
| 75 | + "403|GET /cgi-bin/|${BASE}/cgi-bin/" |
| 76 | + "403|GET /admin/|${BASE}/admin/" |
| 77 | + "403|GET /wp-content/debug.log|${BASE}/wp-content/debug.log" |
| 78 | + "403|GET /config.php|${BASE}/config.php" |
| 79 | + "403|GET /backup.zip|${BASE}/backup.zip" |
| 80 | + "403|GET /actuator/health|${BASE}/actuator/health" |
| 81 | + "403|GET /manager/html|${BASE}/manager/html" |
| 82 | + "403|GET /wp-config.php|${BASE}/wp-config.php" |
| 83 | + "403|GET /eval-stdin.php|${BASE}/eval-stdin.php" |
| 84 | + "403|GET /.aws/credentials|${BASE}/.aws/credentials" |
| 85 | + "403|GET /.ssh/id_rsa|${BASE}/.ssh/id_rsa" |
| 86 | + "403|GET /robots.txt|${BASE}/robots.txt" |
| 87 | + "403|GET /sitemap.xml|${BASE}/sitemap.xml" |
| 88 | + "403|GET /favicon.ico|${BASE}/favicon.ico" |
| 89 | + "403|GET /random/garbage/path|${BASE}/random/garbage/path" |
| 90 | + # Rule 2: Scanner User-Agents — normal UAs |
| 91 | + "200|Normal browser UA|-H|User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36|${BASE}/" |
| 92 | + "200|Default curl UA|${BASE}/" |
| 93 | + # Rule 2: Scanner User-Agents — malicious UAs |
| 94 | + "403|Empty User-Agent|-H|User-Agent:|${BASE}/" |
| 95 | + "403|UA: sqlmap/1.7|-H|User-Agent: sqlmap/1.7|${BASE}/" |
| 96 | + "403|UA: Nikto/2.1.6|-H|User-Agent: Nikto/2.1.6|${BASE}/" |
| 97 | + "403|UA: Nmap Scripting Engine|-H|User-Agent: Nmap Scripting Engine|${BASE}/" |
| 98 | + "403|UA: masscan/1.3|-H|User-Agent: masscan/1.3|${BASE}/" |
| 99 | + "403|UA: Mozilla/5.0 zgrab/0.x|-H|User-Agent: Mozilla/5.0 zgrab/0.x|${BASE}/" |
| 100 | +) |
| 101 | + |
| 102 | +# --- Run in parallel (::: passes array elements directly, avoiding stdin quoting issues) --- |
| 103 | +TOTAL=${#TESTS[@]} |
| 104 | + |
| 105 | +OUTPUT=$(parallel --will-cite -k -j10 --timeout 15 run_spec ::: "${TESTS[@]}") || true |
| 106 | + |
| 107 | +# Detect infrastructure failure (parallel crashed, no tests ran) |
| 108 | +if [[ -z "$OUTPUT" ]]; then |
| 109 | + printf 'Error: test harness produced no output — parallel may have failed\n' >&2 |
| 110 | + exit 2 |
| 111 | +fi |
31 | 112 |
|
32 | | -# ============================================================ |
33 | | -# Rule 1: Path Allowlist |
34 | | -# ============================================================ |
35 | | -echo "=== Rule 1: Path Allowlist ===" |
36 | | -echo "--- Allowed paths (should pass) ---" |
37 | | - |
38 | | -for path in "/" "/login" "/oauth/callback" "/onboarding" "/dashboard" "/settings" "/privacy"; do |
39 | | - status=$(fetch "${BASE}${path}") |
40 | | - assert_status "200" "$status" "GET ${path}" |
41 | | -done |
42 | | - |
43 | | -status=$(fetch "${BASE}/index.html") |
44 | | -assert_status "307" "$status" "GET /index.html (html_handling redirect)" |
45 | | - |
46 | | -status=$(fetch "${BASE}/assets/nonexistent.js") |
47 | | -assert_status "200" "$status" "GET /assets/nonexistent.js" |
48 | | - |
49 | | -status=$(fetch "${BASE}/api/health") |
50 | | -assert_status "200" "$status" "GET /api/health" |
51 | | - |
52 | | -status=$(fetch -X POST "${BASE}/api/oauth/token") |
53 | | -assert_status "400" "$status" "POST /api/oauth/token (no body)" |
54 | | - |
55 | | -status=$(fetch "${BASE}/api/nonexistent") |
56 | | -assert_status "404" "$status" "GET /api/nonexistent" |
57 | | - |
58 | | -echo "--- Blocked paths (should be 403) ---" |
59 | | - |
60 | | -for path in "/wp-admin" "/wp-login.php" "/.env" "/.env.production" \ |
61 | | - "/.git/config" "/.git/HEAD" "/xmlrpc.php" \ |
62 | | - "/phpmyadmin/" "/phpMyAdmin/" "/.htaccess" "/.htpasswd" \ |
63 | | - "/cgi-bin/" "/admin/" "/wp-content/debug.log" \ |
64 | | - "/config.php" "/backup.zip" "/actuator/health" \ |
65 | | - "/manager/html" "/wp-config.php" "/eval-stdin.php" \ |
66 | | - "/.aws/credentials" "/.ssh/id_rsa" "/robots.txt" \ |
67 | | - "/sitemap.xml" "/favicon.ico" "/random/garbage/path"; do |
68 | | - status=$(fetch "${BASE}${path}") |
69 | | - assert_status "403" "$status" "GET ${path}" |
70 | | -done |
71 | | - |
72 | | -# ============================================================ |
73 | | -# Rule 2: Scanner User-Agents |
74 | | -# ============================================================ |
75 | | -echo "" |
76 | | -echo "=== Rule 2: Scanner User-Agents ===" |
77 | | -echo "--- Normal UAs (should pass) ---" |
78 | | - |
79 | | -status=$(fetch -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" "${BASE}/") |
80 | | -assert_status "200" "$status" "Normal browser UA" |
81 | | - |
82 | | -status=$(fetch "${BASE}/") |
83 | | -assert_status "200" "$status" "Default curl UA" |
84 | | - |
85 | | -echo "--- Malicious UAs (should be 403 — managed challenge, no JS) ---" |
86 | | - |
87 | | -status=$(fetch -H "User-Agent:" "${BASE}/") |
88 | | -assert_status "403" "$status" "Empty User-Agent" |
89 | | - |
90 | | -for ua in "sqlmap/1.7" "Nikto/2.1.6" "Nmap Scripting Engine" "masscan/1.3" "Mozilla/5.0 zgrab/0.x"; do |
91 | | - status=$(fetch -H "User-Agent: ${ua}" "${BASE}/") |
92 | | - assert_status "403" "$status" "UA: ${ua}" |
93 | | -done |
| 113 | +# Count results from output |
| 114 | +PASS=$(grep -c "^ PASS" <<< "$OUTPUT" || true) |
| 115 | +FAIL=$((TOTAL - PASS)) |
94 | 116 |
|
95 | | -# ============================================================ |
96 | | -# Summary |
97 | | -# ============================================================ |
98 | | -echo "" |
99 | | -TOTAL=$((PASS + FAIL)) |
100 | | -echo "=== Results: ${PASS}/${TOTAL} passed, ${FAIL} failed ===" |
| 117 | +# Print results (preserving order from parallel -k) |
| 118 | +printf '%s\n' "$OUTPUT" |
| 119 | +printf '\n=== Results: %d/%d passed, %d failed ===\n' "$PASS" "$TOTAL" "$FAIL" |
101 | 120 | if [[ $FAIL -gt 0 ]]; then |
102 | 121 | exit 1 |
103 | 122 | fi |
0 commit comments