diff --git a/appview/repo/blob.go b/appview/repo/blob.go index 8d7ea441..79ddefb4 100644 --- a/appview/repo/blob.go +++ b/appview/repo/blob.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "fmt" "io" + "mime" "net/http" "net/url" "path/filepath" @@ -193,18 +194,28 @@ func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { return } - if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { - // serve all textual content as text/plain + // Normalize to bare media type before classification; strips parameters + // (e.g. "; charset=utf-8") and prevents bypass attempts like + // "image/svg+xml; innocent=param". A parse error yields an empty string + // which falls through to the 415 default — the safe outcome. + mediaType, _, _ := mime.ParseMediaType(contentType) + + // Prevent browser sniffing regardless of branch taken below. + w.Header().Set("X-Content-Type-Options", "nosniff") + + switch { + case strings.HasPrefix(mediaType, "text/") || isTextualMimeType(mediaType): + // Serve all textual content as plain text so the browser never + // interprets knot-supplied markup or scripts. w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Write(body) - } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { - // serve images and videos with their original content type - w.Header().Set("Content-Type", contentType) + case safeBinaryMIMEType(mediaType): + // Use the normalized type, never the raw knot-supplied string. + w.Header().Set("Content-Type", mediaType) w.Write(body) - } else { + default: w.WriteHeader(http.StatusUnsupportedMediaType) w.Write([]byte("unsupported content type")) - return } } @@ -309,6 +320,24 @@ func generateBlobURL(config *config.Config, repo *models.Repo, ref, filePath str return blobURL } +// safeBinaryMIMETypes is an explicit allowlist of binary content types that +// are safe to serve inline. SVG is intentionally absent: it supports embedded +// scripts and would enable XSS if a malicious knot returned one. +var safeBinaryMIMETypes = map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/gif": true, + "image/webp": true, + "image/avif": true, + "video/mp4": true, + "video/webm": true, + "video/ogg": true, +} + +func safeBinaryMIMEType(mediaType string) bool { + return safeBinaryMIMETypes[mediaType] +} + func isTextualMimeType(mimeType string) bool { textualTypes := []string{ "application/json", diff --git a/appview/repo/blob_test.go b/appview/repo/blob_test.go new file mode 100644 index 00000000..c0287365 --- /dev/null +++ b/appview/repo/blob_test.go @@ -0,0 +1,80 @@ +package repo + +import ( + "mime" + "strings" + "testing" +) + +func TestSafeBinaryMIMEType(t *testing.T) { + allowed := []string{ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/avif", + "video/mp4", + "video/webm", + "video/ogg", + } + for _, ct := range allowed { + if !safeBinaryMIMEType(ct) { + t.Errorf("expected %q to be allowed, but it was not", ct) + } + } + + rejected := []string{ + // SVG must be rejected — it supports embedded scripts. + "image/svg+xml", + // Other XML-based or scriptable types. + "image/svg", + "application/pdf", + "application/octet-stream", + "text/html", + "text/javascript", + // Empty / garbage. + "", + "image/", + "video/", + } + for _, ct := range rejected { + if safeBinaryMIMEType(ct) { + t.Errorf("expected %q to be rejected, but it was allowed", ct) + } + } +} + +// TestBlobMIMENormalization verifies that mime.ParseMediaType strips +// parameters before classification, closing bypass attempts such as +// "image/svg+xml; charset=utf-8". +func TestBlobMIMENormalization(t *testing.T) { + cases := []struct { + raw string + wantSafeBinary bool + wantTextual bool + }{ + // Parameters must not smuggle SVG past the allowlist. + {"image/svg+xml; charset=utf-8", false, false}, + {"image/svg+xml; innocent=param", false, false}, + // Parameters on safe types should still be allowed. + {"image/png; q=0.9", true, false}, + // Parameters on textual types. + {"text/plain; charset=utf-8", false, true}, + {"application/json; charset=utf-8", false, true}, + } + + for _, tc := range cases { + mediaType, _, _ := mime.ParseMediaType(tc.raw) + gotSafeBinary := safeBinaryMIMEType(mediaType) + gotTextual := strings.HasPrefix(mediaType, "text/") || isTextualMimeType(mediaType) + + if gotSafeBinary != tc.wantSafeBinary { + t.Errorf("safeBinaryMIMEType(%q): got %v, want %v (parsed as %q)", + tc.raw, gotSafeBinary, tc.wantSafeBinary, mediaType) + } + if gotTextual != tc.wantTextual { + t.Errorf("isTextual(%q): got %v, want %v (parsed as %q)", + tc.raw, gotTextual, tc.wantTextual, mediaType) + } + } +}