Skip to main content

src/middleware/livery_security_headers.erl

-module(livery_security_headers).
-moduledoc """
Security-headers middleware.

Decorates responses with baseline hardening headers. Configure it as a
stack entry `{livery_security_headers, Config}` where every `Config`
key is optional and a value of `false` disables that header:

- `content_type_options` — `true` (default) sends
  `X-Content-Type-Options: nosniff`.
- `frame_options` — header value, default `<<"DENY">>`.
- `referrer_policy` — header value, default `<<"no-referrer">>`.
- `csp` — `Content-Security-Policy` value, default `false` (off): a
  wrong policy breaks apps, so it is opt-in.
- `hsts` — `#{max_age => Secs, include_subdomains => boolean(),
  preload => boolean()}` (defaults `31536000`, `true`, `false`), or
  `false`. `Strict-Transport-Security` is only emitted on secure
  (HTTPS / TLS) requests; on plain HTTP it is meaningless and skipped.

Each header is set only when the handler did not already set it, so a
handler can override any of them per response.
""".
-behaviour(livery_middleware).

-export([call/3]).

-doc "Add the configured security headers to the downstream response.".
-spec call(livery_req:req(), livery_middleware:next(), map() | undefined) ->
    livery_resp:resp().
call(Req, Next, State) ->
    Cfg = config(State),
    Resp = Next(Req),
    Steps = [
        {<<"x-content-type-options">>, content_type_value(Cfg)},
        {<<"x-frame-options">>, maps:get(frame_options, Cfg)},
        {<<"referrer-policy">>, maps:get(referrer_policy, Cfg)},
        {<<"content-security-policy">>, maps:get(csp, Cfg)},
        {<<"strict-transport-security">>, hsts_value(Req, Cfg)}
    ],
    lists:foldl(
        fun({Name, Value}, Acc) -> maybe_set(Name, Value, Acc) end, Resp, Steps
    ).

%%====================================================================
%% Header application
%%====================================================================

-spec maybe_set(binary(), false | binary(), livery_resp:resp()) ->
    livery_resp:resp().
maybe_set(_Name, false, Resp) ->
    Resp;
maybe_set(Name, Value, Resp) when is_binary(Value) ->
    case lists:keymember(Name, 1, livery_resp:headers(Resp)) of
        true -> Resp;
        false -> livery_resp:with_header(Name, Value, Resp)
    end.

-spec content_type_value(map()) -> false | binary().
content_type_value(Cfg) ->
    case maps:get(content_type_options, Cfg) of
        true -> <<"nosniff">>;
        false -> false
    end.

-spec hsts_value(livery_req:req(), map()) -> false | binary().
hsts_value(Req, Cfg) ->
    case maps:get(hsts, Cfg) of
        false ->
            false;
        Opts when is_map(Opts) ->
            case secure(Req) of
                true -> build_hsts(Opts);
                false -> false
            end
    end.

-spec secure(livery_req:req()) -> boolean().
secure(Req) ->
    livery_req:scheme(Req) =:= <<"https">> orelse
        livery_req:tls(Req) =/= undefined.

-spec build_hsts(map()) -> binary().
build_hsts(Opts) ->
    Max = maps:get(max_age, Opts, 31536000),
    Base = <<"max-age=", (integer_to_binary(Max))/binary>>,
    WithSub =
        case maps:get(include_subdomains, Opts, true) of
            true -> <<Base/binary, "; includeSubDomains">>;
            false -> Base
        end,
    case maps:get(preload, Opts, false) of
        true -> <<WithSub/binary, "; preload">>;
        false -> WithSub
    end.

%%====================================================================
%% Config
%%====================================================================

-spec config(map() | undefined) -> map().
config(undefined) ->
    config(#{});
config(State) when is_map(State) ->
    #{
        content_type_options => maps:get(content_type_options, State, true),
        frame_options => maps:get(frame_options, State, <<"DENY">>),
        referrer_policy => maps:get(referrer_policy, State, <<"no-referrer">>),
        hsts => maps:get(hsts, State, default_hsts()),
        csp => maps:get(csp, State, false)
    }.

-spec default_hsts() -> map().
default_hsts() ->
    #{max_age => 31536000, include_subdomains => true, preload => false}.