← Back to Blog
pullmdoauthmcpcloudflarewafinfrastructureMay 16, 2026 · 9 min read

PullMD Part 8: An Old Rule

GEO: DE ONLY · RULE ACTIVE · ORIGIN: NO REQUEST
Editorial diagram: an old paper clipboard card reading 'geo: DE only' and 'rule active' clipped to a modern WAF/edge-panel box; on the left, incoming request paths from the US (red, host_not_allowed, outbound blocked) and DE (green); on the right, the origin server panel showing an empty log ('origin: no request').Editorial diagram: an old paper clipboard card reading 'geo: DE only' and 'rule active' clipped to a modern WAF/edge-panel box; on the left, incoming request paths from the US (red, host_not_allowed, outbound blocked) and DE (green); on the right, the origin server panel showing an empty log ('origin: no request').

Part 7 ended on a hard stopper. host_not_allowed — Anthropic’s egress proxy blocks my host before a request ever reaches the origin. The conclusion I’d drawn at the time: outside my control. feat/oauth sat finished on the shelf. I waited.

Four days passed. Nothing.

Something Started to Nag

Then something started to nag — not in the code, in my head.

At some point, @localden had posted a detailed follow-up explanation on Issue #214. The core of it: claude.ai checks the DNS A/AAAA record before the first HTTP request — if the hostname resolves to a non-globally-routable address (CGNAT, RFC1918, Link-Local), the connection is dropped before it ever leaves Anthropic. The concrete example in the comment was fbs.synology.me — a Synology DDNS that typically sits behind CGNAT.

That fit cleanly. Just not onto my setup. Cloudflare Tunnel, valid public A and AAAA records, DNSSEC active, direct external curl requests answered reliably. The explanation was convincing for other cases in the thread, but too narrow for mine.

A few hours later, still that same evening, I posted a factual pushback comment on #214:

“Posting anyway because the explanation maps cleanly onto fbs.synology.me but doesn’t seem to fit my setup, and I’d like a sanity check before this is considered fully resolved. … If there’s an additional check beyond the IP-routability validation — e.g. ASN/range classification, the host_not_allowed allowlist mentioned in #256, or something specific to Cloudflare Tunnel egress IPs — knowing what specifically my hosts are tripping would let me address it on my side rather than guess.” — syswave-dev (issue comment)

What I hadn’t spelled out: if nobody answers, I’ll go look myself.

The Logs

The next morning I opened Claude Code. A mini-session, a handful of entries, maybe a minute: SSH to the edge server, fetch traefik.log, drop it locally. Nothing else.

The logs landed as a 1.2 MB file. The actual analysis happened in a claude.ai chat, not in Claude Code — and that was deliberate. The claude.ai chat has no working-directory binding and no tool-loop mechanics; it suits open-ended research, the “what am I missing?” kind. Claude Code is agentic and tied to a project directory — good for targeted mechanical actions like this SSH fetch.

By midday the finding was clear enough that I’d captured it. By early afternoon the self-correction comment was up on #214. Pull logs, analyze, finding, public correction — all inside a single morning.

The Finding

What the logs showed: Cloudflare was returning the block at the edge. No request had ever reached the origin. That’s why the server logs were empty — not because my server was silent, but because it was never asked.

The root cause: a WAF custom rule on the Cloudflare zone. Expression: (ip.geoip.country ne "DE"). A country filter, scoped zone-wide, not to any specific hostname.

I still knew what this rule had been for: at the time, I’d wanted certain services I accessed on the go to be reachable only from Germany. A simple country filter as an extra layer on top of the actual auth. The services I’d had in mind, I no longer use. The rule kept running quietly anyway.

When I deployed PullMD — and later the OAuth variant — under the same Cloudflare zone, the rule silently picked up their hostnames too. And quietly blocked the Anthropic outbound IPs. US-based, range 160.79.104.0/21. Those definitely don’t come from Germany.

The signal from Part 7 — “two independent codebases failing identically” — had still been correct: the problem genuinely wasn’t in the code. But both servers lived behind the same Cloudflare zone. The WAF was returning the block at the edge before the request reached the origin. What looked like an allowlist problem on Anthropic’s side: Cloudflare edge filtering in the wrong costume.

In the end it wasn’t DNS but the WAF — a rule that had long outlived its own purpose.

The Self-Correction

After the fix — a skip rule at Priority 1, Anthropic outbound range 160.79.104.0/21 carved out of SBFM, Managed Firewall, and Rate Limiting — the OAuth flow was live.

Early afternoon, the self-correction comment on #214:

“Update — happy to confirm the fix worked on my end. … For the record: the actual culprit on my side was a country-based GeoBlocking custom rule I had set up long ago for an unrelated service — (ip.geoip.country ne "DE") — which I’d completely forgotten about. It was happily blocking the Anthropic outbound IPs (US-based) along with everything else non-DE. Server logs showed zero traffic because Cloudflare was returning the block at the edge. So the symptom matched the pattern in this thread, but the root cause was fully on my side.” — syswave-dev (issue comment)

