From 90578f02d851ab4e28a066404fbcf4be6a0ed9a7 Mon Sep 17 00:00:00 2001 From: grm Date: Fri, 18 Sep 2026 13:45:16 +0300 Subject: Security: Serve /media single-range only http.ServeContent honours any number of comma-separated ranges and chunkReader caches one 512 KiB slice, so a Range header alternating between two chunks costs a substring() query per range: one 1 MB header could make Postgres read tens of gigabytes for a single anonymous request. Browsers and download managers only ever send one range, so a multi-range header is dropped and the file served whole. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- AGENTS.md | 2 ++ internal/web/handlers_media.go | 13 +++++++++++++ internal/web/web_test.go | 13 +++++++++++++ 3 files changed, 28 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1cc12fd..6d7b355 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -294,6 +294,8 @@ internal/web/ server.go (host router, middleware, render helpers) is. The bytes are streamed by `BlogStore.FileReader` (a `chunkReader` over `substring()`, 512 KiB per query, Range requests included), which is why the per-blog limit is capped at 1024 MB (`maxUploadMB`: int4 offsets). + Only a single range is honoured (`singleRangeOnly` drops multi-range + headers): ServeContent's multipart answer would cost a query per range. Ids are immutable, so a renamed file keeps its old download name in browsers that cached it. Markdown keeps the relative `/media/…` form because post bodies render on the blog host; `fileMarkdown` writes diff --git a/internal/web/handlers_media.go b/internal/web/handlers_media.go index 7c4b739..99fdb64 100644 --- a/internal/web/handlers_media.go +++ b/internal/web/handlers_media.go @@ -4,6 +4,7 @@ import ( "errors" "io/fs" "net/http" + "strings" "github.com/google/uuid" "github.com/gramanas/blogspace/internal/store" @@ -38,9 +39,21 @@ func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Header().Set("ETag", etag) w.Header().Set("X-Content-Type-Options", "nosniff") + singleRangeOnly(r) http.ServeContent(w, r, f.Filename, f.CreatedAt, blogStore(r).FileReader(r.Context(), f)) } +// singleRangeOnly drops a multi-range request so the file is served whole. +// ServeContent honours any number of ranges and chunkReader caches one slice, +// so a header alternating between two chunks would cost a substring() query +// per range — tens of thousands per request. Browsers and download managers +// only ever ask for one range. +func singleRangeOnly(r *http.Request) { + if strings.Contains(r.Header.Get("Range"), ",") { + r.Header.Del("Range") + } +} + // handleFavicon answers the browsers that ask for /favicon.ico regardless of // the tags: the blog's own icon if it set one, else the // Blogspace default. Without this the request would render the 404 page. diff --git a/internal/web/web_test.go b/internal/web/web_test.go index fc38536..1d4db4c 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -907,3 +907,16 @@ func TestImagePickKeepsOlderChoice(t *testing.T) { } } } + +func TestSingleRangeOnly(t *testing.T) { + for in, want := range map[string]string{"bytes=0-9": "bytes=0-9", "bytes=0-0,1-1": "", "": ""} { + req := httptest.NewRequest("GET", "/media/x", nil) + if in != "" { + req.Header.Set("Range", in) + } + singleRangeOnly(req) + if got := req.Header.Get("Range"); got != want { + t.Errorf("Range %q: kept %q, want %q", in, got, want) + } + } +} -- cgit v1.2.3