Pass only an allowlist to the model: user.update(params.permit(:display_name, :bio)), or params.expect(user: [:display_name, :bio]) for a nested body. Never call permit! or pass to_unsafe_h, request.request_parameters or parsed JSON to update; a blocklist such as except(:role) leaves every other column writable, including ones added later.
- Search for permit!, to_unsafe_h, request.request_parameters, request.raw_post and JSON.parse(request.body) reaching new, create, update, assign_attributes, insert_all or upsert_all.
- Permit only the fields a user may edit: params.permit(:display_name, :bio) for a flat body, or params.expect(user: [:display_name, :bio]) for a nested one. expect (Rails 8.0 and later) also answers 400 when the key is missing or has the wrong shape.
- Set role, tenant and ownership in server code, from current_user or an admin action, never from the request.
- Set config.action_controller.action_on_unpermitted_parameters = :raise in development and test, so a dropped key fails loudly; the default is :log there and false in production.
- Add regression tests that send role, tenant and case variants of them in an ordinary update and check the stored record, not just the response.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
def self.update_profile(user, changes)
# permit! was added when update() raised ActiveModel::ForbiddenAttributesError.
user.update(changes.permit!)
endWith IMPL=vulnerable, 3 of 6 tests fail (3 exploit tests).
The fix
EDITABLE = %i[display_name bio].freeze # role and tenant_id are set elsewhere
def self.update_profile(user, changes)
# permit() also drops a value that is not a scalar, such as {"bio": {"x": 1}}.
user.update(changes.permit(*EDITABLE))
endWith IMPL=fixed, 6 of 6 tests pass.
A fix that looks safe but is not
def self.update_profile(user, changes)
user.update(changes.except(:role).permit!) # role is set by admins only
endWith IMPL=wrong, 2 of 6 tests fail (2 exploit tests).
The controller: PATCH /me
# PATCH /me always edits the signed-in user's own profile. The API speaks camelCase
# (displayName); the model uses snake_case (display_name).
def update
changes = params.except(:controller, :action, :format).deep_transform_keys(&:underscore)
if ProfileUpdates.current.update_profile(current_user, changes)
render json: as_json(current_user.reload)
else
render json: { error: current_user.errors.full_messages.to_sentence }, status: :bad_request
end
endThe exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Exploit: a signed-in member sends extra fields in an ordinary profile update.
# The server may ignore the extra fields (200) or refuse the request (400). Either way the
# stored record keeps the role and tenant the server gave it.
def assert_server_fields_unchanged(res)
assert_includes [200, 400], res.status, "expected 200 or 400, got #{res.status}: #{res.body}"
record = stored("alice")
assert_equal "member", record[:role], "role changed: #{record}"
assert_equal "red", record[:tenant_id], "tenant_id changed: #{record}"
assert_equal SEED["alice"], record, "the stored record changed: #{record}"
end
test "exploit: role in the body does not make a member an admin" do
assert_server_fields_unchanged(patch_me("alice", '{"displayName": "Alice Martin", "role": "admin"}'))
end
test "exploit: tenantId in the body does not move the user to another tenant" do
assert_server_fields_unchanged(patch_me("alice", '{"displayName": "Alice Martin", "tenantId": "blue"}'))
end
test "exploit: case variants Role and TenantId do not change the record either" do
# The camelCase-to-snake_case step turns Role into role and TenantId into tenant_id.
assert_server_fields_unchanged(patch_me("alice", '{"displayName": "Alice Martin", "Role": "admin", "TenantId": "blue"}'))
end- permit! marks every key as permitted, nested hashes included. It usually appears where update raised ActiveModel::ForbiddenAttributesError, which is the protection working.
- Strong parameters guard ActionController::Parameters only. A plain Hash, such as params.to_unsafe_h, request.request_parameters or JSON.parse(request.raw_post), goes into update without any check.
- A blocklist (except(:role), or permit! after deleting a few keys) leaves every other column writable, including columns added later: this card's wrong implementation stops the role escalation and lets tenantId through.
- Key normalisation runs before the check here (deep_transform_keys(&:underscore) turns Role into role), so the allowlist sees the names the model sees. Normalise after filtering, and an attacker's spelling can slip past it.
- permit(:role) is still mass assignment of a privileged field; Brakeman's PermitAttributes check reports keys such as role, admin and account_id.
- accepts_nested_attributes_for opens the associated records as well: permit their keys explicitly (line_items_attributes: [:id, :quantity]) and leave out _destroy unless users may delete them.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs mass-assignment/ruby-rails does this for you, in the pinned Ruby container).
- Against your own endpoint, send role, tenantId and their case variants in an ordinary profile update and confirm the stored record keeps its role and tenant.
- Brakeman reports permit! and dangerous permitted keys when they are called on params in a controller; with the strong-parameters call moved into a helper, as here, neither Brakeman nor Semgrep reported anything, so the tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | ruby.lang.security.model-attr-accessible.model-attr-accessible | No findings on this card, where permit! is called on a local variable in app/updates/. On a one-file probe it flagged params.permit! and params.permit(:display_name, :bio, :role) in a controller, but not params.except(:role).permit!.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); every Ruby rule in semgrep-rules develop 9bbf021 run on app/ and config/, then --config r/<id> |
| Semgrep | ruby.rails.security.brakeman.check-permit-attributes-high.check-permit-attributes-high | No findings on the card or on the probe's permit(:display_name, :bio, :role).Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); every Ruby rule in semgrep-rules develop 9bbf021 run on app/ and config/, then --config r/<id> |
| Semgrep | p/ruby, p/owasp-top-ten, p/security-audit | No findings on any implementation.Checked: Semgrep 1.180.0 (CE); --config p/ruby, p/owasp-top-ten and p/security-audit on app/ and config/ |
| CodeQL | rb/insecure-mass-assignment | Not expected to report anything on this card: besides the const_get dispatch, the taint on params does not survive except() in the model (its summary keeps only element content), so the permit! calls are never reached by tainted data. The query's own tests flag params.require(:user).permit! passed to User.new or create; permit with listed keys is a barrier.Checked: github/codeql main at b5b8165; query and Ruby library source read, not executed |
| Brakeman | MassAssignment, PermitAttributesMass assignment | No warnings on this card: Brakeman looks for permit! on params itself, and here it is called on a copy passed to app/updates/. On a one-file probe it reported params.permit! and params.except(:role).permit! (MassAssignment, Medium) and permit(:display_name, :bio, :role) (PermitAttributes, Medium), and nothing for permit(:display_name, :bio).Checked: Brakeman 8.1.0 in the ruby:4.0.7 image; brakeman -A -q /card (all checks, optional ones included) |
| Component | Version |
|---|---|
| Language | Ruby |
| Runtime | Ruby 4.0.7 |
| Framework | Ruby on Rails 8.1.4 (strong parameters, Active Record over SQLite) |
| rails (railties, actionpack, activerecord) | 8.1.4 |
| sqlite3 | 2.9.6 (bundles SQLite 3.53.2) |
| minitest | 6.0.6 |
| minitest-reporters (JUnitReporter) | 1.8.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the mass-assignment/ruby-railsfolder.
BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install# Expected: every test passes
IMPL=fixed BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/profile_test.rb
# Expected: an exploit test fails
IMPL=vulnerable BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/profile_test.rb
# Expected: a test fails
IMPL=wrong BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/profile_test.rbWithout Ruby installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.
docker run --rm --init \
-v "$PWD":/card -w /card \
-e BUNDLE_PATH=vendor/bundle \
ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
sh -c 'BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install'# Expected: every test passes
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-e IMPL=fixed -e BUNDLE_PATH=vendor/bundle \
ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
sh -c 'bundle exec ruby -Itest test/profile_test.rb'
# Expected: an exploit test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-e IMPL=vulnerable -e BUNDLE_PATH=vendor/bundle \
ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
sh -c 'bundle exec ruby -Itest test/profile_test.rb'
# Expected: a test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-e IMPL=wrong -e BUNDLE_PATH=vendor/bundle \
ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
sh -c 'bundle exec ruby -Itest test/profile_test.rb'Each run writes a JUnit report to reports/TEST-*.xml. From the top folder of the download, node run-all.mjs mass-assignment/ruby-rails runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.
Verified
- Machine
- Debian 13 (trixie) in Docker, on macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- ruby 4.0.7 (ruby:4.0.7-trixie image, linux/arm64)
- bundler 4.0.20
- rails 8.1.4
- minitest 6.0.6
- minitest-reporters 1.8.0
- sqlite3 2.9.6 (SQLite 3.53.2)
- semgrep 1.180.0
- brakeman 8.1.0
- Matrix
- fixed: 6 of 6 tests pass; vulnerable: 3 of 6 tests fail (3 exploit tests); wrong: 2 of 6 tests fail (2 exploit tests)
- Test results
- fixed 6/6 pass; vulnerable 3/6 fail (3 exploit tests: role, tenantId and the case variants); wrong 2/6 fail (2 exploit tests: tenantId and the case variants), test runs offline (--network=none) after install; node examples/fixes/run-all.mjs ruby-rails --install, results/2026-10-11-fix-rails.json
- Dependency freshness
- Rails 8.1.4 was published on 2026-09-24 and Brakeman 8.1.0 on 2026-10-01. uri 1.1.2 and prettyprint 0.2.1 (both 2026-10-08) were three days old and are not security releases, so the Gemfile holds uri at 1.1.1 and prettyprint at 0.2.0; every other gem in Gemfile.lock is at least 13 days old. OSV reported no known vulnerabilities for any pin. Ruby 4.0.7 (2026-09-15) is the latest 4.0 release.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Mass Assignment Cheat Sheet
- OWASP API Security Top 10 2023: API3 Broken Object Property Level Authorization
- Rails Guides: Action Controller Overview, strong parameters
- Rails API: ActionController::Parameters (permit, expect, permit!)
- Rails Guides: configuring, action_on_unpermitted_parameters
- Brakeman: mass assignment
- CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes