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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/erlang.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,5 @@ jobs:
uses: ./.github/workflows/run_nra.yml
with:
branch: ${{ github.head_ref || github.ref_name }}
repo: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
secrets: inherit
8 changes: 7 additions & 1 deletion .github/workflows/run_nra.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@ on:
description: "Branch name"
required: true
type: string
repo:
description: "Repository full name (owner/repo)"
required: false
type: string
default: "novaframework/nova"

jobs:
run_nra:
uses: novaframework/nova_request_app/.github/workflows/run_nra.yml@main
with:
nova_branch: "${{ inputs.branch }}"
nova_branch: "${{ inputs.branch }}"
nova_repo: "${{ inputs.repo }}"
2 changes: 1 addition & 1 deletion rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
{prod, [{relx, [{dev_mode, false}, {include_erts, true}]}]},
{test, [
{erl_opts, [debug_info, nowarn_export_all]},
{deps, [{proper, "1.4.0"}]}
{deps, [{proper, "1.4.0"}, {meck, "0.9.2"}]}
]}
]}.

Expand Down
14 changes: 13 additions & 1 deletion src/nova_basic_handler.erl
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@

-include_lib("kernel/include/logger.hrl").

-ifdef(TEST).
-export([maybe_inject_csrf_token/2]).
-endif.

-type erlydtl_vars() :: map() | [{Key :: atom() | binary() | string(), Value :: any()}].


Expand Down Expand Up @@ -289,7 +293,8 @@ handle_ws(ok, State) ->
%%%===================================================================

handle_view(View, Variables, Options, Req) ->
{ok, HTML} = render_dtl(View, Variables, []),
Variables1 = maybe_inject_csrf_token(Variables, Req),
{ok, HTML} = render_dtl(View, Variables1, []),
Headers =
case maps:get(headers, Options, undefined) of
undefined ->
Expand Down Expand Up @@ -319,6 +324,13 @@ render_dtl(View, Variables, Options) ->
end.


maybe_inject_csrf_token(Variables, #{csrf_token := Token}) when is_list(Variables) ->
[{csrf_token, Token} | Variables];
maybe_inject_csrf_token(Variables, #{csrf_token := Token}) when is_map(Variables) ->
Variables#{csrf_token => Token};
maybe_inject_csrf_token(Variables, _Req) ->
Variables.

get_view_name({Mod, _Opts}) -> get_view_name(Mod);
get_view_name(Mod) when is_atom(Mod) ->
StrName = get_view_name(erlang:atom_to_list(Mod)),
Expand Down
2 changes: 1 addition & 1 deletion src/nova_plugin.erl
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
-module(nova_plugin).

-type request_type() :: pre_request | post_request.
-export_type([request_type/0]).
-export_type([request_type/0, reply/0]).

-type reply() :: {reply, Body :: binary()} |
{reply, Status :: integer(), Body :: binary()} |
Expand Down
13 changes: 9 additions & 4 deletions src/nova_session.erl
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,16 @@ get_session_module() ->
get_session_id(Req) ->
case nova:get_env(use_sessions, true) of
true ->
#{session_id := SessionId} = cowboy_req:match_cookies([{session_id, [], undefined}], Req),
case SessionId of
case maps:get(nova_session_id, Req, undefined) of
undefined ->
{error, not_found};
_ ->
#{session_id := SessionId} = cowboy_req:match_cookies([{session_id, [], undefined}], Req),
case SessionId of
undefined ->
{error, not_found};
_ ->
{ok, SessionId}
end;
SessionId ->
{ok, SessionId}
end;
_ ->
Expand Down
3 changes: 2 additions & 1 deletion src/nova_stream_h.erl
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ init(StreamID, Req, Opts) ->
{_, _} -> Req;
_ ->
{ok, SessionId} = nova_session:generate_session_id(),
cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req)
ReqWithCookie = cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req),
ReqWithCookie#{nova_session_id => SessionId}
end;
_ ->
Req
Expand Down
160 changes: 160 additions & 0 deletions src/plugins/nova_csrf_plugin.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
%% @doc CSRF protection plugin for Nova using the synchronizer token pattern.
%%
%% Generates a random token per session, stores it server-side, and validates
%% it on state-changing requests (POST, PUT, PATCH, DELETE).
%%
%% <b>Important:</b> `nova_request_plugin' must run before this plugin so that
%% form params are parsed into the `params' key of the request map.
%%
%% == Options ==
%% <ul>
%% <li>`field_name' — form field name (default `<<"_csrf_token">>')</li>
%% <li>`header_name' — header name (default `<<"x-csrf-token">>')</li>
%% <li>`session_key' — session storage key (default `<<"_csrf_token">>')</li>
%% <li>`excluded_paths' — list of path prefixes to skip (default `[]')</li>
%% </ul>
-module(nova_csrf_plugin).
-behaviour(nova_plugin).

