fix Sandbox raw blob responses

Serve raw blobs with Content-Security-Policy: sandbox and X-Content-Type-Options: nosniff. Without them, an SVG pushed to a hosted repository could run script with the site authority when served inline as image/svg+xml.

Commit
318378bffa1ef1f9b56c35d789139bce7e43554b
Author
Claude Fable 5 (high reasoning) <claude-fable-5@agents.anthropic.invalid>
Author date
Committer
Claude Fable 5 (high reasoning) <claude-fable-5@agents.anthropic.invalid>
Committer date
Changed files
lib/handlers.ml
index dcac9036..5391b2cb 100644..100644
@@ -269,6 +269,17 @@
269 269 | ".avif" -> "image/avif"
270 270 | _ -> "text/plain; charset=utf-8"
271 271
272 Added: (* Raw blobs are repository content served from ogit's own origin. The
273 Added: sandbox policy stops any active content — an SVG carrying a script is the
274 Added: canonical case — from running with the site's authority, and [nosniff]
275 Added: stops browsers from promoting text/plain to something executable. *)
276 Added: let raw_headers content_type =
277 Added: [
278 Added: ("Content-Type", content_type);
279 Added: ("Content-Security-Policy", "sandbox");
280 Added: ("X-Content-Type-Options", "nosniff");
281 Added: ]
282 Added:
272 283 let raw_file repository _context id =
273 284 let* trail = Resolvers.Tree.find_path repository id in
274 285 let* object_ = Resolvers.blob_or_tree repository id in
@@ -280,9 +291,7 @@
280 291 | [] -> "text/plain; charset=utf-8"
281 292 in
282 293 Lwt.return
283 Removed: (Dream.response
284 Removed: ~headers:[ ("Content-Type", content_type) ]
285 Removed: blob.content)
294 Added: (Dream.response ~headers:(raw_headers content_type) blob.content)
286 295 | `Tree _ ->
287 296 error_response (Resolvers.Bad_request "object is a tree, not a blob")
288 297 end
test/test_router.ml
index 47dfc814..2115645d 100644..100644
@@ -59,6 +59,41 @@
59 59 let status = Dream.request ~target "" |> request |> Dream.status in
60 60 Alcotest.(check int) "404" 404 (Dream.status_to_int status))
61 61
62 Added: (* Raw responses must never let repository content run with the site's
63 Added: authority: the sandbox policy and nosniff header are the contract. An SVG is
64 Added: used because it is the content type that made the headers necessary. *)
65 Added: let test_raw_response_headers () =
66 Added: with_temp_directory "ogit-router" (fun root ->
67 Added: let name = "project" in
68 Added: let path = Filename.concat root name in
69 Added: Unix.mkdir path 0o755;
70 Added: ignore (git [ "-C"; path; "init"; "-q"; "-b"; "main" ]);
71 Added: ignore (git [ "-C"; path; "config"; "user.name"; "Test" ]);
72 Added: ignore (git [ "-C"; path; "config"; "user.email"; "t@t.invalid" ]);
73 Added: Out_channel.with_open_text (Filename.concat path "img.svg") (fun ch ->
74 Added: output_string ch "<svg xmlns=\"http://www.w3.org/2000/svg\"/>\n");
75 Added: ignore (git [ "-C"; path; "add"; "." ]);
76 Added: ignore (git [ "-C"; path; "commit"; "-q"; "-m"; "init" ]);
77 Added: let blob_hash = git [ "-C"; path; "rev-parse"; "HEAD:img.svg" ] in
78 Added: let config = Ogit.Config.{ default with git_project_root = root } in
79 Added: let request = Dream.test (Dream.router (Ogit.Handlers.routes config)) in
80 Added: let target = "/project/raw/" ^ blob_hash in
81 Added: let response = Dream.request ~target "" |> request in
82 Added: Alcotest.(check int)
83 Added: "200" 200
84 Added: (Dream.status_to_int (Dream.status response));
85 Added: let header name =
86 Added: Dream.header response name |> Option.value ~default:"<missing>"
87 Added: in
88 Added: Alcotest.(check string)
89 Added: "content type" "image/svg+xml" (header "Content-Type");
90 Added: Alcotest.(check string)
91 Added: "sandboxed" "sandbox"
92 Added: (header "Content-Security-Policy");
93 Added: Alcotest.(check string)
94 Added: "nosniff" "nosniff"
95 Added: (header "X-Content-Type-Options"))
96 Added:
62 97 let suite =
63 98 ( "router",
64 99 [
@@ -66,4 +101,5 @@
66 101 Alcotest.test_case "missing repo" `Slow test_missing_repo;
67 102 Alcotest.test_case "invalid hash" `Slow test_invalid_hash;
68 103 Alcotest.test_case "missing object" `Slow test_missing_object;
104 Added: Alcotest.test_case "raw response headers" `Slow test_raw_response_headers;
69 105 ] )