diff --git a/appview/repo/blob.go b/appview/repo/blob.go index 6ad4dafd..5fa21d02 100644 --- a/appview/repo/blob.go +++ b/appview/repo/blob.go @@ -3,12 +3,9 @@ package repo import ( "encoding/base64" "fmt" - "io" - "mime" "net/http" "net/url" "path/filepath" - "slices" "strings" "time" @@ -130,69 +127,8 @@ func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { blobURL := generateBlobURL(rp.config, f, ref, filePath) - req, err := http.NewRequest("GET", blobURL, nil) - if err != nil { - l.Error("failed to create request", "err", err) - return - } - - // forward the If-None-Match header - if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { - req.Header.Set("If-None-Match", clientETag) - } - client := &http.Client{} - - resp, err := client.Do(req) - if err != nil { - l.Error("failed to reach knotserver", "err", err) - rp.pages.Error503(w) - return - } - - defer resp.Body.Close() - - // forward 304 not modified - if resp.StatusCode == http.StatusNotModified { - w.WriteHeader(http.StatusNotModified) - return - } - - if resp.StatusCode != http.StatusOK { - l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.WriteHeader(resp.StatusCode) - return - } - - contentType := resp.Header.Get("Content-Type") - - // 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") - case safeBinaryMIMEType(mediaType): - // Use the normalized type, never the raw knot-supplied string. - w.Header().Set("Content-Type", mediaType) - default: - // If mediatype is unknown or it's unsafe (e.g. SVG which allows XSS,) - // fallback to octet-stream - w.Header().Set("Content-Type", "application/octet-stream") - } - if _, err := io.Copy(w, resp.Body); err != nil { - l.Error("error streaming knotmirror response", "err", err) - w.WriteHeader(http.StatusInternalServerError) - return - } + w.Header().Set("Cache-Control", "public, no-cache") + http.Redirect(w, r, blobURL, http.StatusFound) } // NewBlobView creates a BlobView from the XRPC response @@ -275,44 +211,11 @@ func generateBlobURL(config *config.Config, repo *models.Repo, ref, filePath str query.Set("repo", repo.RepoDid) query.Set("ref", ref) query.Set("path", filePath) - query.Set("raw", "true") blobURL := fmt.Sprintf("%s/xrpc/%s?%s", config.KnotMirror.Url, tangled.GitTempGetBlobNSID, query.Encode()) 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", - "application/xml", - "application/yaml", - "application/x-yaml", - "application/toml", - "application/javascript", - "application/ecmascript", - "message/", - } - return slices.Contains(textualTypes, mimeType) -} - // TODO: dedup with strings func countLines(content string) int { if content == "" { diff --git a/appview/repo/blob_test.go b/appview/repo/blob_test.go deleted file mode 100644 index c0287365..00000000 --- a/appview/repo/blob_test.go +++ /dev/null @@ -1,80 +0,0 @@ -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) - } - } -} diff --git a/knotmirror/xrpc/git_get_blob.go b/knotmirror/xrpc/git_get_blob.go index 05e04af3..ef610ad1 100644 --- a/knotmirror/xrpc/git_get_blob.go +++ b/knotmirror/xrpc/git_get_blob.go @@ -47,19 +47,13 @@ func (x *Xrpc) GetBlob(w http.ResponseWriter, r *http.Request) { entry, err := x.getFile(ctx, repoPath, ref, path) if err != nil { - l.Warn("local mirror failed, trying proxy", "err", err) - if x.proxyToKnot(w, r, repo) { - return - } + l.Warn("local mirror failed", "err", err) writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to get blob"}) return } size, reader, err := gitea.ReadBlob(ctx, repoPath, entry.Hash) if err != nil { - l.Warn("local mirror failed, trying proxy", "err", err) - if x.proxyToKnot(w, r, repo) { - return - } + l.Warn("local mirror failed", "err", err) writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to get blob"}) return } diff --git a/knotmirror/xrpc/proxy.go b/knotmirror/xrpc/proxy.go index c518bfde..4fc6a2d4 100644 --- a/knotmirror/xrpc/proxy.go +++ b/knotmirror/xrpc/proxy.go @@ -23,7 +23,6 @@ var mirrorToKnotNSID = map[string]string{ tangled.GitTempListCommitsNSID: tangled.RepoLogNSID, tangled.GitTempGetTreeNSID: tangled.RepoTreeNSID, tangled.GitTempGetBranchNSID: tangled.RepoBranchNSID, - tangled.GitTempGetBlobNSID: tangled.RepoBlobNSID, tangled.GitTempGetTagNSID: tangled.RepoTagNSID, tangled.GitTempGetArchiveNSID: tangled.RepoArchiveNSID, tangled.RepoBlobNSID: tangled.RepoBlobNSID, @@ -152,9 +151,6 @@ func (x *Xrpc) proxyToKnot(w http.ResponseWriter, r *http.Request, repoDid synta params := make(url.Values) maps.Copy(params, r.URL.Query()) params.Set("repo", knot.repoIdentifier) - if mirrorNSID == tangled.GitTempGetBlobNSID { - params.Set("raw", "true") - } target := fmt.Sprintf("%s/xrpc/%s?%s", knot.baseURL, knotNSID, params.Encode())