From c6dc92d4c537c18e438a2a4ad309a43ac86e7f6b Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Thu, 12 Mar 2026 09:00:44 +0100 Subject: [PATCH 01/11] fix: harden session cookie security attributes Set HttpOnly, Secure, SameSite=Lax, and Path=/ on all session cookies to mitigate XSS cookie theft, man-in-the-middle attacks, and CSRF. Replace rand:uniform/1 with crypto:strong_rand_bytes/1 for cryptographically secure session ID generation. Cookie options are configurable via the session_cookie_opts application environment. Co-Authored-By: Claude Opus 4.6 --- src/nova_session.erl | 15 ++++++++++----- src/nova_stream_h.erl | 3 ++- test/nova_session_test.erl | 38 +++++++++++++++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/nova_session.erl b/src/nova_session.erl index 098dc79..4a6cad7 100644 --- a/src/nova_session.erl +++ b/src/nova_session.erl @@ -10,7 +10,8 @@ set/3, delete/1, delete/2, - generate_session_id/0 + generate_session_id/0, + cookie_opts/0 ]). -include_lib("kernel/include/logger.hrl"). @@ -89,7 +90,7 @@ delete(Req) -> Mod = get_session_module(), Mod:delete_value(SessionId), Req1 = cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req, - #{max_age => 0}), + (cookie_opts())#{max_age => 0}), {ok, Req1}; _ -> %% Session not found @@ -137,7 +138,11 @@ get_session_id(Req) -> end. generate_session_id() -> - SessionId = - << <> || - X <- [ rand:uniform(255) || _ <- lists:seq(0, 31) ] >>, + SessionId = crypto:strong_rand_bytes(32), {ok, base64:encode(SessionId)}. + +-spec cookie_opts() -> map(). +cookie_opts() -> + Defaults = #{http_only => true, secure => true, same_site => lax, path => <<"/">>}, + Overrides = nova:get_env(session_cookie_opts, #{}), + maps:merge(Defaults, Overrides). diff --git a/src/nova_stream_h.erl b/src/nova_stream_h.erl index f3509c2..608a4e4 100644 --- a/src/nova_stream_h.erl +++ b/src/nova_stream_h.erl @@ -27,7 +27,8 @@ init(StreamID, Req, Opts) -> {_, _} -> Req; _ -> {ok, SessionId} = nova_session:generate_session_id(), - ReqWithCookie = cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req), + ReqWithCookie = cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req, + nova_session:cookie_opts()), ReqWithCookie#{nova_session_id => SessionId} end; _ -> diff --git a/test/nova_session_test.erl b/test/nova_session_test.erl index f9b03bf..5ea22ca 100644 --- a/test/nova_session_test.erl +++ b/test/nova_session_test.erl @@ -84,6 +84,39 @@ nova_session_id_takes_priority_over_cookie_test() -> cleanup_meck() end. +%%==================================================================== +%% Tests for cookie_opts/0 +%%==================================================================== + +cookie_opts_returns_secure_defaults_test() -> + meck:new(nova, [passthrough]), + try + meck:expect(nova, get_env, + fun(session_cookie_opts, #{}) -> #{} end), + Opts = nova_session:cookie_opts(), + ?assertEqual(true, maps:get(http_only, Opts)), + ?assertEqual(true, maps:get(secure, Opts)), + ?assertEqual(lax, maps:get(same_site, Opts)), + ?assertEqual(<<"/">>, maps:get(path, Opts)) + after + meck:unload(nova) + end. + +cookie_opts_allows_overrides_test() -> + meck:new(nova, [passthrough]), + try + meck:expect(nova, get_env, + fun(session_cookie_opts, #{}) -> + #{same_site => strict, secure => false} + end), + Opts = nova_session:cookie_opts(), + ?assertEqual(true, maps:get(http_only, Opts)), + ?assertEqual(false, maps:get(secure, Opts)), + ?assertEqual(strict, maps:get(same_site, Opts)) + after + meck:unload(nova) + end. + %%==================================================================== %% Helpers %%==================================================================== @@ -92,7 +125,10 @@ setup_meck() -> meck:new(nova, [passthrough]), meck:new(cowboy_req, [passthrough]), meck:new(nova_session_ets, [passthrough]), - meck:expect(nova, get_env, fun(use_sessions, true) -> true end), + meck:expect(nova, get_env, + fun(use_sessions, true) -> true; + (session_cookie_opts, #{}) -> #{} + end), application:set_env(nova, session_manager, nova_session_ets). cleanup_meck() -> From 8a1614f9d570ad64277297701c05a80cfa049da1 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Thu, 12 Mar 2026 09:43:46 +0100 Subject: [PATCH 02/11] feat: add nova_secure_headers_plugin for HTTP security headers Adds a configurable plugin that sets security headers on every response: - X-Frame-Options: DENY (clickjacking protection) - X-Content-Type-Options: nosniff (MIME sniffing protection) - X-XSS-Protection: 1; mode=block - Referrer-Policy: strict-origin-when-cross-origin - Permissions-Policy: restrictive defaults Optional HSTS and CSP support via plugin options. All headers are overridable per-application. Co-Authored-By: Claude Opus 4.6 --- src/plugins/nova_secure_headers_plugin.erl | 90 ++++++++++++++++++ test/nova_secure_headers_plugin_tests.erl | 105 +++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 src/plugins/nova_secure_headers_plugin.erl create mode 100644 test/nova_secure_headers_plugin_tests.erl diff --git a/src/plugins/nova_secure_headers_plugin.erl b/src/plugins/nova_secure_headers_plugin.erl new file mode 100644 index 0000000..bf4fe13 --- /dev/null +++ b/src/plugins/nova_secure_headers_plugin.erl @@ -0,0 +1,90 @@ +-module(nova_secure_headers_plugin). +-behaviour(nova_plugin). + +-export([ + pre_request/4, + post_request/4, + plugin_info/0 + ]). + +-define(DEFAULT_HEADERS, #{ + <<"x-frame-options">> => <<"DENY">>, + <<"x-content-type-options">> => <<"nosniff">>, + <<"x-xss-protection">> => <<"1; mode=block">>, + <<"referrer-policy">> => <<"strict-origin-when-cross-origin">>, + <<"permissions-policy">> => <<"geolocation=(), camera=(), microphone=()">> +}). + +%%-------------------------------------------------------------------- +%% Pre-request: set security headers on every response +%%-------------------------------------------------------------------- +-spec pre_request(Req :: cowboy_req:req(), Env :: any(), Options :: map(), State :: any()) -> + {ok, Req0 :: cowboy_req:req(), NewState :: any()}. +pre_request(Req, _Env, Options, State) -> + Defaults = ?DEFAULT_HEADERS, + Extra = maps:get(extra_headers, Options, #{}), + Overrides = maps:get(headers, Options, #{}), + Merged = maps:merge(maps:merge(Defaults, Extra), Overrides), + Req1 = add_hsts(Req, Options), + Req2 = add_csp(Req1, Options), + Req3 = maps:fold(fun(Name, Value, ReqAcc) -> + cowboy_req:set_resp_header(Name, Value, ReqAcc) + end, Req2, Merged), + {ok, Req3, State}. + +%%-------------------------------------------------------------------- +%% Post-request: pass-through +%%-------------------------------------------------------------------- +-spec post_request(Req :: cowboy_req:req(), Env :: any(), Options :: map(), State :: any()) -> + {ok, Req0 :: cowboy_req:req(), NewState :: any()}. +post_request(Req, _Env, _Options, State) -> + {ok, Req, State}. + +%%-------------------------------------------------------------------- +%% Plugin info +%%-------------------------------------------------------------------- +-spec plugin_info() -> #{title := binary(), + version := binary(), + url := binary(), + authors := [binary()], + description := binary(), + options := [{Key :: atom(), OptionDescription :: binary()}]}. +plugin_info() -> + #{title => <<"Nova Secure Headers Plugin">>, + version => <<"0.1.0">>, + url => <<"https://github.com/novaframework/nova">>, + authors => [<<"Nova team >], + description => <<"Sets security-related HTTP response headers.">>, + options => [ + {headers, <<"Map of header name => value to override defaults">>}, + {extra_headers, <<"Map of additional headers to merge with defaults">>}, + {hsts, <<"true | false — enable Strict-Transport-Security (default: false)">>}, + {hsts_max_age, <<"Max-age in seconds for HSTS (default: 31536000)">>}, + {hsts_include_subdomains, <<"Include subdomains in HSTS (default: true)">>}, + {csp, <<"Content-Security-Policy value (binary), or false to disable">>} + ]}. + +%%%%%%%%%%%%%%%%%%%%%% +%% Private functions +%%%%%%%%%%%%%%%%%%%%%% + +add_hsts(Req, #{hsts := true} = Options) -> + MaxAge = maps:get(hsts_max_age, Options, 31536000), + IncludeSub = maps:get(hsts_include_subdomains, Options, true), + Value = case IncludeSub of + true -> + iolist_to_binary([<<"max-age=">>, integer_to_binary(MaxAge), + <<"; includeSubDomains">>]); + false -> + iolist_to_binary([<<"max-age=">>, integer_to_binary(MaxAge)]) + end, + cowboy_req:set_resp_header(<<"strict-transport-security">>, Value, Req); +add_hsts(Req, _Options) -> + Req. + +add_csp(Req, #{csp := false}) -> + Req; +add_csp(Req, #{csp := Policy}) when is_binary(Policy) -> + cowboy_req:set_resp_header(<<"content-security-policy">>, Policy, Req); +add_csp(Req, _Options) -> + Req. diff --git a/test/nova_secure_headers_plugin_tests.erl b/test/nova_secure_headers_plugin_tests.erl new file mode 100644 index 0000000..fdb01f6 --- /dev/null +++ b/test/nova_secure_headers_plugin_tests.erl @@ -0,0 +1,105 @@ +-module(nova_secure_headers_plugin_tests). +-include_lib("eunit/include/eunit.hrl"). + +%%==================================================================== +%% Default headers +%%==================================================================== + +default_headers_test() -> + Req = nova_test_helper:mock_req(<<"GET">>, <<"/">>), + {ok, Req1, _} = nova_secure_headers_plugin:pre_request(Req, #{}, #{}, state), + Headers = maps:get(resp_headers, Req1), + ?assertEqual(<<"DENY">>, maps:get(<<"x-frame-options">>, Headers)), + ?assertEqual(<<"nosniff">>, maps:get(<<"x-content-type-options">>, Headers)), + ?assertEqual(<<"1; mode=block">>, maps:get(<<"x-xss-protection">>, Headers)), + ?assertEqual(<<"strict-origin-when-cross-origin">>, maps:get(<<"referrer-policy">>, Headers)), + ?assertMatch(<<"geolocation=()", _/binary>>, maps:get(<<"permissions-policy">>, Headers)). + +%%==================================================================== +%% Header overrides +%%==================================================================== + +override_headers_test() -> + Req = nova_test_helper:mock_req(<<"GET">>, <<"/">>), + Opts = #{headers => #{<<"x-frame-options">> => <<"SAMEORIGIN">>}}, + {ok, Req1, _} = nova_secure_headers_plugin:pre_request(Req, #{}, Opts, state), + Headers = maps:get(resp_headers, Req1), + ?assertEqual(<<"SAMEORIGIN">>, maps:get(<<"x-frame-options">>, Headers)). + +extra_headers_test() -> + Req = nova_test_helper:mock_req(<<"GET">>, <<"/">>), + Opts = #{extra_headers => #{<<"x-custom">> => <<"value">>}}, + {ok, Req1, _} = nova_secure_headers_plugin:pre_request(Req, #{}, Opts, state), + Headers = maps:get(resp_headers, Req1), + ?assertEqual(<<"value">>, maps:get(<<"x-custom">>, Headers)), + ?assertEqual(<<"DENY">>, maps:get(<<"x-frame-options">>, Headers)). + +%%==================================================================== +%% HSTS +%%==================================================================== + +hsts_disabled_by_default_test() -> + Req = nova_test_helper:mock_req(<<"GET">>, <<"/">>), + {ok, Req1, _} = nova_secure_headers_plugin:pre_request(Req, #{}, #{}, state), + Headers = maps:get(resp_headers, Req1), + ?assertEqual(error, maps:find(<<"strict-transport-security">>, Headers)). + +hsts_enabled_test() -> + Req = nova_test_helper:mock_req(<<"GET">>, <<"/">>), + Opts = #{hsts => true}, + {ok, Req1, _} = nova_secure_headers_plugin:pre_request(Req, #{}, Opts, state), + Headers = maps:get(resp_headers, Req1), + ?assertEqual(<<"max-age=31536000; includeSubDomains">>, + maps:get(<<"strict-transport-security">>, Headers)). + +hsts_custom_max_age_test() -> + Req = nova_test_helper:mock_req(<<"GET">>, <<"/">>), + Opts = #{hsts => true, hsts_max_age => 86400, hsts_include_subdomains => false}, + {ok, Req1, _} = nova_secure_headers_plugin:pre_request(Req, #{}, Opts, state), + Headers = maps:get(resp_headers, Req1), + ?assertEqual(<<"max-age=86400">>, + maps:get(<<"strict-transport-security">>, Headers)). + +%%==================================================================== +%% CSP +%%==================================================================== + +csp_not_set_by_default_test() -> + Req = nova_test_helper:mock_req(<<"GET">>, <<"/">>), + {ok, Req1, _} = nova_secure_headers_plugin:pre_request(Req, #{}, #{}, state), + Headers = maps:get(resp_headers, Req1), + ?assertEqual(error, maps:find(<<"content-security-policy">>, Headers)). + +csp_set_when_configured_test() -> + Req = nova_test_helper:mock_req(<<"GET">>, <<"/">>), + Policy = <<"default-src 'self'">>, + Opts = #{csp => Policy}, + {ok, Req1, _} = nova_secure_headers_plugin:pre_request(Req, #{}, Opts, state), + Headers = maps:get(resp_headers, Req1), + ?assertEqual(Policy, maps:get(<<"content-security-policy">>, Headers)). + +csp_explicitly_disabled_test() -> + Req = nova_test_helper:mock_req(<<"GET">>, <<"/">>), + Opts = #{csp => false}, + {ok, Req1, _} = nova_secure_headers_plugin:pre_request(Req, #{}, Opts, state), + Headers = maps:get(resp_headers, Req1), + ?assertEqual(error, maps:find(<<"content-security-policy">>, Headers)). + +%%==================================================================== +%% post_request passthrough +%%==================================================================== + +post_request_passthrough_test() -> + Req = nova_test_helper:mock_req(<<"GET">>, <<"/">>), + {ok, Req1, state} = nova_secure_headers_plugin:post_request(Req, #{}, #{}, state), + ?assertEqual(Req, Req1). + +%%==================================================================== +%% plugin_info +%%==================================================================== + +plugin_info_test() -> + Info = nova_secure_headers_plugin:plugin_info(), + ?assertEqual(<<"Nova Secure Headers Plugin">>, maps:get(title, Info)), + ?assert(is_binary(maps:get(version, Info))), + ?assert(is_list(maps:get(options, Info))). From 9de1709c5d434855c889049d7cd8ea1f3b997493 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Thu, 12 Mar 2026 09:44:39 +0100 Subject: [PATCH 03/11] feat: add nova_rate_limit_plugin for DoS/brute-force protection Sliding window rate limiter using ETS counters. Tracks requests per client IP within configurable time windows. Returns 429 with Retry-After header when limit exceeded. Supports path-prefix filtering and custom key functions for flexible rate limiting (e.g. per API key, per user). Co-Authored-By: Claude Opus 4.6 --- src/plugins/nova_rate_limit_plugin.erl | 129 +++++++++++++++++++++++++ test/nova_rate_limit_plugin_tests.erl | 123 +++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 src/plugins/nova_rate_limit_plugin.erl create mode 100644 test/nova_rate_limit_plugin_tests.erl diff --git a/src/plugins/nova_rate_limit_plugin.erl b/src/plugins/nova_rate_limit_plugin.erl new file mode 100644 index 0000000..42b55dc --- /dev/null +++ b/src/plugins/nova_rate_limit_plugin.erl @@ -0,0 +1,129 @@ +%% @doc Rate limiting plugin using a sliding window counter in ETS. +%% +%% Limits requests per client IP (or custom key function) within a +%% configurable time window. Returns 429 Too Many Requests when exceeded. +%% +%% == Options == +%%
    +%%
  • `max_requests' — max requests per window (default 100)
  • +%%
  • `window_ms' — window size in milliseconds (default 60000 = 1 min)
  • +%%
  • `paths' — list of path prefixes to rate-limit (default all paths)
  • +%%
  • `key_fun' — fun(Req) -> binary() for custom client keys
  • +%%
