aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-18 13:45:16 +0300
committergrm <grm@eyesin.space>2026-09-18 13:45:16 +0300
commit90578f02d851ab4e28a066404fbcf4be6a0ed9a7 (patch)
treed8e5f5fea70c0e8f11a93eebaa3d3add79b6c14a
parent19353a51c869f4b24ef2253d856084b6e6728048 (diff)
downloadblogspace-90578f02d851ab4e28a066404fbcf4be6a0ed9a7.tar.gz
blogspace-90578f02d851ab4e28a066404fbcf4be6a0ed9a7.tar.bz2
blogspace-90578f02d851ab4e28a066404fbcf4be6a0ed9a7.zip
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
-rw-r--r--AGENTS.md2
-rw-r--r--internal/web/handlers_media.go13
-rw-r--r--internal/web/web_test.go13
3 files changed, 28 insertions, 0 deletions
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 <link rel="icon"> 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)
+ }
+ }
+}