Match DNS records by exact name, not substring - #52

Merged
kierenj merged 2 commits into
developfrom
fix/exact-dns-record-match
Aug 19, 2026
Merged

Match DNS records by exact name, not substring#52
kierenj merged 2 commits into
developfrom
fix/exact-dns-record-match

Conversation

@kierenj

Copy link
Copy Markdown
Member

The bug

Both record lookups passed the bare $subdomain to Cloudflare's search parameter and then filtered with contains. Both are substring matches, so any subdomain that is a prefix of another selected the other service's record as well.

curl .../dns_records?search=$subdomain \
| jq -r ".result[] | select(.name | contains(\"$subdomain\")) | .id"

$cloudflare_record_id is also unquoted, so a multi-match expands several ids into the request URL.

What it did in production

The Red Pepper portal deploys to live with subdomain pepper. That matched both pepper.river.red and pepper-mcp.river.red, and the deploy removed the MCP service's record:

23:12:03 backend job → created pepper.river.red
23:12:28 mcp job → created pepper-mcp.river.red
~later frontend job → search "pepper" matched BOTH → pepper-mcp deleted

pepper-mcp.river.red went NXDOMAIN while its pod stayed healthy. The zone's SOA minimum is 1800s, so clients that resolved it during the gap kept failing for up to 30 minutes after the record was restored — which is what made it look intermittent.

Reproduced against the real response shape:

$ echo "$FIXTURE" | jq -r '.result[] | select(.name | contains("pepper")) | .id'
AAA BBB # pepper.river.red AND pepper-mcp.river.red

The fix

Both lookups now match the full $subdomain.$CF_API_DOMAIN, in the API query and again in jq, via a shared lookup_record_id. A duplicate aborts rather than building a URL from several ids. The zone lookup had the same flaw — river.red also matched myriver.red — and is exact now too.

Two changes came with it:

  • Both credential styles are accepted. Auth moved to API tokens in 0.0.21, but the charts still pin 0.0.20 and still pass CF_API_EMAIL. Bumping them to a Bearer-only version would have broken every DNS job in the estate until the credential was migrated, so CF_API_EMAIL now selects the Global API Key headers. This is what makes the chart bump safe to merge on its own.
  • Deleting a non-existent record is a no-op, rather than a DELETE against an empty id.

Verification

bash -n clean, and run end to end against a stubbed Cloudflare API and kubectl:

caseresult
create pepperPATCH .../AAA only — pepper-mcp untouched
create pepper-mcpPATCH .../BBB only
delete pepperDELETE .../AAA only
create brand-newno match → create path
zone lookuppicks river.red, not myriver.red

Rollout

This needs an image publish before the chart bump lands:

az acr login -n RedRiver
docker build . --tag redriver.azurecr.io/cloudflare-cli:0.0.24 --push

Companion PR bumping saffron-app-helm3 and cinnamon-app-helm3 from 0.0.20 to 0.0.24 follows in RedRiverSoftware/k8s.

🤖 Generated with Claude Code

A deploy could delete or repoint another service's DNS record. Both lookups passed the bare
subdomain to Cloudflare's `search` parameter and then filtered with `contains`, and both are
substring matches, so a subdomain that is a prefix of another selected the other service's record
too.
Live example: the portal deploys with subdomain `pepper`, which matched both `pepper.river.red` and
`pepper-mcp.river.red`. The lookup returned two ids into an unquoted variable, and the request URL
that got built from it deleted the MCP service's record. That host then went NXDOMAIN, and because
the zone's SOA minimum is 1800s, clients that looked it up during the gap stayed broken for up to
half an hour after the record came back.
Lookups now filter on the full `$subdomain.$CF_API_DOMAIN`, in the query and again in jq, and a
duplicate stops the script rather than expanding several ids into a URL. The zone lookup had the
same flaw - `river.red` also matched `myriver.red` - and is exact now too.
Two things came with it:
- Both credential styles are accepted. Auth moved to API tokens in 0.0.21 but the charts still pin
0.0.20 and still pass CF_API_EMAIL, so a chart bump would have broken every DNS job in the estate
until the credential was migrated. CF_API_EMAIL now selects the Global API Key headers.
- Deleting a record that does not exist is a no-op rather than a DELETE against an empty id.
Verified against a stubbed API: `create pepper` and `delete pepper` touch only pepper.river.red,
`create pepper-mcp` touches only pepper-mcp.river.red, an unknown subdomain creates rather than
patches, and the zone lookup picks river.red over myriver.red.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CopilotAI lite review requested due to automatic review settings August 19, 2026 05:56

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a production-impacting Cloudflare DNS record lookup bug by switching from substring-based matching to exact FQDN matching, preventing one service from modifying or deleting another service’s DNS record when names share prefixes (e.g., pepper vs pepper-mcp). Also adds compatibility for both Cloudflare API token auth and Global API Key auth to keep older Helm charts working during rollout.

Changes:

  • Switch DNS record and zone lookups to exact-name matching (name=$fqdn + exact jq filter), and abort on duplicates.
  • Add dual authentication support: Bearer token by default, Global API Key headers when CF_API_EMAIL is present.
  • Make delete a no-op when the target record does not exist.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

FileDescription
readme.mdDocuments the new authentication behavior and exact-record matching rationale.
k8s-tools.shImplements exact DNS record lookup via shared helper, adds dual auth header selection, and makes delete idempotent when record is missing.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadk8s-tools.sh
Comment on lines +55 to +68
lookup_record_id() {
local ids
ids=$(curl -s -G "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
--data-urlencode "name=$fqdn" \
--data-urlencode "type=$record_type" \
"${auth[@]}" | jq -r --arg fqdn "$fqdn" '.result[] | select(.name == $fqdn) | .id')

if [ "$(printf '%s' "$ids" | grep -c .)" -gt 1 ]; then
echo "found more than one $record_type record named $fqdn - refusing to guess" >&2
exit 1
fi

printf '%s' "$ids"
}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the location — the guard was broken. The stated mechanism isn't the cause though, so recording the real one.

Command substitution preserves embedded newlines on assignment; collapsing to spaces happens on unquoted expansion, which this doesn't do:

$ ids=$(printf 'AAA\nBBB\n'); printf '%s' "$ids" | od -c
0000000 A A A \n B B B
$ printf '%s' "$ids" | grep -c . # quoted
2
$ printf '%s' $ids | grep -c . # unquoted, for contrast
1

So grep -c . counted correctly. The actual defect was the call site: cloudflare_record_id=$(lookup_record_id) runs the function in a subshell, so exit 1 ended only the subshell. The caller received empty output, read it as "no record exists", and took the create branch — so a duplicate quietly added a third record instead of aborting. Worse than building a bad URL.

Confirmed by running it against a stubbed API returning two records of one name: it printed creating for first time... and exited 0.

Fixed by having the helper assign to the global and calling it plainly, so the exit ends the script. Same stub now gives:

create DUPLICATE : exit=1 (no PATCH/POST/DELETE issued)
delete DUPLICATE : exit=1 (no PATCH/POST/DELETE issued)

Full matrix re-run and unchanged otherwise: create pepper.../AAA only, create pepper-mcp.../BBB only, delete pepperDELETE .../AAA, unknown subdomain → create path, delete-missing → no request.

The guard was called as cloudflare_record_id=$(lookup_record_id), which runs the function in a
subshell, so its `exit 1` ended only that subshell. The caller got empty output, read it as "no
record exists", and created another record - the opposite of stopping.
The helper now assigns to the global and is called plainly, so the exit ends the script.
Verified against the stubbed API: a zone holding two records of the same name exits 1 on both
create and delete having issued no PATCH, POST or DELETE. The other cases are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kierenj

Copy link
Copy Markdown
MemberAuthor

Verified against the live Cloudflare API

Re-tested with the real credential (the Global API Key from the cluster secret, i.e. exactly what the charts pass today) rather than a stub.

The bug is worse than described

The old lookup for subdomain pepper on river.red returns seven records, not two:

cdf598c6… pepper-mcp.river.red A ← different service
21f0024d… pepper.river.red A ← its own
070eeb9a… _acme-challenge.pepper.river.red TXT
b55622cd… _acme-challenge.pepper.river.red TXT
8aed2e72… _acme-challenge.pepper.river.red TXT
456b71ec… _acme-challenge.pepper.river.red TXT
33cf8312… _acme-challenge.pepper.river.red TXT

So the blast radius includes cert-manager's DNS-01 challenge records, not just sibling services — pepper.river.red is issued by letsencrypt-production-dnschallenge. Mixed record types come back too, which is why the fix filters on type as well as exact name.

The exact lookup returns precisely one:

?name=pepper.river.red&type=A → 21f0024d… pepper.river.red
?name=pepper-mcp.river.red&type=A → cdf598c6… pepper-mcp.river.red

End-to-end, real API

Ran the actual script against red-river.app with scratch names reproducing the pepper / pepper-mcp shape:

stepresult
create zz-cfcli-verify (absent)create path, new record
create zz-cfcli-verify-mcpcreate path, second record
create zz-cfcli-verify (both exist)update path — both ids unchanged, sibling untouched
delete zz-cfcli-verifydeleted its own id only; sibling remained
delete zz-cfcli-verify againnothing to delete, exit 0
delete zz-cfcli-verify-mcpcleanup, zone clean

The "both exist" listing was produced with the old query (search=zz-cfcli-verify) and returned both records — so the old code would have had two ids at that point.

Auth

The Global API Key path is exercised by all of the above, confirming the dual-auth change: a Bearer-only build would have failed against this credential, which is what would have broken the estate on a naive chart bump.

All scratch records removed. Live river.red untouched — 21f0024d… and cdf598c6… unchanged, pepper-mcp.river.red/health/ready and pepper.river.red/authorize both 200.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@kierenj
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Match DNS records by exact name, not substring - #52

