permittable 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/permittable.rb CHANGED
@@ -136,7 +136,8 @@ require "permittable/filter_parameter_registry"
136
136
  # declaring `transform:`: its default is stored exactly AS AUTHORED, never
137
137
  # cast — `transform:` never runs on a default either (see OUTPUT RESHAPING),
138
138
  # so author such a default already in the shape the action should receive.
139
- # Either way it is deep-frozen on a copy, and each request gets its own deep
139
+ # An `example:` is cast either way and never transformed: it documents what a
140
+ # client sends. Either way it is deep-frozen on a copy, and each request gets its own deep
140
141
  # copy, so no request can corrupt it for the next.
141
142
  #
142
143
  # `nullable: true` splits that rule in two for one field, which is how a PATCH
@@ -218,18 +219,29 @@ module Permittable
218
219
  UNKNOWN_MODES = %i[ignore log error].freeze
219
220
  MODES = %i[enforce monitor].freeze
220
221
  ERROR_FORMATS = %i[envelope problem].freeze
222
+ # A Permittable.audit_ignore entry: a controller path the way Rails names
223
+ # it (lowercase, `/`-separated — "active_storage/direct_uploads"),
224
+ # optionally followed by "#action".
225
+ AUDIT_IGNORE_ENTRY = %r{\A[a-z0-9_]+(?:/[a-z0-9_]+)*(?:#\w+)?\z}
221
226
  # Rails merges routing bookkeeping into params; a top-level (root: false)
222
227
  # unknown-keys check must not flag them.
223
228
  ROUTING_KEYS = %w[controller action format].freeze
224
229
  # Nor the keys an ordinary form POST carries — the CSRF token, the verb
225
- # override, the encoding probe, and the submit button's name. Without this
226
- # `unknown: :error` was unusable outside a JSON API: every browser form
227
- # failed on the framework's own keys rather than on anything the client got
228
- # wrong. Exempt from the CHECK only: unlike the routing keys these are NOT
229
- # stripped from monitor mode's raw pass-through, where handing back an
230
- # untouched params hash is the whole promise and a legacy action may well
231
- # read `_method` itself.
232
- FORM_KEYS = %w[authenticity_token _method utf8 commit].freeze
230
+ # override, the encoding probe, and the submit button's name: `commit` from
231
+ # f.submit/submit_tag, `button` from f.button/button_tag (which render
232
+ # <button name="button">, so a browser sends `button=` even with no
233
+ # value). Without this `unknown: :error` was unusable outside a JSON API:
234
+ # every browser form failed on the framework's own keys rather than on
235
+ # anything the client got wrong. Exempt from the CHECK only: unlike the
236
+ # routing keys these are NOT stripped from monitor mode's raw
237
+ # pass-through, where handing back an untouched params hash is the whole
238
+ # promise and a legacy action may well read `_method` itself.
239
+ #
240
+ # The click point of an image submit (image_submit_tag, which has no name
241
+ # of its own to exempt) is a form key too, but not a fixed one: `x` and `y`,
242
+ # or `<name>.x` and `<name>.y`, as a PAIR of digits. See
243
+ # permittable_image_submit_keys.
244
+ FORM_KEYS = %w[authenticity_token _method utf8 commit button].freeze
233
245
  # ROUTING_KEYS/FORM_KEYS name where the keys come FROM; these two name what
234
246
  # is DECIDED with them, which is what the call sites care about — and the
235
247
  # asymmetry between them is the deliberate point, so spell it once here
@@ -238,7 +250,7 @@ module Permittable
238
250
  MONITOR_DROPPED_KEYS = ROUTING_KEYS
239
251
  # The field kinds that can hold ParamsWrapper's copy of a body — a hash. A
240
252
  # rootless contract declaring the wrapper key as one of these reads the
241
- # copy deliberately, so it is kept (see permittable_without_wrapper_copy).
253
+ # copy deliberately, so it is kept (see permittable_drops_wrapper_copy?).
242
254
  WRAPPER_CONTAINER_KINDS = [:nested, JSON_TYPE].freeze
243
255
  # A log line and an exception message are PROSE, written for a person. They
244
256
  # list at most this many names and count the rest, so one request cannot
@@ -512,6 +524,55 @@ module Permittable
512
524
  @check_column_types = value
513
525
  end
514
526
 
527
+ # Routed actions that `permittable:audit` leaves out of its counts and of
528
+ # `[strict]`, and that `permittable:generate` does not draft for — the
529
+ # endpoints that are not the app's to guard:
530
+ #
531
+ # Permittable.audit_ignore = %w[active_storage/direct_uploads application#not_found]
532
+ #
533
+ # Each entry is a controller path as Rails routes it (every action of that
534
+ # controller) or "controller#action" (that one action, on every verb — a
535
+ # `via: :all` catch-all is one entry). Ignored actions are still listed,
536
+ # in a section of their own, and an entry that matches no routed action is
537
+ # listed too: an ignore list nothing checks would hide its own typos.
538
+ #
539
+ # Strings only, checked here. A class name is the likeliest mistake and
540
+ # the error names the path meant; a Class is refused because naming one
541
+ # in an initializer autoloads it at boot, which Zeitwerk refuses for a
542
+ # reloadable controller; a Regexp is refused because a pattern would also
543
+ # ignore controllers written AFTER it, and catching a new unguarded
544
+ # endpoint is what the gate is for. Validated as a whole before anything
545
+ # is stored, so a rejected list leaves the previous one in place. nil
546
+ # clears it.
547
+ def audit_ignore
548
+ @audit_ignore || [].freeze
549
+ end
550
+
551
+ def audit_ignore=(entries)
552
+ entries = Array(entries).map { |entry| audit_ignore_entry(entry) }
553
+ @audit_ignore = entries.uniq.freeze
554
+ end
555
+
556
+ # Whether the audit and the generator also leave out every controller
557
+ # whose source is outside Rails.root — engines and gems: ActiveStorage,
558
+ # ActionMailbox, Rails's own rails/* controllers. On by default, since an
559
+ # app cannot add a contract to a gem's controller; those are listed in
560
+ # the report under their own reason rather than dropped. Turn it off to
561
+ # audit them like the app's own:
562
+ #
563
+ # Permittable.audit_ignore_outside_root = false
564
+ #
565
+ # See Audit.outside_root? for how a controller is located.
566
+ def audit_ignore_outside_root
567
+ @audit_ignore_outside_root.nil? || @audit_ignore_outside_root
568
+ end
569
+
570
+ def audit_ignore_outside_root=(value)
571
+ raise ArgumentError, "#{LABEL}: audit_ignore_outside_root must be true or false" unless [true, false].include?(value)
572
+
573
+ @audit_ignore_outside_root = value
574
+ end
575
+
515
576
  # App-wide fallback copy for a violation code, looked up through I18n
516
577
  # under permittable.errors.<code> ("missing", "inclusion", or any Symbol
517
578
  # a validate: returned). Consulted only when the field declares no
@@ -526,6 +587,24 @@ module Permittable
526
587
  message = ::I18n.t("permittable.errors.#{code}", default: nil)
527
588
  message.is_a?(String) ? message : nil
528
589
  end
590
+
591
+ private
592
+
593
+ # One audit_ignore entry, frozen, or an ArgumentError that says what an
594
+ # entry looks like — and, for a class or a class name, which path it
595
+ # meant: `ActiveStorage::DirectUploadsController` is the path
596
+ # "active_storage/direct_uploads", the same derivation Rails's own
597
+ # controller_path makes.
598
+ def audit_ignore_entry(entry)
599
+ return entry.dup.freeze if entry.is_a?(String) && AUDIT_IGNORE_ENTRY.match?(entry)
600
+
601
+ meant = entry.controller_path if entry.is_a?(Class) && entry.respond_to?(:controller_path)
602
+ meant ||= entry.underscore.delete_suffix("_controller") if entry.is_a?(String) && entry.match?(/\A[A-Z]\w*(::[A-Z]\w*)*\z/)
603
+ raise ArgumentError,
604
+ "#{LABEL}: audit_ignore takes controller paths as Rails routes them (\"active_storage/direct_uploads\") " \
605
+ "or \"controller#action\" (\"application#not_found\"), got #{entry.inspect}" \
606
+ "#{" — did you mean #{meant.inspect}?" if meant}"
607
+ end
529
608
  end
530
609
 
531
610
  # Raised when the request violates the matching contract. `details` is an
@@ -545,6 +624,12 @@ module Permittable
545
624
  class_attribute :permittable_contracts, instance_accessor: false, default: []
546
625
 
547
626
  rescue_from InvalidParameters, with: :render_invalid_parameters if respond_to?(:rescue_from)
627
+ # Registered here, at the include, and not at the first `permit_params`:
628
+ # the README documents `skip_before_action :enforce_params_contract`, and
629
+ # Rails raises when it is asked to skip a callback that is not defined
630
+ # yet. The cost is the order: this check runs before any callback
631
+ # declared after the include (see the README's "Per-request locales"),
632
+ # so a locale switch that must cover it is a `prepend_around_action`.
548
633
  before_action :enforce_params_contract if respond_to?(:before_action)
549
634
  end
550
635
 
@@ -808,8 +893,11 @@ module Permittable
808
893
  source.split(/[eE]/, 2).first.match?(/[1-9]/)
809
894
  end
810
895
 
896
+ # Rational#to_s is "1/2", which BigDecimal() cannot read, so the generic
897
+ # Numeric branch refused every Rational, even a whole one (3r).
811
898
  def cast_decimal(value)
812
899
  case value
900
+ when Rational then rational_to_decimal(value)
813
901
  when Numeric then finite_decimal(BigDecimal(value.to_s))
814
902
  when String then value.match?(NUMERIC_FORMAT) ? finite_decimal(BigDecimal(value)) : [:error, "invalid_type"]
815
903
  else [:error, "invalid_type"]
@@ -818,6 +906,20 @@ module Permittable
818
906
  [:error, "invalid_type"]
819
907
  end
820
908
 
909
+ # A Rational is exactly a decimal only when its reduced denominator has
910
+ # no prime factor but 2 and 5 (1/2, 3/40), and then it divides 10**k for
911
+ # k = its bit length, which is at least the exponent of either factor.
912
+ # Anything else (1/3) has no finite expansion, so a BigDecimal could hold
913
+ # it only rounded at a precision this gem would have to pick. That is the
914
+ # guess every other coercion here refuses, so it is invalid_type.
915
+ def rational_to_decimal(value)
916
+ denominator = value.denominator
917
+ scale = denominator.bit_length
918
+ return [:error, "invalid_type"] unless ((10**scale) % denominator).zero?
919
+
920
+ finite_decimal(BigDecimal("#{value.numerator * (10**scale) / denominator}e-#{scale}"))
921
+ end
922
+
821
923
  # BigDecimal has no exponent limit, so a :decimal cannot overflow — but
822
924
  # BigDecimal("NaN") and BigDecimal("Infinity") SUCCEED where Float()
823
925
  # raises, so a client could send the literal string "NaN" for a price and
@@ -1128,6 +1230,7 @@ module Permittable
1128
1230
  validate_array_authored_value!(field, :default) if field.key?(:default)
1129
1231
  validate_array_authored_value!(field, :example) if field.key?(:example)
1130
1232
  validate_message!(field)
1233
+ freeze_desc!(field)
1131
1234
  @fields << field
1132
1235
  end
1133
1236
 
@@ -1204,6 +1307,7 @@ module Permittable
1204
1307
  fields: nested_fields!(name, &block), **opts }
1205
1308
  field[:fields] = cascade_sensitive(field[:fields], field[:sensitive])
1206
1309
  validate_message!(field)
1310
+ freeze_desc!(field)
1207
1311
  elsif type&.to_sym == JSON_TYPE
1208
1312
  assert_opts!(name, opts, JSON_OPTS)
1209
1313
  # `type:` is carried alongside `kind:` so the same `as(:json)` matcher
@@ -1308,6 +1412,7 @@ module Permittable
1308
1412
  validate_authored_value!(field, :default)
1309
1413
  validate_authored_value!(field, :example)
1310
1414
  validate_message!(field)
1415
+ freeze_desc!(field)
1311
1416
  end
1312
1417
 
1313
1418
  # `in:` is a Range (bounds-checked with cover?), a list of values, or an
@@ -1323,19 +1428,34 @@ module Permittable
1323
1428
  # enum rule all read the members the runtime compares against. A member
1324
1429
  # no request value could ever equal is a contract mistake, and fails here
1325
1430
  # rather than as an `inclusion` on every request.
1431
+ #
1432
+ # An empty list or a reversed Range is refused for the same reason, but
1433
+ # an object used as given is not asked: it is read on every request, so
1434
+ # a live registry that is empty now (filled in an initializer or
1435
+ # `after_initialize`, after `eager_load!` has run this) is the very use
1436
+ # the README recommends it for.
1326
1437
  def resolve_in!(field)
1327
1438
  name = field[:name]
1328
1439
  allowed = field[:in]
1329
1440
  if allowed.is_a?(Range)
1330
1441
  assert_comparable_range!(field, allowed)
1442
+ assert_satisfiable!(name, :in, allowed)
1331
1443
  elsif (members = Coercion.in_list(allowed))
1332
1444
  cast_in_members!(field, members)
1445
+ assert_satisfiable!(name, :in, field[:in])
1333
1446
  elsif allowed.is_a?(String) || !allowed.respond_to?(:include?)
1334
1447
  raise ArgumentError, "#{LABEL}: :in for field :#{name} must be a Range, a list of values (an Array, Set, " \
1335
1448
  "or a Hash read as its keys), or an object answering include? " \
1336
1449
  "(got #{allowed.inspect})#{string_in_hint(allowed)}"
1337
1450
  end
1338
- assert_satisfiable!(name, :in, field[:in])
1451
+ end
1452
+
1453
+ # An `in:` that is neither a Range nor a list the gem read and cast
1454
+ # itself, so one resolve_in! kept exactly as given: its contents are the
1455
+ # host's, and unknown at class load.
1456
+ def live_in?(field)
1457
+ allowed = field[:in]
1458
+ !allowed.nil? && !allowed.is_a?(Range) && Coercion.in_list(allowed).nil?
1339
1459
  end
1340
1460
 
1341
1461
  def string_in_hint(allowed)
@@ -1413,6 +1533,7 @@ module Permittable
1413
1533
  validate_json_authored_value!(field, :default)
1414
1534
  validate_json_authored_value!(field, :example)
1415
1535
  validate_message!(field)
1536
+ freeze_desc!(field)
1416
1537
  end
1417
1538
 
1418
1539
  def validate_max_depth!(name, depth)
@@ -1543,7 +1664,7 @@ module Permittable
1543
1664
  # same value gets, and what used to be thrown away: `default: "18"` used
1544
1665
  # to be handed to every request omitting it as the String "18", and
1545
1666
  # `:boolean, default: "false"` gave the app a truthy String.
1546
- # With a `transform:`, the value is stored exactly AS AUTHORED instead —
1667
+ # With a `transform:`, a default is stored exactly AS AUTHORED instead —
1547
1668
  # `transform:` never runs on a default (see AuthoredValues), so casting it
1548
1669
  # here would silently change its type out from under an author who, per
1549
1670
  # the README, writes such a default in the shape the action should
@@ -1551,6 +1672,17 @@ module Permittable
1551
1672
  # :string field means the app gets the Integer 25 either way, whether the
1552
1673
  # request sent "25" (cast then transformed) or omitted the field
1553
1674
  # (authored as the already-final Integer).
1675
+ #
1676
+ # An `example:` is cast either way. It documents what a client SENDS and
1677
+ # never reaches the action, so that reason does not apply to it: under
1678
+ # `transform:` it used to be published uncast, `"type": "integer",
1679
+ # "examples": ["18"]`, the very lie the cast exists to stop.
1680
+ #
1681
+ # The one rule not checked is `in:` when it is an object used as given
1682
+ # (see resolve_in!): a live registry is empty at class load, so
1683
+ # `in: LIVE, default: "order.paid"` would be refused for a member it will
1684
+ # hold by the first request. What it will contain is unknown then, so its
1685
+ # `include?` is not asked; every other rule is still checked.
1554
1686
  def validate_authored_value!(field, opt)
1555
1687
  return unless field.key?(opt)
1556
1688
  return if authored_nil!(field, opt)
@@ -1559,10 +1691,10 @@ module Permittable
1559
1691
  # proc cannot rewrite the host's own literal.
1560
1692
  authored = field[opt].is_a?(String) ? field[opt].dup : field[opt]
1561
1693
  value = Coercion.apply_normalize(field[:normalize], authored)
1562
- status, result = Coercion.check_scalar(field, value)
1694
+ status, result = Coercion.check_scalar(live_in?(field) ? field.except(:in) : field, value)
1563
1695
  raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{result})" unless status == :ok
