diff --git a/lib/permittable.rb b/lib/permittable.rb index 745f905..f116add 100644 --- a/lib/permittable.rb +++ b/lib/permittable.rb @@ -744,6 +744,17 @@ def cast_string(value) end end + # Kernel#Integer/Float and BigDecimal() all accept underscore digit + # separators and surrounding whitespace — a convenience for a NUMBER + # LITERAL IN RUBY SOURCE, not for a request body. "1_8" is not how a + # client spells eighteen, and " 99 " is not how one spells ninety-nine; + # silently accepting either is the same kind of leniency as the + # NaN/Infinity/`0e10` cases below, just arriving from a different door. + # Checked against the raw String before any of those delegate, so a + # non-canonical spelling never reaches them at all. + INTEGER_FORMAT = /\A[+-]?\d+\z/ + NUMERIC_FORMAT = /\A[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?\z/ + def cast_integer(value) case value when Integer then [:ok, value] @@ -751,7 +762,7 @@ def cast_integer(value) # RangeError, which the ArgumentError rescue below does not catch), and # no integer is what either one sent. Same rule as finite_float. when Float then value.finite? && value == value.truncate ? [:ok, value.to_i] : [:error, "invalid_type"] - when String then [:ok, Integer(value, 10)] + when String then value.match?(INTEGER_FORMAT) ? [:ok, Integer(value, 10)] : [:error, "invalid_type"] else [:error, "invalid_type"] end rescue ArgumentError @@ -761,7 +772,7 @@ def cast_integer(value) def cast_float(value) case value when Numeric then finite_float(value.to_f) - when String then finite_float(Float(value), source: value) + when String then value.match?(NUMERIC_FORMAT) ? finite_float(Float(value), source: value) : [:error, "invalid_type"] else [:error, "invalid_type"] end rescue ArgumentError @@ -792,7 +803,8 @@ def nonzero_significand?(source) def cast_decimal(value) case value - when Numeric, String then finite_decimal(BigDecimal(value.to_s)) + when Numeric then finite_decimal(BigDecimal(value.to_s)) + when String then value.match?(NUMERIC_FORMAT) ? finite_decimal(BigDecimal(value)) : [:error, "invalid_type"] else [:error, "invalid_type"] end rescue ArgumentError diff --git a/spec/permittable_spec.rb b/spec/permittable_spec.rb index 5cc9e93..08b2da4 100644 --- a/spec/permittable_spec.rb +++ b/spec/permittable_spec.rb @@ -511,6 +511,15 @@ def include?(value) = any? { |candidate| candidate.to_s.include?(value.to_s) } expect(violations_for({ n: "4.5" }) { permit_params(:create) { required :n, :integer } }.details.first[:code]).to eq("invalid_type") end + it "rejects underscore digit separators and surrounding whitespace for :integer" do + # Integer() treats both as harmless formatting for a SOURCE LITERAL — + # neither is how a client spells a number in a request body. + ["1_8", " 99 "].each do |value| + e = violations_for({ n: value }) { permit_params(:create) { required :n, :integer } } + expect(e.details).to eq([{ param: "n", code: "invalid_type" }]), "for #{value.inspect}" + end + end + it "accepts a whole Float for :integer but rejects a fractional one (JSON numbers)" do klass = permittable_class { permit_params(:create) { required :n, :integer } } expect(controller(klass, params: { n: 42.0 }).permitted_params[:n]).to eq(42) @@ -696,6 +705,15 @@ def rejected(key, value, &decl) end end + it "rejects underscore digit separators and surrounding whitespace for :float and :decimal" do + # Float()/BigDecimal() treat both as harmless formatting for a SOURCE + # LITERAL, same leniency as the :integer version of this rule above. + ["1_8.5", " 1.5 "].each do |value| + expect(rejected(:f, value, &decl)).to eq([{ param: "f", code: "invalid_type" }]), "for :float #{value.inspect}" + expect(rejected(:d, value, &decl)).to eq([{ param: "d", code: "invalid_type" }]), "for :decimal #{value.inspect}" + end + end + it "keeps accepting the large exponents BigDecimal genuinely represents" do expect(permit({ d: "1e400" }, &decl)[:d]).to eq(BigDecimal("1e400")) expect(permit({ d: "0.0000000000000000001" }, &decl)[:d]).to eq(BigDecimal("1e-19"))