Merged
kierenj merged 2 commits into
developfrom
fix/exact-dns-record-match
Aug 19, 2026
Merged

Match DNS records by exact name, not substring#52
kierenj merged 2 commits into
developfrom
fix/exact-dns-record-match

Conversation

@kierenj

Copy link
Copy Markdown
Member

The bug

Both record lookups passed the bare $subdomain to Cloudflare's search parameter and then filtered with contains. Both are substring matches, so any subdomain that is a prefix of another selected the other service's record as well.

curl .../dns_records?search=$subdomain \
| jq -r ".result[] | select(.name | contains(\"$subdomain\")) | .id"

$cloudflare_record_id is also unquoted, so a multi-match expands several ids into the request URL.

What it did in production

The Red Pepper portal deploys to live with subdomain pepper. That matched both pepper.river.red and pepper-mcp.river.red, and the deploy removed the MCP service's record:

23:12:03 backend job → created pepper.river.red
23:12:28 mcp job → created pepper-mcp.river.red
~later frontend job → search "pepper" matched BOTH → pepper-mcp deleted

pepper-mcp.river.red went NXDOMAIN while its pod stayed healthy. The zone's SOA minimum is 1800s, so clients that resolved it during the gap kept failing for up to 30 minutes after the record was restored — which is what made it look intermittent.

Reproduced against the real response shape:

$ echo "$FIXTURE" | jq -r '.result[] | select(.name | contains("pepper")) | .id'
AAA BBB # pepper.river.red AND pepper-mcp.river.red

The fix

Both lookups now match the full $subdomain.$CF_API_DOMAIN, in the API query and again in jq, via a shared lookup_record_id. A duplicate aborts rather than building a URL from several ids. The zone lookup had the same flaw — river.red also matched myriver.red — and is exact now too.

Two changes came with it:

  • Both credential styles are accepted. Auth moved to API tokens in 0.0.21, but the charts still pin 0.0.20 and still pass CF_API_EMAIL. Bumping them to a Bearer-only version would have broken every DNS job in the estate until the credential was migrated, so CF_API_EMAIL now selects the Global API Key headers. This is what makes the chart bump safe to merge on its own.
  • Deleting a non-existent record is a no-op, rather than a DELETE against an empty id.

Verification

bash -n clean, and run end to end against a stubbed Cloudflare API and kubectl:

caseresult
create pepperPATCH .../AAA only — pepper-mcp untouched
create pepper-mcpPATCH .../BBB only
delete pepperDELETE .../AAA only
create brand-newno match → create path
zone lookuppicks river.red, not myriver.red

Rollout

This needs an image publish before the chart bump lands:

az acr login -n RedRiver
docker build . --tag redriver.azurecr.io/cloudflare-cli:0.0.24 --push

Companion PR bumping saffron-app-helm3 and cinnamon-app-helm3 from 0.0.20 to 0.0.24 follows in RedRiverSoftware/k8s.

🤖 Generated with Claude Code

A deploy could delete or repoint another service's DNS record. Both lookups passed the bare
subdomain to Cloudflare's `search` parameter and then filtered with `contains`, and both are
substring matches, so a subdomain that is a prefix of another selected the other service's record
too.
Live example: the portal deploys with subdomain `pepper`, which matched both `pepper.river.red` and
`pepper-mcp.river.red`. The lookup returned two ids into an unquoted variable, and the request URL
that got built from it deleted the MCP service's record. That host then went NXDOMAIN, and because
the zone's SOA minimum is 1800s, clients that looked it up during the gap stayed broken for up to
half an hour after the record came back.
Lookups now filter on the full `$subdomain.$CF_API_DOMAIN`, in the query and again in jq, and a
duplicate stops the script rather than expanding several ids into a URL. The zone lookup had the
same flaw - `river.red` also matched `myriver.red` - and is exact now too.
Two things came with it:
- Both credential styles are accepted. Auth moved to API tokens in 0.0.21 but the charts still pin
0.0.20 and still pass CF_API_EMAIL, so a chart bump would have broken every DNS job in the estate
until the credential was migrated. CF_API_EMAIL now selects the Global API Key headers.
- Deleting a record that does not exist is a no-op rather than a DELETE against an empty id.
Verified against a stubbed API: `create pepper` and `delete pepper` touch only pepper.river.red,
`create pepper-mcp` touches only pepper-mcp.river.red, an unknown subdomain creates rather than
patches, and the zone lookup picks river.red over myriver.red.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CopilotAI lite review requested due to automatic review settings August 19, 2026 05:56

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a production-impacting Cloudflare DNS record lookup bug by switching from substring-based matching to exact FQDN matching, preventing one service from modifying or deleting another service’s DNS record when names share prefixes (e.g., pepper vs pepper-mcp). Also adds compatibility for both Cloudflare API token auth and Global API Key auth to keep older Helm charts working during rollout.

Changes:

  • Switch DNS record and zone lookups to exact-name matching (name=$fqdn + exact jq filter), and abort on duplicates.
  • Add dual authentication support: Bearer token by default, Global API Key headers when CF_API_EMAIL is present.
  • Make delete a no-op when the target record does not exist.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

FileDescription
readme.mdDocuments the new authentication behavior and exact-record matching rationale.
k8s-tools.shImplements exact DNS record lookup via shared helper, adds dual auth header selection, and makes delete idempotent when record is missing.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadk8s-tools.sh
Comment on lines +55 to +68
lookup_record_id() {
local ids
ids=$(curl -s -G "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
--data-urlencode "name=$fqdn" \
--data-urlencode "type=$record_type" \
"${auth[@]}" | jq -r --arg fqdn "$fqdn" '.result[] | select(.name == $fqdn) | .id')

if [ "$(printf '%s' "$ids" | grep -c .)" -gt 1 ]; then
echo "found more than one $record_type record named $fqdn - refusing to guess" >&2
exit 1
fi

printf '%s' "$ids"
}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the location — the guard was broken. The stated mechanism isn't the cause though, so recording the real one.

Command substitution preserves embedded newlines on assignment; collapsing to spaces happens on unquoted expansion, which this doesn't do:

$ ids=$(printf 'AAA\nBBB\n'); printf '%s' "$ids" | od -c
0000000 A A A \n B B B
$ printf '%s' "$ids" | grep -c . # quoted
2
$ printf '%s' $ids | grep -c . # unquoted, for contrast
1

So grep -c . counted correctly. The actual defect was the call site: cloudflare_record_id=$(lookup_record_id) runs the function in a subshell, so exit 1 ended only the subshell. The caller received empty output, read it as "no record exists", and took the create branch — so a duplicate quietly added a third record instead of aborting. Worse than building a bad URL.

Confirmed by running it against a stubbed API returning two records of one name: it printed creating for first time... and exited 0.

Fixed by having the helper assign to the global and calling it plainly, so the exit ends the script. Same stub now gives:

create DUPLICATE : exit=1 (no PATCH/POST/DELETE issued)
delete DUPLICATE : exit=1 (no PATCH/POST/DELETE issued)

Full matrix re-run and unchanged otherwise: create pepper.../AAA only, create pepper-mcp.../BBB only, delete pepperDELETE .../AAA, unknown subdomain → create path, delete-missing → no request.

The guard was called as cloudflare_record_id=$(lookup_record_id), which runs the function in a
subshell, so its `exit 1` ended only that subshell. The caller got empty output, read it as "no
record exists", and created another record - the opposite of stopping.
The helper now assigns to the global and is called plainly, so the exit ends the script.
Verified against the stubbed API: a zone holding two records of the same name exits 1 on both
create and delete having issued no PATCH, POST or DELETE. The other cases are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kierenj

Copy link
Copy Markdown
MemberAuthor

Verified against the live Cloudflare API

Re-tested with the real credential (the Global API Key from the cluster secret, i.e. exactly what the charts pass today) rather than a stub.

The bug is worse than described

The old lookup for subdomain pepper on river.red returns seven records, not two:

cdf598c6… pepper-mcp.river.red A ← different service
21f0024d… pepper.river.red A ← its own
070eeb9a… _acme-challenge.pepper.river.red TXT
b55622cd… _acme-challenge.pepper.river.red TXT
8aed2e72… _acme-challenge.pepper.river.red TXT
456b71ec… _acme-challenge.pepper.river.red TXT
33cf8312… _acme-challenge.pepper.river.red TXT

So the blast radius includes cert-manager's DNS-01 challenge records, not just sibling services — pepper.river.red is issued by letsencrypt-production-dnschallenge. Mixed record types come back too, which is why the fix filters on type as well as exact name.

The exact lookup returns precisely one:

?name=pepper.river.red&type=A → 21f0024d… pepper.river.red
?name=pepper-mcp.river.red&type=A → cdf598c6… pepper-mcp.river.red

End-to-end, real API

Ran the actual script against red-river.app with scratch names reproducing the pepper / pepper-mcp shape:

stepresult
create zz-cfcli-verify (absent)create path, new record
create zz-cfcli-verify-mcpcreate path, second record
create zz-cfcli-verify (both exist)update path — both ids unchanged, sibling untouched
delete zz-cfcli-verifydeleted its own id only; sibling remained
delete zz-cfcli-verify againnothing to delete, exit 0
delete zz-cfcli-verify-mcpcleanup, zone clean

The "both exist" listing was produced with the old query (search=zz-cfcli-verify) and returned both records — so the old code would have had two ids at that point.

Auth

The Global API Key path is exercised by all of the above, confirming the dual-auth change: a Bearer-only build would have failed against this credential, which is what would have broken the estate on a naive chart bump.

All scratch records removed. Live river.red untouched — 21f0024d… and cdf598c6… unchanged, pepper-mcp.river.red/health/ready and pepper.river.red/authorize both 200.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@kierenj
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Match DNS records by exact name, not substring - #52

