Subscribe to a calendar published at a URL #211

Closed
opened 2026-09-02 07:52:28 +00:00 by jcoffey-dev · 0 comments
Owner

A timetable, a rota, a public holiday list — the calendars people are given as a link, which ihasmail could not show at all.

Nothing is stored

The document is fetched when the calendar is opened and parsed in the browser. The server keeps no copy, no cache and no schedule, which is what lets an immutable container serve this.

There is no timer either — ihasmail has nowhere to run one — so the guarantee is that a subscription is as current as the last time somebody looked, which is also when it matters. That is stated plainly in FEATURES rather than implied.

One SSRF guard, not two

The fetch has to happen on the server: a calendar URL belongs to whoever published it and almost none send CORS headers. That makes this the second place the app knocks on a door somebody else chose.

Rather than write a second guard, I lifted the image proxy's out into safeFetch and both now call it. A second SSRF implementation is how one of them ends up missing a case. The extraction is covered by the image proxy's own tests continuing to pass unchanged.

webcal: is understood, because that is how these are published — and it is read as https: rather than waved past the checks. webcal://127.0.0.1/x.ics is refused exactly like the http form.

Two things it deliberately does not do

Recurrence is not expanded. RRULE is a small language with a lot of edge cases, and a subscription quietly showing the wrong dates would be worse than one showing the first occurrence and saying so.

A calendar on a private address is refused, including one on your own machine. That is the guard working, but it does mean you cannot test this against a local file server — noted because it is surprising.

The parser

A subscription parser, not an importer: a subscribed calendar is read-only and redrawn from scratch each refresh, so nothing has to round-trip or survive an edit — which is most of what makes a full iCalendar implementation large. What it does have to do is never mis-state a time and never hang on a document somebody else wrote.

The case I'd point at: a DATE is built in local time, not UTC midnight, which would land on the day before for anyone west of Greenwich. And the content line splitter finds the first colon outside a quoted parameter, because DTSTART;TZID="GMT+01:00":… is real and a naive indexOf(":") reads it as a property called DTSTART;TZID="GMT+01.

Reuse

Events go through instancesIn like the birthdays, so no view has to know they are not real calendars, and the virtual calendar reports no write rights — so everything that asks before offering an edit declines on its own. updateEvent/destroyEvent refuse a subscription id as well.

Testing

28 parser tests (unfolding, the quoted-colon case, all three date forms, DURATION, non-VEVENT components stepped over, recurring counted but not expanded, missing start dropped, end-before-start repaired) and 6 server tests on the guard (private addresses, webcal: not being a bypass, refused schemes, credentials in the URL, status mapping).

Suite green: web 833 across 86 files, server 122, typecheck clean.

Verified in the running app

/api/ics against the cloud metadata address returns 403 forbidden_target. Adding a subscription through Settings works, and a subscription that cannot be read shows in the sidebar with a warning and the reason on hover — rather than drawing an empty calendar.

Not verified against a real published calendar. Every address I could reach from here is either private (refused by design) or a third party I chose not to fetch. The parser is tested against documents built to the RFC, not captured from Google or Fastmail, and that is the gap worth closing before this is trusted.

Merged 2026-09-02 as coffey-labs/ihasmail@13a6bcd66b

Rebuilt from: git history, session transcript.

A timetable, a rota, a public holiday list — the calendars people are given as a link, which ihasmail could not show at all. ## Nothing is stored The document is fetched when the calendar is opened and parsed in the browser. The server keeps no copy, no cache and no schedule, which is what lets an immutable container serve this. There is no timer either — ihasmail has nowhere to run one — so the guarantee is that a subscription is **as current as the last time somebody looked**, which is also when it matters. That is stated plainly in FEATURES rather than implied. ## One SSRF guard, not two The fetch has to happen on the server: a calendar URL belongs to whoever published it and almost none send CORS headers. That makes this the **second** place the app knocks on a door somebody else chose. Rather than write a second guard, I lifted the image proxy's out into `safeFetch` and both now call it. A second SSRF implementation is how one of them ends up missing a case. The extraction is covered by the image proxy's own tests continuing to pass unchanged. **`webcal:` is understood**, because that is how these are published — and it is read as `https:` rather than waved past the checks. `webcal://127.0.0.1/x.ics` is refused exactly like the http form. ## Two things it deliberately does not do **Recurrence is not expanded.** `RRULE` is a small language with a lot of edge cases, and a subscription quietly showing the wrong dates would be worse than one showing the first occurrence and saying so. **A calendar on a private address is refused**, including one on your own machine. That is the guard working, but it does mean you cannot test this against a local file server — noted because it is surprising. ## The parser A *subscription* parser, not an importer: a subscribed calendar is read-only and redrawn from scratch each refresh, so nothing has to round-trip or survive an edit — which is most of what makes a full iCalendar implementation large. What it does have to do is never mis-state a time and never hang on a document somebody else wrote. The case I'd point at: a `DATE` is built in **local time, not UTC midnight**, which would land on the day before for anyone west of Greenwich. And the content line splitter finds the first colon *outside* a quoted parameter, because `DTSTART;TZID="GMT+01:00":…` is real and a naive `indexOf(":")` reads it as a property called `DTSTART;TZID="GMT+01`. ## Reuse Events go through `instancesIn` like the birthdays, so no view has to know they are not real calendars, and the virtual calendar reports no write rights — so everything that asks before offering an edit declines on its own. `updateEvent`/`destroyEvent` refuse a subscription id as well. ## Testing 28 parser tests (unfolding, the quoted-colon case, all three date forms, DURATION, non-VEVENT components stepped over, recurring counted but not expanded, missing start dropped, end-before-start repaired) and 6 server tests on the guard (private addresses, `webcal:` not being a bypass, refused schemes, credentials in the URL, status mapping). Suite green: web 833 across 86 files, server **122**, typecheck clean. ## Verified in the running app `/api/ics` against the cloud metadata address returns **403 `forbidden_target`**. Adding a subscription through Settings works, and a subscription that cannot be read shows in the sidebar with a warning and the reason on hover — rather than drawing an empty calendar. **Not verified against a real published calendar.** Every address I could reach from here is either private (refused by design) or a third party I chose not to fetch. The parser is tested against documents built to the RFC, not captured from Google or Fastmail, and that is the gap worth closing before this is trusted. **Merged** 2026-09-02 as coffey-labs/ihasmail@13a6bcd66b39 <sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.