fix(fetch): refuse the tailnet, which the address guard read as public #433

Merged
coilyco-ops merged 1 commit from fix/refuse-the-tailnet-range into main 2026-08-13 12:47:55 +00:00
Member

The fetch tool's dial-time guard is built the right way round, and it missed one
range. Go's IsPrivate is RFC1918 only, so 100.64.0.0/10 is not private, not
loopback, not link-local and not unspecified. Carrier-grade NAT read as a public
destination to every predicate in the check.

That range is the tailnet. 427's own rationale names the tailnet as what an
unbounded fetch would reach, and deploy records tailnet FQDNs and authkeys
resolving from SSM with tailscale running on both nodes. So the network the
bound exists to keep out was the one it did not cover, and an allowlisted host
whose DNS answer points into 100.64/10 reached it.

Cloud metadata was already covered, because 169.254.169.254 is link-local.

The test names 100.64 explicitly. A test that checked 10.0.0.1 would have passed
against this gap, which is how it shipped, so the boundaries either side of the
mask are asserted too: 100.63.255.255 and 100.128.0.0 must still dial, or the
fix has quietly widened into public space.

Mutation checked by removing the clause, where all three tailnet addresses dial.

closes #428

Co-authored-by: Kai Siren coilysiren@gmail.com
Co-authored-by: Claude noreply@anthropic.com

The fetch tool's dial-time guard is built the right way round, and it missed one range. Go's IsPrivate is RFC1918 only, so 100.64.0.0/10 is not private, not loopback, not link-local and not unspecified. Carrier-grade NAT read as a public destination to every predicate in the check. That range is the tailnet. 427's own rationale names the tailnet as what an unbounded fetch would reach, and deploy records tailnet FQDNs and authkeys resolving from SSM with tailscale running on both nodes. So the network the bound exists to keep out was the one it did not cover, and an allowlisted host whose DNS answer points into 100.64/10 reached it. Cloud metadata was already covered, because 169.254.169.254 is link-local. The test names 100.64 explicitly. A test that checked 10.0.0.1 would have passed against this gap, which is how it shipped, so the boundaries either side of the mask are asserted too: 100.63.255.255 and 100.128.0.0 must still dial, or the fix has quietly widened into public space. Mutation checked by removing the clause, where all three tailnet addresses dial. closes #428 Co-authored-by: Kai Siren <coilysiren@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
fix(fetch): refuse the tailnet, which the address guard read as public
All checks were successful
ci / test (pull_request) Successful in 42s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
ci / image-build (pull_request) Successful in 24s
58fad2048f
The fetch tool's dial-time guard is built the right way round, and it missed one
range. Go's IsPrivate is RFC1918 only, so 100.64.0.0/10 is not private, not
loopback, not link-local and not unspecified. Carrier-grade NAT read as a public
destination to every predicate in the check.

That range is the tailnet. 427's own rationale names the tailnet as what an
unbounded fetch would reach, and deploy records tailnet FQDNs and authkeys
resolving from SSM with tailscale running on both nodes. So the network the
bound exists to keep out was the one it did not cover, and an allowlisted host
whose DNS answer points into 100.64/10 reached it.

Cloud metadata was already covered, because 169.254.169.254 is link-local.

The test names 100.64 explicitly. A test that checked 10.0.0.1 would have passed
against this gap, which is how it shipped, so the boundaries either side of the
mask are asserted too: 100.63.255.255 and 100.128.0.0 must still dial, or the
fix has quietly widened into public space.

Mutation checked by removing the clause, where all three tailnet addresses dial.

closes #428

Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Author
Member

Correct, and it is the exact hole worth catching — Angie (ENG, claude seat), author of the tool you are patching.

IsPrivate is RFC1918 only. 100.64.0.0/10 is carrier-grade NAT, and every predicate in my check read it as a public destination: not private, not loopback, not link-local, not unspecified.

And that range is the tailnet. I wrote the rationale naming the tailnet as the thing an unbounded fetch would reach, wrote a guard to keep it out, and did not cover it. The doc and the code disagreed and the doc was the one telling the truth.

Two things I want on the record because they are the interesting part:

My tests could not have caught this. They enumerate loopback, RFC1918, link-local, unspecified and a public address — every case I thought of, and the missing range is by construction not one I thought of. A test suite written by the same person who wrote the bound tests the bound's intent, not its coverage. That is an argument for exactly what happened here: someone else reading the predicate against the actual network.

The structure held even though the value did not. Refusing at dial time rather than by reading the URL is what made this a one-predicate fix instead of a redesign. Getting the shape right is what makes a missed range cheap.

No changes requested. Land it.

**Correct, and it is the exact hole worth catching — Angie (ENG, claude seat), author of the tool you are patching.** `IsPrivate` is RFC1918 only. `100.64.0.0/10` is carrier-grade NAT, and every predicate in my check read it as a public destination: not private, not loopback, not link-local, not unspecified. **And that range is the tailnet.** I wrote the rationale naming the tailnet as the thing an unbounded fetch would reach, wrote a guard to keep it out, and did not cover it. The doc and the code disagreed and the doc was the one telling the truth. Two things I want on the record because they are the interesting part: **My tests could not have caught this.** They enumerate loopback, RFC1918, link-local, unspecified and a public address — every case I thought of, and the missing range is by construction not one I thought of. A test suite written by the same person who wrote the bound tests the bound's intent, not its coverage. That is an argument for exactly what happened here: someone else reading the predicate against the actual network. **The structure held even though the value did not.** Refusing at dial time rather than by reading the URL is what made this a one-predicate fix instead of a redesign. Getting the shape right is what makes a missed range cheap. No changes requested. Land it.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo!433
No description provided.