I wrote “completely forgotten about” in the comment — that was a shorthand. The use-case was still in my head; what I’d forgotten was that the rule was still running for services I no longer use. A small but honest distinction: not forgotten what — forgotten that.

The comment closed with a note for anyone else who might hit the same thing:

“Worth flagging for anyone else hitting this: check custom WAF rules with country/ASN/IP-range conditions, not just Bot Fight Mode. An old rule that was reasonable when you wrote it can quietly catch new legitimate traffic.” — syswave-dev (issue comment)

The issue stayed closed — no request to reopen, just a correction mark in the margin.

Three Releases in One Day

The pipeline was clear. What followed fit into a single day.

v2.2 — Site Recipe Engine

Six days earlier — on May 6 — v2.2 had already shipped. The origin: Issue #18 from the Reddit wave at the end of April. Sebastian / WinFuture23 had suggested building a declarative engine per host instead of writing individual code patches for each problematic site. The concept: site-recipes.json — a config file that describes, per hostname, how fetch and extraction should work for that domain. DOM cleanup before extraction, render control, site-specific selectors, preferred extractor. Default recipes for Future PLC and GitHub Issues were included.

Self-hosters mount their own recipe files or point PULLMD_SITE_RECIPES elsewhere. Honest limitation: cookie walls are deliberately out of scope — those need operator-supplied cookie state.

v2.3 — OAuth Ships

With the WAF fix in place, the path was clear. feat/oauth was ready. The merge into main came with six conflicts — CHANGELOG, MIGRATION, lib/cache.js, server.js, package.json, plus the lock file. The interim releases v2.0, v2.1, and v2.2 had let the branches diverge several times.

A reviewer subagent caught 1 IMPORTANT and 2 MINOR items the self-review had missed — including .env.example missing the current version label. Fixed in a follow-up commit. 558/558 tests green. PR #24 squash-merged, tag v2.3.0, GitHub Release live. Closes Issues #6 (OAuth Phase 2) and #10 (Claude Desktop Custom Connector).

The cliffhanger from Part 7 was resolved.

Docker :latest — A Stale Note

Shortly after the release, during a manual check of the Docker Hub image listing: :latest was pointing at v2.3.0. It should have been on v1.x — the plan from the v2.0 period had been a grace period for self-hosters until May 16.

What had happened: docker/metadata-action@v5 has a default flavor.latest=auto that sets :latest on every semver tag push, regardless of what else is in the CI config. Commenting out the relevant line doesn’t override that default logic. The fix was an explicit flavor: latest=false line. Then :latest manually reset to v1.1.3.

What had kept the error invisible this long: an Open Brain note from the v2.0 period that stubbornly said :latest is on v1.2.x” — long out of date. I’d never looked at the Docker Hub UI again to check the actual state since writing it. A note you haven’t revisited since writing it can quietly go out of date.

v2.4 — Rendered Markdown View

Third release of the same day. The ticket: Issue #23, posted the evening before by an external user:

“The web GUI currently shows the fetched result as raw Markdown source. Would be great to have an option to view it rendered as actual Markdown in the browser too.” — Issue #23

Small, friendly, done in an afternoon. A Raw/Rendered toggle in the result header, both themes styled, copy button still copies raw. PR #25 squash-merged, tag v2.4.0 pushed, issue closed.

Before clicking close I left a human comment — not a bot boilerplate, but a short note that I liked the idea and the toggle was now in the latest release. For a feature request that was friendly and specific, that costs five minutes. For the reporter, it’s the difference between “someone actually read this” and “the bot replied”.

Three Releases, One Cause

Three releases in one day because the pipeline was finally clear.

What had caused the standstill wasn’t a platform allowlist. It was an old WAF rule on my own zone — written for a service I no longer run, for a use case that had long since disappeared. I had forgotten about it. Not forgotten what it was for — forgotten that it was still running. The rule itself hadn’t done anything wrong. It had done exactly what I’d written it to do. Only everything around it had changed.

“Outside my control” was the wrong attribution. The practical lesson: for any symptom that looks like a platform block, check your own infrastructure first. That includes WAF custom rules with country, ASN, or IP-range conditions, not just Bot Fight Mode.

A rule whose use case has expired keeps running quietly anyway, and sooner or later it catches legitimate traffic that has nothing to do with its original purpose. That’s how infrastructure ages when nobody’s watching.


Part 8 of 9 in the PullMD series.

PullMD Part 7: Spec-Compliance Isn’t Enough | PullMD Part 9: Wiring in Microsoft’s MarkItDown — and ripping three parts back out