1564
1696
 
1565
- field[opt] = freeze_authored(field[:transform] ? field[opt] : result)
1697
+ field[opt] = freeze_authored(opt == :default && field[:transform] ? field[opt] : result)
1566
1698
  end
1567
1699
 
1568
1700
  # An array's authored value is validated by the REQUEST walker itself
@@ -1575,27 +1707,61 @@ module Permittable
1575
1707
  # cast only the top level of each element, and got every one of those
1576
1708
  # wrong.
1577
1709
  #
1578
- # What is STORED follows the same split as a scalar's: without
1579
- # `transform:`, the walker's read (exactly what a request sending it
1580
- # gets); with one, the array exactly AS AUTHORED — the walker still runs,
1581
- # so a declaration mistake (an element `validate:` refuses, a sub-field
1582
- # default out of bounds) still fails at class load, but its cast result
1583
- # is discarded rather than stored. The array's OWN `transform:` is
1584
- # deliberately never run on a default either way; a SUB-FIELD's
1585
- # `transform:` still runs during that walk, so "the walker's read" above
1586
- # really is what an equivalent request produces — see AuthoredValues.
1710
+ # What is STORED for a default follows the same split as a scalar's:
1711
+ # without `transform:`, the walker's read (exactly what a request sending
1712
+ # it gets); with one, the array exactly AS AUTHORED — the walker still
1713
+ # runs, so a declaration mistake (an element `validate:` refuses, a
1714
+ # sub-field default out of bounds) still fails at class load, but its
1715
+ # cast result is discarded rather than stored. The array's OWN
1716
+ # `transform:` is deliberately never run on a default either way. A
1717
+ # SUB-FIELD's `transform:` still runs during that walk, so "the walker's
1718
+ # read" above really is what an equivalent request produces: all of them
1719
+ # when the array has no `transform:` of its own, and, when it has one,
1720
+ # those below an array declaring `validate:`, the only readers of their
1721
+ # output (a request runs sub-field transforms, then that `validate:`,
1722
+ # then the array's own transform) — see AuthoredValues.
1723
+ #
1724
+ # An `example:` is validated the same way, as a request would be, but what
1725
+ # is STORED is a second read of it exactly as a client SENDS it: cast and
1726
+ # normalized, with no `transform:` run at any depth and no sub-field
1727
+ # `default:` filled in. Read through a sub-field's transform,
1728
+ # `[{ price: "9.99" }]` under `transform: ->(d) { (d * 100).to_i }` was
1729
+ # published as `[{ "price": 999 }]`: 100 times the price, and a `tags`
1730
+ # split into an Array the server refuses. The two reads cannot be one: an
1731
+ # array-level `validate:` must see the transformed sub-fields a request
1732
+ # hands it (`i[:tags].is_a?(Array)`), so the validating read keeps them,
1733
+ # and the stored read, which runs no `validate:`, discards them.
1734
+ #
1735
+ # The transforming read exists only for that `validate:`, so it runs only
1736
+ # when one could consume it. Anywhere else its output is thrown away, yet
1737
+ # it would still call app code at class load: `optional :id, :string,
1738
+ # transform: ->(s) { Account.find(s) }` under an `example:` hit the
1739
+ # database, or raised, at boot. Only an array carries a `validate:` that
1740
+ # sees its transformed children: a scalar's or a :json field's runs on
1741
+ # its own value before its own transform, and a nested hash takes none.
1742
+ # Without one, the as-sent read both validates and is stored.
1587
1743
  def validate_array_authored_value!(field, opt)
