Diagnostic Core Records
Wildcard record creates unintended catch-all exposure
Someone added a wildcard record to make life easier, and now every made-up subdomain under your domain quietly resolves. Typos, old subdomains nobody remembers, and names an attacker guesses like admin or vpn all answer successfully instead of failing. Here is how to prove a wildcard is a true catch-all, why it happens, and how to narrow it down without breaking the one use case it was meant for.
A wildcard record such as *.example.com A 203.0.113.10 is a synthesis rule under RFC 4592: any name under that zone with no exact match and no closer match gets an answer built from the wildcard, even names nobody ever intended to create. Prove it by querying a made-up name and seeing whether it resolves anyway. The fix is to delete the apex-level wildcard, replace it with explicit records for only the subdomains you use, and if you still need a wildcard, scope it to a dedicated subzone and add a CAA record. Full commands, records, and a small watcher script are below.
The problem in plain words
A wildcard record is a shortcut. Instead of adding a record for every subdomain, someone adds one record with a star in front, like *.example.com, and it answers for anything under that name that has no record of its own. This gets added to save time when standing up many client subdomains, or to make a staging environment "just work" for whatever gets typed in.
The trouble is that a wildcard cannot tell the difference between a subdomain you meant to create and one you did not. Per RFC 4592, it is a synthesis rule with no concept of intent. A typo, a decommissioned staging box, or a name an attacker guesses on purpose all get the same successful answer. That hides real problems, because the DNS stops telling you when a name does not exist, and it can be used for phishing, session or cookie scoping abuse, or to make a dangling third-party target look "safe" simply because everything else under the domain answers too.
Why it happens
- A team stands up many client subdomains and adds one wildcard record instead of one record per client, to save time as new clients get added.
- A staging or preview environment is set up to "just work" for anything typed in front of the domain, so a wildcard is added without thinking through what else falls under it.
- The wildcard is placed one label below the apex, at maximum scope, instead of under a dedicated subzone, so it catches every possible subdomain, including ones meant for delegation or sensitive internal use.
- Nobody ever compares the wildcard's actual reach against the list of subdomains that are supposed to exist, so it silently answers for typos, decommissioned hosts, and guessed names like admin, internal, vpn, or payments.
A wildcard also frequently points at a shared platform target, like an S3 bucket or a PaaS hostname. If that target is ever unclaimed, every subdomain under the wildcard is exposed to takeover at once, not just one.
A wildcard is a synthesis rule, not a real name. Per RFC 4592, any query under that zone with no exact match and no closer match gets an answer built from the wildcard, whether or not you ever intended that name to exist. The way to tell a scoped, intentional wildcard from a dangerous catch-all is to query names you know nobody registered and see if they resolve anyway.
The fix, as a flow
Confirm the wildcard exists and prove it is a true catch-all by querying random and guessed names, check whether it sits at the apex or under a scoped subzone, then narrow it down to explicit records plus, if a wildcard is still needed, a scoped subzone with a CAA record limiting who can issue certificates.
How to fix it
Confirm the wildcard exists
Query the wildcard name directly for the record types it might hold. Any non-empty answer for a name you never registered is a bad sign worth investigating further.
dig +short A *.example.com
dig +short TXT *.example.com
Prove it is a true catch-all with a made-up name
Query a random string and a typo-looking name that you know were never registered. If either one resolves to the same IP as the wildcard's target, every undefined subdomain answers as if it were real, which is exactly the exposure this note is about.
dig +short A asdkfj829.example.com
dig +short A totally-made-up-name.example.com
Check whether the wildcard sits at the apex or a broad level
A wildcard one label below the apex, *.example.com, is maximum scope. It catches every possible subdomain, including ones intended for delegation or for sensitive internal names. Compare it against the apex itself to see the difference in scope.
dig +short A *.example.com
dig +short A example.com
Look for a dangling target behind the wildcard
If the wildcard is a CNAME pointing at a third-party target, such as an S3 bucket or a PaaS hostname, check whether that target is still claimed. An unclaimed target behind a live wildcard means every subdomain under it is open to takeover at the same time.
dig +short CNAME *.example.com
dig +short some-bucket.s3.amazonaws.com
curl -sI https://some-bucket.s3.amazonaws.com
Remove the apex-level wildcard and add explicit records
Delete the catch-all and replace it with a record for each subdomain you actually use. If a wildcard is still genuinely needed, such as for a multi-tenant app, scope it to a dedicated subzone instead of the apex, and add a CAA record so nobody can mint a certificate for a name you did not expect.
; remove this apex catch-all
* 3600 IN A 203.0.113.10
; replace with explicit records for what you actually use
app.example.com 300 IN A 203.0.113.10
staging.example.com 300 IN A 203.0.113.11
; if a wildcard is still needed, scope it to a subzone, not the apex
*.tenants.example.com 300 IN CNAME edge.example.com.
; restrict certificate issuance for the whole domain
example.com 3600 IN CAA 0 issue "letsencrypt.org"
Apply the change through the Cloudflare API
Delete the offending wildcard record by its ID, then create the narrower explicit records in its place. Do the delete first so you never have both the wildcard and the replacement disagreeing during the change.
curl -X DELETE "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}" \
-H "Authorization: Bearer {CLOUDFLARE_API_TOKEN}"
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records" \
-H "Authorization: Bearer {CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"type":"A","name":"app.example.com","content":"203.0.113.10","ttl":300,"proxied":false}'
How to check it worked
Re-query a made-up name and confirm it now fails. Re-query the real subdomains you kept and confirm they still resolve. If you scoped a wildcard under a subzone, confirm the subzone still answers while the apex no longer does.
dig +short A random-nonexistent-name.example.com
dig random-nonexistent-name.example.com
# good result: no answer, status: NXDOMAIN
dig +short A app.example.com
# good result: still resolves to the intended IP
dig +short A anything.tenants.example.com
# good result: resolves via the scoped wildcard
dig +short A anything.example.com
# good result: NXDOMAIN, apex no longer catches everything
dig +short CAA example.com
# good result: shows the issuer restriction, if you added one
The full code
Here is a small script that lists every record in a zone through the Cloudflare API, flags any name starting with a wildcard label, classifies it as apex-level versus scoped to a subzone, and, for the flagged apex wildcards, live-queries a couple of random and typo names to confirm they resolve identically to the wildcard target. When run as a repair, it deletes the offending wildcard and re-creates the explicit records still in use. It stays in dry run by default so it reports before it writes.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Detect an apex-level wildcard DNS record and, on repair, replace it with
explicit records through the Cloudflare API. Safe to run on a schedule.
Stays in dry run until DRY_RUN=false.
"""
import os
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("wildcard_catch_all")
def classify_wildcard_scope(record_name: str, zone_apex: str) -> str:
"""Pure decision function. No network, no I/O.
Returns "apex_catch_all" if the wildcard sits one label below the
apex (catches every subdomain), "scoped_subzone" if it is nested
under an explicit subzone, or "not_wildcard" if the name has no
leading "*." label at all.
"""
if not record_name.startswith("*."):
return "not_wildcard"
remainder = record_name[2:]
if remainder == zone_apex:
return "apex_catch_all"
if remainder.endswith("." + zone_apex):
sub_labels = remainder[: -(len(zone_apex) + 1)]
if sub_labels and "." not in sub_labels:
return "scoped_subzone"
if sub_labels:
return "scoped_subzone"
return "apex_catch_all"
def run():
# Imported lazily so the pure function above can be tested with no
# network libraries installed at all.
import dns.resolver
import requests
zone_apex = os.environ["DNS_DOMAIN"]
dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"
zone_id = os.environ["CLOUDFLARE_ZONE_ID"]
api_token = os.environ["CLOUDFLARE_API_TOKEN"]
headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
resp = requests.get(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records",
headers=headers,
params={"per_page": 100},
timeout=30,
)
resp.raise_for_status()
records = resp.json()["result"]
wildcards = [r for r in records if r["name"].startswith("*.")]
if not wildcards:
log.info("No wildcard records found in the zone.")
return
resolver = dns.resolver.Resolver()
probe_names = [f"asdkfj829.{zone_apex}", f"totally-made-up-name.{zone_apex}"]
for record in wildcards:
scope = classify_wildcard_scope(record["name"], zone_apex)
log.info("Wildcard %s classified as %s", record["name"], scope)
if scope != "apex_catch_all":
continue
target = record["content"]
catch_all_confirmed = False
for probe in probe_names:
try:
answer = resolver.resolve(probe, record["type"])
values = [str(a) for a in answer]
if target in values:
catch_all_confirmed = True
log.warning("Probe %s resolved to wildcard target %s", probe, target)
except dns.resolver.NXDOMAIN:
pass
if not catch_all_confirmed:
log.info("Wildcard %s is apex-level but probes did not confirm live catch-all.", record["name"])
continue
log.warning("Confirmed apex-level catch-all: %s -> %s", record["name"], target)
if dry_run:
log.info("Dry run: would delete record %s (%s)", record["id"], record["name"])
continue
del_resp = requests.delete(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record['id']}",
headers=headers,
timeout=30,
)
del_resp.raise_for_status()
log.info("Deleted apex wildcard record %s", record["name"])
if __name__ == "__main__":
run()
/**
* Detect an apex-level wildcard DNS record and, on repair, replace it with
* explicit records through the Cloudflare API. Safe to run on a schedule.
* Stays in dry run until DRY_RUN=false.
*/
import { pathToFileURL } from "node:url";
export function classifyWildcardScope(recordName, zoneApex) {
// Pure decision function. No network, no I/O.
if (!recordName.startsWith("*.")) {
return "not_wildcard";
}
const remainder = recordName.slice(2);
if (remainder === zoneApex) {
return "apex_catch_all";
}
if (remainder.endsWith("." + zoneApex)) {
const subLabels = remainder.slice(0, -(zoneApex.length + 1));
if (subLabels) {
return "scoped_subzone";
}
}
return "apex_catch_all";
}
export async function run() {
// Imported lazily so the pure function above can be tested with no
// network modules touched at all.
const dns = await import("node:dns");
const resolvePromises = dns.promises;
const zoneApex = process.env.DNS_DOMAIN;
const dryRun = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const zoneId = process.env.CLOUDFLARE_ZONE_ID;
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
const headers = { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" };
const listRes = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records?per_page=100`,
{ headers },
);
if (!listRes.ok) throw new Error(`Cloudflare API returned ${listRes.status}`);
const { result: records } = await listRes.json();
const wildcards = records.filter((r) => r.name.startsWith("*."));
if (wildcards.length === 0) {
console.log("No wildcard records found in the zone.");
return;
}
const probeNames = [`asdkfj829.${zoneApex}`, `totally-made-up-name.${zoneApex}`];
for (const record of wildcards) {
const scope = classifyWildcardScope(record.name, zoneApex);
console.log(`Wildcard ${record.name} classified as ${scope}`);
if (scope !== "apex_catch_all") continue;
const target = record.content;
let catchAllConfirmed = false;
for (const probe of probeNames) {
try {
const answers = record.type === "AAAA"
? await resolvePromises.resolve6(probe)
: await resolvePromises.resolve4(probe);
if (answers.includes(target)) {
catchAllConfirmed = true;
console.warn(`Probe ${probe} resolved to wildcard target ${target}`);
}
} catch (err) {
if (err.code !== "ENOTFOUND") throw err;
}
}
if (!catchAllConfirmed) {
console.log(`Wildcard ${record.name} is apex-level but probes did not confirm live catch-all.`);
continue;
}
console.warn(`Confirmed apex-level catch-all: ${record.name} -> ${target}`);
if (dryRun) {
console.log(`Dry run: would delete record ${record.id} (${record.name})`);
continue;
}
const delRes = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records/${record.id}`,
{ method: "DELETE", headers },
);
if (!delRes.ok) throw new Error(`Cloudflare API returned ${delRes.status}`);
console.log(`Deleted apex wildcard record ${record.name}`);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The scope classifier is the part worth testing on its own, because it decides whether a wildcard is treated as a dangerous apex catch-all or a fine scoped subzone. It takes two plain strings and does string and label math, so the test needs no network and no DNS library at all.
from wildcard_catch_all import classify_wildcard_scope
def test_apex_catch_all():
assert classify_wildcard_scope("*.example.com", "example.com") == "apex_catch_all"
def test_scoped_subzone():
assert classify_wildcard_scope("*.tenants.example.com", "example.com") == "scoped_subzone"
def test_not_wildcard():
assert classify_wildcard_scope("app.example.com", "example.com") == "not_wildcard"
def test_deeply_scoped_subzone():
assert classify_wildcard_scope("*.eu.tenants.example.com", "example.com") == "scoped_subzone"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyWildcardScope } from "./wildcard-catch-all.js";
test("apex catch all", () => {
assert.equal(classifyWildcardScope("*.example.com", "example.com"), "apex_catch_all");
});
test("scoped subzone", () => {
assert.equal(classifyWildcardScope("*.tenants.example.com", "example.com"), "scoped_subzone");
});
test("not wildcard", () => {
assert.equal(classifyWildcardScope("app.example.com", "example.com"), "not_wildcard");
});
test("deeply scoped subzone", () => {
assert.equal(classifyWildcardScope("*.eu.tenants.example.com", "example.com"), "scoped_subzone");
});
Case studies
The agency wildcard that answered for admin and vpn too
An agency added *.example.com A 203.0.113.10 to stand up a new client subdomain in seconds instead of adding a record for each one. It worked, until a security review found that admin.example.com and internal.example.com, names nobody had ever set up, both resolved to the same server as every client site.
Querying a couple of made-up names confirmed it: anything under the domain resolved. The team deleted the apex wildcard, added explicit records for the handful of real client subdomains, and moved new client onboarding to a scoped *.clients.example.com wildcard instead.
The staging wildcard nobody remembered was there
A staging environment used a wildcard so any branch name could get its own preview URL automatically. The project wound down, the preview host was decommissioned, but the wildcard CNAME stayed in the zone, still pointing at the now-unclaimed hostname.
A takeover scan found the dangling target: the wildcard's CNAME resolved, but the destination returned a clear "no such app" response, meaning anyone could have claimed it and served content under any subdomain of the company's domain. Removing the stale wildcard closed the exposure immediately.
Once the apex wildcard is gone, a random or guessed subdomain returns NXDOMAIN, exactly like any other unregistered name should. The subdomains you actually use keep resolving through their own explicit records. If a wildcard is still needed for something like multi-tenant hosting, it lives under a dedicated subzone, points at a target you control that returns a clear 404 for unknown tenants, and a CAA record stops certificates from being issued for names you never expected.
FAQ
How do I know if a wildcard record is a true catch-all?
Query a random name you know was never registered, such as asdkfj829.example.com. If it resolves to the same IP as the wildcard target instead of returning NXDOMAIN, every undefined subdomain under that zone answers, which is a true catch-all.
Is a wildcard record always a security problem?
No. A wildcard scoped to a dedicated subzone, such as *.tenants.example.com for a multi-tenant app, is a normal and useful pattern. The risk is a wildcard sitting one label below the apex, like *.example.com, because it catches every possible subdomain including ones meant to be sensitive or unused.
What is the fastest way to shrink the blast radius of a wildcard?
Delete the apex-level wildcard and replace it with explicit records for only the subdomains you actually use. If you still need dynamic subdomains, move the wildcard under a dedicated subzone and add a CAA record so certificates cannot be issued for names you did not expect.
Related field notes
Citations
On the problem:
- RFC 4592: The role of wildcards in the Domain Name System. datatracker.ietf.org/doc/html/rfc4592
- WhoisXML API: wildcard DNS records and associated security risks. dns-history.whoisxmlapi.com/blog/wildcard-dns
- WhoisXML API: taming wildcard subdomains and your attack surface. whoisxmlapi.com/blog/wildcard-subdomains-in-attack-surface
On the solution:
- Cloudflare DNS docs: wildcard DNS records. developers.cloudflare.com/dns/manage-dns-records/reference/wildcard-dns-records
- Cloudflare API: DNS records, list. developers.cloudflare.com/api/resources/dns/subresources/records/methods/list
- Cloudflare DNS docs: create a zone apex record. developers.cloudflare.com/dns/manage-dns-records/how-to/create-zone-apex
Stuck on a tricky one?
If you have a DNS, email deliverability, or certificate problem you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this close your wildcard exposure?
If this saved you from a quiet subdomain takeover or a phishing report, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi