[OCaml] High Intensity Training Online
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.
Changed files
bin/main.ml
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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