diff --git a/.gitignore b/.gitignore index 13b80875..f9991a24 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ _build/ ebin/ rebar3.crashdump +erl_crash.dump doc/ \ No newline at end of file diff --git a/guides/security.md b/guides/security.md new file mode 100644 index 00000000..ceb8a4a9 --- /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) diff --git a/src/controllers/nova_error_controller.erl b/src/controllers/nova_error_controller.erl index f38598de..895df5ca 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
- {{ extra_msg|safe }}
+ {{ extra_msg }}
{% if stacktrace %}
{% for s in stacktrace %}
diff --git a/test/nova_cors_plugin_tests.erl b/test/nova_cors_plugin_tests.erl
index caf4d0d1..4b1b62b0 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">>),
diff --git a/test/nova_force_ssl_plugin_tests.erl b/test/nova_force_ssl_plugin_tests.erl
new file mode 100644
index 00000000..a78c4749
--- /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}.
diff --git a/test/nova_log_filter_tests.erl b/test/nova_log_filter_tests.erl
new file mode 100644
index 00000000..84232eac
--- /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, #{})).
diff --git a/test/nova_rate_limit_plugin_tests.erl b/test/nova_rate_limit_plugin_tests.erl
new file mode 100644
index 00000000..b6084ee1
--- /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}.
diff --git a/test/nova_secure_headers_plugin_tests.erl b/test/nova_secure_headers_plugin_tests.erl
new file mode 100644
index 00000000..fdb01f62
--- /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))).
diff --git a/test/nova_session_test.erl b/test/nova_session_test.erl
index f9b03bf9..32021944 100644
--- a/test/nova_session_test.erl
+++ b/test/nova_session_test.erl
@@ -84,6 +84,76 @@ 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.
+
+%%====================================================================
+%% 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
%%====================================================================
@@ -92,7 +162,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() ->