fix: strengthen API CSRF protection contract #32
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/w15-api-csrf-contract"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes W15 from bugs.md.
ApiCSRFno longer accepts a bareX-Requested-With: XMLHttpRequestheader as sufficient for mutating requests — only anx-csrf-tokenthat validates against the session token viaPlug.CSRFProtectionpasses. The header carries no session binding and would have been a silent bypass on the first mutating API endpoint; since every API route is GET-only today, nothing breaks. Tests updated to assert the header alone now gets 403.Verification: focused plug tests passed (11), full suite passed (2235),
mix credo --strict, and commit hooks including Dialyzer passed.🤖 Skippy PR review
3 findings — 2 blocking before merge.
lib/aprsme_web/plugs/api_csrf.ex:33test/aprsme_web/plugs/api_csrf_test.exs:15lib/aprsme_web/plugs/api_csrf.ex:13Reviewed
72401e054f8b. Commentskippy reviewto re-run.@ -4,0 +10,4 @@sufficient. Custom headers do block simple cross-site form posts, but theheader carries no session binding: anything that can script the origin(or a future permissive CORS response) would satisfy it. Since every APIroute is currently GET-only, requiring a real token breaks no caller and🔵 Suggestion — API docs still advertise the header this PR now rejects
docs/web/api-rest.md:46-52says API requests must includeX-Requested-With: XMLHttpRequestor a valid CSRF token; after this change the first option is a hard 403. Update that section in the same PR — and note it can no longer promise 'valid CSRF token' for a session-less API client.@ -26,1 +31,3 @@conncase get_req_header(conn, "x-csrf-token") do[token] when byte_size(token) > 0 ->validate_csrf_token(conn, token)🟠 High — The token path can never pass: the :api pipeline never fetches the session
pipeline :api(router.ex:43-47) is:accepts+RateLimiter+ApiCSRF— noplug :fetch_session. The endpoint'splug Plug.Sessiononly registers a fetch function;conn.private[:plug_session]is populated byfetch_session/2alone. Soget_session(conn, "_csrf_token")at line 41 raisesArgumentError "session not fetched, call fetch_session/2", the blanketrescue _ ->at lines 52-54 swallows it, and every non-GET request to/api/v1/*gets 403 — valid token or not. Fail-closed, so nothing is exploitable today, but the 'real token' contract this PR advertises is unreachable, and the first mutating endpoint 403s every legitimate caller with a token that is correct. Fix by addingplug :fetch_sessionto the:apipipeline ahead ofApiCSRF(and giving token holders a way to obtain one), or — if the JSON API is meant to be session-less — drop the session-bound scheme here instead of shipping a guard that can only deny.@ -13,3 +13,3 @@|> ApiCSRF.call([])refute conn.haltedassert conn.halted🟡 Warning — Accept path now has no real coverage — a reject-everything plug stays green
This PR converts the only test that exercised acceptance (
refute conn.halted) into a 403 assertion, and the remaining "allows JSON request with valid CSRF token" test asserts the tautologyconn.halted in [true, false]— so the suite passes even if the plug rejects 100% of valid requests (which is what the:apipipeline does; see the other finding). That test can't pass as written anyway: it putsget_csrf_token/0into the session, butvalid_state_and_csrf_token?/2requires the 24-byte state, and the masked client token is 56 bytes, so thewithfalls through tofalse. Pair them correctly:and assert
refute conn.halted.Resolved all 3 earlier findings at
5abd3d29::apipipeline now runsplug :fetch_sessionahead ofApiCSRF(router.ex:50), so the token comparison no longer raises and a correct token can pass.api_csrf_test.exs:110pairsdump_state/0withget_csrf_token/0and assertsrefute conn.halted, which matches Plug 1.20.3's 24-byte state / 56-byte masked-token split.docs/web/api-rest.mdnow states the token-only contract and the 403 response.Nothing new in
72401e05..5abd3d29.