Skip to content

fix(meetings): the calendar URL vet approved CGNAT and IPv6 site-local - #5217

Merged
kyleseaman merged 1 commit into
mainfrom
fix/meetings-calendar-ssrf-vet
Aug 23, 2026
Merged

fix(meetings): the calendar URL vet approved CGNAT and IPv6 site-local#5217
kyleseaman merged 1 commit into
mainfrom
fix/meetings-calendar-ssrf-vet

Conversation

@iamwhatever

Copy link
Copy Markdown
Collaborator

Problem / Motivation

calendar.source is fetched by the gateway, so the address check applied to
it 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 not
public:

>>> import ipaddress
>>> 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 address space — what a Tailscale tailnet
    and most carrier NAT hand out. On a machine on a tailnet, that range is the
    private network.
  • fec0::/10 is deprecated IPv6 site-local.
  • .local and .onion were approved too: the local copy had no host-suffix
    rule, 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 body
lands 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_url already owns this decision for the unfurl endpoint
and is strictly stronger:

local copy link_unfurl
100.64.0.1 (CGNAT) approved refused (is_global allowlist)
fec0::1 (site-local) approved refused (is_site_local + is_global)
.local, .onion approved refused (BLOCKED_HOST_SUFFIXES)
0177.0.0.1, 0x7f000001 refused, but only via the resolver refused by the vet (canonicalize_ip)

It also 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.

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_host and
the URL is never rewritten to an IP, so Host, SNI and certificate verification
stay on the real hostname. Only the address decision is delegated.

Why resolve is injected. VettedUrl reports a single ip; the pin serves
every vetted address so a multi-homed calendar host keeps its fallbacks. Every
address recorded is one vet_unfurl_url checked — it vets the whole answer, not
just the address it returns. One refusal is layered on top rather than delegated:
a resolved address ipaddress cannot read is refused, not skipped, because
_reject_if_internal_ip returns silently for a non-literal (to it, a non-literal
is a hostname still to resolve) and here the list is already a resolution result.

Two behavior changes, called out rather than buried

  • 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, and the port is the cheapest place to stop
    this endpoint being used to probe for one. The ics provider only ever
    documented a published https:// URL, so no working configuration breaks;
    relaxing later is one line in _ALLOWED_SCHEMES' consequence.
  • Rejection wording changes, since the two UnfurlRejected codes are now
    what 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:

FAILED test_addresses_the_local_vet_approved_are_now_refused[100.64.0.1] - DID NOT RAISE
FAILED test_addresses_the_local_vet_approved_are_now_refused[[fec0::1]]  - DID NOT RAISE
FAILED test_hosts_that_cannot_name_a_public_service_are_refused[printer.local]   - DID NOT RAISE
FAILED test_hosts_that_cannot_name_a_public_service_are_refused[abcdefgh.onion]  - DID NOT RAISE
FAILED test_a_non_standard_port_is_refused                              - DID NOT RAISE
FAILED test_port_80_is_refused_even_though_the_shared_vet_allows_it     - DID NOT RAISE

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, in
their own separately-named test. They were never reachable: ipaddress declined
to 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 a
local 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.py 162 passed; with
test_meetings_routes.py, test_meetings_store.py, test_link_unfurl.py and
test_security.py, 1150 passed / 1 skipped. isort, flake8, mypy clean.

Manual verification

The two is_private readings at the top of this description were run against
this 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.md still documents only none and ics). This
half 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-by trailer.

Checklist

  • Single commit with a Conventional Commits title
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable) — N/A: no user-facing surface changes;
    the ics provider's documented contract (a published https:// URL) is
    unchanged
  • No secrets, credentials, or internal references in the diff

@iamwhatever
iamwhatever requested a review from a team as a code owner August 23, 2026 07:19
@iamwhatever
iamwhatever requested a review from cixuuz August 23, 2026 07:19
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 23, 2026
@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 10bc77e433dbd8e62e3f1f7df783b60f17ad1709 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 10bc77e

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 10bc77e433dbd8e62e3f1f7df783b60f17ad1709: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 23, 2026
@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of 10bc77e433dbd8e62e3f1f7df783b60f17ad1709 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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

  • The claim "no working configuration breaks" is too strong: a self-hosted calendar (Radicale/Baïkal-style) on a public IP with a nonstandard https port was accepted before and is refused after upgrade, and the only relaxation path is a code edit to _ALLOWED_SCHEMES' consequence — no config knob. The probe-hardening trade may still be right, but a human should own that regression knowingly ("A calendar on another port is nearly always an internal service").
  • The port ceiling is keyed on _ALLOWED_SCHEMES == ("https",) tuple equality; any future legitimate widening of the scheme list silently deletes the port rule entirely rather than widening it — the guard's off-switch is an unrelated-looking edit.

