permittable 0.9.0 → 0.11.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -0
- data/README.md +79 -18
- data/lib/permittable/audit.rb +231 -20
- data/lib/permittable/authored_values.rb +24 -8
- data/lib/permittable/generator.rb +69 -14
- data/lib/permittable/json_schema.rb +28 -4
- data/lib/permittable/open_api.rb +438 -80
- data/lib/permittable/tasks/generate.rake +9 -6
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +214 -49
- metadata +2 -2
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
# params.expect calls in its source. Drafts go to stdout (paste-ready); the
|
|
4
4
|
# summary goes to stderr.
|
|
5
5
|
#
|
|
6
|
-
# bin/rails permittable:generate # every uncovered controller
|
|
6
|
+
# bin/rails permittable:generate # every uncovered controller not
|
|
7
|
+
# # left out by Permittable.audit_ignore
|
|
7
8
|
# bin/rails "permittable:generate[UsersController]" # one controller, even if covered
|
|
8
9
|
namespace :permittable do
|
|
9
10
|
desc "Draft Permittable contracts from models and existing permit calls"
|
|
@@ -15,23 +16,25 @@ namespace :permittable do
|
|
|
15
16
|
bases << ActionController::API if defined?(ActionController::API)
|
|
16
17
|
controllers = bases.flat_map(&:descendants).uniq.select(&:name)
|
|
17
18
|
|
|
19
|
+
# { controller => actions to leave out of its draft }. A named controller
|
|
20
|
+
# is drafted whole, even if covered or ignored; the sweep skips both, and
|
|
21
|
+
# leaves out what Permittable.audit_ignore names (see Generator.targets).
|
|
18
22
|
if task_args[:controller]
|
|
19
23
|
controllers = controllers.select { |c| c.name == task_args[:controller] }
|
|
20
24
|
abort "Permittable: no controller named #{task_args[:controller]} was found" if controllers.empty?
|
|
25
|
+
targets = controllers.to_h { |c| [c, []] }
|
|
21
26
|
else
|
|
22
|
-
|
|
23
|
-
c.respond_to?(:permittable_contracts) && c.permittable_contracts.any?
|
|
24
|
-
end
|
|
27
|
+
targets = Permittable::Generator.targets(controllers)
|
|
25
28
|
end
|
|
26
29
|
|
|
27
|
-
drafted =
|
|
30
|
+
drafted = targets.sort_by { |controller, _| controller.name }.count do |controller, except|
|
|
28
31
|
path = begin
|
|
29
32
|
Object.const_source_location(controller.name)&.first
|
|
30
33
|
rescue StandardError
|
|
31
34
|
nil
|
|
32
35
|
end
|
|
33
36
|
source = path && File.exist?(path) ? File.read(path) : nil
|
|
34
|
-
snippet = Permittable::Generator.for_controller(controller, source: source)
|
|
37
|
+
snippet = Permittable::Generator.for_controller(controller, source: source, except: except)
|
|
35
38
|
next false unless snippet
|
|
36
39
|
|
|
37
40
|
puts ["# ====", controller.name, path && "(#{path})", "===="].compact.join(" ")
|
data/lib/permittable/version.rb
CHANGED
data/lib/permittable.rb
CHANGED
|
@@ -218,6 +218,10 @@ module Permittable
|
|
|
218
218
|
UNKNOWN_MODES = %i[ignore log error].freeze
|
|
219
219
|
MODES = %i[enforce monitor].freeze
|
|
220
220
|
ERROR_FORMATS = %i[envelope problem].freeze
|
|
221
|
+
# A Permittable.audit_ignore entry: a controller path the way Rails names
|
|
222
|
+
# it (lowercase, `/`-separated — "active_storage/direct_uploads"),
|
|
223
|
+
# optionally followed by "#action".
|
|
224
|
+
AUDIT_IGNORE_ENTRY = %r{\A[a-z0-9_]+(?:/[a-z0-9_]+)*(?:#\w+)?\z}
|
|
221
225
|
# Rails merges routing bookkeeping into params; a top-level (root: false)
|
|
222
226
|
# unknown-keys check must not flag them.
|
|
223
227
|
ROUTING_KEYS = %w[controller action format].freeze
|
|
@@ -238,7 +242,7 @@ module Permittable
|
|
|
238
242
|
MONITOR_DROPPED_KEYS = ROUTING_KEYS
|
|
239
243
|
# The field kinds that can hold ParamsWrapper's copy of a body — a hash. A
|
|
240
244
|
# rootless contract declaring the wrapper key as one of these reads the
|
|
241
|
-
# copy deliberately, so it is kept (see
|
|
245
|
+
# copy deliberately, so it is kept (see permittable_drops_wrapper_copy?).
|
|
242
246
|
WRAPPER_CONTAINER_KINDS = [:nested, JSON_TYPE].freeze
|
|
243
247
|
# A log line and an exception message are PROSE, written for a person. They
|
|
244
248
|
# list at most this many names and count the rest, so one request cannot
|
|
@@ -403,7 +407,14 @@ module Permittable
|
|
|
403
407
|
# replay of #names — otherwise a sink deduplicating by value would hold
|
|
404
408
|
# both :ssn and "ssn".
|
|
405
409
|
name = name.to_s.downcase
|
|
406
|
-
|
|
410
|
+
unless name.empty?
|
|
411
|
+
# sensitive_parameter_sinks is the same process-global Array
|
|
412
|
+
# on_sensitive_parameter appends to under @registry_mutex. Reading it
|
|
413
|
+
# here without that lock is an unsynchronized concurrent mutation
|
|
414
|
+
# during iteration on any Ruby without a GVL — a thread class-loading
|
|
415
|
+
# a sensitive: true contract can race a thread installing a sink.
|
|
416
|
+
@registry_mutex.synchronize { sensitive_parameter_sinks.dup }.each { |sink| sink.call(name) }
|
|
417
|
+
end
|
|
407
418
|
nil
|
|
408
419
|
end
|
|
409
420
|
|
|
@@ -505,6 +516,55 @@ module Permittable
|
|
|
505
516
|
@check_column_types = value
|
|
506
517
|
end
|
|
507
518
|
|
|
519
|
+
# Routed actions that `permittable:audit` leaves out of its counts and of
|
|
520
|
+
# `[strict]`, and that `permittable:generate` does not draft for — the
|
|
521
|
+
# endpoints that are not the app's to guard:
|
|
522
|
+
#
|
|
523
|
+
# Permittable.audit_ignore = %w[active_storage/direct_uploads application#not_found]
|
|
524
|
+
#
|
|
525
|
+
# Each entry is a controller path as Rails routes it (every action of that
|
|
526
|
+
# controller) or "controller#action" (that one action, on every verb — a
|
|
527
|
+
# `via: :all` catch-all is one entry). Ignored actions are still listed,
|
|
528
|
+
# in a section of their own, and an entry that matches no routed action is
|
|
529
|
+
# listed too: an ignore list nothing checks would hide its own typos.
|
|
530
|
+
#
|
|
531
|
+
# Strings only, checked here. A class name is the likeliest mistake and
|
|
532
|
+
# the error names the path meant; a Class is refused because naming one
|
|
533
|
+
# in an initializer autoloads it at boot, which Zeitwerk refuses for a
|
|
534
|
+
# reloadable controller; a Regexp is refused because a pattern would also
|
|
535
|
+
# ignore controllers written AFTER it, and catching a new unguarded
|
|
536
|
+
# endpoint is what the gate is for. Validated as a whole before anything
|
|
537
|
+
# is stored, so a rejected list leaves the previous one in place. nil
|
|
538
|
+
# clears it.
|
|
539
|
+
def audit_ignore
|
|
540
|
+
@audit_ignore || [].freeze
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def audit_ignore=(entries)
|
|
544
|
+
entries = Array(entries).map { |entry| audit_ignore_entry(entry) }
|
|
545
|
+
@audit_ignore = entries.uniq.freeze
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# Whether the audit and the generator also leave out every controller
|
|
549
|
+
# whose source is outside Rails.root — engines and gems: ActiveStorage,
|
|
550
|
+
# ActionMailbox, Rails's own rails/* controllers. On by default, since an
|
|
551
|
+
# app cannot add a contract to a gem's controller; those are listed in
|
|
552
|
+
# the report under their own reason rather than dropped. Turn it off to
|
|
553
|
+
# audit them like the app's own:
|
|
554
|
+
#
|
|
555
|
+
# Permittable.audit_ignore_outside_root = false
|
|
556
|
+
#
|
|
557
|
+
# See Audit.outside_root? for how a controller is located.
|
|
558
|
+
def audit_ignore_outside_root
|
|
559
|
+
@audit_ignore_outside_root.nil? || @audit_ignore_outside_root
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def audit_ignore_outside_root=(value)
|
|
563
|
+
raise ArgumentError, "#{LABEL}: audit_ignore_outside_root must be true or false" unless [true, false].include?(value)
|
|
564
|
+
|
|
565
|
+
@audit_ignore_outside_root = value
|
|
566
|
+
end
|
|
567
|
+
|
|
508
568
|
# App-wide fallback copy for a violation code, looked up through I18n
|
|
509
569
|
# under permittable.errors.<code> ("missing", "inclusion", or any Symbol
|
|
510
570
|
# a validate: returned). Consulted only when the field declares no
|
|
@@ -519,6 +579,24 @@ module Permittable
|
|
|
519
579
|
message = ::I18n.t("permittable.errors.#{code}", default: nil)
|
|
520
580
|
message.is_a?(String) ? message : nil
|
|
521
581
|
end
|
|
582
|
+
|
|
583
|
+
private
|
|
584
|
+
|
|
585
|
+
# One audit_ignore entry, frozen, or an ArgumentError that says what an
|
|
586
|
+
# entry looks like — and, for a class or a class name, which path it
|
|
587
|
+
# meant: `ActiveStorage::DirectUploadsController` is the path
|
|
588
|
+
# "active_storage/direct_uploads", the same derivation Rails's own
|
|
589
|
+
# controller_path makes.
|
|
590
|
+
def audit_ignore_entry(entry)
|
|
591
|
+
return entry.dup.freeze if entry.is_a?(String) && AUDIT_IGNORE_ENTRY.match?(entry)
|
|
592
|
+
|
|
593
|
+
meant = entry.controller_path if entry.is_a?(Class) && entry.respond_to?(:controller_path)
|
|
594
|
+
meant ||= entry.underscore.delete_suffix("_controller") if entry.is_a?(String) && entry.match?(/\A[A-Z]\w*(::[A-Z]\w*)*\z/)
|
|
595
|
+
raise ArgumentError,
|
|
596
|
+
"#{LABEL}: audit_ignore takes controller paths as Rails routes them (\"active_storage/direct_uploads\") " \
|
|
597
|
+
"or \"controller#action\" (\"application#not_found\"), got #{entry.inspect}" \
|
|
598
|
+
"#{" — did you mean #{meant.inspect}?" if meant}"
|
|
599
|
+
end
|
|
522
600
|
end
|
|
523
601
|
|
|
524
602
|
# Raised when the request violates the matching contract. `details` is an
|
|
@@ -744,6 +822,17 @@ module Permittable
|
|
|
744
822
|
end
|
|
745
823
|
end
|
|
746
824
|
|
|
825
|
+
# Kernel#Integer/Float and BigDecimal() all accept underscore digit
|
|
826
|
+
# separators and surrounding whitespace — a convenience for a NUMBER
|
|
827
|
+
# LITERAL IN RUBY SOURCE, not for a request body. "1_8" is not how a
|
|
828
|
+
# client spells eighteen, and " 99 " is not how one spells ninety-nine;
|
|
829
|
+
# silently accepting either is the same kind of leniency as the
|
|
830
|
+
# NaN/Infinity/`0e10` cases below, just arriving from a different door.
|
|
831
|
+
# Checked against the raw String before any of those delegate, so a
|
|
832
|
+
# non-canonical spelling never reaches them at all.
|
|
833
|
+
INTEGER_FORMAT = /\A[+-]?\d+\z/
|
|
834
|
+
NUMERIC_FORMAT = /\A[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?\z/
|
|
835
|
+
|
|
747
836
|
def cast_integer(value)
|
|
748
837
|
case value
|
|
749
838
|
when Integer then [:ok, value]
|
|
@@ -751,7 +840,7 @@ module Permittable
|
|
|
751
840
|
# RangeError, which the ArgumentError rescue below does not catch), and
|
|
752
841
|
# no integer is what either one sent. Same rule as finite_float.
|
|
753
842
|
when Float then value.finite? && value == value.truncate ? [:ok, value.to_i] : [:error, "invalid_type"]
|
|
754
|
-
when String then [:ok, Integer(value, 10)]
|
|
843
|
+
when String then value.match?(INTEGER_FORMAT) ? [:ok, Integer(value, 10)] : [:error, "invalid_type"]
|
|
755
844
|
else [:error, "invalid_type"]
|
|
756
845
|
end
|
|
757
846
|
rescue ArgumentError
|
|
@@ -761,7 +850,7 @@ module Permittable
|
|
|
761
850
|
def cast_float(value)
|
|
762
851
|
case value
|
|
763
852
|
when Numeric then finite_float(value.to_f)
|
|
764
|
-
when String then finite_float(Float(value), source: value)
|
|
853
|
+
when String then value.match?(NUMERIC_FORMAT) ? finite_float(Float(value), source: value) : [:error, "invalid_type"]
|
|
765
854
|
else [:error, "invalid_type"]
|
|
766
855
|
end
|
|
767
856
|
rescue ArgumentError
|
|
@@ -792,7 +881,8 @@ module Permittable
|
|
|
792
881
|
|
|
793
882
|
def cast_decimal(value)
|
|
794
883
|
case value
|
|
795
|
-
when Numeric
|
|
884
|
+
when Numeric then finite_decimal(BigDecimal(value.to_s))
|
|
885
|
+
when String then value.match?(NUMERIC_FORMAT) ? finite_decimal(BigDecimal(value)) : [:error, "invalid_type"]
|
|
796
886
|
else [:error, "invalid_type"]
|
|
797
887
|
end
|
|
798
888
|
rescue ArgumentError
|
|
@@ -1561,8 +1651,10 @@ module Permittable
|
|
|
1561
1651
|
# gets); with one, the array exactly AS AUTHORED — the walker still runs,
|
|
1562
1652
|
# so a declaration mistake (an element `validate:` refuses, a sub-field
|
|
1563
1653
|
# default out of bounds) still fails at class load, but its cast result
|
|
1564
|
-
# is discarded rather than stored. `transform:`
|
|
1565
|
-
# never run on a default either way
|
|
1654
|
+
# is discarded rather than stored. The array's OWN `transform:` is
|
|
1655
|
+
# deliberately never run on a default either way; a SUB-FIELD's
|
|
1656
|
+
# `transform:` still runs during that walk, so "the walker's read" above
|
|
1657
|
+
# really is what an equivalent request produces — see AuthoredValues.
|
|
1566
1658
|
def validate_array_authored_value!(field, opt)
|
|
1567
1659
|
value = field[opt]
|
|
1568
1660
|
return if authored_nil!(field, opt)
|
|
@@ -1621,7 +1713,11 @@ module Permittable
|
|
|
1621
1713
|
def validate_message!(field)
|
|
1622
1714
|
spec = field[:message]
|
|
1623
1715
|
return if spec.nil?
|
|
1624
|
-
|
|
1716
|
+
|
|
1717
|
+
if spec.is_a?(String)
|
|
1718
|
+
field[:message] = freeze_authored(spec)
|
|
1719
|
+
return
|
|
1720
|
+
end
|
|
1625
1721
|
|
|
1626
1722
|
valid_hash = spec.is_a?(Hash) && !spec.empty? &&
|
|
1627
1723
|
spec.all? { |code, text| (code.is_a?(Symbol) || code.is_a?(String)) && text.is_a?(String) }
|
|
@@ -1630,7 +1726,7 @@ module Permittable
|
|
|
1630
1726
|
"or a Hash of violation code => String (e.g. { missing: \"is required\" })"
|
|
1631
1727
|
end
|
|
1632
1728
|
|
|
1633
|
-
field[:message] = spec.transform_keys(&:to_sym)
|
|
1729
|
+
field[:message] = freeze_authored(spec.transform_keys(&:to_sym))
|
|
1634
1730
|
end
|
|
1635
1731
|
end
|
|
1636
1732
|
|
|
@@ -1866,38 +1962,74 @@ module Permittable
|
|
|
1866
1962
|
)
|
|
1867
1963
|
end
|
|
1868
1964
|
|
|
1869
|
-
private
|
|
1870
|
-
|
|
1871
1965
|
# ParamsWrapper copies a JSON body under the controller's wrapper key
|
|
1872
1966
|
# (`user` for UsersController) — on by default in a Rails app — and a
|
|
1873
1967
|
# rootless contract then saw that copy as an unknown top-level key on every
|
|
1874
|
-
# well-formed request. Whether the copy is Rails' own has to be read
|
|
1875
|
-
#
|
|
1876
|
-
#
|
|
1877
|
-
#
|
|
1878
|
-
#
|
|
1879
|
-
#
|
|
1880
|
-
#
|
|
1881
|
-
#
|
|
1968
|
+
# well-formed request. Whether the copy is Rails' own has to be read before
|
|
1969
|
+
# ParamsWrapper#process_action wraps: afterwards `_wrapper_enabled?` answers
|
|
1970
|
+
# false, because params now carry the key, and nothing left in the request
|
|
1971
|
+
# tells Rails' copy from a client that sent `user` itself, which is exactly
|
|
1972
|
+
# the key the check must still flag. (Comparing the key's value with what
|
|
1973
|
+
# the wrapper would have built only guesses: a client can send that value.)
|
|
1974
|
+
#
|
|
1975
|
+
# So it is read here, in `process`, not in process_action. Every
|
|
1976
|
+
# process_action, ParamsWrapper's included, runs inside
|
|
1977
|
+
# AbstractController::Base#process, so no module order can put the
|
|
1978
|
+
# wrapping first. A process_action hook relied on Rails' own order:
|
|
1979
|
+
# ActionController::Base includes ParamsWrapper, so an
|
|
1980
|
+
# ApplicationController's Permittable runs before it. A Metal controller
|
|
1981
|
+
# that includes ParamsWrapper AFTER Permittable (in a subclass, say) ran
|
|
1982
|
+
# ParamsWrapper's process_action first, and every wrapped JSON request was
|
|
1983
|
+
# a 422 again.
|
|
1984
|
+
#
|
|
1985
|
+
# Public, like the method it wraps, so a caller that sends `process`
|
|
1986
|
+
# explicitly still can. It never becomes an action:
|
|
1987
|
+
# AbstractController::Base#process is a public method of every abstract
|
|
1988
|
+
# controller class (Metal, Base, API), and action_methods leaves those out
|
|
1989
|
+
# as internal. On a plain duck, which has no ParamsWrapper, the guards
|
|
1990
|
+
# make it a pass-through to whatever `process` the host has.
|
|
1882
1991
|
# Assigned on every request, never only when true: a controller instance
|
|
1883
1992
|
# dispatched twice would otherwise carry one request's exemption into the
|
|
1884
1993
|
# next, where a `user` the client did send would pass as Rails' copy.
|
|
1885
|
-
def
|
|
1994
|
+
def process(...)
|
|
1886
1995
|
@permittable_wrapper_key = permittable_wrapper_copy_key
|
|
1887
1996
|
super
|
|
1888
1997
|
end
|
|
1889
1998
|
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
#
|
|
1893
|
-
#
|
|
1894
|
-
#
|
|
1895
|
-
#
|
|
1896
|
-
#
|
|
1999
|
+
private
|
|
2000
|
+
|
|
2001
|
+
# The wrapper key, if ParamsWrapper is about to copy the body under it and
|
|
2002
|
+
# a contract on this controller would act on that; nil otherwise.
|
|
2003
|
+
#
|
|
2004
|
+
# The request is asked only when the answer can matter. A controller none
|
|
2005
|
+
# of whose contracts reads its input without the copy (see
|
|
2006
|
+
# permittable_drops_wrapper_copy?) — only rooted contracts, say, or the
|
|
2007
|
+
# default `unknown: :ignore` with no field of the wrapper's name — paid a
|
|
2008
|
+
# content-type lookup and a params read on every request for nothing.
|
|
2009
|
+
# Everything the gate reads is class-level (the wrapper's formats and name,
|
|
2010
|
+
# and the contracts), so a skipped probe touches nothing of the request.
|
|
2011
|
+
# The formats come first, in ParamsWrapper's own order: with none it never
|
|
2012
|
+
# wraps and never asks for its name — which it could not give, an
|
|
2013
|
+
# unconfigured wrapper having no controller to derive one from. The
|
|
2014
|
+
# contract gate is ALL of the controller's contracts, not only the one
|
|
2015
|
+
# covering this action: permitted_params(:create) can read another
|
|
2016
|
+
# action's contract from inside #update, and by then ParamsWrapper has
|
|
2017
|
+
# wrapped and the question can no longer be answered.
|
|
2018
|
+
#
|
|
2019
|
+
# `_wrapper_enabled?` asks the params for the key as configured. Rails'
|
|
2020
|
+
# params are indifferent on every supported version, so that already
|
|
2021
|
+
# covers `wrap_parameters :user` and "user" alike. Asking again for the
|
|
2022
|
+
# String keeps a client's `user` the client's on a plain string-keyed
|
|
2023
|
+
# params Hash too — one a test seeds into the request directly — where
|
|
2024
|
+
# :user would answer "not sent" and ParamsWrapper would wrap anyway.
|
|
1897
2025
|
def permittable_wrapper_copy_key
|
|
1898
|
-
return unless
|
|
2026
|
+
return unless %i[_wrapper_enabled? _wrapper_formats _wrapper_key].all? { |name| respond_to?(name, true) }
|
|
2027
|
+
return if _wrapper_formats.empty?
|
|
2028
|
+
|
|
2029
|
+
key = _wrapper_key&.to_s
|
|
2030
|
+
return unless key && self.class.permittable_contracts.any? { |rule| permittable_drops_wrapper_copy?(rule, key) }
|
|
2031
|
+
return unless _wrapper_enabled?
|
|
1899
2032
|
|
|
1900
|
-
key = _wrapper_key.to_s
|
|
1901
2033
|
key unless request.parameters.key?(key)
|
|
1902
2034
|
end
|
|
1903
2035
|
|
|
@@ -1919,7 +2051,7 @@ module Permittable
|
|
|
1919
2051
|
source = permittable_root_hash(rule, violations)
|
|
1920
2052
|
result = ActiveSupport::HashWithIndifferentAccess.new
|
|
1921
2053
|
if source
|
|
1922
|
-
checked =
|
|
2054
|
+
checked = permittable_without_wrapper_copy(rule, source)
|
|
1923
2055
|
result = permittable_check_hash(rule[:fields], checked, path: rule[:root] ? rule[:root].to_s : nil,
|
|
1924
2056
|
unknown: rule[:unknown], top_level: !rule[:root], violations: violations)
|
|
1925
2057
|
end
|
|
@@ -2357,7 +2489,10 @@ module Permittable
|
|
|
2357
2489
|
|
|
2358
2490
|
declared = fields.map { |f| f[:name].to_s }
|
|
2359
2491
|
extra = hash.keys.map(&:to_s) - declared
|
|
2360
|
-
|
|
2492
|
+
if top_level
|
|
2493
|
+
extra -= UNCHECKED_TOP_LEVEL_KEYS + permittable_request_supplied_keys
|
|
2494
|
+
extra -= [permittable_configured_csrf_key].compact
|
|
2495
|
+
end
|
|
2361
2496
|
return if extra.empty?
|
|
2362
2497
|
|
|
2363
2498
|
if unknown == :error
|
|
@@ -2382,28 +2517,58 @@ module Permittable
|
|
|
2382
2517
|
request.path_parameters.keys.map(&:to_s)
|
|
2383
2518
|
end
|
|
2384
2519
|
|
|
2385
|
-
#
|
|
2386
|
-
#
|
|
2387
|
-
#
|
|
2388
|
-
#
|
|
2520
|
+
# FORM_KEYS bakes in "authenticity_token" — Rails' DEFAULT CSRF parameter
|
|
2521
|
+
# name — but `config.action_controller.request_forgery_protection_token`
|
|
2522
|
+
# lets an app rename it, and an app that does trips `unknown: :error` on
|
|
2523
|
+
# every ordinary form submission: exactly the bug FORM_KEYS exists to
|
|
2524
|
+
# prevent, just spelled with the app's own key instead of the default one.
|
|
2525
|
+
# The configured name isn't knowable at class-load time (it can vary per
|
|
2526
|
+
# controller, and Rails may not have finished initializing yet), so it's
|
|
2527
|
+
# read fresh here off the live controller instead of folded into a frozen
|
|
2528
|
+
# constant. `request_forgery_protection_token` comes from
|
|
2529
|
+
# ActionController::RequestForgeryProtection, included by ActionController
|
|
2530
|
+
# ::Base; a plain params duck or a standalone Contract has no such method
|
|
2531
|
+
# and exempts nothing beyond FORM_KEYS's own "authenticity_token".
|
|
2532
|
+
def permittable_configured_csrf_key
|
|
2533
|
+
return nil unless respond_to?(:request_forgery_protection_token)
|
|
2534
|
+
|
|
2535
|
+
token = request_forgery_protection_token
|
|
2536
|
+
token && token.to_s
|
|
2537
|
+
end
|
|
2538
|
+
|
|
2539
|
+
# A contract's input without ParamsWrapper's copy of the body, when Rails
|
|
2540
|
+
# made one (see process) and the contract reads its input without it (see
|
|
2541
|
+
# permittable_drops_wrapper_copy?). Removed rather than merely exempted
|
|
2542
|
+
# from the unknown-keys check, because the client never sent that key: a
|
|
2543
|
+
# contract that happens to declare a scalar or array field of the
|
|
2389
2544
|
# wrapper's name (`optional :feedback, :string` on FeedbackController)
|
|
2390
2545
|
# would otherwise validate Rails' copy of the whole body as that field — a
|
|
2391
|
-
# false 422 invalid_type for a well-formed request.
|
|
2392
|
-
#
|
|
2393
|
-
#
|
|
2394
|
-
#
|
|
2395
|
-
|
|
2396
|
-
# copy was ever dropped — dropping it would turn every such request into
|
|
2397
|
-
# `user missing`. Top level only, where the copy lives; a rooted contract
|
|
2398
|
-
# reads the copy as its root, which is exactly what ParamsWrapper is for.
|
|
2399
|
-
# What is checked changes, not what monitor mode hands back: its raw
|
|
2400
|
-
# pass-through still carries the copy, as the pre-contract app's params did.
|
|
2401
|
-
def permittable_without_wrapper_copy(fields, source)
|
|
2546
|
+
# false 422 invalid_type for a well-formed request. Top level only, where
|
|
2547
|
+
# the copy lives. What is checked changes, not what monitor mode hands
|
|
2548
|
+
# back: its raw pass-through still carries the copy, as the pre-contract
|
|
2549
|
+
# app's params did.
|
|
2550
|
+
def permittable_without_wrapper_copy(rule, source)
|
|
2402
2551
|
key = @permittable_wrapper_key
|
|
2403
|
-
|
|
2404
|
-
|
|
2552
|
+
key && permittable_drops_wrapper_copy?(rule, key) ? source.except(key) : source
|
|
2553
|
+
end
|
|
2405
2554
|
|
|
2406
|
-
|
|
2555
|
+
# Whether `rule` reads its input without ParamsWrapper's copy under `key`
|
|
2556
|
+
# — and so whether the probe's answer changes anything for it, which is
|
|
2557
|
+
# why permittable_wrapper_copy_key asks this too before touching the
|
|
2558
|
+
# request. Not for a rooted contract: it reads the copy as its root, which
|
|
2559
|
+
# is exactly what ParamsWrapper is for. Not for a rootless one declaring
|
|
2560
|
+
# `key` as a hash container (a nested block or :json) either: that
|
|
2561
|
+
# contract is reading the copy ON PURPOSE, a root: spelled as a field, and
|
|
2562
|
+
# it worked that way before the copy was ever dropped — dropping it would
|
|
2563
|
+
# turn every such request into `user missing`. Any other field of that
|
|
2564
|
+
# name would validate the copy, so the copy goes whatever the unknown:
|
|
2565
|
+
# mode. Undeclared, it would only be flagged as unknown, which
|
|
2566
|
+
# `unknown: :ignore` never does, so there the copy changes nothing.
|
|
2567
|
+
def permittable_drops_wrapper_copy?(rule, key)
|
|
2568
|
+
return false if rule[:root]
|
|
2569
|
+
|
|
2570
|
+
field = rule[:fields].find { |f| f[:name].to_s == key }
|
|
2571
|
+
field ? !WRAPPER_CONTAINER_KINDS.include?(field[:kind]) : rule[:unknown] != :ignore
|
|
2407
2572
|
end
|
|
2408
2573
|
|
|
2409
2574
|
def permittable_path(path, key)
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: permittable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Nguyen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|