1588
1744
  value = field[opt]
1589
1745
  return if authored_nil!(field, opt)
1590
1746
  raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} must be an Array" unless value.is_a?(Array)
1591
1747
 
1592
- read, violations = AuthoredValues.read_array(field, value)
1748
+ as_sent = opt == :example && !array_validate_in?(field)
1749
+ read, violations = AuthoredValues.read_array(field, value, as_sent: as_sent)
1593
1750
  unless violations.empty?
1594
1751
  raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} violates its own contract: " \
1595
1752
  "#{AuthoredValues.summary(violations)}"
1596
1753
  end
1597
1754
 
1598
- field[opt] = freeze_authored(field[:transform] ? value : read)
1755
+ read, = AuthoredValues.read_array(field, value, as_sent: true) if opt == :example && !as_sent
1756
+ field[opt] = freeze_authored(opt == :default && field[:transform] ? value : read)
1757
+ end
1758
+
1759
+ # Whether `field`, or any array or nested hash below it, declares an
1760
+ # array-level `validate:`.
1761
+ def array_validate_in?(field)
1762
+ return true if field[:kind] == :array && field[:validate]
1763
+
1764
+ Array(field[:fields]).any? { |sub| array_validate_in?(sub) }
1599
1765
  end
1600
1766
 