Suggestions

  • Replace the side-effect _resolve_and_record closure with VettedUrl carrying the full vetted address tuple (keep ip as addresses[0] for compat); the current design silently under-pins if vet_unfurl_url ever caches, short-circuits, or re-resolves, and the multi-address need is generic, not calendar-specific.

[DESIGN-REVIEWED] 10bc77e

@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of 10bc77e433dbd8e62e3f1f7df783b60f17ad1709 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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 ships

Intent: stop the gateway's calendar fetch approving non-public addresses, by deleting the local vet copy — a FIX.

  1. CGNAT 100.64.0.0/10 calendar URLs now refused — justified
  2. IPv6 site-local fec0::/10 now refused — justified
  3. .local / .onion hosts now refused — justified
  4. Local _vet_host_addresses/_refuse_private_address deleted; delegates to link_unfurl — justified (the deletion IS the fix)
  5. Calendar ports narrow to 443; :8443 previously worked and was test-pinned — rides along, declared
  6. Operator-facing rejection wording changes — declared
  7. Shared vet: lone-surrogate host refused instead of escaping as UnicodeError — rides along, undeclared
  8. Shared vet: 6to4 judged on both readings on pre-gh-113171 interpreters — rides along, undeclared
  9. Alternate IPv4 encodings decided by the vet, not resolver agreement — justified, cause-level
  10. Rebinding tests patch link_unfurl internals instead of a local name — justified

Watch

  • Items 7 and 8 change the unfurl endpoint's behavior too, and the description never mentions either. Both are load-bearing (8 prevents the delegation weakening the calendar's old sixtofour unwrap on old patch releases; 7 stops a config-borne surrogate becoming a 500), so they belong — but a reader of the description would not know the shared vet changed.
  • Item 5's stated harm is thin: "stop this endpoint being used to probe for [an internal service]" — but the address vet this PR installs already refuses every internal address, so the 443-only rule mostly removes a shipped capability (test_port_is_taken_from_the_url pinned :8443). Declared and one-line-reversible, so a concern, not a blocker.
  • Point patch, siblings counted. Grepped is_private|is_global under src/: besides the two files fixed, four other server-side gates hand-roll this decision. Two carry the exact defect this PR fixes — skill_providers/skillsh.py:378 and apps/builtins/auto_improvement/backend/clone_setup.py:98 both approve 100.64.0.1 and fec0::1 via is_private-style enumeration before a server-side fetch/clone. Two more (mcp_tools/browser.py:149, dashboard/chat_runner.py:2297) use bare is_global, which this PR's own docstring proves approves fec0::/10 and ff00::/8 on IPv6. The general fix — pointing them at the _is_not_public union this PR just wrote — is accepted-and-deferred, but the four paths should be named somewhere the next PR finds them.

[FIRST-PRINCIPLES-REVIEWED] 10bc77e

@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Disposition: GPT 5.6 round 1

BLOCKING — "Delegation drops the 6to4 embedded-address guard" — accepted and fixed in d096d55.

Correct finding. The local vet unwrapped both ipv4_mapped and sixtofour; link_unfurl handled only the first, so the delegation lost the second. Fixed in link_unfurl rather than by layering a local check back around the delegation — the unfurl endpoint has the same gap today, and one owner of the decision is the whole point of the previous commit.

One correction to the suggested fix, worth recording because the obvious shape is wrong: retaining the previous sixtofour handling as a substitution (ip = ip.sixtofour) is a regression, not a fix. 2002:8000:: carries the public 128.0.0.0, so substituting makes a 6to4 address pass that the v6 is_private had refused. I wrote it that way first and test_vet_rejects_every_special_purpose_range[2002::/16] failed. The two encodings are not symmetric:

  • ::ffff:1.2.3.4 has no meaning as a v6 destination → the mapped address is the only thing to judge → substitution (unchanged).
  • 2002:xxxx:yyyy::/48 is a routable v6 prefix and names a v4 tunnel endpoint → both readings must pass → an additional refusal.

The flag union moved into _is_not_public so one formulation covers both readings.

On reachability, stated plainly rather than argued down: your Python 3.10 premise is right in principle — 2002::/16 entered CPython's IPv6 private table only with gh-113171 (3.10.14 / 3.11.9 / 3.12.4). On this repo's CI the 3.10 runner is new enough that is_private already refuses it, so the guard was not reachable there. It is fixed anyway: the refusal should not depend on the runner's patch level, which is the same reason the ipv4_mapped unwrap next to it exists.

