Skip to content

Exempt the app's configured CSRF param, not just authenticity_token - #84

Merged
VSN2015 merged 1 commit into
masterfrom
fix/form-keys-csrf-param
Sep 29, 2026
Merged

VSN2015 merged 1 commit into
masterfrom
fix/form-keys-csrf-param

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 28, 2026

Copy link
Copy Markdown
Owner

The bug

FORM_KEYS (lib/permittable.rb, around line 232) hardcodes the literal "authenticity_token" as the CSRF parameter name to exempt from the top-level unknown: :error check:

FORM_KEYS = %w[authenticity_token _method utf8 commit].freeze

That's Rails' default CSRF parameter name, but config.action_controller.request_forgery_protection_token lets an app rename it. An app that does trips unknown: :error on every ordinary browser form submission — reproducing exactly the bug FORM_KEYS was added to fix in the first place (per the comment right above it), just under the app's own key instead of the default one:

# config/application.rb
config.action_controller.request_forgery_protection_token = :csrf_token
permit_params :create, unknown: :error do
  required :name, :string
end

A normal form POST then carries csrf_token instead of authenticity_token, FORM_KEYS doesn't recognize it, and the request fails with { param: "csrf_token", code: "unknown" } even though nothing is wrong with it.

Since the configured name can differ per controller and isn't knowable at class-load time (Rails may not have finished initializing when this file first loads), a frozen constant computed once can't hold it — it has to be read live, per request.

The fix

permittable_check_unknown now also subtracts the live controller's own request_forgery_protection_token (from ActionController::RequestForgeryProtection, included by ActionController::Base) from the top-level exemption, alongside the existing FORM_KEYS/ROUTING_KEYS constant and permittable_request_supplied_keys:

if top_level
  extra -= UNCHECKED_TOP_LEVEL_KEYS + permittable_request_supplied_keys
  extra -= [permittable_configured_csrf_key].compact
end
def permittable_configured_csrf_key
  return nil unless respond_to?(:request_forgery_protection_token)

  token = request_forgery_protection_token
  token && token.to_s
end

A plain params duck or a standalone Contract has no request_forgery_protection_token method, so permittable_configured_csrf_key returns nil there and behavior is unchanged — they still only get the FORM_KEYS default's "authenticity_token". FORM_KEYS, ROUTING_KEYS, and MONITOR_DROPPED_KEYS are untouched: monitor mode's raw pass-through still only drops routing keys, exactly as before.

Verification

  • New test written first (spec/permittable_spec.rb), confirmed to fail (422 instead of 200) before the implementation change and pass after
  • bundle exec rspec — 852 examples, 0 failures
  • bundle exec rubocop lib/permittable.rb spec/permittable_spec.rb — no offenses

🤖 Generated with Claude Code

FORM_KEYS hardcoded "authenticity_token" for the top-level unknown:
:error exemption, but config.action_controller.request_forgery_protection_token
lets an app rename that parameter. Renaming it reproduced the exact bug
FORM_KEYS was added to fix: unknown: :error tripped on every ordinary
browser form submission, just under the app's own CSRF key instead of
the default one.

permittable_check_unknown now also subtracts the live controller's own
request_forgery_protection_token (read fresh per request, since the
configured name can vary by controller and isn't knowable at class-load
time) alongside the existing FORM_KEYS/ROUTING_KEYS exemption. A plain
params duck or a standalone Contract has no such method and keeps the
FORM_KEYS default unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@VSN2015
VSN2015 merged commit ecb4af0 into master Sep 29, 2026
16 checks passed
VSN2015 pushed a commit that referenced this pull request Sep 29, 2026
Bumps version.rb, adds the 0.10.0 CHANGELOG section, and includes the
regenerated Gemfile.lock — CI runs bundler in frozen mode, so a version bump
without the lockfile fails the tag build.

Eleven PRs since 0.9.0 (#74-#84): numeric strings must be spelled
canonically (no underscore separators or surrounding whitespace), an array
default: runs its sub-fields' transform: so an omitted field and an
explicitly-sent identical value agree, a CSRF param renamed via
request_forgery_protection_token no longer trips unknown: :error, a
sensitive: field's default:/example: are omitted from the exported schema,
message: is deep-frozen like the other authored values, the generator guards
an enum accessor the model lacks and no longer swallows non-ActiveRecord
errors, the error-response and scalar schema constants are handed out as
deep copies, the sensitive-parameter sink list is read under its mutex, and
the Rails dev dependencies move to 8.1.4.

Minor rather than patch: no new surface, but a client sending "1_8" or " 99 "
for a numeric field now gets 422 instead of a number, and a sub-field
transform: now runs over an array default: at class load. See CHANGELOG.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant