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