Fix 500 on REPORT for recurring events with Windows timezone IDs

DAVx5 reported a 500 Internal Server Error on a time-range REPORT
against a calendar containing a recurring event whose DTSTART/DTEND used
a Windows-style TZID (e.g. "W. Europe Standard Time", as commonly written
by Outlook/Exchange and some Thunderbird/Lightning setups) instead of an
IANA zone name.

go-ical resolves TZID via a plain time.LoadLocation call, which only
understands IANA names. A simple decode of such an event succeeds (RRULE
dates aren't parsed eagerly), but expanding its recurrence - which
go-webdav's caldav.Filter does for every time-range REPORT - calls
Component.RecurrenceSet, which does call time.LoadLocation(tzid) and
fails with "ical: error parsing start time: unknown time zone ...".

Add internal/icalfix, a small shared helper that rewrites recognized
Windows timezone identifiers (TZID parameters and VTIMEZONE TZID: lines)
to their IANA equivalent in raw ICS bytes before decoding. Wire it into
every ical.NewDecoder call site: internal/caldav/backend.go's
decodeObject (fixes the reported bug), internal/web/calendar.go's event
rendering/ICS import, and internal/icssub's remote feed fetching, so a
subscribed feed with the same issue doesn't hit it either.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-20 22:23:11 +02:00
co-authored by Copilot
parent 1707ab4060
commit 213b22f821
5 changed files with 295 additions and 5 deletions
+8 -1
View File
@@ -20,6 +20,7 @@ import (
"github.com/yourusername/caldav-server/internal/auth" "github.com/yourusername/caldav-server/internal/auth"
"github.com/yourusername/caldav-server/internal/config" "github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db" "github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/icalfix"
"github.com/yourusername/caldav-server/internal/icssub" "github.com/yourusername/caldav-server/internal/icssub"
"github.com/yourusername/caldav-server/internal/store" "github.com/yourusername/caldav-server/internal/store"
) )
@@ -383,7 +384,13 @@ func (b *Backend) calendarMeta(owner, realName, localName string) caldav.Calenda
} }
func (b *Backend) decodeObject(objPath string, data []byte) (*caldav.CalendarObject, error) { func (b *Backend) decodeObject(objPath string, data []byte) (*caldav.CalendarObject, error) {
cal, err := ical.NewDecoder(strings.NewReader(string(data))).Decode() // Some clients (Outlook/Exchange, some Thunderbird/Lightning setups)
// write Windows timezone names into TZID instead of IANA ones, which
// go-ical can't resolve. A recurring event with such a TZID decodes
// fine here but fails later, as a 500, the moment a CalDAV client
// issues a time-range query that expands its recurrence — so fix it
// up before decoding rather than only in QueryCalendarObjects.
cal, err := ical.NewDecoder(bytes.NewReader(icalfix.NormalizeTimeZones(data))).Decode()
if err != nil { if err != nil {
return nil, fmt.Errorf("decoding ical: %w", err) return nil, fmt.Errorf("decoding ical: %w", err)
} }
+201
View File
@@ -0,0 +1,201 @@
// Package icalfix normalizes non-standard timezone identifiers in raw
// iCalendar data before it's handed to go-ical's decoder.
//
// go-ical resolves a DTSTART/DTEND's TZID parameter (and, for recurring
// events, the RRULE expansion's start time) via a direct
// time.LoadLocation(tzid) call — see
// github.com/emersion/go-ical@.../ical.go's Prop.DateTime and
// components.go's Component.RecurrenceSet. That only understands IANA
// zone names ("Europe/Berlin"), but Windows/Outlook (and anything built
// on the Windows timezone database, e.g. Exchange, Thunderbird's Lightning
// on some setups) commonly emits Windows zone names instead
// ("W. Europe Standard Time"), which time.LoadLocation doesn't recognize.
// A recurring event stored with such a TZID decodes fine (go-ical doesn't
// eagerly parse RRULE dates), but fails later — as a 500 — the moment a
// CalDAV client issues a time-range REPORT that has to expand its
// recurrence, since that's when RecurrenceSet actually calls
// time.LoadLocation.
package icalfix
import "regexp"
// tzidParamRe matches a "TZID=<name>" (optionally quoted) parameter value
// as it appears attached to any property (e.g. "DTSTART;TZID=W. Europe
// Standard Time:..."). The name runs until the next ':' or ';' delimiter
// or line break, so it captures the parameter's full value even though it
// contains spaces and periods.
var tzidParamRe = regexp.MustCompile(`TZID=("?)([^";:\r\n]+)("?)`)
// tzidLineRe matches a standalone "TZID:<name>" property line, as found
// inside a VTIMEZONE component.
var tzidLineRe = regexp.MustCompile(`(?m)^TZID:([^\r\n]+)$`)
// NormalizeTimeZones rewrites any recognized Windows timezone identifier
// in data to its IANA equivalent, leaving everything else (including
// unrecognized TZIDs) untouched. It's safe to call on data that has no
// Windows TZIDs at all — such data passes through unchanged.
func NormalizeTimeZones(data []byte) []byte {
data = tzidParamRe.ReplaceAllFunc(data, func(m []byte) []byte {
sub := tzidParamRe.FindSubmatch(m)
name := string(sub[2])
iana, ok := windowsToIANA[name]
if !ok {
return m
}
return []byte("TZID=" + string(sub[1]) + iana + string(sub[3]))
})
data = tzidLineRe.ReplaceAllFunc(data, func(m []byte) []byte {
sub := tzidLineRe.FindSubmatch(m)
name := string(sub[1])
iana, ok := windowsToIANA[name]
if !ok {
return m
}
return []byte("TZID:" + iana)
})
return data
}
// windowsToIANA maps common Windows timezone identifiers (as found in the
// CLDR windowsZones.xml "001"/default territory mapping) to an IANA zone
// name recognized by Go's tzdata. It's not exhaustive, but covers the
// zones seen in practice from Outlook/Exchange-generated ICS data.
var windowsToIANA = map[string]string{
"Dateline Standard Time": "Etc/GMT+12",
"UTC-11": "Etc/GMT+11",
"Aleutian Standard Time": "America/Adak",
"Hawaiian Standard Time": "Pacific/Honolulu",
"Marquesas Standard Time": "Pacific/Marquesas",
"Alaskan Standard Time": "America/Anchorage",
"UTC-09": "Etc/GMT+9",
"Pacific Standard Time (Mexico)": "America/Tijuana",
"UTC-08": "Etc/GMT+8",
"Pacific Standard Time": "America/Los_Angeles",
"US Mountain Standard Time": "America/Phoenix",
"Mountain Standard Time (Mexico)": "America/Chihuahua",
"Mountain Standard Time": "America/Denver",
"Central America Standard Time": "America/Guatemala",
"Central Standard Time": "America/Chicago",
"Central Standard Time (Mexico)": "America/Mexico_City",
"Canada Central Standard Time": "America/Regina",
"SA Pacific Standard Time": "America/Bogota",
"Eastern Standard Time (Mexico)": "America/Cancun",
"Eastern Standard Time": "America/New_York",
"Haiti Standard Time": "America/Port-au-Prince",
"Cuba Standard Time": "America/Havana",
"US Eastern Standard Time": "America/Indianapolis",
"Turks And Caicos Standard Time": "America/Grand_Turk",
"Paraguay Standard Time": "America/Asuncion",
"Atlantic Standard Time": "America/Halifax",
"Venezuela Standard Time": "America/Caracas",
"Central Brazilian Standard Time": "America/Cuiaba",
"SA Western Standard Time": "America/La_Paz",
"Pacific SA Standard Time": "America/Santiago",
"Newfoundland Standard Time": "America/St_Johns",
"Tocantins Standard Time": "America/Araguaina",
"E. South America Standard Time": "America/Sao_Paulo",
"SA Eastern Standard Time": "America/Cayenne",
"Argentina Standard Time": "America/Buenos_Aires",
"Greenland Standard Time": "America/Godthab",
"Montevideo Standard Time": "America/Montevideo",
"Magallanes Standard Time": "America/Punta_Arenas",
"Saint Pierre Standard Time": "America/Miquelon",
"Bahia Standard Time": "America/Bahia",
"UTC-02": "Etc/GMT+2",
"Azores Standard Time": "Atlantic/Azores",
"Cape Verde Standard Time": "Atlantic/Cape_Verde",
"UTC": "Etc/UTC",
"GMT Standard Time": "Europe/London",
"Greenwich Standard Time": "Atlantic/Reykjavik",
"Sao Tome Standard Time": "Africa/Sao_Tome",
"Morocco Standard Time": "Africa/Casablanca",
"W. Europe Standard Time": "Europe/Berlin",
"Central Europe Standard Time": "Europe/Budapest",
"Romance Standard Time": "Europe/Paris",
"Central European Standard Time": "Europe/Warsaw",
"W. Central Africa Standard Time": "Africa/Lagos",
"Jordan Standard Time": "Asia/Amman",
"GTB Standard Time": "Europe/Bucharest",
"Middle East Standard Time": "Asia/Beirut",
"Egypt Standard Time": "Africa/Cairo",
"E. Europe Standard Time": "Europe/Chisinau",
"Syria Standard Time": "Asia/Damascus",
"West Bank Standard Time": "Asia/Hebron",
"South Africa Standard Time": "Africa/Johannesburg",
"FLE Standard Time": "Europe/Kiev",
"Israel Standard Time": "Asia/Jerusalem",
"Kaliningrad Standard Time": "Europe/Kaliningrad",
"Sudan Standard Time": "Africa/Khartoum",
"Libya Standard Time": "Africa/Tripoli",
"Namibia Standard Time": "Africa/Windhoek",
"Arabic Standard Time": "Asia/Baghdad",
"Turkey Standard Time": "Europe/Istanbul",
"Arab Standard Time": "Asia/Riyadh",
"Belarus Standard Time": "Europe/Minsk",
"Russian Standard Time": "Europe/Moscow",
"E. Africa Standard Time": "Africa/Nairobi",
"Volgograd Standard Time": "Europe/Volgograd",
"Iran Standard Time": "Asia/Tehran",
"Arabian Standard Time": "Asia/Dubai",
"Astrakhan Standard Time": "Europe/Astrakhan",
"Azerbaijan Standard Time": "Asia/Baku",
"Russia Time Zone 3": "Europe/Samara",
"Mauritius Standard Time": "Indian/Mauritius",
"Saratov Standard Time": "Europe/Saratov",
"Georgian Standard Time": "Asia/Tbilisi",
"Caucasus Standard Time": "Asia/Yerevan",
"Afghanistan Standard Time": "Asia/Kabul",
"West Asia Standard Time": "Asia/Tashkent",
"Ekaterinburg Standard Time": "Asia/Yekaterinburg",
"Pakistan Standard Time": "Asia/Karachi",
"Qyzylorda Standard Time": "Asia/Qyzylorda",
"India Standard Time": "Asia/Calcutta",
"Sri Lanka Standard Time": "Asia/Colombo",
"Nepal Standard Time": "Asia/Katmandu",
"Central Asia Standard Time": "Asia/Almaty",
"Bangladesh Standard Time": "Asia/Dhaka",
"Omsk Standard Time": "Asia/Omsk",
"Myanmar Standard Time": "Asia/Rangoon",
"SE Asia Standard Time": "Asia/Bangkok",
"Altai Standard Time": "Asia/Barnaul",
"W. Mongolia Standard Time": "Asia/Hovd",
"North Asia Standard Time": "Asia/Krasnoyarsk",
"N. Central Asia Standard Time": "Asia/Novosibirsk",
"Tomsk Standard Time": "Asia/Tomsk",
"China Standard Time": "Asia/Shanghai",
"North Asia East Standard Time": "Asia/Irkutsk",
"Singapore Standard Time": "Asia/Singapore",
"W. Australia Standard Time": "Australia/Perth",
"Taipei Standard Time": "Asia/Taipei",
"Ulaanbaatar Standard Time": "Asia/Ulaanbaatar",
"Aus Central W. Standard Time": "Australia/Eucla",
"Transbaikal Standard Time": "Asia/Chita",
"Tokyo Standard Time": "Asia/Tokyo",
"North Korea Standard Time": "Asia/Pyongyang",
"Korea Standard Time": "Asia/Seoul",
"Yakutsk Standard Time": "Asia/Yakutsk",
"Cen. Australia Standard Time": "Australia/Adelaide",
"AUS Central Standard Time": "Australia/Darwin",
"E. Australia Standard Time": "Australia/Brisbane",
"AUS Eastern Standard Time": "Australia/Sydney",
"West Pacific Standard Time": "Pacific/Port_Moresby",
"Tasmania Standard Time": "Australia/Hobart",
"Vladivostok Standard Time": "Asia/Vladivostok",
"Lord Howe Standard Time": "Australia/Lord_Howe",
"Bougainville Standard Time": "Pacific/Bougainville",
"Russia Time Zone 10": "Asia/Srednekolymsk",
"Magadan Standard Time": "Asia/Magadan",
"Norfolk Standard Time": "Pacific/Norfolk",
"Sakhalin Standard Time": "Asia/Sakhalin",
"Central Pacific Standard Time": "Pacific/Guadalcanal",
"Russia Time Zone 11": "Asia/Kamchatka",
"New Zealand Standard Time": "Pacific/Auckland",
"UTC+12": "Etc/GMT-12",
"Fiji Standard Time": "Pacific/Fiji",
"Kamchatka Standard Time": "Asia/Kamchatka",
"Chatham Islands Standard Time": "Pacific/Chatham",
"UTC+13": "Etc/GMT-13",
"Tonga Standard Time": "Pacific/Tongatapu",
"Samoa Standard Time": "Pacific/Apia",
"Line Islands Standard Time": "Pacific/Kiritimati",
}
+71
View File
@@ -0,0 +1,71 @@
package icalfix
import (
"bytes"
"testing"
ical "github.com/emersion/go-ical"
)
// TestNormalizeTimeZonesFixesRecurringEvent reproduces the bug reported by
// a DAVx5 client: a recurring VEVENT with a Windows-style TZID
// ("W. Europe Standard Time") decodes fine, but expanding its recurrence
// via RecurrenceSet (as go-webdav's caldav.Filter does for a time-range
// REPORT) fails with "unknown time zone" because go-ical resolves TZID
// via a plain time.LoadLocation call. Normalizing the TZID to its IANA
// equivalent before decoding must make that expansion succeed.
func TestNormalizeTimeZonesFixesRecurringEvent(t *testing.T) {
const raw = "BEGIN:VCALENDAR\r\n" +
"VERSION:2.0\r\n" +
"PRODID:-//Test//Test//EN\r\n" +
"BEGIN:VTIMEZONE\r\n" +
"TZID:W. Europe Standard Time\r\n" +
"END:VTIMEZONE\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:recurring@example.com\r\n" +
"DTSTAMP:20240101T000000Z\r\n" +
"DTSTART;TZID=W. Europe Standard Time:20260522T220000\r\n" +
"DTEND;TZID=W. Europe Standard Time:20260522T230000\r\n" +
"RRULE:FREQ=WEEKLY;COUNT=5\r\n" +
"SUMMARY:Weekly sync\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
normalized := NormalizeTimeZones([]byte(raw))
cal, err := ical.NewDecoder(bytes.NewReader(normalized)).Decode()
if err != nil {
t.Fatalf("decoding normalized calendar: %v", err)
}
events := cal.Events()
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
if _, err := events[0].RecurrenceSet(nil); err != nil {
t.Fatalf("expanding recurrence after normalization: %v", err)
}
// The un-normalized original must reproduce the reported failure, so
// this test actually demonstrates the fix rather than trivially
// passing regardless.
origCal, err := ical.NewDecoder(bytes.NewReader([]byte(raw))).Decode()
if err != nil {
t.Fatalf("decoding original calendar: %v", err)
}
if _, err := origCal.Events()[0].RecurrenceSet(nil); err == nil {
t.Fatalf("expected RecurrenceSet to fail on un-normalized Windows TZID, it didn't")
}
}
func TestNormalizeTimeZonesLeavesUnknownAndIANAZonesAlone(t *testing.T) {
const raw = "BEGIN:VCALENDAR\r\n" +
"DTSTART;TZID=Europe/Berlin:20260522T220000\r\n" +
"DTEND;TZID=Some/Unknown-Zone:20260522T230000\r\n" +
"END:VCALENDAR\r\n"
got := string(NormalizeTimeZones([]byte(raw)))
if got != raw {
t.Fatalf("expected data to be unchanged, got:\n%s", got)
}
}
+11 -1
View File
@@ -5,6 +5,7 @@
package icssub package icssub
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"io" "io"
@@ -14,6 +15,8 @@ import (
"time" "time"
ical "github.com/emersion/go-ical" ical "github.com/emersion/go-ical"
"github.com/yourusername/caldav-server/internal/icalfix"
) )
// DefaultTTL is how long a fetched calendar is cached before being // DefaultTTL is how long a fetched calendar is cached before being
@@ -103,7 +106,14 @@ func (c *Cache) fetch(rawURL string) (*ical.Calendar, error) {
return nil, fmt.Errorf("fetching calendar: unexpected status %s", resp.Status) return nil, fmt.Errorf("fetching calendar: unexpected status %s", resp.Status)
} }
cal, err := ical.NewDecoder(io.LimitReader(resp.Body, maxBodySize)).Decode() body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize))
if err != nil {
return nil, fmt.Errorf("reading calendar: %w", err)
}
// Some remote feeds (Outlook/Exchange-backed ones especially) use
// Windows timezone names instead of IANA ones, which go-ical can't
// resolve — fix those up before decoding (see internal/icalfix).
cal, err := ical.NewDecoder(bytes.NewReader(icalfix.NormalizeTimeZones(body))).Decode()
if err != nil { if err != nil {
return nil, fmt.Errorf("parsing calendar: %w", err) return nil, fmt.Errorf("parsing calendar: %w", err)
} }
+4 -3
View File
@@ -18,6 +18,7 @@ import (
ical "github.com/emersion/go-ical" ical "github.com/emersion/go-ical"
"github.com/yourusername/caldav-server/internal/birthdays" "github.com/yourusername/caldav-server/internal/birthdays"
"github.com/yourusername/caldav-server/internal/db" "github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/icalfix"
"github.com/yourusername/caldav-server/internal/web/templates" "github.com/yourusername/caldav-server/internal/web/templates"
) )
@@ -675,7 +676,7 @@ func (s *Server) saveEventFromForm(w http.ResponseWriter, r *http.Request, owner
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
existing, err := ical.NewDecoder(strings.NewReader(string(data))).Decode() existing, err := ical.NewDecoder(bytes.NewReader(icalfix.NormalizeTimeZones(data))).Decode()
if err != nil { if err != nil {
s.logger.Error("decoding existing event", "error", err) s.logger.Error("decoding existing event", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
@@ -791,7 +792,7 @@ func (s *Server) saveEvent(owner, cal, id string, calendar *ical.Calendar) error
// EventFormData shape used both for pre-filling the edit form and for // EventFormData shape used both for pre-filling the edit form and for
// placing the event on the month grid. // placing the event on the month grid.
func eventFormFromICS(id string, data []byte) (templates.EventFormData, error) { func eventFormFromICS(id string, data []byte) (templates.EventFormData, error) {
calendar, err := ical.NewDecoder(strings.NewReader(string(data))).Decode() calendar, err := ical.NewDecoder(bytes.NewReader(icalfix.NormalizeTimeZones(data))).Decode()
if err != nil { if err != nil {
return templates.EventFormData{}, err return templates.EventFormData{}, err
} }
@@ -974,7 +975,7 @@ func (s *Server) handleCalendarImport(w http.ResponseWriter, r *http.Request) {
} }
imported := 0 imported := 0
dec := ical.NewDecoder(bytes.NewReader(body)) dec := ical.NewDecoder(bytes.NewReader(icalfix.NormalizeTimeZones(body)))
for { for {
srcCal, err := dec.Decode() srcCal, err := dec.Decode()
if errors.Is(err, io.EOF) { if errors.Is(err, io.EOF) {