1601
1767
  # A contract is frozen data, but `@fields.map(&:freeze)` freezes only the
@@ -1634,6 +1800,18 @@ module Permittable
1634
1800
  "declare nullable: true to make an explicit null part of the contract"
1635
1801
  end
1636
1802
 
1803
+ # `desc:` is exported as the schema's `description`, and was handed out
1804
+ # by reference: a caller appending to a generated document's
1805
+ # description (" (deprecated)", a gsub! for localisation) rewrote the
1806
+ # contract inside the frozen rule, and every later export in the process
1807
+ # carried the edit. Frozen as a copy, like message:, default: and
1808
+ # example:, so the host's own String is left as it passed it. The
1809
+ # exporters hand out an unfrozen `dup` of it, so editing a generated
1810
+ # document in place works and still cannot reach the contract.
1811
+ def freeze_desc!(field)
1812
+ field[:desc] = freeze_authored(field[:desc]) unless field[:desc].nil?
1813
+ end
1814
+
1637
1815
  # `message:` customizes what the client reads for a violation on this
1638
1816
  # field: one String covering every code, or a Hash of code => String
1639
1817
  # (codes without an entry keep the default rendering). Keys are
@@ -1733,7 +1911,7 @@ module Permittable
1733
1911
 
1734
1912
  rule = { actions: actions.flatten.map(&:to_s).freeze, root: root && root.to_sym,
1735
1913
  model: model_class, unknown: unknown, enforce: !!enforce, mode: mode, fields: fields,
1736
- finalize: builder.finalizer, desc: desc }.freeze
1914
+ finalize: builder.finalizer, desc: desc&.deep_dup&.freeze }.freeze
1737
1915
  self.permittable_contracts = permittable_contracts + [rule]