-export([
pre_request/4,
post_request/4,
plugin_info/0
]).

-ifdef(TEST).
-export([
generate_token/0,
is_safe_method/1,
is_excluded_path/2,
get_submitted_token/3,
constant_time_compare/2
]).
-endif.

%%--------------------------------------------------------------------
%% @doc
%% Pre-request callback. On safe methods, ensures a CSRF token exists
%% in the session and injects it into the Req map. On unsafe methods,
%% validates the submitted token against the session token.
%% @end
%%--------------------------------------------------------------------
-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 = #{method := Method, path := Path}, _Env, Options, State) ->
FieldName = maps:get(field_name, Options, <<"_csrf_token">>),
HeaderName = maps:get(header_name, Options, <<"x-csrf-token">>),
SessionKey = maps:get(session_key, Options, <<"_csrf_token">>),
ExcludedPaths = maps:get(excluded_paths, Options, []),
case is_safe_method(Method) orelse is_excluded_path(Path, ExcludedPaths) of
true ->
handle_safe_request(Req, SessionKey, State);
false ->
handle_unsafe_request(Req, SessionKey, FieldName, HeaderName, State)
end.

%%--------------------------------------------------------------------
%% @doc
%% Post-request callback. Pass-through.
%% @end
%%--------------------------------------------------------------------
-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}.

%%--------------------------------------------------------------------
%% @doc
%% Plugin info callback.
%% @end
%%--------------------------------------------------------------------
-spec plugin_info() -> #{title := binary(),
version := binary(),
url := binary(),
authors := [binary()],
description := binary(),
options := [{Key :: atom(), OptionDescription :: binary()}]}.
plugin_info() ->
#{title => <<"Nova CSRF Plugin">>,
version => <<"0.1.0">>,
url => <<"https://github.com/novaframework/nova">>,
authors => [<<"Nova team <info@novaframework.org">>],
description => <<"CSRF protection using synchronizer token pattern.">>,
options => [
{field_name, <<"Form field name for CSRF token (default: _csrf_token)">>},
{header_name, <<"Header name for CSRF token (default: x-csrf-token)">>},
{session_key, <<"Session key for CSRF token (default: _csrf_token)">>},
{excluded_paths, <<"List of path prefixes to exclude from CSRF protection">>}
]}.

%%%%%%%%%%%%%%%%%%%%%%
%% Private functions
%%%%%%%%%%%%%%%%%%%%%%

handle_safe_request(Req, SessionKey, State) ->
case nova_session:get(Req, SessionKey) of
{ok, Token} ->
{ok, Req#{csrf_token => Token}, State};
{error, _} ->
%% No session yet (first visit) — generate token and store it
Token = generate_token(),
case nova_session:set(Req, SessionKey, Token) of
ok ->
{ok, Req#{csrf_token => Token}, State};
{error, _} ->
%% Session not established yet (no cookie), proceed without token
{ok, Req, State}
end
end.

handle_unsafe_request(Req, SessionKey, FieldName, HeaderName, State) ->
case nova_session:get(Req, SessionKey) of
{ok, SessionToken} ->
SubmittedToken = get_submitted_token(Req, FieldName, HeaderName),
case constant_time_compare(SessionToken, SubmittedToken) of
true ->
{ok, Req#{csrf_token => SessionToken}, State};
false ->
reject(Req, State)
end;
{error, _} ->
reject(Req, State)
end.

reject(Req, State) ->
{stop, {reply, 403, [{<<"content-type">>, <<"text/plain">>}], <<"Forbidden - CSRF token invalid">>}, Req, State}.

generate_token() ->
base64:encode(crypto:strong_rand_bytes(32)).

is_safe_method(<<"GET">>) -> true;
is_safe_method(<<"HEAD">>) -> true;
is_safe_method(<<"OPTIONS">>) -> true;
is_safe_method(_) -> false.

is_excluded_path(_Path, []) ->
false;
is_excluded_path(Path, [Prefix | Rest]) ->
case binary:match(Path, Prefix) of
{0, _} -> true;
_ -> is_excluded_path(Path, Rest)
end.

get_submitted_token(Req, FieldName, HeaderName) ->
case cowboy_req:header(HeaderName, Req) of
undefined ->
case Req of
#{params := Params} when is_map(Params) ->
maps:get(FieldName, Params, undefined);
_ ->
undefined
end;
HeaderValue ->
HeaderValue
end.

constant_time_compare(A, B) when is_binary(A), is_binary(B), byte_size(A) =:= byte_size(B) ->
crypto:hash_equals(A, B);
constant_time_compare(_, _) ->
false.
52 changes: 52 additions & 0 deletions test/nova_basic_handler_test.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
-module(nova_basic_handler_test).
-include_lib("eunit/include/eunit.hrl").

%%====================================================================
%% maybe_inject_csrf_token/2 tests
%%====================================================================

inject_token_into_proplist_test() ->
Vars = [{title, <<"Home">>}],
Req = #{csrf_token => <<"tok123">>},
Result = nova_basic_handler:maybe_inject_csrf_token(Vars, Req),
?assertEqual(<<"tok123">>, proplists:get_value(csrf_token, Result)).

inject_token_into_proplist_preserves_existing_test() ->
Vars = [{title, <<"Home">>}, {user, <<"alice">>}],
Req = #{csrf_token => <<"tok123">>},
Result = nova_basic_handler:maybe_inject_csrf_token(Vars, Req),
?assertEqual(<<"Home">>, proplists:get_value(title, Result)),
?assertEqual(<<"alice">>, proplists:get_value(user, Result)).

inject_token_into_map_test() ->
Vars = #{title => <<"Home">>},
Req = #{csrf_token => <<"tok123">>},
Result = nova_basic_handler:maybe_inject_csrf_token(Vars, Req),
?assertEqual(<<"tok123">>, maps:get(csrf_token, Result)).

inject_token_into_map_preserves_existing_test() ->
Vars = #{title => <<"Home">>, user => <<"alice">>},
Req = #{csrf_token => <<"tok123">>},
Result = nova_basic_handler:maybe_inject_csrf_token(Vars, Req),
?assertEqual(<<"Home">>, maps:get(title, Result)),
?assertEqual(<<"alice">>, maps:get(user, Result)).

no_token_in_req_leaves_proplist_unchanged_test() ->
Vars = [{title, <<"Home">>}],
Req = #{method => <<"GET">>},
Result = nova_basic_handler:maybe_inject_csrf_token(Vars, Req),
?assertEqual(Vars, Result).

no_token_in_req_leaves_map_unchanged_test() ->
Vars = #{title => <<"Home">>},
Req = #{method => <<"GET">>},
Result = nova_basic_handler:maybe_inject_csrf_token(Vars, Req),
?assertEqual(Vars, Result).

inject_token_into_empty_proplist_test() ->
Result = nova_basic_handler:maybe_inject_csrf_token([], #{csrf_token => <<"tok">>}),
?assertEqual([{csrf_token, <<"tok">>}], Result).

inject_token_into_empty_map_test() ->
Result = nova_basic_handler:maybe_inject_csrf_token(#{}, #{csrf_token => <<"tok">>}),
?assertEqual(#{csrf_token => <<"tok">>}, Result).
Loading