Skip to content

Guard sensitive_parameter_sinks reads with @registry_mutex - #81

Merged
VSN2015 merged 1 commit into
masterfrom
fix/sensitive-sink-race
Sep 29, 2026
Merged

VSN2015 merged 1 commit into
masterfrom
fix/sensitive-sink-race

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 28, 2026

Copy link
Copy Markdown
Owner

The bug

register_sensitive_parameter (lib/permittable.rb) iterated the process-global sensitive_parameter_sinks Array without holding @registry_mutex:

sensitive_parameter_sinks.each { |sink| sink.call(name) } unless name.empty?

on_sensitive_parameter, meanwhile, appends to that very same Array under the mutex:

@registry_mutex.synchronize { sensitive_parameter_sinks << sink }

sensitive_parameter_sinks is a plain lazily-initialized Array (@sensitive_parameter_sinks ||= []). On MRI the GVL masks most damage from an unsynchronized reader racing a synchronized writer, but on JRuby/TruffleRuby — real Rails deployment targets — this is a genuine data race: a thread class-loading a sensitive: true contract (which calls register_sensitive_parameter's unguarded .each) can run concurrently with another thread calling Permittable.on_sensitive_parameter (mutex-guarded <<). An unguarded concurrent Array mutation-during-iteration can raise or silently drop a sink call — meaning a sensitive parameter name never reaches config.filter_parameters, a silent security regression.

The fix

Take a snapshot of sensitive_parameter_sinks under @registry_mutex before iterating, then call the sinks against that snapshot outside the lock:

@registry_mutex.synchronize { sensitive_parameter_sinks.dup }.each { |sink| sink.call(name) }

This mirrors the pattern on_sensitive_parameter already uses for its own replay of previously-registered names (registry.names.each { ... } runs outside the mutex, after the guarded append) — so sink callbacks, which are arbitrary caller-supplied code, are never invoked while holding the lock, which would risk deadlock if a sink called back into a mutex-guarded Permittable method.

Deadlock check: register_sensitive_parameter is only ever called from register_sensitive_params (plural), which processes contract field metadata outside of any @registry_mutex.synchronize block — so taking the lock here cannot self-deadlock.

Verification

  • New test written first (spec/permittable_spec.rb), confirmed to fail before the implementation change and pass after. Since a real cross-thread race is inherently timing-dependent (and thus flaky to assert directly), the test instead asserts the invariant that rules the race out: every read of sensitive_parameter_sinks from within register_sensitive_parameter happens while @registry_mutex is locked, exactly like the append does.
  • bundle exec rspec — 852 examples, 0 failures
  • bundle exec rubocop lib/permittable.rb spec/permittable_spec.rb — no offenses

🤖 Generated with Claude Code

register_sensitive_parameter iterated the process-global
sensitive_parameter_sinks Array without holding @registry_mutex, while
on_sensitive_parameter appends to that same Array under the mutex. On
MRI the GVL masks most damage, but on JRuby/TruffleRuby a thread
class-loading a sensitive: true contract can race a thread installing
a sink via an unguarded concurrent Array mutation-during-iteration,
which can raise or silently drop a sink call.

Snapshot the sinks under @registry_mutex before iterating, mirroring
the replay in on_sensitive_parameter which already avoids holding the
lock while invoking arbitrary sink callbacks. register_sensitive_parameter
is never called while @registry_mutex is already held by the calling
thread, so this introduces no deadlock risk.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@VSN2015
VSN2015 merged commit d38dc41 into master Sep 29, 2026
16 checks passed
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