Merged
kierenj merged 2 commits into
developfrom
fix/exact-dns-record-match
Aug 19, 2026
Merged

Match DNS records by exact name, not substring#52
kierenj merged 2 commits into
developfrom
fix/exact-dns-record-match

Conversation

@kierenj

Copy link
Copy Markdown
Member

The bug

Both record lookups passed the bare $subdomain to Cloudflare's search parameter and then filtered with contains. Both are substring matches, so any subdomain that is a prefix of another selected the other service's record as well.

curl .../dns_records?search=$subdomain \
| jq -r ".result[] | select(.name | contains(\"$subdomain\")) | .id"

$cloudflare_record_id is also unquoted, so a multi-match expands several ids into the request URL.

What it did in production

The Red Pepper portal deploys to live with subdomain pepper. That matched both pepper.river.red and pepper-mcp.river.red, and the deploy removed the MCP service's record:

23:12:03 backend job → created pepper.river.red
23:12:28 mcp job → created pepper-mcp.river.red
~later frontend job → search "pepper" matched BOTH → pepper-mcp deleted

pepper-mcp.river.red went NXDOMAIN while its pod stayed healthy. The zone's SOA minimum is 1800s, so clients that resolved it during the gap kept failing for up to 30 minutes after the record was restored — which is what made it look intermittent.

Reproduced against the real response shape:

$ echo "$FIXTURE" | jq -r '.result[] | select(.name | contains("pepper")) | .id'
AAA BBB # pepper.river.red AND pepper-mcp.river.red

The fix

Both lookups now match the full $subdomain.$CF_API_DOMAIN, in the API query and again in jq, via a shared lookup_record_id. A duplicate aborts rather than building a URL from several ids. The zone lookup had the same flaw — river.red also matched myriver.red — and is exact now too.

Two changes came with it:

  • Both credential styles are accepted. Auth moved to API tokens in 0.0.21, but the charts still pin 0.0.20 and still pass CF_API_EMAIL. Bumping them to a Bearer-only version would have broken every DNS job in the estate until the credential was migrated, so CF_API_EMAIL now selects the Global API Key headers. This is what makes the chart bump safe to merge on its own.
  • Deleting a non-existent record is a no-op, rather than a DELETE against an empty id.

Verification

bash -n clean, and run end to end against a stubbed Cloudflare API and kubectl:

caseresult
create pepperPATCH .../AAA only — pepper-mcp untouched
create pepper-mcpPATCH .../BBB only
delete pepperDELETE .../AAA only
create brand-newno match → create path
zone lookuppicks river.red, not myriver.red

Rollout

This needs an image publish before the chart bump lands:

az acr login -n RedRiver
docker build . --tag redriver.azurecr.io/cloudflare-cli:0.0.24 --push

Companion PR bumping saffron-app-helm3 and cinnamon-app-helm3 from 0.0.20 to 0.0.24 follows in RedRiverSoftware/k8s.

🤖 Generated with Claude Code

A deploy could delete or repoint another service's DNS record. Both lookups passed the bare
subdomain to Cloudflare's `search` parameter and then filtered with `contains`, and both are
substring matches, so a subdomain that is a prefix of another selected the other service's record
too.
Live example: the portal deploys with subdomain `pepper`, which matched both `pepper.river.red` and
`pepper-mcp.river.red`. The lookup returned two ids into an unquoted variable, and the request URL
that got built from it deleted the MCP service's record. That host then went NXDOMAIN, and because
the zone's SOA minimum is 1800s, clients that looked it up during the gap stayed broken for up to
half an hour after the record came back.
Lookups now filter on the full `$subdomain.$CF_API_DOMAIN`, in the query and again in jq, and a
duplicate stops the script rather than expanding several ids into a URL. The zone lookup had the
same flaw - `river.red` also matched `myriver.red` - and is exact now too.
Two things came with it:
- Both credential styles are accepted. Auth moved to API tokens in 0.0.21 but the charts still pin
0.0.20 and still pass CF_API_EMAIL, so a chart bump would have broken every DNS job in the estate
until the credential was migrated. CF_API_EMAIL now selects the Global API Key headers.
- Deleting a record that does not exist is a no-op rather than a DELETE against an empty id.
Verified against a stubbed API: `create pepper` and `delete pepper` touch only pepper.river.red,
`create pepper-mcp` touches only pepper-mcp.river.red, an unknown subdomain creates rather than
patches, and the zone lookup picks river.red over myriver.red.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CopilotAI lite review requested due to automatic review settings August 19, 2026 05:56

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a production-impacting Cloudflare DNS record lookup bug by switching from substring-based matching to exact FQDN matching, preventing one service from modifying or deleting another service’s DNS record when names share prefixes (e.g., pepper vs pepper-mcp). Also adds compatibility for both Cloudflare API token auth and Global API Key auth to keep older Helm charts working during rollout.

Changes:

  • Switch DNS record and zone lookups to exact-name matching (name=$fqdn + exact jq filter), and abort on duplicates.
  • Add dual authentication support: Bearer token by default, Global API Key headers when CF_API_EMAIL is present.
  • Make delete a no-op when the target record does not exist.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

FileDescription
readme.mdDocuments the new authentication behavior and exact-record matching rationale.
k8s-tools.shImplements exact DNS record lookup via shared helper, adds dual auth header selection, and makes delete idempotent when record is missing.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadk8s-tools.sh
Comment on lines +55 to +68
lookup_record_id() {
local ids
ids=$(curl -s -G "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
--data-urlencode "name=$fqdn" \
--data-urlencode "type=$record_type" \
"${auth[@]}" | jq -r --arg fqdn "$fqdn" '.result[] | select(.name == $fqdn) | .id')

if [ "$(printf '%s' "$ids" | grep -c .)" -gt 1 ]; then
echo "found more than one $record_type record named $fqdn - refusing to guess" >&2
exit 1
fi

printf '%s' "$ids"
}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the location — the guard was broken. The stated mechanism isn't the cause though, so recording the real one.

Command substitution preserves embedded newlines on assignment; collapsing to spaces happens on unquoted expansion, which this doesn't do:

$ ids=$(printf 'AAA\nBBB\n'); printf '%s' "$ids" | od -c
0000000 A A A \n B B B
$ printf '%s' "$ids" | grep -c . # quoted
2
$ printf '%s' $ids | grep -c . # unquoted, for contrast
1

So grep -c . counted correctly. The actual defect was the call site: cloudflare_record_id=$(lookup_record_id) runs the function in a subshell, so exit 1 ended only the subshell. The caller received empty output, read it as "no record exists", and took the create branch — so a duplicate quietly added a third record instead of aborting. Worse than building a bad URL.

Confirmed by running it against a stubbed API returning two records of one name: it printed creating for first time... and exited 0.

Fixed by having the helper assign to the global and calling it plainly, so the exit ends the script. Same stub now gives:

create DUPLICATE : exit=1 (no PATCH/POST/DELETE issued)
delete DUPLICATE : exit=1 (no PATCH/POST/DELETE issued)

Full matrix re-run and unchanged otherwise: create pepper.../AAA only, create pepper-mcp.../BBB only, delete pepperDELETE .../AAA, unknown subdomain → create path, delete-missing → no request.

The guard was called as cloudflare_record_id=$(lookup_record_id), which runs the function in a
subshell, so its `exit 1` ended only that subshell. The caller got empty output, read it as "no
record exists", and created another record - the opposite of stopping.
The helper now assigns to the global and is called plainly, so the exit ends the script.
Verified against the stubbed API: a zone holding two records of the same name exits 1 on both
create and delete having issued no PATCH, POST or DELETE. The other cases are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kierenj

Copy link
Copy Markdown
MemberAuthor

Verified against the live Cloudflare API

Re-tested with the real credential (the Global API Key from the cluster secret, i.e. exactly what the charts pass today) rather than a stub.

The bug is worse than described

The old lookup for subdomain pepper on river.red returns seven records, not two:

cdf598c6… pepper-mcp.river.red A ← different service
21f0024d… pepper.river.red A ← its own
070eeb9a… _acme-challenge.pepper.river.red TXT
b55622cd… _acme-challenge.pepper.river.red TXT
8aed2e72… _acme-challenge.pepper.river.red TXT
456b71ec… _acme-challenge.pepper.river.red TXT
33cf8312… _acme-challenge.pepper.river.red TXT

So the blast radius includes cert-manager's DNS-01 challenge records, not just sibling services — pepper.river.red is issued by letsencrypt-production-dnschallenge. Mixed record types come back too, which is why the fix filters on type as well as exact name.

The exact lookup returns precisely one:

?name=pepper.river.red&type=A → 21f0024d… pepper.river.red
?name=pepper-mcp.river.red&type=A → cdf598c6… pepper-mcp.river.red

End-to-end, real API

Ran the actual script against red-river.app with scratch names reproducing the pepper / pepper-mcp shape:

stepresult
create zz-cfcli-verify (absent)create path, new record
create zz-cfcli-verify-mcpcreate path, second record
create zz-cfcli-verify (both exist)update path — both ids unchanged, sibling untouched
delete zz-cfcli-verifydeleted its own id only; sibling remained
delete zz-cfcli-verify againnothing to delete, exit 0
delete zz-cfcli-verify-mcpcleanup, zone clean

The "both exist" listing was produced with the old query (search=zz-cfcli-verify) and returned both records — so the old code would have had two ids at that point.

Auth

The Global API Key path is exercised by all of the above, confirming the dual-auth change: a Bearer-only build would have failed against this credential, which is what would have broken the estate on a naive chart bump.

All scratch records removed. Live river.red untouched — 21f0024d… and cdf598c6… unchanged, pepper-mcp.river.red/health/ready and pepper.river.red/authorize both 200.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@kierenj
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Match DNS records by exact name, not substring - #52

