Skip to content

Commit 7486031

Browse files
Taureclaude
andauthored
fix: pass fork repo URL to nova_request_app CI (#363)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2547201 commit 7486031

File tree

11 files changed

+597
-9
lines changed

11 files changed

+597
-9
lines changed

.github/workflows/erlang.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,5 @@ jobs:
117117
uses: ./.github/workflows/run_nra.yml
118118
with:
119119
branch: ${{ github.head_ref || github.ref_name }}
120+
repo: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
120121
secrets: inherit

.github/workflows/run_nra.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,15 @@ on:
55
description: "Branch name"
66
required: true
77
type: string
8+
repo:
9+
description: "Repository full name (owner/repo)"
10+
required: false
11+
type: string
12+
default: "novaframework/nova"
813

914
jobs:
1015
run_nra:
1116
uses: novaframework/nova_request_app/.github/workflows/run_nra.yml@main
1217
with:
13-
nova_branch: "${{ inputs.branch }}"
18+
nova_branch: "${{ inputs.branch }}"
19+
nova_repo: "${{ inputs.repo }}"

rebar.config

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
{prod, [{relx, [{dev_mode, false}, {include_erts, true}]}]},
2323
{test, [
2424
{erl_opts, [debug_info, nowarn_export_all]},
25-
{deps, [{proper, "1.4.0"}]}
25+
{deps, [{proper, "1.4.0"}, {meck, "0.9.2"}]}
2626
]}
2727
]}.
2828

src/nova_basic_handler.erl

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212

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

15+
-ifdef(TEST).
16+
-export([maybe_inject_csrf_token/2]).
17+
-endif.
18+
1519
-type erlydtl_vars() :: map() | [{Key :: atom() | binary() | string(), Value :: any()}].
1620

1721

@@ -289,7 +293,8 @@ handle_ws(ok, State) ->
289293
%%%===================================================================
290294

291295
handle_view(View, Variables, Options, Req) ->
292-
{ok, HTML} = render_dtl(View, Variables, []),
296+
Variables1 = maybe_inject_csrf_token(Variables, Req),
297+
{ok, HTML} = render_dtl(View, Variables1, []),
293298
Headers =
294299
case maps:get(headers, Options, undefined) of
295300
undefined ->
@@ -319,6 +324,13 @@ render_dtl(View, Variables, Options) ->
319324
end.
320325

321326

327+
maybe_inject_csrf_token(Variables, #{csrf_token := Token}) when is_list(Variables) ->
328+
[{csrf_token, Token} | Variables];
329+
maybe_inject_csrf_token(Variables, #{csrf_token := Token}) when is_map(Variables) ->
330+
Variables#{csrf_token => Token};
331+
maybe_inject_csrf_token(Variables, _Req) ->
332+
Variables.
333+
322334
get_view_name({Mod, _Opts}) -> get_view_name(Mod);
323335
get_view_name(Mod) when is_atom(Mod) ->
324336
StrName = get_view_name(erlang:atom_to_list(Mod)),

src/nova_plugin.erl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
-module(nova_plugin).
2929

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

3333
-type reply() :: {reply, Body :: binary()} |
3434
{reply, Status :: integer(), Body :: binary()} |

src/nova_session.erl

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,16 @@ get_session_module() ->
119119
get_session_id(Req) ->
120120
case nova:get_env(use_sessions, true) of
121121
true ->
122-
#{session_id := SessionId} = cowboy_req:match_cookies([{session_id, [], undefined}], Req),
123-
case SessionId of
122+
case maps:get(nova_session_id, Req, undefined) of
124123
undefined ->
125-
{error, not_found};
126-
_ ->
124+
#{session_id := SessionId} = cowboy_req:match_cookies([{session_id, [], undefined}], Req),
125+
case SessionId of
126+
undefined ->
127+
{error, not_found};
128+
_ ->
129+
{ok, SessionId}
130+
end;
131+
SessionId ->
127132
{ok, SessionId}
128133
end;
129134
_ ->

src/nova_stream_h.erl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ init(StreamID, Req, Opts) ->
2727
{_, _} -> Req;
2828
_ ->
2929
{ok, SessionId} = nova_session:generate_session_id(),
30-
cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req)
30+
ReqWithCookie = cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req),
31+
ReqWithCookie#{nova_session_id => SessionId}
3132
end;
3233
_ ->
3334
Req

