Redact a sensitive: value that is not a String - #41
Open
VSN2015 wants to merge 1 commit into
Open
Conversation
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 tomaster.sensitive: truepromises the field will not be printed. It kept that promise only for Strings.The bug
Two distinct causes, both in the mechanism rather than the registration:
ParameterFilterdups the value before invoking a proc filter and expects in-place mutation —value.replace(FILTERED)— which no Integer, Float or boolean supports.value_for_keychecksvalue.is_a?(Hash)before@blocksand 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@maskoutright, 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 andActionDispatchreads the same object per request:Why a name and not a live matcher
Worth recording, because a
Regexpsubclass whosematch?consults the registry is the obvious clever fix, and it fails open.config.precompile_filter_parametersistruefromload_defaults "7.1", and railties then doesconfig.filter_parameters.replace(ParameterFilter.precompile_filters(...)), which joins every Regexp by source and drops the object: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_parameteris 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 (aContractat 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:ssnand"ssn".One correction to note
The audit claimed model
#inspectleaked too. It does not, and I was wrong about that:format_for_inspectcallsvalue.inspectand passes the resulting String toinspection_filter.filter_param, so ActiveRecord always hands the filter a String and the existing proc already redacted there. The probe confirmedpin_code: [FILTERED]in#inspectbefore 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%.:integerfield, a sensitive nested block,#inspect, the app's own:passwordfilter still working, a non-sensitive field untouched, and a registry swapped from an initializer — with precompilation on.🤖 Generated with Claude Code