Merged
kierenj merged 2 commits into
developfrom
fix/exact-dns-record-match
Aug 19, 2026
Merged

Match DNS records by exact name, not substring#52
kierenj merged 2 commits into
developfrom
fix/exact-dns-record-match

Conversation

@kierenj

Copy link
Copy Markdown
Member

The bug

Both record lookups passed the bare $subdomain to Cloudflare's search parameter and then filtered with contains. Both are substring matches, so any subdomain that is a prefix of another selected the other service's record as well.

curl .../dns_records?search=$subdomain \
| jq -r ".result[] | select(.name | contains(\"$subdomain\")) | .id"

$cloudflare_record_id is also unquoted, so a multi-match expands several ids into the request URL.

What it did in production

The Red Pepper portal deploys to live with subdomain pepper. That matched both pepper.river.red and pepper-mcp.river.red, and the deploy removed the MCP service's record:

23:12:03 backend job → created pepper.river.red
23:12:28 mcp job → created pepper-mcp.river.red
~later frontend job → search "pepper" matched BOTH → pepper-mcp deleted

pepper-mcp.river.red went NXDOMAIN while its pod stayed healthy. The zone's SOA minimum is 1800s, so clients that resolved it during the gap kept failing for up to 30 minutes after the record was restored — which is what made it look intermittent.

Reproduced against the real response shape:

$ echo "$FIXTURE" | jq -r '.result[] | select(.name | contains("pepper")) | .id'
AAA BBB # pepper.river.red AND pepper-mcp.river.red

The fix

Both lookups now match the full $subdomain.$CF_API_DOMAIN, in the API query and again in jq, via a shared lookup_record_id. A duplicate aborts rather than building a URL from several ids. The zone lookup had the same flaw — river.red also matched myriver.red — and is exact now too.

Two changes came with it:

  • Both credential styles are accepted. Auth moved to API tokens in 0.0.21, but the charts still pin 0.0.20 and still pass CF_API_EMAIL. Bumping them to a Bearer-only version would have broken every DNS job in the estate until the credential was migrated, so CF_API_EMAIL now selects the Global API Key headers. This is what makes the chart bump safe to merge on its own.
  • Deleting a non-existent record is a no-op, rather than a DELETE against an empty id.

Verification

bash -n clean, and run end to end against a stubbed Cloudflare API and kubectl:

caseresult
create pepperPATCH .../AAA only — pepper-mcp untouched
create pepper-mcpPATCH .../BBB only
delete pepperDELETE .../AAA only
create brand-newno match → create path
zone lookuppicks river.red, not myriver.red

Rollout

This needs an image publish before the chart bump lands:

az acr login -n RedRiver
docker build . --tag redriver.azurecr.io/cloudflare-cli:0.0.24 --push

Companion PR bumping saffron-app-helm3 and cinnamon-app-helm3 from 0.0.20 to 0.0.24 follows in RedRiverSoftware/k8s.

🤖 Generated with Claude Code

A deploy could delete or repoint another service's DNS record. Both lookups passed the bare
subdomain to Cloudflare's `search` parameter and then filtered with `contains`, and both are
substring matches, so a subdomain that is a prefix of another selected the other service's record
too.
Live example: the portal deploys with subdomain `pepper`, which matched both `pepper.river.red` and
`pepper-mcp.river.red`. The lookup returned two ids into an unquoted variable, and the request URL
that got built from it deleted the MCP service's record. That host then went NXDOMAIN, and because
the zone's SOA minimum is 1800s, clients that looked it up during the gap stayed broken for up to
half an hour after the record came back.
Lookups now filter on the full `$subdomain.$CF_API_DOMAIN`, in the query and again in jq, and a
duplicate stops the script rather than expanding several ids into a URL. The zone lookup had the
same flaw - `river.red` also matched `myriver.red` - and is exact now too.
Two things came with it:
- Both credential styles are accepted. Auth moved to API tokens in 0.0.21 but the charts still pin
0.0.20 and still pass CF_API_EMAIL, so a chart bump would have broken every DNS job in the estate
until the credential was migrated. CF_API_EMAIL now selects the Global API Key headers.
- Deleting a record that does not exist is a no-op rather than a DELETE against an empty id.
Verified against a stubbed API: `create pepper` and `delete pepper` touch only pepper.river.red,
`create pepper-mcp` touches only pepper-mcp.river.red, an unknown subdomain creates rather than
patches, and the zone lookup picks river.red over myriver.red.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CopilotAI lite review requested due to automatic review settings August 19, 2026 05:56

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a production-impacting Cloudflare DNS record lookup bug by switching from substring-based matching to exact FQDN matching, preventing one service from modifying or deleting another service’s DNS record when names share prefixes (e.g., pepper vs pepper-mcp). Also adds compatibility for both Cloudflare API token auth and Global API Key auth to keep older Helm charts working during rollout.

Changes:

  • Switch DNS record and zone lookups to exact-name matching (name=$fqdn + exact jq filter), and abort on duplicates.
  • Add dual authentication support: Bearer token by default, Global API Key headers when CF_API_EMAIL is present.
  • Make delete a no-op when the target record does not exist.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

FileDescription
readme.mdDocuments the new authentication behavior and exact-record matching rationale.
k8s-tools.shImplements exact DNS record lookup via shared helper, adds dual auth header selection, and makes delete idempotent when record is missing.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadk8s-tools.sh
Comment on lines +55 to +68
lookup_record_id() {
local ids
ids=$(curl -s -G "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
--data-urlencode "name=$fqdn" \
--data-urlencode "type=$record_type" \
"${auth[@]}" | jq -r --arg fqdn "$fqdn" '.result[] | select(.name == $fqdn) | .id')

if [ "$(printf '%s' "$ids" | grep -c .)" -gt 1 ]; then
echo "found more than one $record_type record named $fqdn - refusing to guess" >&2
exit 1
fi

printf '%s' "$ids"
}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the location — the guard was broken. The stated mechanism isn't the cause though, so recording the real one.

Command substitution preserves embedded newlines on assignment; collapsing to spaces happens on unquoted expansion, which this doesn't do:

$ ids=$(printf 'AAA\nBBB\n'); printf '%s' "$ids" | od -c
0000000 A A A \n B B B
$ printf '%s' "$ids" | grep -c . # quoted
2
$ printf '%s' $ids | grep -c . # unquoted, for contrast
1

So grep -c . counted correctly. The actual defect was the call site: cloudflare_record_id=$(lookup_record_id) runs the function in a subshell, so exit 1 ended only the subshell. The caller received empty output, read it as "no record exists", and took the create branch — so a duplicate quietly added a third record instead of aborting. Worse than building a bad URL.

Confirmed by running it against a stubbed API returning two records of one name: it printed creating for first time... and exited 0.

Fixed by having the helper assign to the global and calling it plainly, so the exit ends the script. Same stub now gives:

create DUPLICATE : exit=1 (no PATCH/POST/DELETE issued)
delete DUPLICATE : exit=1 (no PATCH/POST/DELETE issued)

Full matrix re-run and unchanged otherwise: create pepper.../AAA only, create pepper-mcp.../BBB only, delete pepperDELETE .../AAA, unknown subdomain → create path, delete-missing → no request.

The guard was called as cloudflare_record_id=$(lookup_record_id), which runs the function in a
subshell, so its `exit 1` ended only that subshell. The caller got empty output, read it as "no
record exists", and created another record - the opposite of stopping.
The helper now assigns to the global and is called plainly, so the exit ends the script.
Verified against the stubbed API: a zone holding two records of the same name exits 1 on both
create and delete having issued no PATCH, POST or DELETE. The other cases are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kierenj

Copy link
Copy Markdown
MemberAuthor

Verified against the live Cloudflare API

Re-tested with the real credential (the Global API Key from the cluster secret, i.e. exactly what the charts pass today) rather than a stub.

The bug is worse than described

The old lookup for subdomain pepper on river.red returns seven records, not two:

cdf598c6… pepper-mcp.river.red A ← different service
21f0024d… pepper.river.red A ← its own
070eeb9a… _acme-challenge.pepper.river.red TXT
b55622cd… _acme-challenge.pepper.river.red TXT
8aed2e72… _acme-challenge.pepper.river.red TXT
456b71ec… _acme-challenge.pepper.river.red TXT
33cf8312… _acme-challenge.pepper.river.red TXT

So the blast radius includes cert-manager's DNS-01 challenge records, not just sibling services — pepper.river.red is issued by letsencrypt-production-dnschallenge. Mixed record types come back too, which is why the fix filters on type as well as exact name.

The exact lookup returns precisely one:

?name=pepper.river.red&type=A → 21f0024d… pepper.river.red
?name=pepper-mcp.river.red&type=A → cdf598c6… pepper-mcp.river.red

End-to-end, real API

Ran the actual script against red-river.app with scratch names reproducing the pepper / pepper-mcp shape:

stepresult
create zz-cfcli-verify (absent)create path, new record
create zz-cfcli-verify-mcpcreate path, second record
create zz-cfcli-verify (both exist)update path — both ids unchanged, sibling untouched
delete zz-cfcli-verifydeleted its own id only; sibling remained
delete zz-cfcli-verify againnothing to delete, exit 0
delete zz-cfcli-verify-mcpcleanup, zone clean

The "both exist" listing was produced with the old query (search=zz-cfcli-verify) and returned both records — so the old code would have had two ids at that point.

Auth

The Global API Key path is exercised by all of the above, confirming the dual-auth change: a Bearer-only build would have failed against this credential, which is what would have broken the estate on a naive chart bump.

All scratch records removed. Live river.red untouched — 21f0024d… and cdf598c6… unchanged, pepper-mcp.river.red/health/ready and pepper.river.red/authorize both 200.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@kierenj
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Match DNS records by exact name, not substring - #52