1738
1916
  end
1739
1917
 
@@ -1780,10 +1958,11 @@ module Permittable
1780
1958
 
1781
1959
  types = checked.to_h { |f| [f[:name], f[:type]] }
1782
1960
  allowed = checked.select { |f| f.key?(:in) }.to_h { |f| [f[:name], f[:in]] }
1961
+ transformed = checked.select { |f| f[:transform] }.map { |f| f[:name].to_sym }
1783
1962
  begin
1784
1963
  ColumnGuard.ensure_columns_on!(LABEL, model_class, *checked.map { |f| f[:name] },
1785
1964
  types: types, check_types: Permittable.check_column_types,
1786
- allowed: allowed)
1965
+ allowed: allowed, transformed: transformed)
1787
1966
  rescue ArgumentError => e
1788
1967
  # The type error carries its own guidance; only the missing-column one
1789
1968
  # needs the virtual: hint appended.
@@ -1858,10 +2037,33 @@ module Permittable
1858
2037
  return nil unless action
1859
2038
 
1860
2039
  rule = self.class.permit_rule_for(action)
1861
- permitted_params(action) if rule && (rule[:enforce] || permittable_mode(rule) == :monitor)
2040
+ return nil unless rule
2041
+
2042
+ if permittable_mode(rule) == :monitor
2043
+ permittable_monitor_eagerly(action)
2044
+ elsif rule[:enforce]
2045
+ permitted_params(action)
2046
+ end
1862
2047
  nil
1863
2048
  end
1864
2049
 
2050
+ # Monitor mode's promise is that the action runs, whatever the request
2051
+ # holds — that is what makes it safe to turn on. Checking eagerly reads
2052
+ # `params`, which raises ActionDispatch's ParseError on a body Rails
2053
+ # cannot parse: a webhook action reading request.raw_post itself got a
2054
+ # 400 instead of running. An exception from the contract's own code
2055
+ # (validate:, transform:, finalize) became a 500 the same way. Either is
2056
+ # now logged, by class only (a parse error's message can quote the body),
2057
+ # and the action runs; one that reads permitted_params meets the same
2058
+ # exception itself, as it would with no contract at all.
2059
+ def permittable_monitor_eagerly(action)
2060
+ permitted_params(action)
2061
+ rescue StandardError => e
2062
+ return unless respond_to?(:logger) && logger
2063
+
2064
+ logger.warn("#{LABEL}: [monitor] ##{action} could not be checked: #{e.class} — the action runs unchecked")
2065
+ end
2066
+
1865
2067
  # The violation details recorded by validating `action` (default: the
1866
2068
  # current action) — [] when the request satisfied the contract. Triggers
1867
2069
  # the same memoized validation as permitted_params, so under monitor mode
