|
| 1 | +#!/usr/bin/env bash |
| 2 | +# Creates WAF skip rules for machine-to-machine endpoints that must not be |
| 3 | +# gated by Bot Fight Mode or Browser Integrity Check. |
| 4 | +# |
| 5 | +# /ingest — protected by GitHub Actions OIDC token |
| 6 | +# /webhooks/github — protected by HMAC webhook signature |
| 7 | +# |
| 8 | +# Usage: |
| 9 | +# CLOUDFLARE_API_TOKEN=... ZONE_DOMAIN=yourdomain.com bash scripts/setup-waf-rules.sh |
| 10 | +# |
| 11 | +# Requires: curl, jq |
| 12 | + |
| 13 | +set -euo pipefail |
| 14 | + |
| 15 | +: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required}" |
| 16 | +: "${ZONE_DOMAIN:?ZONE_DOMAIN is required (e.g. yourdomain.com)}" |
| 17 | + |
| 18 | +API="https://api.cloudflare.com/client/v4" |
| 19 | +AUTH=(-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json") |
| 20 | +PHASE="http_request_firewall_custom" |
| 21 | +DESCRIPTION="Skip bot/BIC checks for OIDC+HMAC-protected endpoints" |
| 22 | +EXPRESSION='(http.request.uri.path eq "/ingest") or (http.request.uri.path eq "/webhooks/github")' |
| 23 | + |
| 24 | +# 1. Look up zone ID by domain name |
| 25 | +echo "Looking up zone for ${ZONE_DOMAIN}..." |
| 26 | +ZONE_ID=$(curl -sf "${AUTH[@]}" "${API}/zones?name=${ZONE_DOMAIN}" \ |
| 27 | + | jq -r '.result[0].id // empty') |
| 28 | +if [[ -z "${ZONE_ID}" ]]; then |
| 29 | + echo "Error: no zone found for ${ZONE_DOMAIN}. Check ZONE_DOMAIN and token permissions." |
| 30 | + exit 1 |
| 31 | +fi |
| 32 | +echo "Zone ID: ${ZONE_ID}" |
| 33 | + |
| 34 | +# 2. Get (or create) the WAF custom rules phase entrypoint |
| 35 | +ENTRYPOINT=$(curl -sf "${AUTH[@]}" "${API}/zones/${ZONE_ID}/rulesets/phases/${PHASE}/entrypoint" || echo '{"result":{}}') |
| 36 | +RULESET_ID=$(echo "${ENTRYPOINT}" | jq -r '.result.id // empty') |
| 37 | + |
| 38 | +if [[ -z "${RULESET_ID}" ]]; then |
| 39 | + echo "No WAF custom ruleset found — creating empty entrypoint..." |
| 40 | + RULESET_ID=$(curl -sf -X PUT "${AUTH[@]}" \ |
| 41 | + "${API}/zones/${ZONE_ID}/rulesets/phases/${PHASE}/entrypoint" \ |
| 42 | + -d '{"rules":[]}' | jq -r '.result.id') |
| 43 | +fi |
| 44 | +echo "Ruleset ID: ${RULESET_ID}" |
| 45 | + |
| 46 | +# 3. Check if the skip rule already exists (idempotent) |
| 47 | +EXISTING=$(curl -sf "${AUTH[@]}" "${API}/zones/${ZONE_ID}/rulesets/${RULESET_ID}" \ |
| 48 | + | jq -r --arg desc "${DESCRIPTION}" '.result.rules[]? | select(.description == $desc) | .id') |
| 49 | +if [[ -n "${EXISTING}" ]]; then |
| 50 | + echo "Skip rule already exists (${EXISTING}) — nothing to do." |
| 51 | + exit 0 |
| 52 | +fi |
| 53 | + |
| 54 | +# 4. Add the skip rule |
| 55 | +echo "Adding skip rule..." |
| 56 | +RESULT=$(curl -sf -X POST "${AUTH[@]}" \ |
| 57 | + "${API}/zones/${ZONE_ID}/rulesets/${RULESET_ID}/rules" \ |
| 58 | + -d "{ |
| 59 | + \"action\": \"skip\", |
| 60 | + \"action_parameters\": { |
| 61 | + \"products\": [\"botFightMode\", \"browserIntegrityCheck\"] |
| 62 | + }, |
| 63 | + \"expression\": \"${EXPRESSION}\", |
| 64 | + \"description\": \"${DESCRIPTION}\", |
| 65 | + \"enabled\": true |
| 66 | + }") |
| 67 | + |
| 68 | +RULE_ID=$(echo "${RESULT}" | jq -r '.result.id') |
| 69 | +echo "Done — rule ID: ${RULE_ID}" |
0 commit comments