Merged
kierenj merged 2 commits into
developfrom
fix/exact-dns-record-match
Aug 19, 2026
Merged

Match DNS records by exact name, not substring#52
kierenj merged 2 commits into
developfrom
fix/exact-dns-record-match

Conversation

@kierenj

Copy link
Copy Markdown
Member

The bug

Both record lookups passed the bare $subdomain to Cloudflare's search parameter and then filtered with contains. Both are substring matches, so any subdomain that is a prefix of another selected the other service's record as well.

curl .../dns_records?search=$subdomain \
| jq -r ".result[] | select(.name | contains(\"$subdomain\")) | .id"

$cloudflare_record_id is also unquoted, so a multi-match expands several ids into the request URL.

What it did in production

The Red Pepper portal deploys to live with subdomain pepper. That matched both pepper.river.red and pepper-mcp.river.red, and the deploy removed the MCP service's record:

23:12:03 backend job → created pepper.river.red
23:12:28 mcp job → created pepper-mcp.river.red
~later frontend job → search "pepper" matched BOTH → pepper-mcp deleted

pepper-mcp.river.red went NXDOMAIN while its pod stayed healthy. The zone's SOA minimum is 1800s, so clients that resolved it during the gap kept failing for up to 30 minutes after the record was restored — which is what made it look intermittent.

Reproduced against the real response shape:

$ echo "$FIXTURE" | jq -r '.result[] | select(.name | contains("pepper")) | .id'
AAA BBB # pepper.river.red AND pepper-mcp.river.red

The fix

Both lookups now match the full $subdomain.$CF_API_DOMAIN, in the API query and again in jq, via a shared lookup_record_id. A duplicate aborts rather than building a URL from several ids. The zone lookup had the same flaw — river.red also matched myriver.red — and is exact now too.

Two changes came with it:

  • Both credential styles are accepted. Auth moved to API tokens in 0.0.21, but the charts still pin 0.0.20 and still pass CF_API_EMAIL. Bumping them to a Bearer-only version would have broken every DNS job in the estate until the credential was migrated, so CF_API_EMAIL now selects the Global API Key headers. This is what makes the chart bump safe to merge on its own.
  • Deleting a non-existent record is a no-op, rather than a DELETE against an empty id.

Verification

bash -n clean, and run end to end against a stubbed Cloudflare API and kubectl:

caseresult
create pepperPATCH .../AAA only — pepper-mcp untouched
create pepper-mcpPATCH .../BBB only
delete pepperDELETE .../AAA only
create brand-newno match → create path
zone lookuppicks river.red, not myriver.red

Rollout

This needs an image publish before the chart bump lands:

az acr login -n RedRiver
docker build . --tag redriver.azurecr.io/cloudflare-cli:0.0.24 --push

Companion PR bumping saffron-app-helm3 and cinnamon-app-helm3 from 0.0.20 to 0.0.24 follows in RedRiverSoftware/k8s.

🤖 Generated with Claude Code

A deploy could delete or repoint another service's DNS record. Both lookups passed the bare
subdomain to Cloudflare's `search` parameter and then filtered with `contains`, and both are
substring matches, so a subdomain that is a prefix of another selected the other service's record
too.
Live example: the portal deploys with subdomain `pepper`, which matched both `pepper.river.red` and
`pepper-mcp.river.red`. The lookup returned two ids into an unquoted variable, and the request URL
that got built from it deleted the MCP service's record. That host then went NXDOMAIN, and because
the zone's SOA minimum is 1800s, clients that looked it up during the gap stayed broken for up to
half an hour after the record came back.
Lookups now filter on the full `$subdomain.$CF_API_DOMAIN`, in the query and again in jq, and a
duplicate stops the script rather than expanding several ids into a URL. The zone lookup had the
same flaw - `river.red` also matched `myriver.red` - and is exact now too.
Two things came with it:
- Both credential styles are accepted. Auth moved to API tokens in 0.0.21 but the charts still pin
0.0.20 and still pass CF_API_EMAIL, so a chart bump would have broken every DNS job in the estate
until the credential was migrated. CF_API_EMAIL now selects the Global API Key headers.
- Deleting a record that does not exist is a no-op rather than a DELETE against an empty id.
Verified against a stubbed API: `create pepper` and `delete pepper` touch only pepper.river.red,
`create pepper-mcp` touches only pepper-mcp.river.red, an unknown subdomain creates rather than
patches, and the zone lookup picks river.red over myriver.red.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CopilotAI lite review requested due to automatic review settings August 19, 2026 05:56

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a production-impacting Cloudflare DNS record lookup bug by switching from substring-based matching to exact FQDN matching, preventing one service from modifying or deleting another service’s DNS record when names share prefixes (e.g., pepper vs pepper-mcp). Also adds compatibility for both Cloudflare API token auth and Global API Key auth to keep older Helm charts working during rollout.

Changes:

  • Switch DNS record and zone lookups to exact-name matching (name=$fqdn + exact jq filter), and abort on duplicates.
  • Add dual authentication support: Bearer token by default, Global API Key headers when CF_API_EMAIL is present.
  • Make delete a no-op when the target record does not exist.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

FileDescription
readme.mdDocuments the new authentication behavior and exact-record matching rationale.
k8s-tools.shImplements exact DNS record lookup via shared helper, adds dual auth header selection, and makes delete idempotent when record is missing.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadk8s-tools.sh
Comment on lines +55 to +68
lookup_record_id() {
local ids
ids=$(curl -s -G "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
--data-urlencode "name=$fqdn" \
--data-urlencode "type=$record_type" \
"${auth[@]}" | jq -r --arg fqdn "$fqdn" '.result[] | select(.name == $fqdn) | .id')

if [ "$(printf '%s' "$ids" | grep -c .)" -gt 1 ]; then
echo "found more than one $record_type record named $fqdn - refusing to guess" >&2
exit 1
fi

printf '%s' "$ids"
}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the location — the guard was broken. The stated mechanism isn't the cause though, so recording the real one.

Command substitution preserves embedded newlines on assignment; collapsing to spaces happens on unquoted expansion, which this doesn't do:

$ ids=$(printf 'AAA\nBBB\n'); printf '%s' "$ids" | od -c
0000000 A A A \n B B B
$ printf '%s' "$ids" | grep -c . # quoted
2
$ printf '%s' $ids | grep -c . # unquoted, for contrast
1

So grep -c . counted correctly. The actual defect was the call site: cloudflare_record_id=$(lookup_record_id) runs the function in a subshell, so exit 1 ended only the subshell. The caller received empty output, read it as "no record exists", and took the create branch — so a duplicate quietly added a third record instead of aborting. Worse than building a bad URL.

Confirmed by running it against a stubbed API returning two records of one name: it printed creating for first time... and exited 0.

Fixed by having the helper assign to the global and calling it plainly, so the exit ends the script. Same stub now gives:

create DUPLICATE : exit=1 (no PATCH/POST/DELETE issued)
delete DUPLICATE : exit=1 (no PATCH/POST/DELETE issued)

Full matrix re-run and unchanged otherwise: create pepper.../AAA only, create pepper-mcp.../BBB only, delete pepperDELETE .../AAA, unknown subdomain → create path, delete-missing → no request.

The guard was called as cloudflare_record_id=$(lookup_record_id), which runs the function in a
subshell, so its `exit 1` ended only that subshell. The caller got empty output, read it as "no
record exists", and created another record - the opposite of stopping.
The helper now assigns to the global and is called plainly, so the exit ends the script.
Verified against the stubbed API: a zone holding two records of the same name exits 1 on both
create and delete having issued no PATCH, POST or DELETE. The other cases are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kierenj

Copy link
Copy Markdown
MemberAuthor

Verified against the live Cloudflare API

Re-tested with the real credential (the Global API Key from the cluster secret, i.e. exactly what the charts pass today) rather than a stub.

The bug is worse than described

The old lookup for subdomain pepper on river.red returns seven records, not two:

cdf598c6… pepper-mcp.river.red A ← different service
21f0024d… pepper.river.red A ← its own
070eeb9a… _acme-challenge.pepper.river.red TXT
b55622cd… _acme-challenge.pepper.river.red TXT
8aed2e72… _acme-challenge.pepper.river.red TXT
456b71ec… _acme-challenge.pepper.river.red TXT
33cf8312… _acme-challenge.pepper.river.red TXT

So the blast radius includes cert-manager's DNS-01 challenge records, not just sibling services — pepper.river.red is issued by letsencrypt-production-dnschallenge. Mixed record types come back too, which is why the fix filters on type as well as exact name.

The exact lookup returns precisely one:

?name=pepper.river.red&type=A → 21f0024d… pepper.river.red
?name=pepper-mcp.river.red&type=A → cdf598c6… pepper-mcp.river.red

End-to-end, real API

Ran the actual script against red-river.app with scratch names reproducing the pepper / pepper-mcp shape:

stepresult
create zz-cfcli-verify (absent)create path, new record
create zz-cfcli-verify-mcpcreate path, second record
create zz-cfcli-verify (both exist)update path — both ids unchanged, sibling untouched
delete zz-cfcli-verifydeleted its own id only; sibling remained
delete zz-cfcli-verify againnothing to delete, exit 0
delete zz-cfcli-verify-mcpcleanup, zone clean

The "both exist" listing was produced with the old query (search=zz-cfcli-verify) and returned both records — so the old code would have had two ids at that point.

Auth

The Global API Key path is exercised by all of the above, confirming the dual-auth change: a Bearer-only build would have failed against this credential, which is what would have broken the estate on a naive chart bump.

All scratch records removed. Live river.red untouched — 21f0024d… and cdf598c6… unchanged, pepper-mcp.river.red/health/ready and pepper.river.red/authorize both 200.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@kierenj
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Match DNS records by exact name, not substring - #52

