Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions doc/api.mld
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Redirectmod
Revproxy
Rewritemod
Outputfilter
Securityheaders
Userconf
Cors
}
Expand Down
4 changes: 3 additions & 1 deletion doc/index.mld
Original file line number Diff line number Diff line change
@@ -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}

Expand Down Expand Up @@ -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}
Expand Down
75 changes: 75 additions & 0 deletions doc/securityheaders.mld
Original file line number Diff line number Diff line change
@@ -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 [<securityheaders/>] element to a host, after the
elements producing the responses to decorate:
{v
<extension findlib-package="ocsigenserver.ext.securityheaders"/>
...
<host hostfilter="*">
<static dir="/path/to/the/local/directory" />
<securityheaders/>
</host>
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
<securityheaders
frame-options="DENY"
referrer-policy="no-referrer"
nosniff="true"
hsts="max-age=63072000; includeSubDomains"
content-security-policy="default-src 'self'" />
v}
6 changes: 6 additions & 0 deletions src/extensions/dune
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
143 changes: 143 additions & 0 deletions src/extensions/securityheaders.ml
Original file line number Diff line number Diff line change
@@ -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 <securityheaders>" 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 ())
49 changes: 49 additions & 0 deletions src/extensions/securityheaders.mli
Original file line number Diff line number Diff line change
@@ -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. *)
6 changes: 6 additions & 0 deletions src/files/ocsigenserver.conf/gen.ml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ let conf_in =
<findlib path="_METADIR_"/>_FINDLIBEXTRA_

<extension findlib-package="_EXTPACKAGENAME_.staticmod"/>
<extension findlib-package="_EXTPACKAGENAME_.securityheaders"/>

<!-- Inclusion of all external configuration files matching *.conf
from the directory 'dir' (in alphabetical order): -->
Expand All @@ -34,6 +35,11 @@ let conf_in =

<static dir="_STATICPAGESDIR_" />

<!-- Add safe security headers (nosniff, X-Frame-Options, Referrer-Policy)
to every response. HSTS and CSP are opt-in: see the securityheaders
manual page. -->
<securityheaders/>

</host>

</server>
Expand Down
42 changes: 30 additions & 12 deletions src/server/Ocsigen/server.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Loading