@@ -1891,38 +2093,74 @@ module Permittable
1891
2093
  )
1892
2094
  end
1893
2095
 
1894
- private
1895
-
1896
2096
  # ParamsWrapper copies a JSON body under the controller's wrapper key
1897
2097
  # (`user` for UsersController) — on by default in a Rails app — and a
1898
2098
  # rootless contract then saw that copy as an unknown top-level key on every
1899
- # well-formed request. Whether the copy is Rails' own has to be read HERE,
1900
- # before ParamsWrapper#process_action (next in the chain) runs: once it has
1901
- # wrapped, `_wrapper_enabled?` answers false, because params now carry the
1902
- # key. Asking afterwards could not tell Rails' copy from a client that sent
1903
- # `user` itself, which is exactly the key the check must still flag.
1904
- # Private, like the method it wraps — a public one would become an action.
1905
- # A plain duck has no process_action and no ParamsWrapper, so this never
1906
- # runs there, and the guards keep an actionpack-free host inert.
2099
+ # well-formed request. Whether the copy is Rails' own has to be read before
2100
+ # ParamsWrapper#process_action wraps: afterwards `_wrapper_enabled?` answers
2101
+ # false, because params now carry the key, and nothing left in the request
2102
+ # tells Rails' copy from a client that sent `user` itself, which is exactly
2103
+ # the key the check must still flag. (Comparing the key's value with what
2104
+ # the wrapper would have built only guesses: a client can send that value.)
2105
+ #
2106
+ # So it is read here, in `process`, not in process_action. Every
2107
+ # process_action, ParamsWrapper's included, runs inside
2108
+ # AbstractController::Base#process, so no module order can put the
2109
+ # wrapping first. A process_action hook relied on Rails' own order:
2110
+ # ActionController::Base includes ParamsWrapper, so an
2111
+ # ApplicationController's Permittable runs before it. A Metal controller
2112
+ # that includes ParamsWrapper AFTER Permittable (in a subclass, say) ran
2113
+ # ParamsWrapper's process_action first, and every wrapped JSON request was
2114
+ # a 422 again.
2115
+ #
2116
+ # Public, like the method it wraps, so a caller that sends `process`
2117
+ # explicitly still can. It never becomes an action:
2118
+ # AbstractController::Base#process is a public method of every abstract
2119
+ # controller class (Metal, Base, API), and action_methods leaves those out
2120
+ # as internal. On a plain duck, which has no ParamsWrapper, the guards
2121
+ # make it a pass-through to whatever `process` the host has.
1907
2122
  # Assigned on every request, never only when true: a controller instance
1908
2123
  # dispatched twice would otherwise carry one request's exemption into the
1909
2124
  # next, where a `user` the client did send would pass as Rails' copy.
1910
- def process_action(*)
2125
+ def process(...)
1911
2126
  @permittable_wrapper_key = permittable_wrapper_copy_key
1912
2127
  super
1913
2128
  end
1914
2129
 
1915
- # The wrapper key, if ParamsWrapper is about to copy the body under it;
1916
- # nil otherwise. `_wrapper_enabled?` alone is not enough: it asks the
1917
- # string-keyed params for the key AS CONFIGURED, so `wrap_parameters :user`
1918
- # — the form the Rails docs use — answers "not sent" even when the client
1919
- # sent `user` itself, and Rails wraps anyway. Asking the same params for
1920
- # the String keeps that client's key the client's, whichever spelling the
1921
- # host chose.
2130
+ private
2131
+
2132
+ # The wrapper key, if ParamsWrapper is about to copy the body under it and
2133
+ # a contract on this controller would act on that; nil otherwise.
2134
+ #
2135
+ # The request is asked only when the answer can matter. A controller none
2136
+ # of whose contracts reads its input without the copy (see
2137
+ # permittable_drops_wrapper_copy?) — only rooted contracts, say, or the
2138
+ # default `unknown: :ignore` with no field of the wrapper's name — paid a
2139
+ # content-type lookup and a params read on every request for nothing.
2140
+ # Everything the gate reads is class-level (the wrapper's formats and name,
2141
+ # and the contracts), so a skipped probe touches nothing of the request.
2142
+ # The formats come first, in ParamsWrapper's own order: with none it never
2143
+ # wraps and never asks for its name — which it could not give, an
2144
+ # unconfigured wrapper having no controller to derive one from. The
2145
+ # contract gate is ALL of the controller's contracts, not only the one
2146
+ # covering this action: permitted_params(:create) can read another
2147
+ # action's contract from inside #update, and by then ParamsWrapper has
2148
+ # wrapped and the question can no longer be answered.
2149
+ #
2150
+ # `_wrapper_enabled?` asks the params for the key as configured. Rails'
2151
+ # params are indifferent on every supported version, so that already
2152
+ # covers `wrap_parameters :user` and "user" alike. Asking again for the
2153
+ # String keeps a client's `user` the client's on a plain string-keyed
2154
+ # params Hash too — one a test seeds into the request directly — where
2155
+ # :user would answer "not sent" and ParamsWrapper would wrap anyway.
1922
2156
  def permittable_wrapper_copy_key