Merged
kierenj merged 2 commits into
developfrom
fix/exact-dns-record-match
Aug 19, 2026
Merged

Match DNS records by exact name, not substring#52
kierenj merged 2 commits into
developfrom
fix/exact-dns-record-match

Conversation

@kierenj

Copy link
Copy Markdown
Member

The bug

Both record lookups passed the bare $subdomain to Cloudflare's search parameter and then filtered with contains. Both are substring matches, so any subdomain that is a prefix of another selected the other service's record as well.

curl .../dns_records?search=$subdomain \
| jq -r ".result[] | select(.name | contains(\"$subdomain\")) | .id"

$cloudflare_record_id is also unquoted, so a multi-match expands several ids into the request URL.

What it did in production

The Red Pepper portal deploys to live with subdomain pepper. That matched both pepper.river.red and pepper-mcp.river.red, and the deploy removed the MCP service's record:

23:12:03 backend job → created pepper.river.red
23:12:28 mcp job → created pepper-mcp.river.red
~later frontend job → search "pepper" matched BOTH → pepper-mcp deleted

pepper-mcp.river.red went NXDOMAIN while its pod stayed healthy. The zone's SOA minimum is 1800s, so clients that resolved it during the gap kept failing for up to 30 minutes after the record was restored — which is what made it look intermittent.

Reproduced against the real response shape:

$ echo "$FIXTURE" | jq -r '.result[] | select(.name | contains("pepper")) | .id'
AAA BBB # pepper.river.red AND pepper-mcp.river.red

The fix

Both lookups now match the full $subdomain.$CF_API_DOMAIN, in the API query and again in jq, via a shared lookup_record_id. A duplicate aborts rather than building a URL from several ids. The zone lookup had the same flaw — river.red also matched myriver.red — and is exact now too.

Two changes came with it:

  • Both credential styles are accepted. Auth moved to API tokens in 0.0.21, but the charts still pin 0.0.20 and still pass CF_API_EMAIL. Bumping them to a Bearer-only version would have broken every DNS job in the estate until the credential was migrated, so CF_API_EMAIL now selects the Global API Key headers. This is what makes the chart bump safe to merge on its own.
  • Deleting a non-existent record is a no-op, rather than a DELETE against an empty id.

Verification

bash -n clean, and run end to end against a stubbed Cloudflare API and kubectl:

caseresult
create pepperPATCH .../AAA only — pepper-mcp untouched
create pepper-mcpPATCH .../BBB only
delete pepperDELETE .../AAA only
create brand-newno match → create path
zone lookuppicks river.red, not myriver.red

Rollout

This needs an image publish before the chart bump lands:

az acr login -n RedRiver
docker build . --tag redriver.azurecr.io/cloudflare-cli:0.0.24 --push

Companion PR bumping saffron-app-helm3 and cinnamon-app-helm3 from 0.0.20 to 0.0.24 follows in RedRiverSoftware/k8s.

🤖 Generated with Claude Code

A deploy could delete or repoint another service's DNS record. Both lookups passed the bare
subdomain to Cloudflare's `search` parameter and then filtered with `contains`, and both are
substring matches, so a subdomain that is a prefix of another selected the other service's record
too.
Live example: the portal deploys with subdomain `pepper`, which matched both `pepper.river.red` and
`pepper-mcp.river.red`. The lookup returned two ids into an unquoted variable, and the request URL
that got built from it deleted the MCP service's record. That host then went NXDOMAIN, and because
the zone's SOA minimum is 1800s, clients that looked it up during the gap stayed broken for up to
half an hour after the record came back.
Lookups now filter on the full `$subdomain.$CF_API_DOMAIN`, in the query and again in jq, and a
duplicate stops the script rather than expanding several ids into a URL. The zone lookup had the
same flaw - `river.red` also matched `myriver.red` - and is exact now too.
Two things came with it:
- Both credential styles are accepted. Auth moved to API tokens in 0.0.21 but the charts still pin
0.0.20 and still pass CF_API_EMAIL, so a chart bump would have broken every DNS job in the estate
until the credential was migrated. CF_API_EMAIL now selects the Global API Key headers.
- Deleting a record that does not exist is a no-op rather than a DELETE against an empty id.
Verified against a stubbed API: `create pepper` and `delete pepper` touch only pepper.river.red,
`create pepper-mcp` touches only pepper-mcp.river.red, an unknown subdomain creates rather than
patches, and the zone lookup picks river.red over myriver.red.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CopilotAI lite review requested due to automatic review settings August 19, 2026 05:56

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a production-impacting Cloudflare DNS record lookup bug by switching from substring-based matching to exact FQDN matching, preventing one service from modifying or deleting another service’s DNS record when names share prefixes (e.g., pepper vs pepper-mcp). Also adds compatibility for both Cloudflare API token auth and Global API Key auth to keep older Helm charts working during rollout.

Changes:

  • Switch DNS record and zone lookups to exact-name matching (name=$fqdn + exact jq filter), and abort on duplicates.
  • Add dual authentication support: Bearer token by default, Global API Key headers when CF_API_EMAIL is present.
  • Make delete a no-op when the target record does not exist.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

FileDescription
readme.mdDocuments the new authentication behavior and exact-record matching rationale.
k8s-tools.shImplements exact DNS record lookup via shared helper, adds dual auth header selection, and makes delete idempotent when record is missing.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadk8s-tools.sh
Comment on lines +55 to +68
lookup_record_id() {
local ids
ids=$(curl -s -G "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
--data-urlencode "name=$fqdn" \
--data-urlencode "type=$record_type" \
"${auth[@]}" | jq -r --arg fqdn "$fqdn" '.result[] | select(.name == $fqdn) | .id')

if [ "$(printf '%s' "$ids" | grep -c .)" -gt 1 ]; then
echo "found more than one $record_type record named $fqdn - refusing to guess" >&2
exit 1
fi

printf '%s' "$ids"
}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the location — the guard was broken. The stated mechanism isn't the cause though, so recording the real one.

Command substitution preserves embedded newlines on assignment; collapsing to spaces happens on unquoted expansion, which this doesn't do:

$ ids=$(printf 'AAA\nBBB\n'); printf '%s' "$ids" | od -c
0000000 A A A \n B B B
$ printf '%s' "$ids" | grep -c . # quoted
2
$ printf '%s' $ids | grep -c . # unquoted, for contrast
1

So grep -c . counted correctly. The actual defect was the call site: cloudflare_record_id=$(lookup_record_id) runs the function in a subshell, so exit 1 ended only the subshell. The caller received empty output, read it as "no record exists", and took the create branch — so a duplicate quietly added a third record instead of aborting. Worse than building a bad URL.

Confirmed by running it against a stubbed API returning two records of one name: it printed creating for first time... and exited 0.

Fixed by having the helper assign to the global and calling it plainly, so the exit ends the script. Same stub now gives:

create DUPLICATE : exit=1 (no PATCH/POST/DELETE issued)
delete DUPLICATE : exit=1 (no PATCH/POST/DELETE issued)

Full matrix re-run and unchanged otherwise: create pepper.../AAA only, create pepper-mcp.../BBB only, delete pepperDELETE .../AAA, unknown subdomain → create path, delete-missing → no request.

The guard was called as cloudflare_record_id=$(lookup_record_id), which runs the function in a
subshell, so its `exit 1` ended only that subshell. The caller got empty output, read it as "no
record exists", and created another record - the opposite of stopping.
The helper now assigns to the global and is called plainly, so the exit ends the script.
Verified against the stubbed API: a zone holding two records of the same name exits 1 on both
create and delete having issued no PATCH, POST or DELETE. The other cases are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kierenj

Copy link
Copy Markdown
MemberAuthor

Verified against the live Cloudflare API

Re-tested with the real credential (the Global API Key from the cluster secret, i.e. exactly what the charts pass today) rather than a stub.

The bug is worse than described

The old lookup for subdomain pepper on river.red returns seven records, not two:

cdf598c6… pepper-mcp.river.red A ← different service
21f0024d… pepper.river.red A ← its own
070eeb9a… _acme-challenge.pepper.river.red TXT
b55622cd… _acme-challenge.pepper.river.red TXT
8aed2e72… _acme-challenge.pepper.river.red TXT
456b71ec… _acme-challenge.pepper.river.red TXT
33cf8312… _acme-challenge.pepper.river.red TXT

So the blast radius includes cert-manager's DNS-01 challenge records, not just sibling services — pepper.river.red is issued by letsencrypt-production-dnschallenge. Mixed record types come back too, which is why the fix filters on type as well as exact name.

The exact lookup returns precisely one:

?name=pepper.river.red&type=A → 21f0024d… pepper.river.red
?name=pepper-mcp.river.red&type=A → cdf598c6… pepper-mcp.river.red

End-to-end, real API

Ran the actual script against red-river.app with scratch names reproducing the pepper / pepper-mcp shape:

stepresult
create zz-cfcli-verify (absent)create path, new record
create zz-cfcli-verify-mcpcreate path, second record
create zz-cfcli-verify (both exist)update path — both ids unchanged, sibling untouched
delete zz-cfcli-verifydeleted its own id only; sibling remained
delete zz-cfcli-verify againnothing to delete, exit 0
delete zz-cfcli-verify-mcpcleanup, zone clean

The "both exist" listing was produced with the old query (search=zz-cfcli-verify) and returned both records — so the old code would have had two ids at that point.

Auth

The Global API Key path is exercised by all of the above, confirming the dual-auth change: a Bearer-only build would have failed against this credential, which is what would have broken the estate on a naive chart bump.

All scratch records removed. Live river.red untouched — 21f0024d… and cdf598c6… unchanged, pepper-mcp.river.red/health/ready and pepper.river.red/authorize both 200.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@kierenj
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Match DNS records by exact name, not substring - #52

