A DNS Block That Looks Like a 503 (Blocked Subresources)¶
🛑 Problem¶
A page behind a DNS sinkhole fails in two completely different-looking ways depending on how the blocked domain is requested — and only one of them looks like DNS.
Navigate to a blocked domain directly and the browser is honest:
But when the blocked domain is a subresource — a script, stylesheet, font, or XHR endpoint loaded by a page that itself resolved fine — the failure arrives as an HTTP status instead:
503 Service Unavailable reads as "the vendor's CDN is down." It is not. Nothing was ever sent. The page renders blank or half-broken and you go hunting for an outage that doesn't exist.
The tell is a single-page app whose HTML shell loads 200 but whose body is only:
The shell is served fine. The bundle that fills it is what's blocked.
This is the third symptom in the same family — see A DNS Block That Looks Like a TLS Error and A Pi-hole Block Reaches an AI Agent as an SSRF-Guard Error. Same 0.0.0.0, different disguise.
🔁 Why This Happens¶
Sinkholes in NULL blocking mode answer blocked names with 0.0.0.0 (A) and :: (AAAA) rather than NXDOMAIN. The client accepts that well-formed answer, opens a socket to 0.0.0.0:443, and the connection is refused locally.
For a top-level navigation Chrome has an error page to show, so it names the failure: ERR_CONNECTION_REFUSED. For a subresource fetch there is no error page — the failure travels the resource-loading path, where the DevTools Network panel and the CDP/extension APIs commonly surface it as a generic 503. Same root cause, two presentations, and the second points at the wrong layer entirely.
It gets worse when one vendor flow spans several hostnames. A tracker, an asset CDN, and an API endpoint are often three separate names, and blocklists rarely catch all three — so you get partial breakage rather than a clean failure:
| Hostname | Role | Blocked? | Symptom |
|---|---|---|---|
click.example.com |
click tracker | no | works, redirects onward |
app.example.com |
page shell / SPA HTML | yes | ERR_CONNECTION_REFUSED |
cdn.example.com |
JS bundle | yes | reported as 503 |
Important
Treat a 5xx on a third-party subresource as a DNS block until proven otherwise — especially when the parent page loaded fine. Check the resolver before you check the vendor's status page.
🔍 Diagnosing¶
1. Get the real request list. Don't guess which host broke. Use the DevTools Network panel, or read the request log from automation. Most tools only record once attached — reload after attaching or you'll see an empty list.
2. Compare the sinkhole against public DNS. This is the decisive test:
dig +short cdn.example.com # 0.0.0.0 <- sinkholed
dig +short cdn.example.com @1.1.1.1 # real CDN addresses
3. Fetch the exact failing URL over public DNS. A 200 here proves the "outage" was your own blocklist:
IP=$(dig +short cdn.example.com @1.1.1.1 | grep -E '^[0-9]' | head -1)
curl -sS --http1.1 -o /dev/null -w 'status=%{http_code}\n' \
--resolve "cdn.example.com:443:$IP" \
'https://cdn.example.com/app/assets/index-ABC123.js'
Try --http1.1 before declaring an endpoint broken
Some CDN endpoints return a bare 400 to curl's default HTTP/2 request but 200 to the identical request over HTTP/1.1. Browser-like User-Agent, Accept, and Accept-Language headers matter too on endpoints that fingerprint clients. A 400 from curl is not proof the URL is bad.
⚠️ The dig Trap¶
dig never reads /etc/hosts
dig, host, and nslookup speak DNS directly to a resolver. They do not consult /etc/hosts. Verifying a hosts-file override with dig shows you the sinkhole's answer and convinces you the override failed — while it is in fact working perfectly.
Use something that goes through the system resolver (getaddrinfo):
# macOS
dscacheutil -q host -a name cdn.example.com
# any platform with Python
python3 -c "import socket; print(socket.getaddrinfo('cdn.example.com',443))"
# ping also resolves via the system path
ping -c1 cdn.example.com
🔧 Temporary Bypass via /etc/hosts¶
When a blocked page needs to work once — completing a vendor flow, reading a page behind a sinkholed asset host — a hosts entry beats allowlisting in the sinkhole. It is machine-local, needs no DNS-server restart, and doesn't re-enable the domain for every other device on the LAN.
Back up first, then add one line per blocked hostname:
sudo cp /etc/hosts /etc/hosts.bak-tempfix
echo '203.0.113.10 cdn.example.com' | sudo tee -a /etc/hosts
sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder # macOS
Don't chain the append behind another sudo command
sudo cp ... && echo '...' | sudo tee -a /etc/hosts && ... can complete the cp while silently never performing the append. You end up with a backup, no override, and no error message. Run the tee as its own command, then confirm the file actually changed:
Two further caveats:
- Only the A record is overridden. The AAAA lookup still reaches the sinkhole and still returns
::. Browsers generally fall back to IPv4, but if a page stays broken behind a correct IPv4 override, suspect the IPv6 path. - Pinned CDN addresses go stale. CloudFront and Cloudflare rotate addresses constantly. A hosts override is good for minutes, not weeks.
Clean up¶
Restore and verify — never leave pinned addresses behind:
sudo cp /etc/hosts.bak-tempfix /etc/hosts
sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder
diff /etc/hosts /etc/hosts.bak-tempfix && echo "restored cleanly"
python3 -c "import socket; print(socket.getaddrinfo('cdn.example.com',443))"
That last check should show 0.0.0.0 again, confirming filtering is back on.
🧭 When curl Isn't Enough¶
If the target is a JavaScript SPA, none of the above completes an action. curl fetches the shell and stops — the buttons, the confirmation, and the API call that does the real work all live in the bundle. Once the blocked hosts resolve, drive it in a real browser. The point of the hosts override is to make the browser work, not to replace it.
✅ Summary¶
- A sinkholed subresource is commonly reported as
503, not as a connection error. A 5xx does not mean the vendor is down. - One vendor flow can span several hostnames; blocklists catch some and miss others, producing partial breakage that's easy to misread.
digignores/etc/hosts— verify overrides withdscacheutil,getaddrinfo, orping.- Piping into
sudo teeinside a chained&&can no-op silently. Run it alone and diff the result. - Back up
/etc/hosts, pin only what you need, and tear it down afterward.