feat set browser hardening headers on every response

Add a middleware that sets a same-origin Content-Security-Policy which refuses framing, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, and a strict referrer policy. The app serves only its own stylesheet and one deferred same-origin script, with no inline style or script, so the strict policy costs nothing and closes off injected script, clickjacking, and type sniffing at the browser. HSTS is left to the TLS-terminating proxy: the server binds loopback and never sees TLS itself. The production server and the test application both wrap the router with the middleware, so every end-to-end test runs under the same policy a browser would see.

Commit
b5d7fb79d22818cefeb8f23d7d502a1fd5f0dab0
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
bin/main.ml
index 61f9c906..54dfd5c0 100644..100644
@@ -101,4 +101,5 @@
101 101 Dream.run ~interface:"localhost" ~port
102 102 ~error_handler:Handlers.error_handler
103 103 @@ Dream.logger @@ Dream.set_secret secret @@ Dream.cookie_sessions
104 Added: @@ Handlers.security_headers
104 105 @@ Dream.router (Handlers.routes handlers)
lib/web/handlers.ml
index aa9eeb63..b5eb40a1 100644..100644
@@ -55,4 +55,26 @@
55 55 let status = Dream.status suggested |> Dream.status_to_int in
56 56 Dream_html.set_body suggested (Pages.error_page ~status ());
57 57 Lwt.return suggested)
58 Added:
59 Added: (* Browser-side hardening on every response. The app serves only its own
60 Added: stylesheet and one deferred same-origin script, with no inline style or
61 Added: script, so a strict same-origin policy costs nothing. Framing is refused,
62 Added: so no other site can overlay the logbook. Type sniffing is off, so the
63 Added: browser trusts the declared content type. The referrer is trimmed to the
64 Added: origin on cross-site navigation. HSTS is left to the reverse proxy that
65 Added: terminates TLS; the server itself binds loopback. *)
66 Added: let security_headers inner request =
67 Added: inner request >|= fun response ->
68 Added: List.iter
69 Added: (fun (name, value) -> Dream.set_header response name value)
70 Added: [
71 Added: ( "Content-Security-Policy",
72 Added: "default-src 'self'; script-src 'self'; style-src 'self'; img-src \
73 Added: 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
74 Added: );
75 Added: ("X-Frame-Options", "DENY");
76 Added: ("X-Content-Type-Options", "nosniff");
77 Added: ("Referrer-Policy", "strict-origin-when-cross-origin");
78 Added: ];
79 Added: response
58 80 end
lib/web/handlers.mli
index e26012ad..8a6059e6 100644..100644
@@ -27,4 +27,10 @@
27 27 (** Renders a branded page for every failure Dream routes here, including
28 28 unmatched paths and [5xx] responses. Pass to {!Dream.run}
29 29 [~error_handler]. It leaks no server-supplied string. *)
30 Added:
31 Added: val security_headers : Dream.middleware
32 Added: (** Sets browser-side hardening headers on every response: a same-origin
33 Added: Content-Security-Policy that refuses framing, [X-Frame-Options: DENY],
34 Added: [X-Content-Type-Options: nosniff], and a strict referrer policy. Wrap the
35 Added: router with it. HSTS belongs to the TLS-terminating proxy. *)
30 36 end
test/web/auth.ml
index 95932fad..23297bc5 100644..100644
@@ -17,6 +17,28 @@
17 17 fun () ->
18 18 let c = client () in
19 19 Alcotest.(check int) "GET /login" 200 (status (get c "/login")) );
20 Added: ( "every response carries the browser hardening headers",
21 Added: `Quick,
22 Added: fun () ->
23 Added: let c = client () in
24 Added: let response = get c "/login" in
25 Added: let header name = Dream.header response name in
26 Added: Alcotest.(check bool)
27 Added: "a same-origin CSP that refuses framing" true
28 Added: (match header "Content-Security-Policy" with
29 Added: | Some csp ->
30 Added: Option.is_some (index_from ~needle:"default-src 'self'" csp 0)
31 Added: && Option.is_some
32 Added: (index_from ~needle:"frame-ancestors 'none'" csp 0)
33 Added: | None -> false);
34 Added: Alcotest.(check (option string))
35 Added: "framing denied" (Some "DENY") (header "X-Frame-Options");
36 Added: Alcotest.(check (option string))
37 Added: "no type sniffing" (Some "nosniff")
38 Added: (header "X-Content-Type-Options");
39 Added: Alcotest.(check (option string))
40 Added: "strict referrer" (Some "strict-origin-when-cross-origin")
41 Added: (header "Referrer-Policy") );
20 42 ( "signing in rotates the session identifier",
21 43 `Quick,
22 44 fun () ->
test/web/support.ml
index 7f5f7e57..419ea2a3 100644..100644
@@ -17,7 +17,8 @@
17 17 let handlers =
18 18 Handlers.make ~repo:(Memory_repo.create ()) ~registration_open:true ()
19 19 in
20 Removed: Dream.memory_sessions @@ Dream.router (Handlers.routes handlers)
20 Added: Dream.memory_sessions @@ Handlers.security_headers
21 Added: @@ Dream.router (Handlers.routes handlers)
21 22 |> fun handler -> Dream.set_secret "test-secret-value" handler
22 23
23 24 let status response = Dream.status response |> Dream.status_to_int