fix(meetings): the calendar URL vet approved CGNAT and IPv6 site-local - #5217
Conversation
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound dedup onto the stronger shared vet, but the 443-only narrowing breaks a real class of working configs with no operator escape hatch. Watch
Suggestions
[DESIGN-REVIEWED] 10bc77e |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of The contract, intent, and patch are read; I've verified the sibling counts against the repository. Final review follows. First-Principles-Verdict: CONCERNS The delegation is a model subtraction, but two unfurl-vet hardenings ride along undeclared, and the root cause has four counted unfixed siblings. What this change shipsIntent: stop the gateway's calendar fetch approving non-public addresses, by deleting the local vet copy — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] 10bc77e |
Disposition: GPT 5.6 round 1BLOCKING — "Delegation drops the 6to4 embedded-address guard" — accepted and fixed in d096d55. Correct finding. The local vet unwrapped both One correction to the suggested fix, worth recording because the obvious shape is wrong: retaining the previous
The flag union moved into On reachability, stated plainly rather than argued down: your Python 3.10 premise is right in principle — The new test does not rely on the interpreter disagreeing. It drops |
d096d55 to
a42867a
Compare
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsBoth candidates fail the survival bar. Let me confirm my reasoning on the key questions before finalizing. Candidate 1 (userinfo rejection breaks basic-auth
Candidate 2 (port-80 narrowing): The candidate itself is "low confidence," notes it is "deliberate and tested" ( Step 2 check — I traced the new paths for introduced crashes/regressions: OSError/UnicodeError from No findings. [OPUS-REVIEWED] 10bc77e Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
`calendar.source` is fetched BY THE GATEWAY, so the address check on it is a
server-side request-forgery gate. That check was a local copy in the meetings
calendar provider, and it judged addresses with `ipaddress.is_private`, which
does not cover two ranges that are plainly not public:
>>> ipaddress.ip_address("100.64.0.1").is_private
False
>>> ipaddress.ip_address("fec0::1").is_private
False
`100.64.0.0/10` is RFC 6598 shared space -- what a tailnet and most carrier NAT
hand out, so on a machine on a tailnet that range IS the private network.
`fec0::/10` is deprecated IPv6 site-local. Both were approved, resolved, and
fetched. `.local` and `.onion` were approved too: the local copy had no
host-suffix rule.
Verified by running the pre-change code, not by reading it: the new regression
cases fail on the parent commit with DID NOT RAISE.
The fix is to stop keeping a second copy. `link_unfurl.vet_unfurl_url` already
owns this decision for the unfurl endpoint, uses `is_global` as an allowlist
alongside the category flags, refuses the blocked host suffixes, canonicalizes
the alternate IPv4 encodings, and carries
`test_vet_rejects_every_special_purpose_range` -- which pins the refusal set
against a table of IANA special-purpose prefixes, so the next gap is found by the
suite instead of by a reviewer. A second implementation inherits none of that,
and is a second place to fix a bypass.
`resolve` is injected for one reason: `VettedUrl` reports a single `ip`, while
the pin serves every vetted address so a multi-homed calendar host keeps its
fallbacks. Every address recorded is one the vet checked -- it vets the whole
answer, not just the address it keeps. The pin, the redirect hop loop and the
TLS-hostname behavior are unchanged; only the address decision moves.
Two behavior changes worth calling out:
* Ports narrow to 443. The shared vet allows {80, 443} because it also serves
plain-http unfurling; https-only leaves 443. A calendar on another port is
nearly always an internal service. `ics` only ever documented a published
`https://` URL, so no working configuration breaks; relaxing later is one line.
* The operator-facing rejection messages change wording, since the two
`UnfurlRejected` codes are now what gets mapped.
The alternate IPv4 encodings (`0177.0.0.1`, `0x7f000001`, `2130706433`, `127.1`)
were NOT reachable before -- getaddrinfo folded them to loopback and the private
rule caught them there. They are pinned anyway, in a separately named test,
because that refusal depended on the resolver's reading of a string the vet had
declined to parse: agreement rather than a decision.
Delegating meant the shared vet had to absorb two things the local copy did, and
both fixes land in `link_unfurl` rather than as local checks layered back around
the delegation -- the unfurl endpoint reaches both today, and one owner of the
decision is the point of this change.
1. **6to4.** The local vet judged an embedded IPv4 address in BOTH v6 encodings;
`link_unfurl` handled only `ipv4_mapped`. `2002:0a00:0001::1` is routed to
`10.0.0.1`, and `2002::/16` entered CPython's IPv6 private table only with
gh-113171 (3.10.14, 3.11.9, 3.12.4), so on an older patch release of a
supported version the v6 form reports `is_global` while the packet goes inward.
The check is an AND, not a substitution -- the encodings are not symmetric.
`::ffff:1.2.3.4` has no meaning as a v6 destination, so the mapped address is
the only thing to judge (unchanged). `2002:xxxx:yyyy::/48` is a routable v6
prefix AND names a v4 tunnel endpoint, so both readings must pass. Substituting
was the first attempt and `test_vet_rejects_every_special_purpose_range` caught
it: `2002:8000::` carries the PUBLIC `128.0.0.0`. The flag union moves into
`_is_not_public` so one formulation covers both readings.
Its test does not trust the interpreter to disagree: it drops `2002::/16` from
`ipaddress._IPv6Constants._private_networks` for its duration to reproduce the
older patch releases, asserts the premise, then asserts the refusal holds.
2. **A host the resolver cannot encode.** `getaddrinfo` and yarl both raise
`UnicodeError` -- a `ValueError`, NOT an `OSError` -- for a host carrying a lone
surrogate, which arrives intact from a JSON string. The vet's fail-closed catch
around the resolver was `OSError`-only and the `wire_host` derivation had no
guard, so `https://\ud800.example/` escaped as an uncaught exception and a 500.
Verified against unmodified origin/main: the unfurl endpoint leaks it there
today. The calendar provider only surfaces it because delegating removed the
`URL(url)` parse that used to reject the surrogate by accident -- so this is
both a live bug in one caller and a regression guard for the other, and it is
pinned in both suites.
Extracted from #2190, which needs this fix plus three new providers, a credential
store and an OAuth handshake. This half stands alone and fixes code that is
already shipped, so it should not wait on the rest.
Co-authored-by: Kai Mitsuzawa <kaizawa97@users.noreply.github.com>
a42867a to
10bc77e
Compare
Disposition: GPT 5.6 round 2BLOCKING — "malformed Unicode hostname escapes error handling" — accepted and fixed in 10bc77e. Correct, and it is a regression I introduced. Before delegating, Two notes on where the fix went and why it is bigger than the suggestion. It is not only the resolver. It is fixed in So Pinned in both suites: |
Problem / Motivation
calendar.sourceis fetched by the gateway, so the address check applied toit is a server-side request-forgery gate. That check was a local copy living in
the meetings calendar provider, and it decided "is this address public?" with
ipaddress.is_private— which does not cover two ranges that are plainly notpublic:
100.64.0.0/10is RFC 6598 shared address space — what a Tailscale tailnetand most carrier NAT hand out. On a machine on a tailnet, that range is the
private network.
fec0::/10is deprecated IPv6 site-local..localand.onionwere approved too: the local copy had no host-suffixrule, so an mDNS name or a hidden service passed the gate.
All of these were approved, resolved, and fetched.
Why it matters
The value reaches the gate from a dashboard
PUT /config, and the response bodylands in
calendar-cache.json, which is inside the agent-readable app data tree.So the pair is a read primitive: something that can set the config value gets the
gateway to fetch an internal address and gets the bytes back out.
Scope, stated honestly: this needs the ability to write that config value, so it
is not remotely reachable on its own. It is a gate that does not hold rather than
an open door — which is exactly the thing worth fixing before more providers are
built on top of it.
What changed (motivation → approach → change)
Approach. Stop keeping a second copy of the vet.
link_unfurl.vet_unfurl_urlalready owns this decision for the unfurl endpointand is strictly stronger:
link_unfurl100.64.0.1(CGNAT)is_globalallowlist)fec0::1(site-local)is_site_local+is_global).local,.onionBLOCKED_HOST_SUFFIXES)0177.0.0.1,0x7f000001canonicalize_ip)It also carries
test_vet_rejects_every_special_purpose_range, which pins therefusal set against a table of IANA special-purpose prefixes — so the next gap is
found by the suite instead of by a reviewer. A second implementation inherits
none of that, and is a second place to fix a bypass.
What did NOT move. The pin, the redirect hop loop, the same-origin check, and
the TLS behavior are untouched. The resolver is still pinned on
wire_hostandthe URL is never rewritten to an IP, so
Host, SNI and certificate verificationstay on the real hostname. Only the address decision is delegated.
Why
resolveis injected.VettedUrlreports a singleip; the pin servesevery vetted address so a multi-homed calendar host keeps its fallbacks. Every
address recorded is one
vet_unfurl_urlchecked — it vets the whole answer, notjust the address it returns. One refusal is layered on top rather than delegated:
a resolved address
ipaddresscannot read is refused, not skipped, because_reject_if_internal_ipreturns silently for a non-literal (to it, a non-literalis a hostname still to resolve) and here the list is already a resolution result.
Two behavior changes, called out rather than buried
{80, 443}because it alsoserves plain-http unfurling; https-only leaves 443. A calendar on another port
is nearly always an internal service, and the port is the cheapest place to stop
this endpoint being used to probe for one. The
icsprovider only everdocumented a published
https://URL, so no working configuration breaks;relaxing later is one line in
_ALLOWED_SCHEMES' consequence.UnfurlRejectedcodes are nowwhat gets mapped to operator-facing messages.
Tests
Non-vacuous, and verified the way round that matters — by running the
pre-change code, not by reading it. On the parent commit:
The alternate IPv4 encodings (
0177.0.0.1,0x7f000001,2130706433,127.1,[::ffff:127.0.0.1]) passed on the parent commit and are pinned anyway, intheir own separately-named test. They were never reachable:
ipaddressdeclinedto parse them, they fell through to DNS, getaddrinfo folded them back to loopback,
and the private-address rule caught them there. The defect was that the refusal
rode on the resolver's reading of a string the vet had given up on — agreement,
not a decision. Naming that separately keeps the test from claiming a finding it
does not have.
The DNS-rebinding tests that stand up a real loopback server now lift
link_unfurl's refusals (_reject_if_internal_ip,ALLOWED_PORTS) instead of alocal function — patched on the module the real code reads them from, since
patching a local name would pass while testing nothing.
Local gate:
test_meetings_providers.py162 passed; withtest_meetings_routes.py,test_meetings_store.py,test_link_unfurl.pyandtest_security.py, 1150 passed / 1 skipped.isort,flake8,mypyclean.Manual verification
The two
is_privatereadings at the top of this description were run againstthis interpreter (CPython 3.12), which is what established that the ranges were
approved rather than assumed.
Not verified here: an end-to-end fetch against a real CGNAT host, which needs a
tailnet. The address decision is covered by the tests above and by
link_unfurl's own IANA-prefix table.Related Issues
Extracted from #2190, which bundles this fix with three new calendar providers, a
credential store and an OAuth handshake. That PR is 464 commits behind and its
remaining half is not user-reachable yet (no settings UI, and
builtin_skills/meetings/SKILL.mdstill documents onlynoneandics). Thishalf stands alone and fixes code that is already shipped, so it should not
wait on the rest. Credit for the delegation approach and its comments goes to
@kaizawa97 — carried as a
Co-authored-bytrailer.Checklist
the
icsprovider's documented contract (a publishedhttps://URL) isunchanged