Skip to content

fix: bound request sizes and recurrence expansion, and set server timeouts (#39) - #42

Merged
gnacho merged 1 commit into
mainfrom
fix/39-limits-and-timeouts
Oct 18, 2026
Merged

gnacho merged 1 commit into
mainfrom
fix/39-limits-and-timeouts

Conversation

@gnacho

@gnacho gnacho commented Oct 18, 2026

Copy link
Copy Markdown
Owner

What was wrong

The endpoint had no limit anywhere: any client could make the server buffer an
unbounded body, and it was built on http.ListenAndServe, i.e. an
http.Server with no timeout at all. Verified against a local build before the
fix:

  • PUT of a valid iCalendar of 64 MiB -> 201 after 0.13 s, 67109032
    bytes written to the volume.
  • Grep for MaxBytesReader, LimitReader, MaxHeaderBytes,
    ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout in cmd/
    and internal/: zero results.
  • The XML decoders and io.ReadAll calls read straight from r.Body.

The worst case was not the disk: recurrence expansion happens inside the
library's caldav.Match, which has no bound of its own, so a single object with
RRULE:FREQ=SECONDLY queried over a decade kept a request running
indefinitely. Measured on the built binary with the cap disabled
(-max-expand 2000000000): the same calendar-query did not finish in 25
seconds
; with the default cap it answers 207 in 0.0016 s.

What this changes

  • Request and object caps. limitRequestBody wraps every request body with
    http.MaxBytesReader before any handler sees it, and handlePut wraps it
    again with the object cap, so an oversized object is rejected while it is
    being read and no partial file is written. Oversized bodies answer 413
    (mapped from http.MaxBytesError in bodyError, used by PUT, REPORT,
    PROPFIND, PROPPATCH and MKCALCOL). Defaults mirror Radicale:
    -max-request-size 100 MiB (max_content_length), -max-item-size 10 MiB
    (max_resource_size), also settable as DAVCLOUD_MAX_REQUEST_SIZE and
    DAVCLOUD_MAX_ITEM_SIZE.
  • The object cap is announced as max-resource-size in PROPFIND, in the
    CalDAV or CardDAV namespace as appropriate, so a well-behaved client can
    refuse to send what will not be stored (Radicale and sabre/dav both do this).
  • Recurrence expansion cap. internal/caldav estimates, before delegating
    a time-range query, how many instances an object's recurrence would produce
    (instanceBudget, counting FREQ, INTERVAL, COUNT, UNTIL against the
    requested range). Over -max-expand (default 10000, Radicale's
    max_expand_occurrence) the object is matched on its plain dates, which is
    the same fallback the code already used for recurrence data it cannot parse,
    and a warning is logged. Trade-off, stated on purpose: for ranges wide enough
    to need more than 10000 instances (a daily event over more than 27 years, or
    a sub-hourly rule over a wide range) an object whose base date falls outside
    the range is no longer matched; in exchange the query always finishes.
  • Explicit server timeouts. newServer builds the http.Server with
    ReadHeaderTimeout 10 s, ReadTimeout/WriteTimeout/IdleTimeout 2 min and
    MaxHeaderBytes 64 KiB instead of relying on Go's defaults, which set none
    (Radicale has a 30 s socket timeout and caps concurrent connections).
  • README (EN and ES) and deploy/README.md document the flags and defaults. The
    ES README typo in the rollback sentence is fixed on the way.

Tests

  • internal/caldav: TestInstanceBudget (15 cases, including COUNT, UNTIL,
    lowercase keys, unknown and empty rules), TestOverInstanceBudget and
    TestQueryRanges (the range lives in the nested VEVENT filter).
  • cmd/server: TestPutOverItemSizeIsRejected (413, nothing left on disk, and
    a smaller object still accepted byte for byte), TestOversizedRequestIsRejected
    (413 on a PROPFIND body, 207 within the limit),
    TestPropfindAdvertisesMaxResourceSize (both namespaces),
    TestServerHasTimeouts, and TestPathologicalRecurrenceQueryIsBounded, which
    does not finish without the cap.

Also checked end to end against a copy of the production volume (temporary
directory, production untouched): with -max-item-size 1024, a 2000 byte object
answers 413 and leaves no file, a 100 byte object answers 201, the collection
lists max-resource-size>1024, and the real items keep being served.

Closes #39

@gnacho
gnacho merged commit ac395f0 into main Oct 18, 2026
4 checks passed
@gnacho
gnacho deleted the fix/39-limits-and-timeouts branch October 18, 2026 08:17
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.

Bound request and item sizes, and set HTTP server timeouts

1 participant