1923
- return unless respond_to?(:_wrapper_enabled?, true) && respond_to?(:_wrapper_key, true) && _wrapper_enabled?
2157
+ return unless %i[_wrapper_enabled? _wrapper_formats _wrapper_key].all? { |name| respond_to?(name, true) }
2158
+ return if _wrapper_formats.empty?
2159
+
2160
+ key = _wrapper_key&.to_s
2161
+ return unless key && self.class.permittable_contracts.any? { |rule| permittable_drops_wrapper_copy?(rule, key) }
2162
+ return unless _wrapper_enabled?
1924
2163
 
1925
- key = _wrapper_key.to_s
1926
2164
  key unless request.parameters.key?(key)
1927
2165
  end
1928
2166
 
@@ -1944,7 +2182,7 @@ module Permittable
1944
2182
  source = permittable_root_hash(rule, violations)
1945
2183
  result = ActiveSupport::HashWithIndifferentAccess.new
1946
2184
  if source
1947
- checked = rule[:root] ? source : permittable_without_wrapper_copy(rule[:fields], source)
2185
+ checked = permittable_without_wrapper_copy(rule, source)
1948
2186
  result = permittable_check_hash(rule[:fields], checked, path: rule[:root] ? rule[:root].to_s : nil,
1949
2187
  unknown: rule[:unknown], top_level: !rule[:root], violations: violations)
1950
2188
  end
@@ -2223,6 +2461,7 @@ module Permittable
2223
2461
  violations << permittable_violation(field, full, "invalid_type")
2224
2462
  end
2225
2463
  when :array
2464
+ value = value.values if permittable_fields_for_records?(field, value)
2226
2465
  if value.is_a?(Array)
2227
2466
  result[key] = permittable_check_array(field, value, path: full, unknown: unknown, violations: violations)
2228
2467
  else
@@ -2231,6 +2470,20 @@ module Permittable
2231
2470
  end
2232
2471
  end
2233
2472
 
2473
+ # Whether `value` is an array of hashes sent the way Rails' fields_for
2474
+ # sends one: `user[addresses_attributes][0][city]` reaches the params as a
2475
+ # Hash keyed "0", "1", ... (or a timestamp, for a record added in the
2476
+ # browser). Strong params permits exactly this shape (its
2477
+ # fields_for_style?: every key an integer, every value a hash) and
2478
+ # accepts_nested_attributes_for reads its values in order, so an
2479
+ # `array ... do` field reads them as its elements. Paths then count the
2480
+ # records in the order sent. An empty Hash, or one of scalars, is not this
2481
+ # shape and stays invalid_type.
2482
+ def permittable_fields_for_records?(field, value)
2483
+ field[:fields] && value.is_a?(Hash) && !value.empty? &&
2484
+ value.all? { |key, record| key.to_s.match?(/\A-?\d+\z/) && record.is_a?(Hash) }
2485
+ end
2486
+
2234
2487
  # Coercion.check_json, with the copy the result needs made in the middle.
2235
2488
  # The opaque hash is handed over whole, and HashWithIndifferentAccess
2236
2489
  # rebuilt its containers but not the Strings inside them, so it is
@@ -2293,8 +2546,9 @@ module Permittable
2293
2546
  # NOTHING violated, validate: included — a transform may rely on what
2294
2547
  # validate: checked (`Math.sqrt` after "all positive").
2295
2548
  elements_valid = violations.drop(before).all? { |v| permittable_unknown_key_violation?(v) }
2296
- if field[:validate] && elements_valid
2297
- status, code = Coercion.check_custom(field[:validate], out)
2549
+ validator = permittable_array_validator(field)
2550
+ if validator && elements_valid
2551
+ status, code = Coercion.check_custom(validator, out)
2298
2552
  violations << permittable_violation(field, path, code) unless status == :ok
2299
2553
  end
2300
2554
  # Transform only a fully-valid array — a partially-nil one (element
@@ -2309,6 +2563,11 @@ module Permittable
2309
2563
  field[:transform] ? field[:transform].call(value) : value
2310
2564
  end
2311
2565
 
2566
+ # A second seam, for the same reader: the array-level `validate:` to run.
2567
+ def permittable_array_validator(field)
2568
+ field[:validate]
2569
+ end
2570
+
2312
2571
  def permittable_check_element(field, element, path, unknown:, violations:)
2313
2572
  if field[:fields]
2314
2573
  unless element.is_a?(Hash)
@@ -2385,6 +2644,7 @@ module Permittable
2385
2644
  if top_level
2386
2645
  extra -= UNCHECKED_TOP_LEVEL_KEYS + permittable_request_supplied_keys
2387
2646
  extra -= [permittable_configured_csrf_key].compact
2647
+ extra -= permittable_image_submit_keys(hash, extra)
2388
2648
  end
2389
2649
  return if extra.empty?
2390
2650
 
@@ -2410,6 +2670,33 @@ module Permittable
2410
2670
  request.path_parameters.keys.map(&:to_s)
2411
2671
  end
2412
2672
 