Merged
kierenj merged 2 commits into
developfrom
fix/exact-dns-record-match
Aug 19, 2026
Merged

Match DNS records by exact name, not substring#52
kierenj merged 2 commits into
developfrom
fix/exact-dns-record-match

Conversation

@kierenj

Copy link
Copy Markdown
Member

The bug

Both record lookups passed the bare $subdomain to Cloudflare's search parameter and then filtered with contains. Both are substring matches, so any subdomain that is a prefix of another selected the other service's record as well.

curl .../dns_records?search=$subdomain \
| jq -r ".result[] | select(.name | contains(\"$subdomain\")) | .id"

$cloudflare_record_id is also unquoted, so a multi-match expands several ids into the request URL.

What it did in production

The Red Pepper portal deploys to live with subdomain pepper. That matched both pepper.river.red and pepper-mcp.river.red, and the deploy removed the MCP service's record:

23:12:03 backend job → created pepper.river.red
23:12:28 mcp job → created pepper-mcp.river.red
~later frontend job → search "pepper" matched BOTH → pepper-mcp deleted

pepper-mcp.river.red went NXDOMAIN while its pod stayed healthy. The zone's SOA minimum is 1800s, so clients that resolved it during the gap kept failing for up to 30 minutes after the record was restored — which is what made it look intermittent.

Reproduced against the real response shape:

$ echo "$FIXTURE" | jq -r '.result[] | select(.name | contains("pepper")) | .id'
AAA BBB # pepper.river.red AND pepper-mcp.river.red

The fix

Both lookups now match the full $subdomain.$CF_API_DOMAIN, in the API query and again in jq, via a shared lookup_record_id. A duplicate aborts rather than building a URL from several ids. The zone lookup had the same flaw — river.red also matched myriver.red — and is exact now too.

Two changes came with it:

  • Both credential styles are accepted. Auth moved to API tokens in 0.0.21, but the charts still pin 0.0.20 and still pass CF_API_EMAIL. Bumping them to a Bearer-only version would have broken every DNS job in the estate until the credential was migrated, so CF_API_EMAIL now selects the Global API Key headers. This is what makes the chart bump safe to merge on its own.
  • Deleting a non-existent record is a no-op, rather than a DELETE against an empty id.

Verification

bash -n clean, and run end to end against a stubbed Cloudflare API and kubectl:

caseresult
create pepperPATCH .../AAA only — pepper-mcp untouched
create pepper-mcpPATCH .../BBB only
delete pepperDELETE .../AAA only
create brand-newno match → create path
zone lookuppicks river.red, not myriver.red

Rollout

This needs an image publish before the chart bump lands:

az acr login -n RedRiver
docker build . --tag redriver.azurecr.io/cloudflare-cli:0.0.24 --push

Companion PR bumping saffron-app-helm3 and cinnamon-app-helm3 from 0.0.20 to 0.0.24 follows in RedRiverSoftware/k8s.

🤖 Generated with Claude Code

A deploy could delete or repoint another service's DNS record. Both lookups passed the bare
subdomain to Cloudflare's `search` parameter and then filtered with `contains`, and both are
substring matches, so a subdomain that is a prefix of another selected the other service's record
too.
Live example: the portal deploys with subdomain `pepper`, which matched both `pepper.river.red` and
`pepper-mcp.river.red`. The lookup returned two ids into an unquoted variable, and the request URL
that got built from it deleted the MCP service's record. That host then went NXDOMAIN, and because
the zone's SOA minimum is 1800s, clients that looked it up during the gap stayed broken for up to
half an hour after the record came back.
Lookups now filter on the full `$subdomain.$CF_API_DOMAIN`, in the query and again in jq, and a
duplicate stops the script rather than expanding several ids into a URL. The zone lookup had the
same flaw - `river.red` also matched `myriver.red` - and is exact now too.
Two things came with it:
- Both credential styles are accepted. Auth moved to API tokens in 0.0.21 but the charts still pin
0.0.20 and still pass CF_API_EMAIL, so a chart bump would have broken every DNS job in the estate
until the credential was migrated. CF_API_EMAIL now selects the Global API Key headers.
- Deleting a record that does not exist is a no-op rather than a DELETE against an empty id.
Verified against a stubbed API: `create pepper` and `delete pepper` touch only pepper.river.red,
`create pepper-mcp` touches only pepper-mcp.river.red, an unknown subdomain creates rather than
patches, and the zone lookup picks river.red over myriver.red.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CopilotAI lite review requested due to automatic review settings August 19, 2026 05:56

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a production-impacting Cloudflare DNS record lookup bug by switching from substring-based matching to exact FQDN matching, preventing one service from modifying or deleting another service’s DNS record when names share prefixes (e.g., pepper vs pepper-mcp). Also adds compatibility for both Cloudflare API token auth and Global API Key auth to keep older Helm charts working during rollout.

Changes:

  • Switch DNS record and zone lookups to exact-name matching (name=$fqdn + exact jq filter), and abort on duplicates.
  • Add dual authentication support: Bearer token by default, Global API Key headers when CF_API_EMAIL is present.
  • Make delete a no-op when the target record does not exist.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

FileDescription
readme.mdDocuments the new authentication behavior and exact-record matching rationale.
k8s-tools.shImplements exact DNS record lookup via shared helper, adds dual auth header selection, and makes delete idempotent when record is missing.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadk8s-tools.sh
Comment on lines +55 to +68
lookup_record_id() {
local ids
ids=$(curl -s -G "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
--data-urlencode "name=$fqdn" \
--data-urlencode "type=$record_type" \
"${auth[@]}" | jq -r --arg fqdn "$fqdn" '.result[] | select(.name == $fqdn) | .id')

if [ "$(printf '%s' "$ids" | grep -c .)" -gt 1 ]; then
echo "found more than one $record_type record named $fqdn - refusing to guess" >&2
exit 1
fi

printf '%s' "$ids"
}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the location — the guard was broken. The stated mechanism isn't the cause though, so recording the real one.

Command substitution preserves embedded newlines on assignment; collapsing to spaces happens on unquoted expansion, which this doesn't do:

$ ids=$(printf 'AAA\nBBB\n'); printf '%s' "$ids" | od -c
0000000 A A A \n B B B
$ printf '%s' "$ids" | grep -c . # quoted
2
$ printf '%s' $ids | grep -c . # unquoted, for contrast
1

So grep -c . counted correctly. The actual defect was the call site: cloudflare_record_id=$(lookup_record_id) runs the function in a subshell, so exit 1 ended only the subshell. The caller received empty output, read it as "no record exists", and took the create branch — so a duplicate quietly added a third record instead of aborting. Worse than building a bad URL.

Confirmed by running it against a stubbed API returning two records of one name: it printed creating for first time... and exited 0.

Fixed by having the helper assign to the global and calling it plainly, so the exit ends the script. Same stub now gives:

create DUPLICATE : exit=1 (no PATCH/POST/DELETE issued)
delete DUPLICATE : exit=1 (no PATCH/POST/DELETE issued)

Full matrix re-run and unchanged otherwise: create pepper.../AAA only, create pepper-mcp.../BBB only, delete pepperDELETE .../AAA, unknown subdomain → create path, delete-missing → no request.

The guard was called as cloudflare_record_id=$(lookup_record_id), which runs the function in a
subshell, so its `exit 1` ended only that subshell. The caller got empty output, read it as "no
record exists", and created another record - the opposite of stopping.
The helper now assigns to the global and is called plainly, so the exit ends the script.
Verified against the stubbed API: a zone holding two records of the same name exits 1 on both
create and delete having issued no PATCH, POST or DELETE. The other cases are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kierenj

Copy link
Copy Markdown
MemberAuthor

Verified against the live Cloudflare API

Re-tested with the real credential (the Global API Key from the cluster secret, i.e. exactly what the charts pass today) rather than a stub.

The bug is worse than described

The old lookup for subdomain pepper on river.red returns seven records, not two:

cdf598c6… pepper-mcp.river.red A ← different service
21f0024d… pepper.river.red A ← its own
070eeb9a… _acme-challenge.pepper.river.red TXT
b55622cd… _acme-challenge.pepper.river.red TXT
8aed2e72… _acme-challenge.pepper.river.red TXT
456b71ec… _acme-challenge.pepper.river.red TXT
33cf8312… _acme-challenge.pepper.river.red TXT

So the blast radius includes cert-manager's DNS-01 challenge records, not just sibling services — pepper.river.red is issued by letsencrypt-production-dnschallenge. Mixed record types come back too, which is why the fix filters on type as well as exact name.

The exact lookup returns precisely one:

?name=pepper.river.red&type=A → 21f0024d… pepper.river.red
?name=pepper-mcp.river.red&type=A → cdf598c6… pepper-mcp.river.red

End-to-end, real API

Ran the actual script against red-river.app with scratch names reproducing the pepper / pepper-mcp shape:

stepresult
create zz-cfcli-verify (absent)create path, new record
create zz-cfcli-verify-mcpcreate path, second record
create zz-cfcli-verify (both exist)update path — both ids unchanged, sibling untouched
delete zz-cfcli-verifydeleted its own id only; sibling remained
delete zz-cfcli-verify againnothing to delete, exit 0
delete zz-cfcli-verify-mcpcleanup, zone clean

The "both exist" listing was produced with the old query (search=zz-cfcli-verify) and returned both records — so the old code would have had two ids at that point.

Auth

The Global API Key path is exercised by all of the above, confirming the dual-auth change: a Bearer-only build would have failed against this credential, which is what would have broken the estate on a naive chart bump.

All scratch records removed. Live river.red untouched — 21f0024d… and cdf598c6… unchanged, pepper-mcp.river.red/health/ready and pepper.river.red/authorize both 200.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@kierenj
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Match DNS records by exact name, not substring - #52