+-module(nova_rate_limit_plugin). +-behaviour(nova_plugin). + +-export([ + init/0, + stop/1, + pre_request/4, + post_request/4, + plugin_info/0 + ]). + +-define(TABLE, nova_rate_limit_entries). + +%%-------------------------------------------------------------------- +%% init/0 — create ETS table for rate limit counters +%%-------------------------------------------------------------------- +init() -> + case ets:info(?TABLE) of + undefined -> + ets:new(?TABLE, [public, named_table, set, {write_concurrency, true}]); + _ -> + ok + end, + #{}. + +%%-------------------------------------------------------------------- +%% stop/1 +%%-------------------------------------------------------------------- +stop(_State) -> + ok. + +%%-------------------------------------------------------------------- +%% pre_request — check and enforce rate limit +%%-------------------------------------------------------------------- +-spec pre_request(Req :: cowboy_req:req(), Env :: any(), Options :: map(), State :: any()) -> + {ok, Req0 :: cowboy_req:req(), NewState :: any()} | + {stop, nova_plugin:reply(), Req0 :: cowboy_req:req(), NewState :: any()}. +pre_request(Req = #{path := Path}, _Env, Options, State) -> + Paths = maps:get(paths, Options, []), + case should_limit(Path, Paths) of + true -> + check_rate(Req, Options, State); + false -> + {ok, Req, State} + end. + +%%-------------------------------------------------------------------- +%% post_request — pass-through +%%-------------------------------------------------------------------- +-spec post_request(Req :: cowboy_req:req(), Env :: any(), Options :: map(), State :: any()) -> + {ok, Req0 :: cowboy_req:req(), NewState :: any()}. +post_request(Req, _Env, _Options, State) -> + {ok, Req, State}. + +%%-------------------------------------------------------------------- +%% plugin_info +%%-------------------------------------------------------------------- +plugin_info() -> + #{title => <<"Nova Rate Limit Plugin">>, + version => <<"0.1.0">>, + url => <<"https://github.com/novaframework/nova">>, + authors => [<<"Nova team >], + description => <<"Sliding window rate limiting per client IP.">>, + options => [ + {max_requests, <<"Max requests per window (default: 100)">>}, + {window_ms, <<"Window size in milliseconds (default: 60000)">>}, + {paths, <<"List of path prefixes to limit (default: all paths)">>}, + {key_fun, <<"fun(Req) -> binary() for custom client identification">>} + ]}. + +%%%%%%%%%%%%%%%%%%%%%% +%% Private functions +%%%%%%%%%%%%%%%%%%%%%% + +should_limit(_Path, []) -> + true; +should_limit(Path, Prefixes) -> + lists:any(fun(Prefix) -> + case binary:match(Path, Prefix) of + {0, _} -> true; + _ -> false + end + end, Prefixes). + +check_rate(Req, Options, State) -> + MaxRequests = maps:get(max_requests, Options, 100), + WindowMs = maps:get(window_ms, Options, 60000), + Key = client_key(Req, Options), + Now = erlang:system_time(millisecond), + WindowStart = Now - WindowMs, + case ets:lookup(?TABLE, Key) of + [{Key, Count, FirstRequestTime}] when FirstRequestTime >= WindowStart -> + if + Count >= MaxRequests -> + RetryAfter = integer_to_binary((FirstRequestTime + WindowMs - Now) div 1000 + 1), + {stop, {reply, 429, + [{<<"content-type">>, <<"text/plain">>}, + {<<"retry-after">>, RetryAfter}], + <<"Too Many Requests">>}, Req, State}; + true -> + ets:update_counter(?TABLE, Key, {2, 1}), + {ok, Req, State} + end; + _ -> + ets:insert(?TABLE, {Key, 1, Now}), + {ok, Req, State} + end. + +client_key(Req, #{key_fun := KeyFun}) -> + KeyFun(Req); +client_key(Req, _Options) -> + peer_ip(Req). + +peer_ip(#{peer := {IP, _Port}}) -> + iolist_to_binary(inet:ntoa(IP)); +peer_ip(_Req) -> + <<"unknown">>. diff --git a/test/nova_rate_limit_plugin_tests.erl b/test/nova_rate_limit_plugin_tests.erl new file mode 100644 index 0000000..b6084ee --- /dev/null +++ b/test/nova_rate_limit_plugin_tests.erl @@ -0,0 +1,123 @@ +-module(nova_rate_limit_plugin_tests). +-include_lib("eunit/include/eunit.hrl"). + +-define(TABLE, nova_rate_limit_entries). + +%%==================================================================== +%% Setup / teardown +%%==================================================================== + +setup() -> + nova_rate_limit_plugin:init(). + +cleanup() -> + catch ets:delete(?TABLE). + +%%==================================================================== +%% Tests +%%==================================================================== + +allows_requests_under_limit_test() -> + setup(), + try + Req = req_with_peer(<<"GET">>, <<"/api">>, {{127, 0, 0, 1}, 12345}), + Opts = #{max_requests => 5, window_ms => 60000}, + {ok, _, _} = nova_rate_limit_plugin:pre_request(Req, #{}, Opts, state) + after + cleanup() + end. + +blocks_requests_over_limit_test() -> + setup(), + try + Req = req_with_peer(<<"GET">>, <<"/api">>, {{10, 0, 0, 1}, 9999}), + Opts = #{max_requests => 3, window_ms => 60000}, + {ok, _, _} = nova_rate_limit_plugin:pre_request(Req, #{}, Opts, state), + {ok, _, _} = nova_rate_limit_plugin:pre_request(Req, #{}, Opts, state), + {ok, _, _} = nova_rate_limit_plugin:pre_request(Req, #{}, Opts, state), + {stop, {reply, 429, _, _}, _, _} = + nova_rate_limit_plugin:pre_request(Req, #{}, Opts, state) + after + cleanup() + end. + +skips_unmatched_paths_test() -> + setup(), + try + Req = req_with_peer(<<"GET">>, <<"/public/index">>, {{127, 0, 0, 1}, 5555}), + Opts = #{max_requests => 1, window_ms => 60000, paths => [<<"/api">>]}, + {ok, _, _} = nova_rate_limit_plugin:pre_request(Req, #{}, Opts, state), + {ok, _, _} = nova_rate_limit_plugin:pre_request(Req, #{}, Opts, state) + after + cleanup() + end. + +limits_matching_paths_test() -> + setup(), + try + Req = req_with_peer(<<"POST">>, <<"/api/login">>, {{192, 168, 1, 1}, 4444}), + Opts = #{max_requests => 1, window_ms => 60000, paths => [<<"/api">>]}, + {ok, _, _} = nova_rate_limit_plugin:pre_request(Req, #{}, Opts, state), + {stop, {reply, 429, _, _}, _, _} = + nova_rate_limit_plugin:pre_request(Req, #{}, Opts, state) + after + cleanup() + end. + +custom_key_fun_test() -> + setup(), + try + Req1 = (req_with_peer(<<"GET">>, <<"/api">>, {{127, 0, 0, 1}, 1111}))#{ + headers => #{<<"x-api-key">> => <<"client-a">>}}, + KeyFun = fun(#{headers := #{<<"x-api-key">> := K}}) -> K end, + Opts = #{max_requests => 1, window_ms => 60000, key_fun => KeyFun}, + {ok, _, _} = nova_rate_limit_plugin:pre_request(Req1, #{}, Opts, state), + {stop, {reply, 429, _, _}, _, _} = + nova_rate_limit_plugin:pre_request(Req1, #{}, Opts, state) + after + cleanup() + end. + +different_ips_tracked_separately_test() -> + setup(), + try + Req1 = req_with_peer(<<"GET">>, <<"/api">>, {{10, 0, 0, 1}, 1111}), + Req2 = req_with_peer(<<"GET">>, <<"/api">>, {{10, 0, 0, 2}, 2222}), + Opts = #{max_requests => 1, window_ms => 60000}, + {ok, _, _} = nova_rate_limit_plugin:pre_request(Req1, #{}, Opts, state), + {stop, {reply, 429, _, _}, _, _} = + nova_rate_limit_plugin:pre_request(Req1, #{}, Opts, state), + {ok, _, _} = nova_rate_limit_plugin:pre_request(Req2, #{}, Opts, state) + after + cleanup() + end. + +retry_after_header_present_test() -> + setup(), + try + Req = req_with_peer(<<"GET">>, <<"/api">>, {{10, 0, 0, 3}, 3333}), + Opts = #{max_requests => 1, window_ms => 60000}, + {ok, _, _} = nova_rate_limit_plugin:pre_request(Req, #{}, Opts, state), + {stop, {reply, 429, Headers, _}, _, _} = + nova_rate_limit_plugin:pre_request(Req, #{}, Opts, state), + ?assert(lists:keymember(<<"retry-after">>, 1, Headers)) + after + cleanup() + end. + +post_request_passthrough_test() -> + Req = nova_test_helper:mock_req(<<"GET">>, <<"/">>), + {ok, Req, state} = nova_rate_limit_plugin:post_request(Req, #{}, #{}, state). + +plugin_info_test() -> + Info = nova_rate_limit_plugin:plugin_info(), + ?assertEqual(<<"Nova Rate Limit Plugin">>, maps:get(title, Info)), + ?assert(is_list(maps:get(options, Info))). + +%%==================================================================== +%% Helpers +%%==================================================================== + +req_with_peer(Method, Path, Peer) -> + Req = nova_test_helper:mock_req(Method, Path), + Req#{peer => Peer}. From 5dedd846310441fcc2eed26d1dc08ead65938482 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Thu, 12 Mar 2026 09:47:08 +0100 Subject: [PATCH 04/11] feat: add session lifecycle management (timeout, rotation, cleanup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add nova_session:rotate/1 for session fixation prevention — generates new session ID and migrates data on privilege changes (login/logout) - Add rotate_session/2 callback to nova_session behaviour (optional) - Add session timestamps (created_at, last_accessed) to ETS backend - Add periodic cleanup of expired sessions (configurable intervals) - session_max_age (default 24h) and session_idle_timeout (default 1h) configurable via application environment - Backward-compatible with legacy {SessionId, Data} tuple format Co-Authored-By: Claude Opus 4.6 --- src/nova_session.erl | 30 ++++++++++++- src/nova_session_ets.erl | 87 +++++++++++++++++++++++++++++++++++--- test/nova_session_test.erl | 37 ++++++++++++++++ 3 files changed, 147 insertions(+), 7 deletions(-) diff --git a/src/nova_session.erl b/src/nova_session.erl index 4a6cad7..0a2fde6 100644 --- a/src/nova_session.erl +++ b/src/nova_session.erl @@ -10,6 +10,7 @@ set/3, delete/1, delete/2, + rotate/1, generate_session_id/0, cookie_opts/0 ]). @@ -48,6 +49,13 @@ when SessionId :: binary(), Key :: binary(). +%% Copy all session data from OldSessionId to NewSessionId and delete old +-callback rotate_session(OldSessionId, NewSessionId) -> + ok | + {error, Reason :: atom()} + when OldSessionId :: binary(), + NewSessionId :: binary(). + %% Start a server process for session backend, if necessary -callback start_link() -> {ok, Pid :: pid()} | @@ -55,7 +63,7 @@ {error, Error :: term()} | ignore. --optional_callbacks([start_link/0]). +-optional_callbacks([start_link/0, rotate_session/2]). %%%=================================================================== %%% Public functions @@ -111,6 +119,26 @@ delete(Req, Key) -> end. +-spec rotate(Req :: cowboy_req:req()) -> + {ok, Req :: cowboy_req:req()} | {error, Reason :: atom()}. +rotate(Req) -> + case get_session_id(Req) of + {ok, OldSessionId} -> + {ok, NewSessionId} = generate_session_id(), + Mod = get_session_module(), + case erlang:function_exported(Mod, rotate_session, 2) of + true -> + Mod:rotate_session(OldSessionId, NewSessionId); + false -> + Mod:delete_value(OldSessionId) + end, + Req1 = cowboy_req:set_resp_cookie(<<"session_id">>, NewSessionId, Req, + cookie_opts()), + {ok, Req1#{nova_session_id => NewSessionId}}; + _ -> + {error, no_session} + end. + %%%=================================================================== %%% Private functions %%%=================================================================== diff --git a/src/nova_session_ets.erl b/src/nova_session_ets.erl index da03b6c..c0b2d80 100644 --- a/src/nova_session_ets.erl +++ b/src/nova_session_ets.erl @@ -17,7 +17,8 @@ get_value/2, set_value/3, delete_value/1, - delete_value/2 + delete_value/2, + rotate_session/2 ]). %% gen_server callbacks @@ -27,6 +28,7 @@ -define(SERVER, ?MODULE). -define(TABLE, nova_session_ets_entries). -define(CHANNEL, '__sessions'). +-define(CLEANUP_INTERVAL, 60000). %% 1 minute -include("../include/nova_pubsub.hrl"). @@ -65,6 +67,10 @@ delete_value(SessionId) -> delete_value(SessionId, Key) -> nova_pubsub:broadcast(?CHANNEL, "delete_value", {SessionId, Key}). +-spec rotate_session(OldSessionId :: binary(), NewSessionId :: binary()) -> ok | {error, Reason :: term()}. +rotate_session(OldSessionId, NewSessionId) -> + nova_pubsub:broadcast(?CHANNEL, "rotate_session", {OldSessionId, NewSessionId}). + %%%=================================================================== %%% gen_server callbacks %%%=================================================================== @@ -84,6 +90,7 @@ init([]) -> process_flag(trap_exit, true), ets:new(?TABLE, [set, named_table]), nova_pubsub:join(?CHANNEL), + erlang:send_after(?CLEANUP_INTERVAL, self(), cleanup_expired), {ok, #state{}}. %%-------------------------------------------------------------------- @@ -105,7 +112,25 @@ handle_call({get_value, SessionId, Key}, _From, State) -> case ets:lookup(?TABLE, SessionId) of [] -> {reply, {error, not_found}, State}; - [{SessionId, Session}|_] -> + [{SessionId, Session, CreatedAt, _LastAccessed}] -> + Now = erlang:system_time(second), + case is_expired(CreatedAt, Now) of + true -> + ets:delete(?TABLE, SessionId), + {reply, {error, not_found}, State}; + false -> + ets:insert(?TABLE, {SessionId, Session, CreatedAt, Now}), + case maps:get(Key, Session, undefined) of + undefined -> + {reply, {error, not_found}, State}; + Value -> + {reply, {ok, Value}, State} + end + end; + [{SessionId, Session}] -> + %% Legacy format — migrate to timestamped format + Now = erlang:system_time(second), + ets:insert(?TABLE, {SessionId, Session, Now, Now}), case maps:get(Key, Session, undefined) of undefined -> {reply, {error, not_found}, State}; @@ -143,24 +168,46 @@ handle_cast(_Request, State) -> {noreply, NewState :: term(), hibernate} | {stop, Reason :: normal | term(), NewState :: term()}. handle_info(#nova_pubsub{topic = "set_value", payload = {SessionId, Key, Value}}, State) -> + Now = erlang:system_time(second), case ets:lookup(?TABLE, SessionId) of [] -> - ets:insert(?TABLE, {SessionId, #{Key => Value}}); - [{_, Session}|_] -> - ets:insert(?TABLE, {SessionId, Session#{Key => Value}}) + ets:insert(?TABLE, {SessionId, #{Key => Value}, Now, Now}); + [{_, Session, CreatedAt, _}] -> + ets:insert(?TABLE, {SessionId, Session#{Key => Value}, CreatedAt, Now}); + [{_, Session}] -> + ets:insert(?TABLE, {SessionId, Session#{Key => Value}, Now, Now}) end, {noreply, State}; handle_info(#nova_pubsub{topic = "delete_value", payload = {SessionId, Key}}, State) -> case ets:lookup(?TABLE, SessionId) of [] -> ok; - [{SessionId, Session}|_] -> + [{_, Session, CreatedAt, LastAccessed}] -> + ets:insert(?TABLE, {SessionId, maps:remove(Key, Session), CreatedAt, LastAccessed}); + [{_, Session}] -> ets:insert(?TABLE, {SessionId, maps:remove(Key, Session)}) end, {noreply, State}; handle_info(#nova_pubsub{topic = "delete_value", payload = SessionId}, State) -> ets:delete(?TABLE, SessionId), {noreply, State}; +handle_info(#nova_pubsub{topic = "rotate_session", payload = {OldSessionId, NewSessionId}}, State) -> + Now = erlang:system_time(second), + case ets:lookup(?TABLE, OldSessionId) of + [{_, Session, _, _}] -> + ets:delete(?TABLE, OldSessionId), + ets:insert(?TABLE, {NewSessionId, Session, Now, Now}); + [{_, Session}] -> + ets:delete(?TABLE, OldSessionId), + ets:insert(?TABLE, {NewSessionId, Session, Now, Now}); + [] -> + ets:insert(?TABLE, {NewSessionId, #{}, Now, Now}) + end, + {noreply, State}; +handle_info(cleanup_expired, State) -> + cleanup_expired_sessions(), + erlang:send_after(?CLEANUP_INTERVAL, self(), cleanup_expired), + {noreply, State}; handle_info(_Info, State) -> {noreply, State}. @@ -207,3 +254,31 @@ format_status(Status) -> %%%=================================================================== %%% Internal functions %%%=================================================================== + +session_max_age() -> + nova:get_env(session_max_age, 86400). %% 24 hours default + +session_idle_timeout() -> + nova:get_env(session_idle_timeout, 3600). %% 1 hour default + +is_expired(CreatedAt, Now) -> + MaxAge = session_max_age(), + (Now - CreatedAt) > MaxAge. + +cleanup_expired_sessions() -> + Now = erlang:system_time(second), + MaxAge = session_max_age(), + IdleTimeout = session_idle_timeout(), + ets:foldl(fun({SessionId, _Data, CreatedAt, LastAccessed}, Acc) -> + Expired = (Now - CreatedAt) > MaxAge, + Idle = (IdleTimeout > 0) andalso ((Now - LastAccessed) > IdleTimeout), + case Expired orelse Idle of + true -> ets:delete(?TABLE, SessionId); + false -> ok + end, + Acc; + ({SessionId, _Data}, Acc) -> + %% Legacy format without timestamps — delete as we can't determine age + ets:delete(?TABLE, SessionId), + Acc + end, ok, ?TABLE). diff --git a/test/nova_session_test.erl b/test/nova_session_test.erl index 5ea22ca..3202194 100644 --- a/test/nova_session_test.erl +++ b/test/nova_session_test.erl @@ -117,6 +117,43 @@ cookie_opts_allows_overrides_test() -> meck:unload(nova) end. +%%==================================================================== +%% Tests for rotate/1 +%%==================================================================== + +rotate_generates_new_session_id_test() -> + setup_meck(), + try + OldSessionId = <<"old-session-id">>, + meck:expect(nova_session_ets, rotate_session, + fun(Old, New) when Old =:= OldSessionId -> + ?assert(is_binary(New)), + ?assertNotEqual(OldSessionId, New), + ok + end), + meck:expect(cowboy_req, set_resp_cookie, + fun(<<"session_id">>, NewId, Req, _Opts) when is_binary(NewId) -> + Req#{resp_cookie_set => NewId} + end), + Req = #{nova_session_id => OldSessionId}, + {ok, Req1} = nova_session:rotate(Req), + ?assert(maps:is_key(nova_session_id, Req1)), + ?assertNotEqual(OldSessionId, maps:get(nova_session_id, Req1)) + after + cleanup_meck() + end. + +rotate_returns_error_without_session_test() -> + setup_meck(), + try + meck:expect(cowboy_req, match_cookies, + fun([{session_id, [], undefined}], _Req) -> #{session_id => undefined} end), + Req = #{}, + ?assertEqual({error, no_session}, nova_session:rotate(Req)) + after + cleanup_meck() + end. + %%==================================================================== %% Helpers %%==================================================================== From feba45f31e65d3c70f442c775f8d2fddae3f1295 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Thu, 12 Mar 2026 09:48:09 +0100 Subject: [PATCH 05/11] fix: tighten CORS plugin defaults from wildcard to explicit values Change default Access-Control-Allow-Headers from "*" to "Content-Type, Authorization" and Access-Control-Allow-Methods from "*" to "GET, POST, PUT, DELETE, OPTIONS". Both are now configurable via allow_headers and allow_methods plugin options. Co-Authored-By: Claude Opus 4.6 --- src/plugins/nova_cors_plugin.erl | 19 +++++++++++++------ test/nova_cors_plugin_tests.erl | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/plugins/nova_cors_plugin.erl b/src/plugins/nova_cors_plugin.erl index d67fbae..d29c05e 100644 --- a/src/plugins/nova_cors_plugin.erl +++ b/src/plugins/nova_cors_plugin.erl @@ -14,8 +14,8 @@ %%-------------------------------------------------------------------- -spec pre_request(Req :: cowboy_req:req(), Env :: any(), Options :: map(), State :: any()) -> {ok, Req0 :: cowboy_req:req(), NewState :: any()}. -pre_request(Req, _Env, #{allow_origins := Origins}, State) -> - ReqWithOptions = add_cors_headers(Req, Origins), +pre_request(Req, _Env, Options = #{allow_origins := Origins}, State) -> + ReqWithOptions = add_cors_headers(Req, Origins, Options), continue(ReqWithOptions, State). %%-------------------------------------------------------------------- @@ -47,7 +47,9 @@ plugin_info() -> authors => [<<"Nova team >], description => <<"Add CORS headers to request">>, options => [ - {allow_origins, <<"Specifies which origins to insert into Access-Control-Allow-Origin">>} + {allow_origins, <<"Specifies which origins to insert into Access-Control-Allow-Origin">>}, + {allow_headers, <<"Comma-separated allowed headers (default: Content-Type, Authorization)">>}, + {allow_methods, <<"Comma-separated allowed methods (default: GET, POST, PUT, DELETE, OPTIONS)">>} ] }. @@ -61,10 +63,15 @@ continue(#{method := <<"OPTIONS">>} = Req, State) -> continue(Req, State) -> {ok, Req, State}. -add_cors_headers(Req, Origins) -> +-define(DEFAULT_HEADERS, <<"Content-Type, Authorization">>). +-define(DEFAULT_METHODS, <<"GET, POST, PUT, DELETE, OPTIONS">>). + +add_cors_headers(Req, Origins, Options) -> + AllowHeaders = maps:get(allow_headers, Options, ?DEFAULT_HEADERS), + AllowMethods = maps:get(allow_methods, Options, ?DEFAULT_METHODS), OriginsReq = cowboy_req:set_resp_header( <<"Access-Control-Allow-Origin">>, Origins, Req), HeadersReq = cowboy_req:set_resp_header( - <<"Access-Control-Allow-Headers">>, <<"*">>, OriginsReq), + <<"Access-Control-Allow-Headers">>, AllowHeaders, OriginsReq), cowboy_req:set_resp_header( - <<"Access-Control-Allow-Methods">>, <<"*">>, HeadersReq). + <<"Access-Control-Allow-Methods">>, AllowMethods, HeadersReq). diff --git a/test/nova_cors_plugin_tests.erl b/test/nova_cors_plugin_tests.erl index caf4d0d..4b1b62b 100644 --- a/test/nova_cors_plugin_tests.erl +++ b/test/nova_cors_plugin_tests.erl @@ -11,8 +11,18 @@ pre_request_non_options_test() -> {ok, Req1, my_state} = nova_cors_plugin:pre_request(Req, #{}, Opts, my_state), Headers = maps:get(resp_headers, Req1), ?assertEqual(<<"*">>, maps:get(<<"Access-Control-Allow-Origin">>, Headers)), - ?assertEqual(<<"*">>, maps:get(<<"Access-Control-Allow-Headers">>, Headers)), - ?assertEqual(<<"*">>, maps:get(<<"Access-Control-Allow-Methods">>, Headers)). + ?assertEqual(<<"Content-Type, Authorization">>, maps:get(<<"Access-Control-Allow-Headers">>, Headers)), + ?assertEqual(<<"GET, POST, PUT, DELETE, OPTIONS">>, maps:get(<<"Access-Control-Allow-Methods">>, Headers)). + +pre_request_custom_headers_and_methods_test() -> + Req = nova_test_helper:mock_req(<<"GET">>, <<"/">>), + Opts = #{allow_origins => <<"https://example.com">>, + allow_headers => <<"X-Custom">>, + allow_methods => <<"GET, POST">>}, + {ok, Req1, _} = nova_cors_plugin:pre_request(Req, #{}, Opts, state), + Headers = maps:get(resp_headers, Req1), + ?assertEqual(<<"X-Custom">>, maps:get(<<"Access-Control-Allow-Headers">>, Headers)), + ?assertEqual(<<"GET, POST">>, maps:get(<<"Access-Control-Allow-Methods">>, Headers)). pre_request_specific_origin_test() -> Req = nova_test_helper:mock_req(<<"POST">>, <<"/api">>), From 8fcd9124f6bb251354ad7d7c9c57587b10aa3683 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Thu, 12 Mar 2026 09:48:57 +0100 Subject: [PATCH 06/11] feat: add nova_force_ssl_plugin for HTTP to HTTPS redirect Redirects HTTP requests to HTTPS with 301 status. Supports custom host/port override, query string preservation, and path exclusions for health checks. Use alongside nova_secure_headers_plugin's HSTS option for complete TLS enforcement. Co-Authored-By: Claude Opus 4.6 --- src/plugins/nova_force_ssl_plugin.erl | 87 +++++++++++++++++++++++++ test/nova_force_ssl_plugin_tests.erl | 91 +++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 src/plugins/nova_force_ssl_plugin.erl create mode 100644 test/nova_force_ssl_plugin_tests.erl diff --git a/src/plugins/nova_force_ssl_plugin.erl b/src/plugins/nova_force_ssl_plugin.erl new file mode 100644 index 0000000..f4bf4b8 --- /dev/null +++ b/src/plugins/nova_force_ssl_plugin.erl @@ -0,0 +1,87 @@ +%% @doc Plugin that redirects HTTP requests to HTTPS. +%% +%% Should be used with nova_secure_headers_plugin's HSTS option for +%% complete TLS enforcement. +%% +%% == Options == +%%
    +%%
  • `host' — override redirect host (default: use request host)
  • +%%
  • `port' — override redirect port (omitted if 443)
  • +%%
  • `excluded_paths' — list of path prefixes to skip (e.g. health checks)
  • +%%
+-module(nova_force_ssl_plugin). +-behaviour(nova_plugin). + +-export([ + pre_request/4, + post_request/4, + plugin_info/0 + ]). + +%%-------------------------------------------------------------------- +%% Pre-request: redirect HTTP to HTTPS +%%-------------------------------------------------------------------- +-spec pre_request(Req :: cowboy_req:req(), Env :: any(), Options :: map(), State :: any()) -> + {ok, Req0 :: cowboy_req:req(), NewState :: any()} | + {stop, Req0 :: cowboy_req:req(), NewState :: any()}. +pre_request(Req = #{scheme := <<"https">>}, _Env, _Options, State) -> + {ok, Req, State}; +pre_request(Req = #{scheme := Scheme, path := Path}, _Env, Options, State) when Scheme =/= <<"https">> -> + ExcludedPaths = maps:get(excluded_paths, Options, []), + case is_excluded(Path, ExcludedPaths) of + true -> + {ok, Req, State}; + false -> + Location = build_https_url(Req, Options), + Reply = cowboy_req:reply(301, #{<<"location">> => Location}, Req), + {stop, Reply, State} + end; +pre_request(Req, _Env, _Options, State) -> + %% No scheme info (e.g. behind TLS offload without x-forwarded-proto) + {ok, Req, State}. + +%%-------------------------------------------------------------------- +%% Post-request: pass-through +%%-------------------------------------------------------------------- +post_request(Req, _Env, _Options, State) -> + {ok, Req, State}. + +%%-------------------------------------------------------------------- +%% Plugin info +%%-------------------------------------------------------------------- +plugin_info() -> + #{title => <<"Nova Force SSL Plugin">>, + version => <<"0.1.0">>, + url => <<"https://github.com/novaframework/nova">>, + authors => [<<"Nova team >], + description => <<"Redirects HTTP requests to HTTPS.">>, + options => [ + {host, <<"Override redirect host (default: request host)">>}, + {port, <<"Override redirect port (omitted if 443)">>}, + {excluded_paths, <<"Path prefixes to skip (e.g. health checks)">>} + ]}. + +%%%%%%%%%%%%%%%%%%%%%% +%% Private functions +%%%%%%%%%%%%%%%%%%%%%% + +build_https_url(#{host := Host, path := Path, qs := Qs}, Options) -> + RedirectHost = maps:get(host, Options, Host), + Port = maps:get(port, Options, 443), + PortStr = case Port of + 443 -> <<>>; + P -> iolist_to_binary([<<":" >>, integer_to_binary(P)]) + end, + QsStr = case Qs of + <<>> -> <<>>; + _ -> <<"?", Qs/binary>> + end, + <<"https://", RedirectHost/binary, PortStr/binary, Path/binary, QsStr/binary>>. + +is_excluded(_Path, []) -> + false; +is_excluded(Path, [Prefix | Rest]) -> + case binary:match(Path, Prefix) of + {0, _} -> true; + _ -> is_excluded(Path, Rest) + end. diff --git a/test/nova_force_ssl_plugin_tests.erl b/test/nova_force_ssl_plugin_tests.erl new file mode 100644 index 0000000..a78c474 --- /dev/null +++ b/test/nova_force_ssl_plugin_tests.erl @@ -0,0 +1,91 @@ +-module(nova_force_ssl_plugin_tests). +-include_lib("eunit/include/eunit.hrl"). + +%%==================================================================== +%% HTTPS requests pass through +%%==================================================================== + +https_passes_through_test() -> + Req = (nova_test_helper:mock_req(<<"GET">>, <<"/page">>))#{scheme => <<"https">>}, + {ok, Req1, state} = nova_force_ssl_plugin:pre_request(Req, #{}, #{}, state), + ?assertEqual(Req, Req1). + +%%==================================================================== +%% HTTP requests get redirected +%%==================================================================== + +http_redirects_to_https_test() -> + Req = mock_http_req(<<"GET">>, <<"example.com">>, <<"/page">>, <<>>), + {stop, _Reply, state} = nova_force_ssl_plugin:pre_request(Req, #{}, #{}, state). + +%%==================================================================== +%% Excluded paths skip redirect +%%==================================================================== + +excluded_path_passes_through_test() -> + Req = mock_http_req(<<"GET">>, <<"example.com">>, <<"/health">>, <<>>), + Opts = #{excluded_paths => [<<"/health">>]}, + {ok, _, state} = nova_force_ssl_plugin:pre_request(Req, #{}, Opts, state). + +%%==================================================================== +%% Query string preserved +%%==================================================================== + +query_string_preserved_test() -> + Req = mock_http_req(<<"GET">>, <<"example.com">>, <<"/search">>, <<"q=test">>), + meck:new(cowboy_req, [passthrough]), + try + meck:expect(cowboy_req, reply, + fun(301, #{<<"location">> := Location}, _Req) -> + ?assertEqual(<<"https://example.com/search?q=test">>, Location), + _Req + end), + {stop, _, state} = nova_force_ssl_plugin:pre_request(Req, #{}, #{}, state) + after + meck:unload(cowboy_req) + end. + +%%==================================================================== +%% Custom host and port +%%==================================================================== + +custom_host_and_port_test() -> + Req = mock_http_req(<<"GET">>, <<"old.example.com">>, <<"/page">>, <<>>), + Opts = #{host => <<"new.example.com">>, port => 8443}, + meck:new(cowboy_req, [passthrough]), + try + meck:expect(cowboy_req, reply, + fun(301, #{<<"location">> := Location}, _Req) -> + ?assertEqual(<<"https://new.example.com:8443/page">>, Location), + _Req + end), + {stop, _, state} = nova_force_ssl_plugin:pre_request(Req, #{}, Opts, state) + after + meck:unload(cowboy_req) + end. + +%%==================================================================== +%% post_request passthrough +%%==================================================================== + +post_request_passthrough_test() -> + Req = nova_test_helper:mock_req(<<"GET">>, <<"/">>), + {ok, Req, state} = nova_force_ssl_plugin:post_request(Req, #{}, #{}, state). + +%%==================================================================== +%% plugin_info +%%==================================================================== + +plugin_info_test() -> + Info = nova_force_ssl_plugin:plugin_info(), + ?assert(is_binary(maps:get(title, Info))), + ?assert(is_list(maps:get(options, Info))). + +%%==================================================================== +%% Helpers +%%==================================================================== + +mock_http_req(Method, Host, Path, Qs) -> + (nova_test_helper:mock_req(Method, Path))#{scheme => <<"http">>, + host => Host, + qs => Qs}. From 2da2db5ddf124cf63a35ef842fa2ee509e24c272 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Thu, 12 Mar 2026 09:50:47 +0100 Subject: [PATCH 07/11] feat: add nova_log_filter for sensitive parameter redaction OTP logger filter that redacts sensitive parameters from log output. Default filtered keys: password, secret, token, api_key, authorization, passwd, credit_card. Configurable via filter_parameters app env. Supports case-insensitive partial matching, nested maps, and both atom and binary keys. Can be used as a logger filter or called directly via redact_params/1,2. Co-Authored-By: Claude Opus 4.6 --- src/nova_log_filter.erl | 77 ++++++++++++++++++++++++++++++++++ test/nova_log_filter_tests.erl | 63 ++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/nova_log_filter.erl create mode 100644 test/nova_log_filter_tests.erl diff --git a/src/nova_log_filter.erl b/src/nova_log_filter.erl new file mode 100644 index 0000000..31e8fb4 --- /dev/null +++ b/src/nova_log_filter.erl @@ -0,0 +1,77 @@ +%% @doc Logger filter for redacting sensitive parameters from log output. +%% +%% Configurable via application environment: +%% {nova, [{filter_parameters, [<<"password">>, <<"secret">>, <<"token">>]}]} +%% +%% Default filtered parameters: password, secret, token, api_key, authorization. +%% +%% Usage in sys.config logger configuration: +%% {kernel, [{logger, [{handler, default, logger_std_h, +%% #{filters => [{nova_param_filter, +%% {fun nova_log_filter:filter/2, #{}}}]}}]}]} +-module(nova_log_filter). + +-export([ + filter/2, + redact_params/1, + redact_params/2 + ]). + +-define(DEFAULT_FILTERED, [<<"password">>, <<"secret">>, <<"token">>, + <<"api_key">>, <<"authorization">>, + <<"passwd">>, <<"credit_card">>]). +-define(REDACTED, <<"[FILTERED]">>). + +%% OTP logger filter callback +-spec filter(logger:log_event(), map()) -> logger:filter_return(). +filter(#{msg := {report, Report}} = Event, _Extra) when is_map(Report) -> + Event#{msg := {report, redact_map(Report)}}; +filter(Event, _Extra) -> + Event. + +%% Redact sensitive keys from a map using default filtered parameters +-spec redact_params(map()) -> map(). +redact_params(Params) when is_map(Params) -> + FilteredKeys = filtered_keys(), + redact_params(Params, FilteredKeys). + +%% Redact sensitive keys from a map using explicit filter list +-spec redact_params(map(), [binary()]) -> map(). +redact_params(Params, FilteredKeys) when is_map(Params) -> + maps:map(fun(Key, Value) -> + BinKey = to_binary(Key), + case lists:any(fun(F) -> matches(BinKey, F) end, FilteredKeys) of + true -> ?REDACTED; + false when is_map(Value) -> redact_params(Value, FilteredKeys); + false -> Value + end + end, Params); +redact_params(Other, _FilteredKeys) -> + Other. + +%%%%%%%%%%%%%%%%%%%%%% +%% Private functions +%%%%%%%%%%%%%%%%%%%%%% + +filtered_keys() -> + nova:get_env(filter_parameters, ?DEFAULT_FILTERED). + +redact_map(Map) -> + FilteredKeys = filtered_keys(), + maps:map(fun(_Key, Value) when is_map(Value) -> + redact_params(Value, FilteredKeys); + (Key, Value) -> + BinKey = to_binary(Key), + case lists:any(fun(F) -> matches(BinKey, F) end, FilteredKeys) of + true -> ?REDACTED; + false -> Value + end + end, Map). + +matches(Key, Filter) -> + binary:match(string:lowercase(Key), string:lowercase(Filter)) =/= nomatch. + +to_binary(Key) when is_binary(Key) -> Key; +to_binary(Key) when is_atom(Key) -> atom_to_binary(Key, utf8); +to_binary(Key) when is_list(Key) -> list_to_binary(Key); +to_binary(_) -> <<>>. diff --git a/test/nova_log_filter_tests.erl b/test/nova_log_filter_tests.erl new file mode 100644 index 0000000..84232ea --- /dev/null +++ b/test/nova_log_filter_tests.erl @@ -0,0 +1,63 @@ +-module(nova_log_filter_tests). +-include_lib("eunit/include/eunit.hrl"). + +%%==================================================================== +%% redact_params/2 +%%==================================================================== + +redacts_password_test() -> + Params = #{<<"username">> => <<"alice">>, <<"password">> => <<"s3cret">>}, + Result = nova_log_filter:redact_params(Params, [<<"password">>]), + ?assertEqual(<<"alice">>, maps:get(<<"username">>, Result)), + ?assertEqual(<<"[FILTERED]">>, maps:get(<<"password">>, Result)). + +redacts_case_insensitive_test() -> + Params = #{<<"Password">> => <<"s3cret">>, <<"API_KEY">> => <<"abc">>}, + Result = nova_log_filter:redact_params(Params, [<<"password">>, <<"api_key">>]), + ?assertEqual(<<"[FILTERED]">>, maps:get(<<"Password">>, Result)), + ?assertEqual(<<"[FILTERED]">>, maps:get(<<"API_KEY">>, Result)). + +redacts_nested_maps_test() -> + Params = #{<<"user">> => #{<<"name">> => <<"bob">>, <<"token">> => <<"xyz">>}}, + Result = nova_log_filter:redact_params(Params, [<<"token">>]), + ?assertEqual(<<"[FILTERED]">>, maps:get(<<"token">>, maps:get(<<"user">>, Result))), + ?assertEqual(<<"bob">>, maps:get(<<"name">>, maps:get(<<"user">>, Result))). + +redacts_atom_keys_test() -> + Params = #{password => <<"s3cret">>, username => <<"alice">>}, + Result = nova_log_filter:redact_params(Params, [<<"password">>]), + ?assertEqual(<<"[FILTERED]">>, maps:get(password, Result)), + ?assertEqual(<<"alice">>, maps:get(username, Result)). + +redacts_partial_match_test() -> + Params = #{<<"password_hash">> => <<"abc123">>}, + Result = nova_log_filter:redact_params(Params, [<<"password">>]), + ?assertEqual(<<"[FILTERED]">>, maps:get(<<"password_hash">>, Result)). + +leaves_non_matching_keys_test() -> + Params = #{<<"email">> => <<"a@b.com">>, <<"name">> => <<"alice">>}, + Result = nova_log_filter:redact_params(Params, [<<"password">>]), + ?assertEqual(Params, Result). + +%%==================================================================== +%% filter/2 (OTP logger callback) +%%==================================================================== + +filter_redacts_report_test() -> + meck:new(nova, [passthrough]), + try + meck:expect(nova, get_env, + fun(filter_parameters, _Default) -> [<<"password">>] end), + Event = #{level => info, + msg => {report, #{password => <<"s3cret">>, user => <<"bob">>}}, + meta => #{}}, + #{msg := {report, Filtered}} = nova_log_filter:filter(Event, #{}), + ?assertEqual(<<"[FILTERED]">>, maps:get(password, Filtered)), + ?assertEqual(<<"bob">>, maps:get(user, Filtered)) + after + meck:unload(nova) + end. + +filter_passes_non_report_test() -> + Event = #{level => info, msg => {string, "hello"}, meta => #{}}, + ?assertEqual(Event, nova_log_filter:filter(Event, #{})). From 3207a1a1a59122aad595c673c0f2c88af90e212b Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Thu, 12 Mar 2026 10:01:36 +0100 Subject: [PATCH 08/11] fix: remove |safe filter from error template to prevent XSS The extra_msg variable in nova_error.dtl was rendered with |safe, bypassing erlydtl's auto-escaping. While the content is from internal crash info, this is a defense-in-depth fix. Changed the error controller to use newlines instead of
HTML tags. Co-Authored-By: Claude Opus 4.6 --- src/controllers/nova_error_controller.erl | 2 +- src/views/nova_error.dtl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controllers/nova_error_controller.erl b/src/controllers/nova_error_controller.erl index f38598d..895df5c 100644 --- a/src/controllers/nova_error_controller.erl +++ b/src/controllers/nova_error_controller.erl @@ -64,7 +64,7 @@ server_error(#{crash_info := #{class := Class, reason := Reason}} = Req) -> Variables = #{status => "Internal Server Error", title => "500 Internal Server Error", message => "Something internal crashed. Please take a look!", - extra_msg => io_lib:format("Class: ~p
Reason: ~p", [Class, Reason]), + extra_msg => io_lib:format("Class: ~p~nReason: ~p", [Class, Reason]), stacktrace => format_stacktrace(Stacktrace)}, case nova:get_environment() of diff --git a/src/views/nova_error.dtl b/src/views/nova_error.dtl index c46f438..7770376 100644 --- a/src/views/nova_error.dtl +++ b/src/views/nova_error.dtl @@ -64,7 +64,7 @@ {{ message }} {% if extra_msg %}
-          {{ extra_msg|safe }}
+          {{ extra_msg }}
           {% if stacktrace %}
           
             {% for s in stacktrace %}

From 1b16ad5efcd11546282b892fe15f587bcaee82b7 Mon Sep 17 00:00:00 2001
From: Daniel Widgren 
Date: Thu, 12 Mar 2026 10:07:46 +0100
Subject: [PATCH 09/11] fix: prevent path traversal in nova_file_controller

Reject path segments containing ".." or "." in wildcard pathinfo
before joining with the static directory path. Without this check,
a request like /static/../../../etc/passwd could escape the
configured static directory.

Co-Authored-By: Claude Opus 4.6 
---
 src/controllers/nova_file_controller.erl | 27 +++++++++++++-----------
 1 file changed, 15 insertions(+), 12 deletions(-)

diff --git a/src/controllers/nova_file_controller.erl b/src/controllers/nova_file_controller.erl
index 4b91e0c..5e30c2a 100644
--- a/src/controllers/nova_file_controller.erl
+++ b/src/controllers/nova_file_controller.erl
@@ -48,20 +48,23 @@ get_file(_Req) ->
 get_dir(#{extra_state := #{pathinfo := Pathinfo, static := Dir, options := Options}} = Req) ->
     %% This case will be invoked if a directory was set with wildcard - pathinfo will then
     %% contain the segments of the wildcard value
-    Filepath = get_filepath(Dir),
-    Filepath0 = lists:foldl(fun(F, Acc) -> filename:join(Acc, binary_to_list(F)) end, Filepath, Pathinfo),
-    case filelib:is_dir(Filepath0) of
+    case lists:any(fun(Seg) -> Seg =:= <<"..">> orelse Seg =:= <<".">> end, Pathinfo) of
+        true ->
+            {status, 400};
         false ->
-            %% Check if it's a file
-            case filelib:is_file(Filepath0) of
-                true ->
-                    %% It's a file
-                    get_file(Req#{extra_state => #{static => {file, Filepath0}, options => Options}});
+            Filepath = get_filepath(Dir),
+            Filepath0 = lists:foldl(fun(F, Acc) -> filename:join(Acc, binary_to_list(F)) end, Filepath, Pathinfo),
+            case filelib:is_dir(Filepath0) of
                 false ->
-                    {status, 404}
-            end;
-        true ->
-            get_dir(Req#{extra_state => #{static => {dir, Filepath0}, options => Options}})
+                    case filelib:is_file(Filepath0) of
+                        true ->
+                            get_file(Req#{extra_state => #{static => {file, Filepath0}, options => Options}});
+                        false ->
+                            {status, 404}
+                    end;
+                true ->
+                    get_dir(Req#{extra_state => #{static => {dir, Filepath0}, options => Options}})
+            end
     end;
 get_dir(#{path := Path, extra_state := #{static := Dir, options := Options}} = Req) ->
     Filepath = get_filepath(Dir),

From cb9d17157d185c4704a360f970ff7d8b5e6b42be Mon Sep 17 00:00:00 2001
From: Daniel Widgren 
Date: Thu, 12 Mar 2026 10:33:21 +0100
Subject: [PATCH 10/11] chore: add erl_crash.dump to .gitignore

Co-Authored-By: Claude Opus 4.6 
---
 .gitignore | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.gitignore b/.gitignore
index 13b8087..f9991a2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
 _build/
 ebin/
 rebar3.crashdump
+erl_crash.dump
 doc/
\ No newline at end of file

From df44bcc04e0916249599e7e865f886055316e6d8 Mon Sep 17 00:00:00 2001
From: Daniel Widgren 
Date: Thu, 12 Mar 2026 10:40:30 +0100
Subject: [PATCH 11/11] docs: add comprehensive security guide

Covers sessions, CSRF, HTTP headers, TLS, rate limiting, CORS, XSS,
SQL injection, authorization, logging, and BEAM-specific concerns.
Includes recommended plugin stack for production deployments and
links to ERLEF, Phoenix, and OWASP references.

Co-Authored-By: Claude Opus 4.6 
---
 guides/security.md | 400 +++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 400 insertions(+)
 create mode 100644 guides/security.md

diff --git a/guides/security.md b/guides/security.md
new file mode 100644
index 0000000..ceb8a4a
--- /dev/null
+++ b/guides/security.md
@@ -0,0 +1,400 @@
+# Security
+
+This guide covers security best practices for Nova applications, based on the [ERLEF Web App Security Best Practices](https://security.erlef.org/web_app_security_best_practices_beam/) and [Phoenix security recommendations](https://hexdocs.pm/phoenix/security.html).
+
+## Session Security
+
+Nova stores sessions server-side in ETS by default (not in cookies), which is already more secure than client-side cookie storage. Session cookies are set with security attributes:
+
+- **HttpOnly** — prevents JavaScript from reading the session cookie (XSS mitigation)
+- **Secure** — cookie only sent over HTTPS
+- **SameSite=Lax** — additional CSRF protection
+- **Path=/** — cookie scoped to entire site
+
+These can be customized in your *sys.config*:
+
+```erlang
+{nova, [
+    {session_cookie_opts, #{secure => false}}  %% For local development over HTTP
+]}
+```
+
+### Session Timeouts
+
+Sessions have configurable maximum age and idle timeout:
+
+```erlang
+{nova, [
+    {session_max_age, 86400},      %% Absolute max age in seconds (default: 24 hours)
+    {session_idle_timeout, 3600}   %% Idle timeout in seconds (default: 1 hour)
+]}
+```
+
+Expired sessions are automatically cleaned up periodically.
+
+### Session Rotation
+
+To prevent session fixation attacks, rotate the session ID when a user's privilege level changes (e.g. after login):
+
+```erlang
+login(Req) ->
+    %% ... validate credentials ...
+    {ok, Req1} = nova_session:rotate(Req),
+    nova_session:set(Req1, <<"user_id">>, UserId),
+    {ok, #{}, Req1}.
+```
+
+This generates a new session ID, migrates session data, and sets a new cookie.
+
+## CSRF Protection
+
+Nova includes the `nova_csrf_plugin` which uses the synchronizer token pattern. Enable it in your plugin configuration:
+
+```erlang
+{pre_request, nova_csrf_plugin, #{}}
+```
+
+The plugin:
+- Generates a cryptographically random token per session
+- Validates tokens on state-changing requests (POST, PUT, PATCH, DELETE)
+- Skips safe methods (GET, HEAD, OPTIONS)
+- Uses constant-time comparison to prevent timing attacks
+
+Include the CSRF token in your forms:
+
+```html
+
+ + ... +
+``` + +Or send it via header for AJAX requests: + +```javascript +fetch('/api/data', { + method: 'POST', + headers: {'X-CSRF-Token': csrfToken} +}); +``` + +### Options + +```erlang +{pre_request, nova_csrf_plugin, #{ + field_name => <<"_csrf_token">>, %% Form field name + header_name => <<"x-csrf-token">>, %% Header name + session_key => <<"_csrf_token">>, %% Session storage key + excluded_paths => [<<"/api/webhooks">>] %% Paths to skip +}} +``` + +**Important:** Never allow state-changing operations via GET requests. + +## HTTP Security Headers + +The `nova_secure_headers_plugin` sets security headers on every response: + +```erlang +{pre_request, nova_secure_headers_plugin, #{}} +``` + +Default headers: + +| Header | Default Value | Purpose | +|--------|--------------|---------| +| `X-Frame-Options` | `DENY` | Prevents clickjacking | +| `X-Content-Type-Options` | `nosniff` | Prevents MIME sniffing | +| `X-XSS-Protection` | `1; mode=block` | Legacy XSS protection | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | Controls referrer information | +| `Permissions-Policy` | `geolocation=(), camera=(), microphone=()` | Restricts browser features | + +### HSTS (HTTP Strict Transport Security) + +Enable HSTS to prevent downgrade attacks: + +```erlang +{pre_request, nova_secure_headers_plugin, #{ + hsts => true, + hsts_max_age => 31536000, %% 1 year (default) + hsts_include_subdomains => true %% default +}} +``` + +### Content Security Policy + +Set a CSP to prevent XSS and data injection: + +```erlang +{pre_request, nova_secure_headers_plugin, #{ + csp => <<"default-src 'self'; script-src 'self'; style-src 'self'">> +}} +``` + +### Overriding Defaults + +```erlang +{pre_request, nova_secure_headers_plugin, #{ + headers => #{<<"x-frame-options">> => <<"SAMEORIGIN">>}, %% Override defaults + extra_headers => #{<<"x-custom">> => <<"value">>} %% Add new headers +}} +``` + +## TLS / HTTPS + +### Force SSL + +The `nova_force_ssl_plugin` redirects HTTP requests to HTTPS: + +```erlang +{pre_request, nova_force_ssl_plugin, #{ + excluded_paths => [<<"/health">>] %% Skip health check endpoints +}} +``` + +Use this together with HSTS for complete TLS enforcement. + +### SSL Configuration + +Configure SSL in your *sys.config*: + +```erlang +{nova, [ + {cowboy_configuration, #{ + ssl => #{ + port => 8443, + ssl_options => #{ + certfile => "/path/to/fullchain.pem", + keyfile => "/path/to/privkey.pem" + } + } + }} +]} +``` + +## Rate Limiting + +The `nova_rate_limit_plugin` protects against brute-force and DoS attacks: + +```erlang +{pre_request, nova_rate_limit_plugin, #{ + max_requests => 100, %% Per window (default) + window_ms => 60000, %% 1 minute (default) + paths => [<<"/api/login">>, <<"/api/register">>] %% Limit specific paths +}} +``` + +When the limit is exceeded, the plugin returns `429 Too Many Requests` with a `Retry-After` header. + +### Custom Rate Limit Keys + +By default, requests are tracked by client IP. Use a custom key function for other strategies: + +```erlang +{pre_request, nova_rate_limit_plugin, #{ + max_requests => 1000, + window_ms => 3600000, + key_fun => fun(#{headers := Headers}) -> + maps:get(<<"x-api-key">>, Headers, <<"anonymous">>) + end +}} +``` + +## CORS + +The `nova_cors_plugin` sets Access-Control headers with restrictive defaults: + +```erlang +{pre_request, nova_cors_plugin, #{ + allow_origins => <<"https://app.example.com">> +}} +``` + +Default values: +- `allow_headers`: `Content-Type, Authorization` +- `allow_methods`: `GET, POST, PUT, DELETE, OPTIONS` + +**Avoid** using wildcard `*` for origins in production. Be explicit: + +```erlang +{pre_request, nova_cors_plugin, #{ + allow_origins => <<"https://app.example.com">>, + allow_headers => <<"Content-Type, Authorization, X-Custom-Header">>, + allow_methods => <<"GET, POST">> +}} +``` + +## Cross-Site Scripting (XSS) + +Nova uses erlydtl (Django Template Language) which **auto-escapes variables by default**: + +```html +{{ user_input }} +{{ user_input|safe }} +``` + +**Never** use the `|safe` filter with user-supplied data. + +### Additional XSS Vectors + +- **`{html, Body}`** — If your controller returns raw HTML, ensure user input is escaped +- **JSON responses** — `json:encode/1` handles encoding safely +- **Content-Type headers** — Never let users control the `Content-Type` response header +- **File uploads** — Validate and restrict content types for uploaded files + +## SQL Injection + +If using Kura or parameterized queries, you're protected by default: + +```erlang +%% Safe — parameterized query +kura:all(users, #{where => #{email => UserInput}}). + +%% Safe — explicit parameters +Sql = "SELECT * FROM users WHERE email = $1", +pgo:query(Sql, [UserInput]). + +%% DANGEROUS — string interpolation +Sql = "SELECT * FROM users WHERE email = '" ++ UserInput ++ "'". +``` + +**Never** interpolate user input into SQL strings. + +## Authorization + +Nova provides `nova_security_handler` for per-route authorization. Always enforce authorization server-side: + +```erlang +%% In your router +{"/admin/users", {admin_controller, index}, #{secure => {auth_module, admin_check}}} +``` + +```erlang +%% auth_module.erl +admin_check(Req) -> + case nova_session:get(Req, <<"role">>) of + {ok, <<"admin">>} -> {true, #{role => admin}}; + _ -> {false, 403, [], <<"Forbidden">>} + end. +``` + +Never rely on client-side hiding for access control. Always validate on the server. + +## Logging + +### Parameter Filtering + +Use `nova_log_filter` to prevent sensitive data from appearing in logs: + +```erlang +%% sys.config — configure which parameters to filter +{nova, [ + {filter_parameters, [<<"password">>, <<"secret">>, <<"token">>, + <<"api_key">>, <<"credit_card">>]} +]} +``` + +Add the filter to your logger configuration: + +```erlang +{kernel, [{logger, [ + {handler, default, logger_std_h, #{ + filters => [{nova_param_filter, + {fun nova_log_filter:filter/2, #{}}}] + }} +]}]} +``` + +You can also use `nova_log_filter:redact_params/1` directly: + +```erlang +FilteredParams = nova_log_filter:redact_params(Params). +%% #{<<"username">> => <<"alice">>, <<"password">> => <<"[FILTERED]">>} +``` + +### Error Pages in Production + +Disable detailed error pages and stacktraces in production: + +```erlang +{nova, [ + {render_error_pages, false}, + {use_stacktrace, false} +]} +``` + +## BEAM-Specific Concerns + +### Atom Exhaustion + +Atoms in the BEAM are never garbage collected. Never create atoms from user input: + +```erlang +%% DANGEROUS — each unique input creates a permanent atom +binary_to_atom(UserInput). +list_to_atom(UserInput). + +%% Safe — only succeeds for existing atoms +binary_to_existing_atom(UserInput). +list_to_existing_atom(UserInput). +``` + +### Binary Deserialization + +Never deserialize untrusted binary data: + +```erlang +%% DANGEROUS — can execute arbitrary code +binary_to_term(UserInput). + +%% Slightly safer but still risky — prevents atom creation only +binary_to_term(UserInput, [safe]). +``` + +Use JSON or another safe format for data exchange with untrusted sources. + +### Remote Code Execution + +Never pass user input to: + +- `os:cmd/1` +- `:erlang.apply/3` with user-controlled module/function +- `code:load_binary/3` + +## Recommended Plugin Stack + +A secure plugin configuration for production: + +```erlang +{nova, [ + {plugins, [ + {pre_request, nova_force_ssl_plugin, #{ + excluded_paths => [<<"/health">>] + }}, + {pre_request, nova_secure_headers_plugin, #{ + hsts => true, + csp => <<"default-src 'self'">> + }}, + {pre_request, nova_rate_limit_plugin, #{ + max_requests => 100, + window_ms => 60000, + paths => [<<"/api/login">>, <<"/api/register">>] + }}, + {pre_request, nova_request_plugin, #{}}, + {pre_request, nova_csrf_plugin, #{ + excluded_paths => [<<"/api/webhooks">>] + }}, + {pre_request, nova_cors_plugin, #{ + allow_origins => <<"https://app.example.com">> + }} + ]} +]} +``` + +## Further Reading + +- [ERLEF Web Application Security Best Practices](https://security.erlef.org/web_app_security_best_practices_beam/) +- [ERLEF Secure Coding and Deployment Hardening Guidelines](https://security.erlef.org/) +- [Phoenix Security Guide](https://hexdocs.pm/phoenix/security.html) +- [OWASP Top 10](https://owasp.org/www-project-top-ten/) +- [Mozilla Server-Side TLS](https://wiki.mozilla.org/Security/Server_Side_TLS)