src/plugins/nova_csrf_plugin.erl

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
%% @doc CSRF protection plugin for Nova using the synchronizer token pattern.
2+
%%
3+
%% Generates a random token per session, stores it server-side, and validates
4+
%% it on state-changing requests (POST, PUT, PATCH, DELETE).
5+
%%
6+
%% <b>Important:</b> `nova_request_plugin' must run before this plugin so that
7+
%% form params are parsed into the `params' key of the request map.
8+
%%
9+
%% == Options ==
10+
%% <ul>
11+
%% <li>`field_name' — form field name (default `<<"_csrf_token">>')</li>
12+
%% <li>`header_name' — header name (default `<<"x-csrf-token">>')</li>
13+
%% <li>`session_key' — session storage key (default `<<"_csrf_token">>')</li>
14+
%% <li>`excluded_paths' — list of path prefixes to skip (default `[]')</li>
15+
%% </ul>
16+
-module(nova_csrf_plugin).
17+
-behaviour(nova_plugin).
18+
19+
-export([
20+
pre_request/4,
21+
post_request/4,
22+
plugin_info/0
23+
]).
24+
25+
-ifdef(TEST).
26+
-export([
27+
generate_token/0,
28+
is_safe_method/1,
29+
is_excluded_path/2,
30+
get_submitted_token/3,
31+
constant_time_compare/2
32+
]).
33+
-endif.
34+
35+
%%--------------------------------------------------------------------
36+
%% @doc
37+
%% Pre-request callback. On safe methods, ensures a CSRF token exists
38+
%% in the session and injects it into the Req map. On unsafe methods,
39+
%% validates the submitted token against the session token.
40+
%% @end
41+
%%--------------------------------------------------------------------
42+
-spec pre_request(Req :: cowboy_req:req(), Env :: any(), Options :: map(), State :: any()) ->
43+
{ok, Req0 :: cowboy_req:req(), NewState :: any()} |
44+
{stop, nova_plugin:reply(), Req0 :: cowboy_req:req(), NewState :: any()}.
45+
pre_request(Req = #{method := Method, path := Path}, _Env, Options, State) ->
46+
FieldName = maps:get(field_name, Options, <<"_csrf_token">>),
47+
HeaderName = maps:get(header_name, Options, <<"x-csrf-token">>),
48+
SessionKey = maps:get(session_key, Options, <<"_csrf_token">>),
49+
ExcludedPaths = maps:get(excluded_paths, Options, []),
50+
case is_safe_method(Method) orelse is_excluded_path(Path, ExcludedPaths) of
51+
true ->
52+
handle_safe_request(Req, SessionKey, State);
53+
false ->
54+
handle_unsafe_request(Req, SessionKey, FieldName, HeaderName, State)
55+
end.
56+
57+
%%--------------------------------------------------------------------
58+
%% @doc
59+
%% Post-request callback. Pass-through.
60+
%% @end
61+
%%--------------------------------------------------------------------
62+
-spec post_request(Req :: cowboy_req:req(), Env :: any(), Options :: map(), State :: any()) ->
63+
{ok, Req0 :: cowboy_req:req(), NewState :: any()}.
64+
post_request(Req, _Env, _Options, State) ->
65+
{ok, Req, State}.
66+
67+
%%--------------------------------------------------------------------
68+
%% @doc
69+
%% Plugin info callback.
70+
%% @end
71+
%%--------------------------------------------------------------------
72+
-spec plugin_info() -> #{title := binary(),
73+
version := binary(),
74+
url := binary(),
75+
authors := [binary()],
76+
description := binary(),
77+
options := [{Key :: atom(), OptionDescription :: binary()}]}.
78+
plugin_info() ->
79+
#{title => <<"Nova CSRF Plugin">>,
80+
version => <<"0.1.0">>,
81+
url => <<"https://github.com/novaframework/nova">>,
82+
authors => [<<"Nova team <info@novaframework.org">>],
83+
description => <<"CSRF protection using synchronizer token pattern.">>,
84+
options => [
85+
{field_name, <<"Form field name for CSRF token (default: _csrf_token)">>},
86+
{header_name, <<"Header name for CSRF token (default: x-csrf-token)">>},
87+
{session_key, <<"Session key for CSRF token (default: _csrf_token)">>},
88+
{excluded_paths, <<"List of path prefixes to exclude from CSRF protection">>}
89+
]}.
90+
91+
%%%%%%%%%%%%%%%%%%%%%%
92+
%% Private functions
93+
%%%%%%%%%%%%%%%%%%%%%%
94+
95+
handle_safe_request(Req, SessionKey, State) ->
96+
case nova_session:get(Req, SessionKey) of
97+
{ok, Token} ->
98+
{ok, Req#{csrf_token => Token}, State};
99+
{error, _} ->
100+
%% No session yet (first visit) — generate token and store it
101+
Token = generate_token(),
102+
case nova_session:set(Req, SessionKey, Token) of
103+
ok ->
104+
{ok, Req#{csrf_token => Token}, State};
105+
{error, _} ->
106+
%% Session not established yet (no cookie), proceed without token
107+
{ok, Req, State}
108+
end
109+
end.
110+
111+
handle_unsafe_request(Req, SessionKey, FieldName, HeaderName, State) ->
112+
case nova_session:get(Req, SessionKey) of
113+
{ok, SessionToken} ->
114+
SubmittedToken = get_submitted_token(Req, FieldName, HeaderName),
115+
case constant_time_compare(SessionToken, SubmittedToken) of
116+
true ->
117+
{ok, Req#{csrf_token => SessionToken}, State};
118+
false ->
119+
reject(Req, State)
120+
end;
121+
{error, _} ->
122+
reject(Req, State)
123+
end.
124+
125+
reject(Req, State) ->
126+
{stop, {reply, 403, [{<<"content-type">>, <<"text/plain">>}], <<"Forbidden - CSRF token invalid">>}, Req, State}.
127+
128+
generate_token() ->
129+
base64:encode(crypto:strong_rand_bytes(32)).
130+
131+
is_safe_method(<<"GET">>) -> true;
132+
is_safe_method(<<"HEAD">>) -> true;
133+
is_safe_method(<<"OPTIONS">>) -> true;
134+
is_safe_method(_) -> false.
135+
136+
is_excluded_path(_Path, []) ->
137+
false;
138+
is_excluded_path(Path, [Prefix | Rest]) ->
139+
case binary:match(Path, Prefix) of
140+
{0, _} -> true;
141+
_ -> is_excluded_path(Path, Rest)
142+
end.
143+
144+
get_submitted_token(Req, FieldName, HeaderName) ->
145+
case cowboy_req:header(HeaderName, Req) of
146+
undefined ->
147+
case Req of
148+
#{params := Params} when is_map(Params) ->
149+
maps:get(FieldName, Params, undefined);
150+
_ ->
151+
undefined
152+
end;
153+
HeaderValue ->
154+
HeaderValue
155+
end.
156+
157+
constant_time_compare(A, B) when is_binary(A), is_binary(B), byte_size(A) =:= byte_size(B) ->
158+
crypto:hash_equals(A, B);
159+
constant_time_compare(_, _) ->
160+
false.

test/nova_basic_handler_test.erl

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
-module(nova_basic_handler_test).
2+
-include_lib("eunit/include/eunit.hrl").
3+
4+
%%====================================================================
5+
%% maybe_inject_csrf_token/2 tests
6+
%%====================================================================
7+
8+
inject_token_into_proplist_test() ->
9+
Vars = [{title, <<"Home">>}],
10+
Req = #{csrf_token => <<"tok123">>},
11+
Result = nova_basic_handler:maybe_inject_csrf_token(Vars, Req),
12+
?assertEqual(<<"tok123">>, proplists:get_value(csrf_token, Result)).
13+
14+
inject_token_into_proplist_preserves_existing_test() ->
15+
Vars = [{title, <<"Home">>}, {user, <<"alice">>}],
16+
Req = #{csrf_token => <<"tok123">>},
17+
Result = nova_basic_handler:maybe_inject_csrf_token(Vars, Req),
18+
?assertEqual(<<"Home">>, proplists:get_value(title, Result)),
19+
?assertEqual(<<"alice">>, proplists:get_value(user, Result)).
20+
21+
inject_token_into_map_test() ->
22+
Vars = #{title => <<"Home">>},
23+
Req = #{csrf_token => <<"tok123">>},
24+
Result = nova_basic_handler:maybe_inject_csrf_token(Vars, Req),
25+
?assertEqual(<<"tok123">>, maps:get(csrf_token, Result)).
26+
27+
inject_token_into_map_preserves_existing_test() ->
28+
Vars = #{title => <<"Home">>, user => <<"alice">>},
29+
Req = #{csrf_token => <<"tok123">>},
30+
Result = nova_basic_handler:maybe_inject_csrf_token(Vars, Req),
31+
?assertEqual(<<"Home">>, maps:get(title, Result)),
32+
?assertEqual(<<"alice">>, maps:get(user, Result)).
33+
34+
no_token_in_req_leaves_proplist_unchanged_test() ->
35+
Vars = [{title, <<"Home">>}],
36+
Req = #{method => <<"GET">>},
37+
Result = nova_basic_handler:maybe_inject_csrf_token(Vars, Req),
38+
?assertEqual(Vars, Result).
39+
40+
no_token_in_req_leaves_map_unchanged_test() ->
41+
Vars = #{title => <<"Home">>},
42+
Req = #{method => <<"GET">>},
43+
Result = nova_basic_handler:maybe_inject_csrf_token(Vars, Req),
44+
?assertEqual(Vars, Result).
45+
46+
inject_token_into_empty_proplist_test() ->
47+
Result = nova_basic_handler:maybe_inject_csrf_token([], #{csrf_token => <<"tok">>}),
48+
?assertEqual([{csrf_token, <<"tok">>}], Result).
49+
50+
inject_token_into_empty_map_test() ->
51+
Result = nova_basic_handler:maybe_inject_csrf_token(#{}, #{csrf_token => <<"tok">>}),
52+
?assertEqual(#{csrf_token => <<"tok">>}, Result).

0 commit comments

Comments
 (0)