PullMD Part 13: An Old Rule
Part 12 had ended on a hard stopper. host_not_allowed. Anthropic’s egress proxy blocks my host before a request ever reaches the origin. The lesson I drew at the time: outside my control.
feat/oauth sat finished on the shelf. I waited. For four days, nothing happened.
Something Started to Nag
Then something started to nag: not a technical problem, more of a mental one.
One evening, @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 leaves Anthropic. As a concrete example, the comment named fbs.synology.me: a Synology DDNS that typically points 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.
A few hours later 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)
The Logs
The next morning I pulled the Traefik logs off the edge server and handed them to a claude.ai chat for analysis, not to 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.
The Finding
What the Traefik log showed: nothing. Not a single request from Anthropic’s proxy, across all the connection attempts. My server wasn’t staying silent, it was never asked in the first place.
So the answer lay further upstream, at the Cloudflare edge. What Cloudflare blocks itself never shows up in my server log.
Claude then walked me through the Cloudflare UI to the Firewall Events, the log that holds the WAF decisions. There they were: requests from Anthropic’s outbound range, every single one blocked, always by the same rule.
I simply hadn’t found that log the first time around, and hadn’t questioned its absence either. Because of the issue, everything looked like “not our problem” anyway.
When other people have problems, I always preach “look at the logs!” This time it got me.
The rule behind it: a WAF custom rule on the Cloudflare zone. Expression: (ip.geoip.country ne "DE"). A country filter, scoped zone-wide, not tied to a hostname.
I still knew what this rule was for: back then I wanted certain services I use while travelling 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 don’t use at all any more. The rule kept running quietly anyway.
When I deployed PullMD and later the OAuth variant under the same zone, they fell under the rule automatically. Nothing was inherited: the rule sat above everything that shows up under that zone.
Which also blocked the Anthropic outbound IPs. US-based, range 160.79.104.0/21. Those definitely don’t come from Germany.
The signal “two independent codebases failing identically” still held from Part 12: the problem genuinely wasn’t in the code. But both servers sat behind the same Cloudflare zone.
That it 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
The fix was a skip rule for the Anthropic range, right at the top of the rule chain. After that the OAuth flow was live.
Early that 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)
The comment closed with a note for anyone else who might run into 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.
Two Releases in One Day
The pipeline was clear. What was still pending fit into a single day.
v2.2 — Site Recipe Engine
Five days earlier, on May 6, v2.2 had already shipped. Its origin: a bug report from Sebastian / WinFuture23, Issue #17 from May 1.
On windowscentral.com the extractor was throwing away paragraphs that sat next to a recommendation widget. His suggestion was a switch that lets you pick the extractor yourself, per site.
The next day that became my Issue #18, and the switch became a declarative engine per host. 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 somewhere else via an environment variable.
Honest limitation: the engine deliberately doesn’t solve cookie walls. Those need operator-supplied cookie state.
v2.3 — OAuth Ships
With the WAF fix, the path was clear. feat/oauth was ready.
I had prepared the merge into main for 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 over.
A reviewer subagent found 1 IMPORTANT and 2 MINOR items the self-review had missed. Among them .env.example without a current version label. Handled in the fix 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).
Docker :latest — A Stale Note
Shortly after the release, during a manual check of the Docker Hub listing: :latest was pointing at v2.3.0. It should have been on v1.x. That was the plan from the v2.0 period, 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 affected line doesn’t override that logic.
The fix was an explicit flavor: latest=false line. Then :latest manually reset to v1.1.3.
What had kept the error invisible for so long: a note from the v2.0 period in my Claude memory project that stubbornly said “:latest is on v1.2.x”. Long out of date. I had never looked at the Docker Hub UI again to check the actual state.
The grace period didn’t hold until May 16 after all: two days later, :latest pointed at the newest release again, v2.4.1.
v2.4 — Rendered Markdown View
Second release of the same day. The ticket: Issue #23, from the evening before, from 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, the copy button still copies raw. PR #25 squash-merged, tag v2.4.0 pushed, issue closed.
Before the close click I left a human comment. Not a bot boilerplate, but a short note that I liked the idea and that the toggle was now in the latest release.
For a friendly feature request that costs five minutes. For the reporter it’s the difference between “someone actually read this” and “the bot replied”.
Two Releases, One Cause
Two 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. Set up for a service I no longer run, with a use case that had long since fallen away. I had forgotten about it.
“Outside my control” was the wrong attribution. The practical lesson: for any symptom that looks like a platform block, check your own infrastructure first.
After that it got quieter for a while. The May push was worked off, the next larger rebuild only came a month later.
In June, Microsoft’s MarkItDown landed in PullMD, as its own sidecar for PDF, Word, PowerPoint, and the rest of the document zoo. Three parts of it I ripped straight back out. One would have quietly sent audio to Google’s API. That’s what Part 14 is about.
Part 13 of 15 in the PullMD series.
← PullMD Part 12: Spec-Compliance Isn’t Enough | PullMD Part 14: Wiring in Microsoft’s MarkItDown — and ripping three parts back out →