diff --git a/CHANGES b/CHANGES index cb081f52f..fcf48e1ea 100644 --- a/CHANGES +++ b/CHANGES @@ -1,4 +1,10 @@ ==== 8.0.0~dev === + * New extension Securityheaders (ocsigenserver.ext.securityheaders): adds + common security headers with one declaration. Safe by default + (X-Content-Type-Options, X-Frame-Options, Referrer-Policy); + Strict-Transport-Security and Content-Security-Policy are opt-in. It is + enabled by default in the sample configuration file and in serve mode. + * Serve mode now logs a startup banner with the URL it listens on. * One-command serve mode: "ocsigenserver ./public" (or "ocsigenserver --serve ./public --port 8000 --directory-listing") now serves a directory of static files without any configuration file or diff --git a/doc/api.mld b/doc/api.mld index b5e85ab07..cc3445db0 100644 --- a/doc/api.mld +++ b/doc/api.mld @@ -19,6 +19,7 @@ Redirectmod Revproxy Rewritemod Outputfilter +Securityheaders Userconf Cors } diff --git a/doc/index.mld b/doc/index.mld index 325b8aab9..cb76149bf 100644 --- a/doc/index.mld +++ b/doc/index.mld @@ -1,4 +1,4 @@ -@children_order launching config staticlink staticmod eliom extendconfiguration accesscontrol authbasic deflatemod redirectmod revproxy rewritemod outputfilter userconf cors extend migration api +@children_order launching config staticlink staticmod eliom extendconfiguration accesscontrol authbasic deflatemod redirectmod revproxy rewritemod outputfilter securityheaders userconf cors extend migration api {0 Ocsigen Server} @@ -115,6 +115,8 @@ The extensions provided are: :if you want to compress the content before sending your pages, ;{{!page-outputfilter}Outputfilter} :allows to change the header of the HTTP response, +;{{!page-securityheaders}Securityheaders} +:adds common security-related HTTP headers (HSTS, nosniff, X-Frame-Options, CSP), ;{{!page-rewritemod}Rewritemod} :allows to rewrite the request before continuing, ;{{!page-extendconfiguration}Extendconfiguration} diff --git a/doc/securityheaders.mld b/doc/securityheaders.mld new file mode 100644 index 000000000..6eae13a93 --- /dev/null +++ b/doc/securityheaders.mld @@ -0,0 +1,75 @@ +{0 Securityheaders} +Securityheaders adds common security-related HTTP headers to responses with a +single declaration, instead of hand-rolling {{!page-outputfilter}output filter} +rules. + +{1 What it adds} + +By default, to every response that does not already set them: + +- [X-Content-Type-Options: nosniff] — forbids the browser from MIME-sniffing, + so it honours the declared [Content-Type] instead of guessing. Prevents a + file served as, say, [text/plain] from being run as HTML or JavaScript. +- [X-Frame-Options: SAMEORIGIN] — only the same origin may embed the page in a + frame, mitigating clickjacking. (The modern equivalent is the CSP + [frame-ancestors] directive; keeping both is common.) +- [Referrer-Policy: strict-origin-when-cross-origin] — limits how much of the + URL is sent in the [Referer] header to other origins. + +Two further headers are {b opt-in} (added only when you configure them): + +- [Strict-Transport-Security] (HSTS) — tells the browser to use HTTPS only for + this domain. {b Use with care:} it is honoured only over HTTPS, it is sticky + (the browser remembers it for the whole [max-age]), and [includeSubDomains] + forces {e every} subdomain of the registrable domain to HTTPS for that + duration — which can take sibling subdomains offline if they are not all on + HTTPS. Enable it only when the whole domain is HTTPS-only. +- [Content-Security-Policy] (CSP) — restricts where scripts, styles, etc. may + come from. Very effective against XSS, but application-specific: there is no + safe generic default, and a wrong policy breaks the site. Set one tailored to + your application. + +Any header already set by the application is left untouched, so you can +override a default per response. + +{1 Ordering} + +This is a response filter: it decorates responses produced by earlier +extensions. {b Place it after} the extension whose responses it should cover +(Staticmod, Eliom, ...). If placed before, it sees no response and silently +adds nothing. + +{1 Using as a library} +Load OCamlfind package [ocsigenserver.ext.securityheaders] from your Dune file, +and see the {{!Securityheaders}API documentation}: +{[ +let _ = + Ocsigen.Server.start + [ Ocsigen.Server.host + [ Staticmod.run ~dir:"static" () + ; Securityheaders.run () ]] +]} + +{1 Configuration file} +Load the extension and add the [] element to a host, after the +elements producing the responses to decorate: +{v + +... + + + + +v} + +Each header can be customised or disabled with an attribute. The values ["no"], +["none"] and the empty string disable a header. HSTS and CSP are added only when +their attribute is present: +{v + +v} diff --git a/src/extensions/dune b/src/extensions/dune index 48e0d7d9e..a545c0496 100644 --- a/src/extensions/dune +++ b/src/extensions/dune @@ -52,6 +52,12 @@ (modules rewritemod) (libraries ocsigenserver)) +(library + (name securityheaders) + (public_name ocsigenserver.ext.securityheaders) + (modules securityheaders) + (libraries ocsigenserver)) + (library (name staticmod) (public_name ocsigenserver.ext.staticmod) diff --git a/src/extensions/securityheaders.ml b/src/extensions/securityheaders.ml new file mode 100644 index 000000000..c970c98cb --- /dev/null +++ b/src/extensions/securityheaders.ml @@ -0,0 +1,143 @@ +(* Ocsigen + * http://www.ocsigen.org + * Module securityheaders.ml + * Copyright (C) 2026 Vincent Balat + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, with linking exception; + * either version 2.1 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + *) + +(* This module adds common security-related headers to responses, with one + declaration instead of hand-rolled output filters. + + Safe-by-default headers (X-Content-Type-Options, X-Frame-Options, + Referrer-Policy) are added unless disabled. Strict-Transport-Security and + Content-Security-Policy are NOT added unless explicitly configured: HSTS is a + sticky, HTTPS-only commitment (especially with [includeSubDomains]) that the + server cannot decide safely on its own, and a wrong CSP breaks the + application. *) + +let default_frame_options = "SAMEORIGIN" +let default_referrer_policy = "strict-origin-when-cross-origin" + +(* Build the list of (header, value) pairs to add. A header is omitted when its + option is [None]; [nosniff] adds [X-Content-Type-Options: nosniff]. *) +let headers_of + ~nosniff + ~frame_options + ~referrer_policy + ~hsts + ~content_security_policy + = + List.filter_map + (fun x -> x) + [ (if nosniff then Some ("X-Content-Type-Options", "nosniff") else None) + ; Option.map (fun v -> "X-Frame-Options", v) frame_options + ; Option.map (fun v -> "Referrer-Policy", v) referrer_policy + ; Option.map (fun v -> "Strict-Transport-Security", v) hsts + ; Option.map (fun v -> "Content-Security-Policy", v) content_security_policy + ] + +(* Add each header to the response unless it is already set, so that a producer + can override the default. *) +let gen headers = function + | Ocsigen.Extensions.Req_not_found (code, _) -> + Lwt.return (Ocsigen.Extensions.Ext_next code) + | Ocsigen.Extensions.Req_found (_ri, res) -> + Lwt.return + @@ Ocsigen.Extensions.Ext_found + (fun () -> + Lwt.return + @@ List.fold_left + (fun res (name, value) -> + let name = Ocsigen_http.Header.Name.of_string name in + match Ocsigen.Response.header res name with + | Some _ -> res + | None -> Ocsigen.Response.add_header res name value) + res headers) + +(* ["no"], ["none"] and the empty string disable a header. *) +let is_disabled s = + match String.lowercase_ascii (String.trim s) with + | "no" | "none" | "" -> true + | _ -> false + +let parse_config config_elem = + (* Reject CR/LF (and other control characters) in operator-supplied values to + avoid header injection / response splitting. *) + let checked name s = + if String.exists (fun c -> Char.code c < 0x20) s + then + Ocsigen.Extensions.badconfig + "Invalid control character in attribute %s of " name; + s + in + let nosniff = ref true in + let frame_options = ref (Some default_frame_options) in + let referrer_policy = ref (Some default_referrer_policy) in + let hsts = ref None in + let content_security_policy = ref None in + let optional name r s = + r := if is_disabled s then None else Some (checked name s) + in + Ocsigen.Extensions.( + Configuration.process_element ~in_tag:"host" + ~other_elements:(fun t _ _ -> raise (Bad_config_tag_for_extension t)) + ~elements: + [ Configuration.element ~name:"securityheaders" + ~attributes: + [ Configuration.attribute ~name:"nosniff" (fun s -> + nosniff := + not + (match String.lowercase_ascii (String.trim s) with + | "false" | "no" | "off" -> true + | _ -> false)) + ; Configuration.attribute ~name:"frame-options" + (optional "frame-options" frame_options) + ; Configuration.attribute ~name:"referrer-policy" + (optional "referrer-policy" referrer_policy) + ; Configuration.attribute ~name:"hsts" (optional "hsts" hsts) + ; Configuration.attribute ~name:"content-security-policy" + (optional "content-security-policy" content_security_policy) + ] + () ] + config_elem); + gen + (headers_of ~nosniff:!nosniff ~frame_options:!frame_options + ~referrer_policy:!referrer_policy ~hsts:!hsts + ~content_security_policy:!content_security_policy) + +let () = + Ocsigen.Extensions.register ~name:"securityheaders" + ~fun_site:(fun _ _ _ _ _ _ -> parse_config) + () + +let run + ?(nosniff = true) + ?(frame_options = Some default_frame_options) + ?(referrer_policy = Some default_referrer_policy) + ?(hsts = None) + ?content_security_policy + () + _ + _ + _ + = + gen + (headers_of ~nosniff ~frame_options ~referrer_policy ~hsts + ~content_security_policy) + +(* Publish the safe-by-default instruction so that the one-command serve mode + can add the security headers after loading this extension dynamically. *) +let () = Ocsigen.Server.register_security_headers (run ()) diff --git a/src/extensions/securityheaders.mli b/src/extensions/securityheaders.mli new file mode 100644 index 000000000..7c538a3da --- /dev/null +++ b/src/extensions/securityheaders.mli @@ -0,0 +1,49 @@ +(** Securityheaders: add common security-related response headers *) + +(** If you want to use this extension with Ocsigen Server's configuration file, + have a look at the {{!page-"securityheaders"}manual page}. If you are using + Ocsigen Server as a library, use the interface described here. +*) + +(** + This module belongs to ocamlfind package + [ocsigenserver.ext.securityheaders]. +*) + +(** {b Ordering matters.} This is a response filter: it only decorates + responses that an earlier extension has produced. Place it {e after} the + extension whose responses it should cover (Staticmod, Eliom, ...). If it is + placed before, it sees no response and silently adds nothing. + + Example of use: +{[ +let _ = + Ocsigen.Server.start + [ Ocsigen.Server.host + [ Staticmod.run ~dir:"static" () + ; Securityheaders.run () ]] +]} + *) + +val run : + ?nosniff:bool + -> ?frame_options:string option + -> ?referrer_policy:string option + -> ?hsts:string option + -> ?content_security_policy:string + -> unit + -> Ocsigen.Server.instruction +(** [run ()] adds, to every response that does not already set them, three + safe-by-default headers: [X-Content-Type-Options: nosniff], + [X-Frame-Options: SAMEORIGIN] and + [Referrer-Policy: strict-origin-when-cross-origin]. Each can be customised + ([~frame_options:(Some "DENY")]) or disabled ([~nosniff:false], + [~frame_options:None]). + + [Strict-Transport-Security] and [Content-Security-Policy] are {b opt-in} + (both default to [None]) and must be set explicitly, e.g. + [~hsts:(Some "max-age=15552000; includeSubDomains")]. HSTS is only honoured + over HTTPS and is a sticky commitment: [includeSubDomains] forces every + subdomain of the registrable domain to HTTPS for the whole [max-age], so + enable it only when the whole domain is HTTPS-only. A [Content-Security-Policy] + has no safe generic default; set one tailored to your application. *) diff --git a/src/files/ocsigenserver.conf/gen.ml b/src/files/ocsigenserver.conf/gen.ml index aa8e4be3d..f3bb13513 100644 --- a/src/files/ocsigenserver.conf/gen.ml +++ b/src/files/ocsigenserver.conf/gen.ml @@ -21,6 +21,7 @@ let conf_in = _FINDLIBEXTRA_ + @@ -34,6 +35,11 @@ let conf_in = + + + diff --git a/src/server/Ocsigen/server.ml b/src/server/Ocsigen/server.ml index 5008cf87c..62683e05d 100644 --- a/src/server/Ocsigen/server.ml +++ b/src/server/Ocsigen/server.ml @@ -482,6 +482,24 @@ let start let static_server : (dir:string -> instruction) option ref = ref None let register_static_server f = static_server := Some f +(* Likewise for the Securityheaders extension, whose serving instruction takes + no argument. *) +let security_headers : instruction option ref = ref None +let register_security_headers f = security_headers := Some f + +(* Load the extension [package] on demand. On failure: exit if [required], + otherwise log a warning and continue. *) +let load_one_command_extension ?(required = true) package = + try + Ocsigen_base.Loader.loadfiles + (fun () -> ()) + (fun () -> ()) + false + (Ocsigen_base.Loader.findfiles package) + with e -> + let msg, errno = errmsg e in + if required then (Messages.errlog msg; exit errno) else Messages.warning msg + let serve ?(port = 8080) ?(directory_listing = false) ~dir () = (* One-command serve mode: no configuration file and no log directory are required. Logs go to stderr and the command pipe is placed in a temporary @@ -491,23 +509,23 @@ let serve ?(port = 8080) ?(directory_listing = false) ~dir () = (Filename.concat (Filename.get_temp_dir_name ()) (Printf.sprintf "ocsigenserver-%d.cmd" (Unix.getpid ()))); - (* Load the Staticmod extension on demand. It registers its serving function - through [register_static_server]. *) - (try - Ocsigen_base.Loader.loadfiles - (fun () -> ()) - (fun () -> ()) - false - (Ocsigen_base.Loader.findfiles "ocsigenserver.ext.staticmod") - with e -> - let msg, errno = errmsg e in - Messages.errlog msg; exit errno); + (* Staticmod is required; Securityheaders is best-effort (safe headers by + default). Both publish their instruction through the registries above. *) + load_one_command_extension "ocsigenserver.ext.staticmod"; + load_one_command_extension ~required:false "ocsigenserver.ext.securityheaders"; match !static_server with | None -> Messages.errlog "The Staticmod extension did not register its serving function"; exit 1 | Some static -> + (* The security-headers filter must come after the file server. *) + let instructions = + static ~dir + :: (match !security_headers with Some sh -> [sh] | None -> []) + in + Logs.app ~src:section (fun fmt -> + fmt "Serving %s on http://localhost:%d" dir port); start ~ports:[`All, port] - [host ~list_directory_content:directory_listing [static ~dir]] + [host ~list_directory_content:directory_listing instructions] diff --git a/src/server/Ocsigen/server.mli b/src/server/Ocsigen/server.mli index ad125b2bd..06dc1f296 100644 --- a/src/server/Ocsigen/server.mli +++ b/src/server/Ocsigen/server.mli @@ -94,6 +94,11 @@ val register_static_server : (dir:string -> instruction) -> unit static-file serving function, so that {!serve} can use it without a static dependency on the extension. *) +val register_security_headers : instruction -> unit +(** Called by the Securityheaders extension when it is loaded to publish its + instruction, so that {!serve} can add the safe-by-default security headers + without a static dependency on the extension. *) + val host : ?regexp:string -> ?port:int diff --git a/test/security-headers.t/run.t b/test/security-headers.t/run.t new file mode 100644 index 000000000..f2bafede3 --- /dev/null +++ b/test/security-headers.t/run.t @@ -0,0 +1,62 @@ +The securityheaders extension adds security headers to responses. + + $ mkdir www + $ printf '' > www/index.html + $ cat > site.conf <<'EOF' + > + > 8067 + > + > + > + > + > + > + > + > EOF + $ ocsigenserver -c site.conf >server.log 2>&1 & + $ SERVER_PID=$! + $ trap 'kill $SERVER_PID 2>/dev/null' EXIT + +The safe-by-default headers are present: + + $ curl -s -D headers -o /dev/null --retry 20 --retry-delay 1 \ + > --retry-connrefused http://127.0.0.1:8067/index.html + $ grep -i '^x-content-type-options:' headers | tr -d '\r' + x-content-type-options: nosniff + $ grep -i '^x-frame-options:' headers | tr -d '\r' + x-frame-options: SAMEORIGIN + $ grep -i '^referrer-policy:' headers | tr -d '\r' + referrer-policy: strict-origin-when-cross-origin + +HSTS and CSP are opt-in, so they are absent by default: + + $ grep -i '^strict-transport-security:' headers || echo absent + absent + $ grep -i '^content-security-policy:' headers || echo absent + absent + +Opting in adds them, and an attribute can disable a default header: + + $ cat > site2.conf <<'EOF' + > + > 8068 + > + > + > + > + > content-security-policy="default-src 'self'" frame-options="no"/> + > + > + > EOF + $ ocsigenserver -c site2.conf >server2.log 2>&1 & + $ SERVER2_PID=$! + $ trap 'kill $SERVER_PID $SERVER2_PID 2>/dev/null' EXIT + $ curl -s -D h2 -o /dev/null --retry 20 --retry-delay 1 \ + > --retry-connrefused http://127.0.0.1:8068/index.html + $ grep -i '^strict-transport-security:' h2 | tr -d '\r' + strict-transport-security: max-age=31536000 + $ grep -i '^content-security-policy:' h2 | tr -d '\r' + content-security-policy: default-src 'self' + $ grep -i '^x-frame-options:' h2 || echo absent + absent diff --git a/test/serve.t/run.t b/test/serve.t/run.t index 5184f865f..27daf9caf 100644 --- a/test/serve.t/run.t +++ b/test/serve.t/run.t @@ -11,25 +11,37 @@ a file here), so no log directory is created. $ SERVER_PID=$! $ trap 'kill $SERVER_PID 2>/dev/null' EXIT -An existing file is served with the correct content type and body. The -unreproducible "date" header is filtered out. +An existing file is served with the correct status, content type and body: - $ curl -s -i --retry 20 --retry-delay 1 --retry-connrefused --user-agent "" \ - > http://127.0.0.1:8061/index.html | grep -v "^date: " - HTTP/1.1 200 OK + $ curl -s -D headers -w '%{http_code}\n' --retry 20 --retry-delay 1 \ + > --retry-connrefused http://127.0.0.1:8061/index.html + hello (no-eol) + 200 + $ grep -i '^content-type:' headers | tr -d '\r' content-type: text/html - server: Ocsigen - content-length: 18 - hello (no-eol) +The safe-by-default security headers are present: + + $ grep -i '^x-content-type-options:' headers | tr -d '\r' + x-content-type-options: nosniff + $ grep -i '^x-frame-options:' headers | tr -d '\r' + x-frame-options: SAMEORIGIN + $ grep -i '^referrer-policy:' headers | tr -d '\r' + referrer-policy: strict-origin-when-cross-origin A missing file gives a 404. $ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8061/nope.html 404 +The server logged a startup banner with the URL: + + $ grep -o 'on http://localhost:8061' server.log + on http://localhost:8061 + No log directory is created in the current directory. $ ls + headers server.log www