reachlin

reachlin's development notes

A staging endpoint was returning 403 with error code: 1020. Spent the session diagnosing it, then turned the whole workflow into a reusable Claude skill so I never have to do it manually again.

Diagnosing a 1020

Error 1020 specifically means Cloudflare’s Firewall Rules (legacy) or WAF Custom Rules fired a block — not a rate limit, not an IP ban, not a bot score. The tell is in the response:

HTTP/2 403
server: cloudflare
cf-ray: a1b2c3d4e5f60000-NRT

error code: 1020

The cf-ray header is the key. Every request through Cloudflare gets a unique Ray ID, and that ID is indexed in the security event logs. To find the specific rule that matched, you query the Cloudflare GraphQL Analytics API:

{
  viewer {
    zones(filter: { zoneTag: "ZONE_ID" }) {
      firewallEventsAdaptive(
        filter: {
          action: "block"
          datetime_geq: "..."
          datetime_leq: "..."
          clientRequestPath: "/api/internal/test"
        }
        limit: 10
        orderBy: [datetime_DESC]
      ) {
        action
        rayName
        ruleId
        source
        clientIP
        datetime
        metadata { key value }
      }
    }
  }
}

A few things I learned about this dataset:

The result came back:

Rule ID : aabbcc1122334455aabbcc1122334455
Source  : firewallrules
Filter  : 11223344556677889900aabbccddeeff

The GraphQL rate limit mystery

The first few queries hit an immediate “budget depleted” error. The Cloudflare GraphQL Analytics API uses a complexity budget — 300 points per 5-minute rolling window — and the budget was already gone before I ran a single query.

Digging into the firewall event logs revealed the cause: two Adyen webhook IPs (147.12.16.11, 147.12.16.12) were generating ~400 log events per minute against a “Non-Supported Countries” rule. Adyen’s servers are in the Netherlands, which isn’t in the rule’s allowed country list (US, CA, CN, HK, TW). The rule action was log so payments weren’t affected, but every Adyen webhook call generated a log event, and querying any window of analytics over that volume consumed the entire complexity budget in one shot.

The fix was simple: disable the country rule (it was outdated anyway). The Adyen IPs resolve to 930c100b/c.adyen.com — easy to verify with a quick ipinfo.io lookup.

The token permission list

Getting the right API token permissions took a few iterations. For this kind of investigation you need:

Permission What it unlocks
Zone → Zone → Read Scope queries to a zone
Zone → Analytics → Read GraphQL firewallEventsAdaptive queries
Zone → Zone WAF → Read Fetch ruleset and rule details by ID
Account → Account Settings → Read Account audit logs

The audit logs turned out to be write-only from an observability standpoint — they record config changes (token creates, ruleset updates) but not API read activity, so you can’t use them to see who’s consuming your analytics budget.

Building it into a skill

The whole workflow — curl the URL, extract the Ray ID, query GraphQL, display the rule — is now a Claude skill (cf-block-check). It auto-triggers on phrases like “is this blocked by CF” and runs the full investigation automatically. The narrow time window query keeps it cheap even when the zone is noisy.

The main script is ~150 lines of Python using python-dotenv to load credentials and urllib for the API calls. No dependencies beyond the stdlib plus dotenv.