2673
+ # The click point of an image submit among `keys` (the undeclared top-level
2674
+ # keys). image_submit_tag renders <input type="image">, and a browser
2675
+ # submitting through it posts where it was clicked: `x=12&y=7`, or
2676
+ # `save.x=12&save.y=7` when the tag is named "save" (Rack keeps a dotted
2677
+ # key as it is). Like the other FORM_KEYS these are the framework's own
2678
+ # keys, so `unknown: :error` must not fail the form for them. Unlike them
2679
+ # `x` and `y` are ordinary names a contract may really be sent, so a
2680
+ # lone `x` is still flagged: a point is exempt only as a PAIR, both of
2681
+ # them in `keys` (a declared `x` or `y` is not, and is checked as a field)
2682
+ # and both digits, which is all a browser sends (coordinates are never
2683
+ # negative or fractional). Read by bytes, since a client's key or value can
2684
+ # be invalid UTF-8, which a regexp refuses to match, or another encoding
2685
+ # altogether (a UTF-16 key makes `end_with?` raise).
2686
+ def permittable_image_submit_keys(hash, keys)
2687
+ by_bytes = keys.to_h { |key| [key.b, key] }
2688
+ by_bytes.each_with_object([]) do |(bytes, key), point|
2689
+ next unless bytes == "x" || bytes.end_with?(".x")
2690
+
2691
+ partner = by_bytes["#{bytes.delete_suffix('x')}y"]
2692
+ point.push(key, partner) if partner && [key, partner].all? { |name| permittable_digits?(hash[name]) }
2693
+ end
2694
+ end
2695
+
2696
+ def permittable_digits?(value)
2697
+ value.is_a?(String) && !value.empty? && value.each_byte.all? { |byte| byte.between?(48, 57) }
2698
+ end
2699
+
2413
2700
  # FORM_KEYS bakes in "authenticity_token" — Rails' DEFAULT CSRF parameter
2414
2701
  # name — but `config.action_controller.request_forgery_protection_token`
2415
2702
  # lets an app rename it, and an app that does trips `unknown: :error` on
@@ -2429,28 +2716,39 @@ module Permittable
2429
2716
  token && token.to_s
2430
2717
  end
2431
2718
 
2432
- # A rootless contract's input without ParamsWrapper's copy of the body,
2433
- # when Rails made one (see process_action). Removed rather than merely
2434
- # exempted from the unknown-keys check, because the client never sent that
2435
- # key: a contract that happens to declare a scalar or array field of the
2719
+ # A contract's input without ParamsWrapper's copy of the body, when Rails
2720
+ # made one (see process) and the contract reads its input without it (see
2721
+ # permittable_drops_wrapper_copy?). Removed rather than merely exempted
2722
+ # from the unknown-keys check, because the client never sent that key: a
2723
+ # contract that happens to declare a scalar or array field of the
2436
2724
  # wrapper's name (`optional :feedback, :string` on FeedbackController)
2437
2725
  # would otherwise validate Rails' copy of the whole body as that field — a
2438
- # false 422 invalid_type for a well-formed request.
2439
- #
2440
- # Kept, though, when the contract declares that key as a hash container (a
2441
- # nested block or :json): that rootless contract is reading the copy ON
2442
- # PURPOSE, a root: spelled as a field, and it worked that way before the
2443
- # copy was ever dropped — dropping it would turn every such request into
2444
- # `user missing`. Top level only, where the copy lives; a rooted contract
2445
- # reads the copy as its root, which is exactly what ParamsWrapper is for.
2446
- # What is checked changes, not what monitor mode hands back: its raw
2447
- # pass-through still carries the copy, as the pre-contract app's params did.
2448
- def permittable_without_wrapper_copy(fields, source)
2726
+ # false 422 invalid_type for a well-formed request. Top level only, where
2727
+ # the copy lives. What is checked changes, not what monitor mode hands
2728
+ # back: its raw pass-through still carries the copy, as the pre-contract
2729
+ # app's params did.
2730
+ def permittable_without_wrapper_copy(rule, source)
2449
2731
  key = @permittable_wrapper_key
2450
- return source unless key
2451
- return source if fields.any? { |f| f[:name].to_s == key && WRAPPER_CONTAINER_KINDS.include?(f[:kind]) }
2732
+ key && permittable_drops_wrapper_copy?(rule, key) ? source.except(key) : source
2733
+ end
2452
2734
 
2453
- source.except(key)
2735
+ # Whether `rule` reads its input without ParamsWrapper's copy under `key`
2736
+ # — and so whether the probe's answer changes anything for it, which is
2737
+ # why permittable_wrapper_copy_key asks this too before touching the
2738
+ # request. Not for a rooted contract: it reads the copy as its root, which
2739
+ # is exactly what ParamsWrapper is for. Not for a rootless one declaring
2740
+ # `key` as a hash container (a nested block or :json) either: that
2741
+ # contract is reading the copy ON PURPOSE, a root: spelled as a field, and
2742
+ # it worked that way before the copy was ever dropped — dropping it would
2743
+ # turn every such request into `user missing`. Any other field of that
2744
+ # name would validate the copy, so the copy goes whatever the unknown:
2745
+ # mode. Undeclared, it would only be flagged as unknown, which
2746
+ # `unknown: :ignore` never does, so there the copy changes nothing.
2747
+ def permittable_drops_wrapper_copy?(rule, key)
2748
+ return false if rule[:root]
2749
+
2750
+ field = rule[:fields].find { |f| f[:name].to_s == key }
2751
+ field ? !WRAPPER_CONTAINER_KINDS.include?(field[:kind]) : rule[:unknown] != :ignore
2454
2752
  end
2455
2753
 
2456
2754
  def permittable_path(path, key)