Skip to content

Redact a sensitive: value that is not a String - #41

Open
VSN2015 wants to merge 1 commit into
fix/late-bound-filter-procfrom
fix/sensitive-value-types
Open

Redact a sensitive: value that is not a String#41
VSN2015 wants to merge 1 commit into
fix/late-bound-filter-procfrom
fix/sensitive-value-types

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Stacked on #33 — it rewrites this exact region (late-binding the appended object, #names, carry-over on swap) and all of it is load-bearing here. Merge #33 first and the base retargets to master.

sensitive: true promises the field will not be printed. It kept that promise only for Strings.

The bug

permit_params(:create) do
  optional :pin_code, :integer, sensitive: true
  optional :payment, sensitive: true do
    required :card_number, :string
  end
end
{"pin_code"=>1234, "payment"=>{"card_number"=>"4111111111111111"}}   # in the log, in the clear

Two distinct causes, both in the mechanism rather than the registration:

  1. ParameterFilter dups the value before invoking a proc filter and expects in-place mutationvalue.replace(FILTERED) — which no Integer, Float or boolean supports.
  2. value_for_key checks value.is_a?(Hash) before @blocks and recurses into it, so a proc filter is never called for a Hash value at all. Verified: a proc filter watching {"a" => "str", "b" => 7, "c" => {"d" => "x"}} sees [["a", String], ["b", Integer], ["d", String]] — the hash itself never reaches it.

So no amount of fixing the proc reaches these. What redacts any value type is a name in config.filter_parameters (a pattern match assigns @mask outright, whatever the value is), so each registered name is now added there too. The proc stays for what only it can reach: consumers that snapshot the array at boot.

Appending after boot still works — Rails' precompilation replaces that array in place and ActionDispatch reads the same object per request:

array = precompile_filters([:password]); array << "pin"   # a registration, post-boot
integer: {"pin"=>"[FILTERED]"}      hash: {"payment"=>{"pin"=>"[FILTERED]"}}

Why a name and not a live matcher

Worth recording, because a Regexp subclass whose match? consults the registry is the obvious clever fix, and it fails open. config.precompile_filter_parameters is true from load_defaults "7.1", and railties then does config.filter_parameters.replace(ParameterFilter.precompile_filters(...)), which joins every Regexp by source and drops the object:

after precompile: [Regexp]    sources: ["(?-mix:(?i:password)|(?-mix:(?!)))"]
pin:  {"pin"=>1234}           # silently stopped redacting

Procs survive precompilation ([Proc, Regexp]), which is the unwritten reason the original mechanism was a proc — and why it inherited the String-only limit. The boot spec now runs with precompilation enabled, asserted rather than assumed (expect(boot["precompiled"]).to be(true)), so a regression there fails CI instead of quietly leaking.

Design

Permittable.on_sensitive_parameter is a publication seam the Railtie installs into, so Rails knowledge stays in the Railtie and out of the registry — which remains duck-typed and dependency-free. Sinks are replayed over the names already registered, since a contract can be declared before the initializer runs (a Contract at require time, an eager-loaded controller). Names are published normalized, so both paths agree and a sink deduplicating by value can't end up holding :ssn and "ssn".

One correction to note

The audit claimed model #inspect leaked too. It does not, and I was wrong about that: format_for_inspect calls value.inspect and passes the resulting String to inspection_filter.filter_param, so ActiveRecord always hands the filter a String and the existing proc already redacted there. The probe confirmed pin_code: [FILTERED] in #inspect before this change. No ActiveRecord wiring was needed, and the spec keeps that assertion as a regression guard with the reason written down.

Verification

  • 267 examples, 0 failures; rubocop clean; coverage 97.21%.
  • The booted-Rails probe covers an :integer field, a sensitive nested block, #inspect, the app's own :password filter still working, a non-sensitive field untouched, and a registry swapped from an initializer — with precompilation on.
  • New unit specs cover the publication seam with no Rails present.

🤖 Generated with Claude Code

`sensitive: true` promised the field would not be printed, and kept
that promise only for Strings.

The mechanism was a proc filter, and ActiveSupport's ParameterFilter
dups the value before invoking one and expects in-place mutation. So
`optional :pin, :integer, sensitive: true` logged 1234 in the clear.
Worse, ParameterFilter checks `value.is_a?(Hash)` BEFORE the proc
filters and recurses into it instead, so a proc is never called for a
Hash at all: `sensitive: true` on a nested block logged the whole hash.
Both are silent disclosures of exactly the fields a contract marked as
the ones not to print.

What redacts a value of any type is a NAME in config.filter_parameters,
so each registered name is now added there as well. The proc stays for
what only it can reach — consumers that snapshot the array at boot.
Appending later still works because Rails' precompile_filter_parameters
replaces that array in place and ActionDispatch reads the same object
per request.

That is also why the mechanism is a name rather than a live matcher
object: precompilation joins filters by pattern source, discarding
anything whose matching is decided at filter time, and it is on by
default from load_defaults 7.1. The boot spec now runs with it enabled
so a regression there fails rather than silently stops redacting.

Permittable.on_sensitive_parameter is the seam the Railtie installs
into, which keeps Rails knowledge out of the registry. Sinks are
replayed over the names already registered, since a contract can be
declared before the initializer runs.

Co-Authored-By: Claude Opus 5 (1M context) <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