123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143(* 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. *)letdefault_frame_options="SAMEORIGIN"letdefault_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]. *)letheaders_of~nosniff~frame_options~referrer_policy~hsts~content_security_policy=List.filter_map(funx->x)[(ifnosniffthenSome("X-Content-Type-Options","nosniff")elseNone);Option.map(funv->"X-Frame-Options",v)frame_options;Option.map(funv->"Referrer-Policy",v)referrer_policy;Option.map(funv->"Strict-Transport-Security",v)hsts;Option.map(funv->"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. *)letgenheaders=function|Ocsigen.Extensions.Req_not_found(code,_)->Lwt.return(Ocsigen.Extensions.Ext_nextcode)|Ocsigen.Extensions.Req_found(_ri,res)->Lwt.return@@Ocsigen.Extensions.Ext_found(fun()->Lwt.return@@List.fold_left(funres(name,value)->letname=Ocsigen_http.Header.Name.of_stringnameinmatchOcsigen.Response.headerresnamewith|Some_->res|None->Ocsigen.Response.add_headerresnamevalue)resheaders)(* ["no"], ["none"] and the empty string disable a header. *)letis_disableds=matchString.lowercase_ascii(String.trims)with|"no"|"none"|""->true|_->falseletparse_configconfig_elem=(* Reject CR/LF (and other control characters) in operator-supplied values to
avoid header injection / response splitting. *)letcheckednames=ifString.exists(func->Char.codec<0x20)sthenOcsigen.Extensions.badconfig"Invalid control character in attribute %s of <securityheaders>"name;sinletnosniff=reftrueinletframe_options=ref(Somedefault_frame_options)inletreferrer_policy=ref(Somedefault_referrer_policy)inlethsts=refNoneinletcontent_security_policy=refNoneinletoptionalnamers=r:=ifis_disabledsthenNoneelseSome(checkednames)inOcsigen.Extensions.(Configuration.process_element~in_tag:"host"~other_elements:(funt__->raise(Bad_config_tag_for_extensiont))~elements:[Configuration.element~name:"securityheaders"~attributes:[Configuration.attribute~name:"nosniff"(funs->nosniff:=not(matchString.lowercase_ascii(String.trims)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)()letrun?(nosniff=true)?(frame_options=Somedefault_frame_options)?(referrer_policy=Somedefault_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())