The new test does not rely on the interpreter disagreeing. It drops 2002::/16 from ipaddress._IPv6Constants._private_networks for its duration, asserts the premise (is_global is now True), then asserts the refusal holds. All four parametrizations fail with DID NOT RAISE without the check.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 23, 2026
@iamwhatever
iamwhatever force-pushed the fix/meetings-calendar-ssrf-vet branch from d096d55 to a42867a Compare August 23, 2026 07:53
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 23, 2026
@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 10bc77e433dbd8e62e3f1f7df783b60f17ad1709 — this comment is updated in place on each push.

Review details

Both candidates fail the survival bar. Let me confirm my reasoning on the key questions before finalizing.

Candidate 1 (userinfo rejection breaks basic-auth .ics): The old code (url = parts.geturl(); host = parsed.raw_host) did pass userinfo through, and the new delegation to vet_unfurl_url rejects parts.username or parts.passwordinvalid_url → "malformed". So the behavior change is real. But:

  • This is a deliberate security tightening (userinfo → Authorization: Basic + unredacted log leakage, per the vet's own comment), not a defect. Confidence that it's an unintended defect rather than intended hardening is well below 80.
  • It is not in the BLOCKING class list (not a crash, data loss, corruption, security hole, or removed guard — it's the opposite of a security hole). A CalendarError is raised cleanly; no crash. At most this is an advisory message-quality nit.
  • No repo evidence (docs, tests) that basic-auth .ics is a supported/used calendar configuration.

Candidate 2 (port-80 narrowing): The candidate itself is "low confidence," notes it is "deliberate and tested" (test_port_80_is_refused_even_though_the_shared_vet_allows_it), and says "None required if intentional." Intended, tested behavior — not a defect.

Step 2 check — I traced the new paths for introduced crashes/regressions: OSError/UnicodeError from _resolve_and_record is caught by the vet's except (OSError, UnicodeError); a CalendarError raised inside the resolver callback propagates as the correct type the caller expects; the literal-IP fallback tuple(dict.fromkeys(approved)) or (vetted.ip,) is correct; _is_not_public is a strict refactor that only adds 6to4 unwrapping. No grounded new defect at 80+.

No findings.

[OPUS-REVIEWED] 10bc77e

Verdict parsed from the review's SHA-scoped output markers for commit 10bc77e433dbd8e62e3f1f7df783b60f17ad1709.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 10bc77e433dbd8e62e3f1f7df783b60f17ad1709: <one-sentence reason>

`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>
@iamwhatever
iamwhatever force-pushed the fix/meetings-calendar-ssrf-vet branch from a42867a to 10bc77e Compare August 23, 2026 08:17
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 23, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Disposition: GPT 5.6 round 2

BLOCKING — "malformed Unicode hostname escapes error handling" — accepted and fixed in 10bc77e.

Correct, and it is a regression I introduced. Before delegating, _normalize_url parsed the URL with yarl.URL(...) inside an except ValueError, which caught the lone surrogate by accident; removing that parse let it through. Reproduced:

# on this branch, before the fix
>>> cal._normalize_url('https://\ud800.example/cal.ics')
LEAKED UnicodeError : Invalid character '\ud800'

# on unmodified origin/main
>>> cal._normalize_url('https://\ud800.example/cal.ics')
CalendarError: calendar URL is malformed

Two notes on where the fix went and why it is bigger than the suggestion.

It is not only the resolver. getaddrinfo is one of two raise sites; the other is yarl.URL(normalized).raw_host in the wire_host derivation. Catching only at the resolver still failed all three parametrizations — the exception simply moved to link_unfurl.py:343. Both are guarded now, and wire_host is derived before VettedUrl is constructed so the failure has one exit rather than a half-built result.

It is fixed in link_unfurl, not in the calendar caller. The shared vet has the same leak on main today, verified directly against an unmodified checkout:

'https://\ud800.example/'      -> LEAKED UnicodeError : Invalid character '\ud800'
'https://ex\udcffample.test/'  -> LEAKED UnicodeError : Invalid character '\udcff'

So link_meta, which takes its URL from a request body, reaches it too. Adding a local try in the calendar provider would have fixed one caller and left the other — the opposite of what this PR is for. UnicodeError is called out by name in the comment because the reason it slipped past a fail-closed catch is that it is a ValueError, not an OSError.

Pinned in both suites: test_a_host_the_resolver_cannot_encode_is_refused_not_raised (3 cases) and test_a_host_the_resolver_cannot_encode_is_a_calendar_error_not_a_500 (2 cases) — the second at the call site whose behavior actually regressed.

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Aug 23, 2026
@kyleseaman
kyleseaman merged commit 2cbd716 into main Aug 23, 2026
63 checks passed
@kyleseaman
kyleseaman deleted the fix/meetings-calendar-ssrf-vet branch August 23, 2026 19:47
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Aug 23, 2026
Sign up for free to 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