Monorepo for Tangled tangled.org

appview/repo/blob.go: fix XSS via raw blob endpoint

Replace the image/ prefix match with an explicit allowlist of safe
binary MIME types. SVG is intentionally excluded as it supports
embedded scripts.

Normalize the knot-supplied Content-Type with mime.ParseMediaType
before classification to strip parameters and prevent bypass attempts.
Add X-Content-Type-Options: nosniff as defence-in-depth.

Add tests covering the allowlist invariants and the normalization
behaviour.

Signed-off-by: Matías Insaurralde <matias@insaurral.de>

+116 -7
+36 -7
appview/repo/blob.go
··· 4 4 "encoding/base64" 5 5 "fmt" 6 6 "io" 7 + "mime" 7 8 "net/http" 8 9 "net/url" 9 10 "path/filepath" ··· 193 194 return 194 195 } 195 196 196 - if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 197 - // serve all textual content as text/plain 197 + // Normalize to bare media type before classification; strips parameters 198 + // (e.g. "; charset=utf-8") and prevents bypass attempts like 199 + // "image/svg+xml; innocent=param". A parse error yields an empty string 200 + // which falls through to the 415 default — the safe outcome. 201 + mediaType, _, _ := mime.ParseMediaType(contentType) 202 + 203 + // Prevent browser sniffing regardless of branch taken below. 204 + w.Header().Set("X-Content-Type-Options", "nosniff") 205 + 206 + switch { 207 + case strings.HasPrefix(mediaType, "text/") || isTextualMimeType(mediaType): 208 + // Serve all textual content as plain text so the browser never 209 + // interprets knot-supplied markup or scripts. 198 210 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 199 211 w.Write(body) 200 - } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 201 - // serve images and videos with their original content type 202 - w.Header().Set("Content-Type", contentType) 212 + case safeBinaryMIMEType(mediaType): 213 + // Use the normalized type, never the raw knot-supplied string. 214 + w.Header().Set("Content-Type", mediaType) 203 215 w.Write(body) 204 - } else { 216 + default: 205 217 w.WriteHeader(http.StatusUnsupportedMediaType) 206 218 w.Write([]byte("unsupported content type")) 207 - return 208 219 } 209 220 } 210 221 ··· 307 318 return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL) 308 319 } 309 320 return blobURL 321 + } 322 + 323 + // safeBinaryMIMETypes is an explicit allowlist of binary content types that 324 + // are safe to serve inline. SVG is intentionally absent: it supports embedded 325 + // scripts and would enable XSS if a malicious knot returned one. 326 + var safeBinaryMIMETypes = map[string]bool{ 327 + "image/png": true, 328 + "image/jpeg": true, 329 + "image/gif": true, 330 + "image/webp": true, 331 + "image/avif": true, 332 + "video/mp4": true, 333 + "video/webm": true, 334 + "video/ogg": true, 335 + } 336 + 337 + func safeBinaryMIMEType(mediaType string) bool { 338 + return safeBinaryMIMETypes[mediaType] 310 339 } 311 340 312 341 func isTextualMimeType(mimeType string) bool {
+80
appview/repo/blob_test.go
··· 1 + package repo 2 + 3 + import ( 4 + "mime" 5 + "strings" 6 + "testing" 7 + ) 8 + 9 + func TestSafeBinaryMIMEType(t *testing.T) { 10 + allowed := []string{ 11 + "image/png", 12 + "image/jpeg", 13 + "image/gif", 14 + "image/webp", 15 + "image/avif", 16 + "video/mp4", 17 + "video/webm", 18 + "video/ogg", 19 + } 20 + for _, ct := range allowed { 21 + if !safeBinaryMIMEType(ct) { 22 + t.Errorf("expected %q to be allowed, but it was not", ct) 23 + } 24 + } 25 + 26 + rejected := []string{ 27 + // SVG must be rejected — it supports embedded scripts. 28 + "image/svg+xml", 29 + // Other XML-based or scriptable types. 30 + "image/svg", 31 + "application/pdf", 32 + "application/octet-stream", 33 + "text/html", 34 + "text/javascript", 35 + // Empty / garbage. 36 + "", 37 + "image/", 38 + "video/", 39 + } 40 + for _, ct := range rejected { 41 + if safeBinaryMIMEType(ct) { 42 + t.Errorf("expected %q to be rejected, but it was allowed", ct) 43 + } 44 + } 45 + } 46 + 47 + // TestBlobMIMENormalization verifies that mime.ParseMediaType strips 48 + // parameters before classification, closing bypass attempts such as 49 + // "image/svg+xml; charset=utf-8". 50 + func TestBlobMIMENormalization(t *testing.T) { 51 + cases := []struct { 52 + raw string 53 + wantSafeBinary bool 54 + wantTextual bool 55 + }{ 56 + // Parameters must not smuggle SVG past the allowlist. 57 + {"image/svg+xml; charset=utf-8", false, false}, 58 + {"image/svg+xml; innocent=param", false, false}, 59 + // Parameters on safe types should still be allowed. 60 + {"image/png; q=0.9", true, false}, 61 + // Parameters on textual types. 62 + {"text/plain; charset=utf-8", false, true}, 63 + {"application/json; charset=utf-8", false, true}, 64 + } 65 + 66 + for _, tc := range cases { 67 + mediaType, _, _ := mime.ParseMediaType(tc.raw) 68 + gotSafeBinary := safeBinaryMIMEType(mediaType) 69 + gotTextual := strings.HasPrefix(mediaType, "text/") || isTextualMimeType(mediaType) 70 + 71 + if gotSafeBinary != tc.wantSafeBinary { 72 + t.Errorf("safeBinaryMIMEType(%q): got %v, want %v (parsed as %q)", 73 + tc.raw, gotSafeBinary, tc.wantSafeBinary, mediaType) 74 + } 75 + if gotTextual != tc.wantTextual { 76 + t.Errorf("isTextual(%q): got %v, want %v (parsed as %q)", 77 + tc.raw, gotTextual, tc.wantTextual, mediaType) 78 + } 79 + } 80 + }