Monorepo for Tangled tangled.org
2

Configure Feed

Select the types of activity you want to include in your feed.

appview,knotmirror: redirect to knotmirror to serve raw contents

Serving raw SVG from same origin can cause XSS attack.
Using octet-stream for SVG file will break the README rendering.
Thus, assuming knotmirror is hosted on different origin, appview will
just redirect to knotmirror for all `/raw/` blob paths.

Signed-off-by: Seongmin Lee <git@boltless.me>

author
Seongmin Lee
committer
Tangled
date (Jun 11, 2026, 10:52 AM +0300) commit 3100001b parent 05862657 change-id wpypnoqx
+4 -191
+2 -99
appview/repo/blob.go
··· 3 3 import ( 4 4 "encoding/base64" 5 5 "fmt" 6 - "io" 7 - "mime" 8 6 "net/http" 9 7 "net/url" 10 8 "path/filepath" 11 - "slices" 12 9 "strings" 13 10 "time" 14 11 ··· 130 127 131 128 blobURL := generateBlobURL(rp.config, f, ref, filePath) 132 129 133 - req, err := http.NewRequest("GET", blobURL, nil) 134 - if err != nil { 135 - l.Error("failed to create request", "err", err) 136 - return 137 - } 138 - 139 - // forward the If-None-Match header 140 - if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 141 - req.Header.Set("If-None-Match", clientETag) 142 - } 143 - client := &http.Client{} 144 - 145 - resp, err := client.Do(req) 146 - if err != nil { 147 - l.Error("failed to reach knotserver", "err", err) 148 - rp.pages.Error503(w) 149 - return 150 - } 151 - 152 - defer resp.Body.Close() 153 - 154 - // forward 304 not modified 155 - if resp.StatusCode == http.StatusNotModified { 156 - w.WriteHeader(http.StatusNotModified) 157 - return 158 - } 159 - 160 - if resp.StatusCode != http.StatusOK { 161 - l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 162 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 163 - w.WriteHeader(resp.StatusCode) 164 - return 165 - } 166 - 167 - contentType := resp.Header.Get("Content-Type") 168 - 169 - // Normalize to bare media type before classification; strips parameters 170 - // (e.g. "; charset=utf-8") and prevents bypass attempts like 171 - // "image/svg+xml; innocent=param". A parse error yields an empty string 172 - // which falls through to the 415 default — the safe outcome. 173 - mediaType, _, _ := mime.ParseMediaType(contentType) 174 - 175 - // Prevent browser sniffing regardless of branch taken below. 176 - w.Header().Set("X-Content-Type-Options", "nosniff") 177 - 178 - switch { 179 - case strings.HasPrefix(mediaType, "text/") || isTextualMimeType(mediaType): 180 - // Serve all textual content as plain text so the browser never 181 - // interprets knot-supplied markup or scripts. 182 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 183 - case safeBinaryMIMEType(mediaType): 184 - // Use the normalized type, never the raw knot-supplied string. 185 - w.Header().Set("Content-Type", mediaType) 186 - default: 187 - // If mediatype is unknown or it's unsafe (e.g. SVG which allows XSS,) 188 - // fallback to octet-stream 189 - w.Header().Set("Content-Type", "application/octet-stream") 190 - } 191 - if _, err := io.Copy(w, resp.Body); err != nil { 192 - l.Error("error streaming knotmirror response", "err", err) 193 - w.WriteHeader(http.StatusInternalServerError) 194 - return 195 - } 130 + w.Header().Set("Cache-Control", "public, no-cache") 131 + http.Redirect(w, r, blobURL, http.StatusFound) 196 132 } 197 133 198 134 // NewBlobView creates a BlobView from the XRPC response ··· 275 211 query.Set("repo", repo.RepoDid) 276 212 query.Set("ref", ref) 277 213 query.Set("path", filePath) 278 - query.Set("raw", "true") 279 214 280 215 blobURL := fmt.Sprintf("%s/xrpc/%s?%s", config.KnotMirror.Url, tangled.GitTempGetBlobNSID, query.Encode()) 281 216 return blobURL 282 - } 283 - 284 - // safeBinaryMIMETypes is an explicit allowlist of binary content types that 285 - // are safe to serve inline. SVG is intentionally absent: it supports embedded 286 - // scripts and would enable XSS if a malicious knot returned one. 287 - var safeBinaryMIMETypes = map[string]bool{ 288 - "image/png": true, 289 - "image/jpeg": true, 290 - "image/gif": true, 291 - "image/webp": true, 292 - "image/avif": true, 293 - "video/mp4": true, 294 - "video/webm": true, 295 - "video/ogg": true, 296 - } 297 - 298 - func safeBinaryMIMEType(mediaType string) bool { 299 - return safeBinaryMIMETypes[mediaType] 300 - } 301 - 302 - func isTextualMimeType(mimeType string) bool { 303 - textualTypes := []string{ 304 - "application/json", 305 - "application/xml", 306 - "application/yaml", 307 - "application/x-yaml", 308 - "application/toml", 309 - "application/javascript", 310 - "application/ecmascript", 311 - "message/", 312 - } 313 - return slices.Contains(textualTypes, mimeType) 314 217 } 315 218 316 219 // TODO: dedup with strings
-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 - }
+2 -8
knotmirror/xrpc/git_get_blob.go
··· 47 47 48 48 entry, err := x.getFile(ctx, repoPath, ref, path) 49 49 if err != nil { 50 - l.Warn("local mirror failed, trying proxy", "err", err) 51 - if x.proxyToKnot(w, r, repo) { 52 - return 53 - } 50 + l.Warn("local mirror failed", "err", err) 54 51 writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to get blob"}) 55 52 return 56 53 } 57 54 size, reader, err := gitea.ReadBlob(ctx, repoPath, entry.Hash) 58 55 if err != nil { 59 - l.Warn("local mirror failed, trying proxy", "err", err) 60 - if x.proxyToKnot(w, r, repo) { 61 - return 62 - } 56 + l.Warn("local mirror failed", "err", err) 63 57 writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to get blob"}) 64 58 return 65 59 }
-4
knotmirror/xrpc/proxy.go
··· 23 23 tangled.GitTempListCommitsNSID: tangled.RepoLogNSID, 24 24 tangled.GitTempGetTreeNSID: tangled.RepoTreeNSID, 25 25 tangled.GitTempGetBranchNSID: tangled.RepoBranchNSID, 26 - tangled.GitTempGetBlobNSID: tangled.RepoBlobNSID, 27 26 tangled.GitTempGetTagNSID: tangled.RepoTagNSID, 28 27 tangled.GitTempGetArchiveNSID: tangled.RepoArchiveNSID, 29 28 tangled.RepoBlobNSID: tangled.RepoBlobNSID, ··· 152 151 params := make(url.Values) 153 152 maps.Copy(params, r.URL.Query()) 154 153 params.Set("repo", knot.repoIdentifier) 155 - if mirrorNSID == tangled.GitTempGetBlobNSID { 156 - params.Set("raw", "true") 157 - } 158 154 159 155 target := fmt.Sprintf("%s/xrpc/%s?%s", knot.baseURL, knotNSID, params.Encode()) 160 156