|
| 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. |
0 commit comments