Skip to content

Commit

Permalink
Fixes strings being interpolated multiple times
Browse files Browse the repository at this point in the history
Similarly to #599, I've observed issues issues where untrusted user input
that includes interpolation patterns gets unintentionally interpolated
and leads to bogus `I18n::MissingInterpolationArgument` exceptions.

This happens when multiple lookups are required for a key to be resolved,
which is common when resolving defaults, or resolving a key that itself
resolves to a Symbol.

As an example let's consider these translations, common for Rails apps:

```yaml
en:
  activerecord:
    errors:
      messages:
        taken: "%{value} has already been taken"
```

If the `value` given to interpolate ends up containing interpolation characters,
and Rails specifies default keys (as [described here](https://guides.rubyonrails.org/i18n.html#error-message-scopes)), resolving
those defaults will cause a `I18n::MissingInterpolationArgument` to be raised:

```rb
I18n.t('activerecord.errors.models.organization.attributes.name.taken',
  value: '%{dont_interpolate_me}',
  default: [
    :"activerecord.errors.models.organization.taken",
    :"activerecord.errors.messages.taken"
  ]
)
```

Raises:

```
I18n::MissingInterpolationArgument: missing interpolation argument :dont_interpolate_me in "%{dont_interpolate_me}" ({:value=>"%{dont_interpolate_me}"} given)
```

Instead of this, we'd expect the translation to resolve to:

```
%{dont_interpolate_me} has already been taken
```

This behaviour is caused by the way that recursive lookups work: whenever a
key can't be resolved to a string directly, the `I18n.translate`
method is called either to walk through the defaults specified, or if a Symbol
is matched, to try to resolve that symbol.

This results in interpolation being executed twice for recursive
lookups... once on the pass that finally resolves to a string, and again
on the original call to `I18n.translate`.

A "proper" fix here would likely revolve around decoupling key resolution
from interpolation... it feels odd to me that the `resolve_entry` method calls
`I18n.translate`... however I see this as a fundamental change beyond
the scope of this fix.

Instead I'm proposing to add a new reserved key `skip_interpolation`
that gets passed down into every recursive call of `I18n.translate` and
instructs the method to skip interpolation.

This ensures that only the initial `I18n.translate` call is the one that
gets its string interpolated.
  • Loading branch information
alexpls committed Aug 23, 2024
1 parent eacc34f commit 229d57e
Show file tree
Hide file tree
Showing 6 changed files with 30 additions and 3 deletions.
1 change: 1 addition & 0 deletions lib/i18n.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ module I18n
RESERVED_KEYS = %i[
cascade
deep_interpolation
skip_interpolation
default
exception_handler
fallback
Expand Down
12 changes: 10 additions & 2 deletions lib/i18n/backend/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ def translate(locale, key, options = EMPTY_HASH)
end

deep_interpolation = options[:deep_interpolation]
skip_interpolation = options[:skip_interpolation]
values = Utils.except(options, *RESERVED_KEYS) unless options.empty?
if values && !values.empty?
if !skip_interpolation && values && !values.empty?
entry = if deep_interpolation
deep_interpolate(locale, entry, values)
else
Expand Down Expand Up @@ -151,7 +152,14 @@ def resolve(locale, object, subject, options = EMPTY_HASH)
result = catch(:exception) do
case subject
when Symbol
I18n.translate(subject, **options.merge(:locale => locale, :throw => true))
I18n.translate(
subject,
**options.merge(
:locale => locale,
:throw => true,
:skip_interpolation => true
)
)
when Proc
date_or_time = options.delete(:object) || object
resolve(locale, object, subject.call(date_or_time, **options))
Expand Down
6 changes: 5 additions & 1 deletion lib/i18n/backend/fallbacks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,11 @@ def resolve_entry(locale, object, subject, options = EMPTY_HASH)

case subject
when Symbol
I18n.translate(subject, **options.merge(:locale => options[:fallback_original_locale], :throw => true))
I18n.translate(subject, **options.merge(
:locale => options[:fallback_original_locale],
:throw => true,
:skip_interpolation => true
))
when Proc
date_or_time = options.delete(:object) || object
resolve_entry(options[:fallback_original_locale], object, subject.call(date_or_time, **options))
Expand Down
7 changes: 7 additions & 0 deletions lib/i18n/tests/defaults.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ def setup
I18n.backend.store_translations(:en, { :foo => { :bar => 'bar' } }, { :separator => '|' })
assert_equal 'bar', I18n.t(nil, :default => :'foo|bar', :separator => '|')
end

# Addresses issue: #599
test "defaults: only interpolates once when resolving defaults" do
I18n.backend.store_translations(:en, :greeting => 'hey %{name}')
assert_equal 'hey %{dont_interpolate_me}',
I18n.t(:does_not_exist, :name => '%{dont_interpolate_me}', default: [:greeting])
end
end
end
end
6 changes: 6 additions & 0 deletions lib/i18n/tests/lookup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ def setup
test "lookup: a resulting Hash is not frozen" do
assert !I18n.t(:hash).frozen?
end

# Addresses issue: #599
test "lookup: only interpolates once when resolving symbols" do
I18n.backend.store_translations(:en, foo: :bar, bar: '%{value}')
assert_equal '%{dont_interpolate_me}', I18n.t(:foo, value: '%{dont_interpolate_me}')
end
end
end
end
1 change: 1 addition & 0 deletions lib/i18n/tests/procs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def self.filter_args(*args)
if arg.is_a?(Hash)
arg.delete(:fallback_in_progress)
arg.delete(:fallback_original_locale)
arg.delete(:skip_interpolation)
end
arg
end.inspect
Expand Down

0 comments on commit 229d57e

Please sign in to comment.