Merged
kierenj merged 2 commits into
developfrom
fix/exact-dns-record-match
Aug 19, 2026
Merged

Match DNS records by exact name, not substring#52
kierenj merged 2 commits into
developfrom
fix/exact-dns-record-match

Conversation

@kierenj

Copy link
Copy Markdown
Member

The bug

Both record lookups passed the bare $subdomain to Cloudflare's search parameter and then filtered with contains. Both are substring matches, so any subdomain that is a prefix of another selected the other service's record as well.

curl .../dns_records?search=$subdomain \
| jq -r ".result[] | select(.name | contains(\"$subdomain\")) | .id"

$cloudflare_record_id is also unquoted, so a multi-match expands several ids into the request URL.

What it did in production

The Red Pepper portal deploys to live with subdomain pepper. That matched both pepper.river.red and pepper-mcp.river.red, and the deploy removed the MCP service's record:

23:12:03 backend job → created pepper.river.red
23:12:28 mcp job → created pepper-mcp.river.red
~later frontend job → search "pepper" matched BOTH → pepper-mcp deleted

pepper-mcp.river.red went NXDOMAIN while its pod stayed healthy. The zone's SOA minimum is 1800s, so clients that resolved it during the gap kept failing for up to 30 minutes after the record was restored — which is what made it look intermittent.

Reproduced against the real response shape:

$ echo "$FIXTURE" | jq -r '.result[] | select(.name | contains("pepper")) | .id'
AAA BBB # pepper.river.red AND pepper-mcp.river.red

The fix

Both lookups now match the full $subdomain.$CF_API_DOMAIN, in the API query and again in jq, via a shared lookup_record_id. A duplicate aborts rather than building a URL from several ids. The zone lookup had the same flaw — river.red also matched myriver.red — and is exact now too.

Two changes came with it:

  • Both credential styles are accepted. Auth moved to API tokens in 0.0.21, but the charts still pin 0.0.20 and still pass CF_API_EMAIL. Bumping them to a Bearer-only version would have broken every DNS job in the estate until the credential was migrated, so CF_API_EMAIL now selects the Global API Key headers. This is what makes the chart bump safe to merge on its own.
  • Deleting a non-existent record is a no-op, rather than a DELETE against an empty id.

Verification

bash -n clean, and run end to end against a stubbed Cloudflare API and kubectl:

caseresult
create pepperPATCH .../AAA only — pepper-mcp untouched
create pepper-mcpPATCH .../BBB only
delete pepperDELETE .../AAA only
create brand-newno match → create path
zone lookuppicks river.red, not myriver.red

Rollout

This needs an image publish before the chart bump lands:

az acr login -n RedRiver
docker build . --tag redriver.azurecr.io/cloudflare-cli:0.0.24 --push

Companion PR bumping saffron-app-helm3 and cinnamon-app-helm3 from 0.0.20 to 0.0.24 follows in RedRiverSoftware/k8s.

🤖 Generated with Claude Code

A deploy could delete or repoint another service's DNS record. Both lookups passed the bare
subdomain to Cloudflare's `search` parameter and then filtered with `contains`, and both are
substring matches, so a subdomain that is a prefix of another selected the other service's record
too.
Live example: the portal deploys with subdomain `pepper`, which matched both `pepper.river.red` and
`pepper-mcp.river.red`. The lookup returned two ids into an unquoted variable, and the request URL
that got built from it deleted the MCP service's record. That host then went NXDOMAIN, and because
the zone's SOA minimum is 1800s, clients that looked it up during the gap stayed broken for up to
half an hour after the record came back.
Lookups now filter on the full `$subdomain.$CF_API_DOMAIN`, in the query and again in jq, and a
duplicate stops the script rather than expanding several ids into a URL. The zone lookup had the
same flaw - `river.red` also matched `myriver.red` - and is exact now too.
Two things came with it:
- Both credential styles are accepted. Auth moved to API tokens in 0.0.21 but the charts still pin
0.0.20 and still pass CF_API_EMAIL, so a chart bump would have broken every DNS job in the estate
until the credential was migrated. CF_API_EMAIL now selects the Global API Key headers.
- Deleting a record that does not exist is a no-op rather than a DELETE against an empty id.
Verified against a stubbed API: `create pepper` and `delete pepper` touch only pepper.river.red,
`create pepper-mcp` touches only pepper-mcp.river.red, an unknown subdomain creates rather than
patches, and the zone lookup picks river.red over myriver.red.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CopilotAI lite review requested due to automatic review settings August 19, 2026 05:56

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a production-impacting Cloudflare DNS record lookup bug by switching from substring-based matching to exact FQDN matching, preventing one service from modifying or deleting another service’s DNS record when names share prefixes (e.g., pepper vs pepper-mcp). Also adds compatibility for both Cloudflare API token auth and Global API Key auth to keep older Helm charts working during rollout.

Changes:

  • Switch DNS record and zone lookups to exact-name matching (name=$fqdn + exact jq filter), and abort on duplicates.
  • Add dual authentication support: Bearer token by default, Global API Key headers when CF_API_EMAIL is present.
  • Make delete a no-op when the target record does not exist.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

FileDescription
readme.mdDocuments the new authentication behavior and exact-record matching rationale.
k8s-tools.shImplements exact DNS record lookup via shared helper, adds dual auth header selection, and makes delete idempotent when record is missing.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadk8s-tools.sh
Comment on lines +55 to +68
lookup_record_id() {
local ids
ids=$(curl -s -G "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
--data-urlencode "name=$fqdn" \
--data-urlencode "type=$record_type" \
"${auth[@]}" | jq -r --arg fqdn "$fqdn" '.result[] | select(.name == $fqdn) | .id')

if [ "$(printf '%s' "$ids" | grep -c .)" -gt 1 ]; then
echo "found more than one $record_type record named $fqdn - refusing to guess" >&2
exit 1
fi

printf '%s' "$ids"
}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the location — the guard was broken. The stated mechanism isn't the cause though, so recording the real one.

Command substitution preserves embedded newlines on assignment; collapsing to spaces happens on unquoted expansion, which this doesn't do:

$ ids=$(printf 'AAA\nBBB\n'); printf '%s' "$ids" | od -c
0000000 A A A \n B B B
$ printf '%s' "$ids" | grep -c . # quoted
2
$ printf '%s' $ids | grep -c . # unquoted, for contrast
1

So grep -c . counted correctly. The actual defect was the call site: cloudflare_record_id=$(lookup_record_id) runs the function in a subshell, so exit 1 ended only the subshell. The caller received empty output, read it as "no record exists", and took the create branch — so a duplicate quietly added a third record instead of aborting. Worse than building a bad URL.

Confirmed by running it against a stubbed API returning two records of one name: it printed creating for first time... and exited 0.

Fixed by having the helper assign to the global and calling it plainly, so the exit ends the script. Same stub now gives:

create DUPLICATE : exit=1 (no PATCH/POST/DELETE issued)
delete DUPLICATE : exit=1 (no PATCH/POST/DELETE issued)

Full matrix re-run and unchanged otherwise: create pepper.../AAA only, create pepper-mcp.../BBB only, delete pepperDELETE .../AAA, unknown subdomain → create path, delete-missing → no request.

The guard was called as cloudflare_record_id=$(lookup_record_id), which runs the function in a
subshell, so its `exit 1` ended only that subshell. The caller got empty output, read it as "no
record exists", and created another record - the opposite of stopping.
The helper now assigns to the global and is called plainly, so the exit ends the script.
Verified against the stubbed API: a zone holding two records of the same name exits 1 on both
create and delete having issued no PATCH, POST or DELETE. The other cases are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kierenj

Copy link
Copy Markdown
MemberAuthor

Verified against the live Cloudflare API

Re-tested with the real credential (the Global API Key from the cluster secret, i.e. exactly what the charts pass today) rather than a stub.

The bug is worse than described

The old lookup for subdomain pepper on river.red returns seven records, not two:

cdf598c6… pepper-mcp.river.red A ← different service
21f0024d… pepper.river.red A ← its own
070eeb9a… _acme-challenge.pepper.river.red TXT
b55622cd… _acme-challenge.pepper.river.red TXT
8aed2e72… _acme-challenge.pepper.river.red TXT
456b71ec… _acme-challenge.pepper.river.red TXT
33cf8312… _acme-challenge.pepper.river.red TXT

So the blast radius includes cert-manager's DNS-01 challenge records, not just sibling services — pepper.river.red is issued by letsencrypt-production-dnschallenge. Mixed record types come back too, which is why the fix filters on type as well as exact name.

The exact lookup returns precisely one:

?name=pepper.river.red&type=A → 21f0024d… pepper.river.red
?name=pepper-mcp.river.red&type=A → cdf598c6… pepper-mcp.river.red

End-to-end, real API

Ran the actual script against red-river.app with scratch names reproducing the pepper / pepper-mcp shape:

stepresult
create zz-cfcli-verify (absent)create path, new record
create zz-cfcli-verify-mcpcreate path, second record
create zz-cfcli-verify (both exist)update path — both ids unchanged, sibling untouched
delete zz-cfcli-verifydeleted its own id only; sibling remained
delete zz-cfcli-verify againnothing to delete, exit 0
delete zz-cfcli-verify-mcpcleanup, zone clean

The "both exist" listing was produced with the old query (search=zz-cfcli-verify) and returned both records — so the old code would have had two ids at that point.

Auth

The Global API Key path is exercised by all of the above, confirming the dual-auth change: a Bearer-only build would have failed against this credential, which is what would have broken the estate on a naive chart bump.

All scratch records removed. Live river.red untouched — 21f0024d… and cdf598c6… unchanged, pepper-mcp.river.red/health/ready and pepper.river.red/authorize both 200.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@kierenj