permittable 0.14.0 → 0.15.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.
@@ -197,19 +197,46 @@ module Permittable
197
197
  # required field (missing).
198
198
  def request_body_for(controller, action)
199
199
  rule = controller.permit_rule_for(action)
200
- rule && rule_request_body(rule)
200
+ rule && rule_request_body(rule, controller)
201
201
  end
202
202
 
203
- def rule_request_body(rule)
203
+ def rule_request_body(rule, controller)
204
204
  media_type = body_media_type(rule)
205
205
  schema = JsonSchema.rule(rule)
206
- schema = form_schema(schema, rule) if media_type == "multipart/form-data"
206
+ exempt = exempt_keys(controller)
207
+ schema = form_schema(schema, rule, exempt) if media_type == "multipart/form-data"
208
+ allow_form_keys!(schema, exempt) if media_type != "multipart/form-data" && rule[:unknown] == :error && !rule[:root]
207
209
  {
208
210
  "required" => !!(rule[:root] || rule[:fields].any? { |f| f[:required] }),
209
211
  "content" => { media_type => { "schema" => schema } }
210
212
  }
211
213
  end
212
214
 
215
+ # The top-level keys the runtime exempts under `unknown: :error`: the
216
+ # constant list it subtracts (UNCHECKED_TOP_LEVEL_KEYS) plus the CSRF
217
+ # parameter the controller renamed, which it reads off the controller too
218
+ # (permittable_configured_csrf_key). Per-instance overrides and an image
219
+ # submit's x/y pair cannot be known here.
220
+ def exempt_keys(controller)
221
+ token = controller.request_forgery_protection_token if controller.respond_to?(:request_forgery_protection_token)
222
+ (Permittable::UNCHECKED_TOP_LEVEL_KEYS + [token&.to_s]).compact.uniq
223
+ end
224
+
225
+ # Under `unknown: :error` the runtime still exempts Rails' routing and form
226
+ # keys (`exempt`, see exempt_keys) at the top of a rootless contract, so a
227
+ # JSON body carrying `authenticity_token`, `format` or `_method` (a form's
228
+ # fields sent as JSON, a record echoed back) is accepted, and the body
229
+ # schema must not forbid it. They
230
+ # stay in as free, never-required properties, as the multipart body lists
231
+ # them (see form_schema). A name the contract declares keeps its own
232
+ # schema. A `root:` rule needs none of this: the keys sit outside the root,
233
+ # in a wrapper that is permissive already. The plain JsonSchema.rule and
234
+ # Contract#json_schema describe the contract, not an HTTP body, and a
235
+ # standalone Contract exempts no form key, so they do not list them.
236
+ def allow_form_keys!(schema, exempt)
237
+ exempt.each { |key| schema["properties"][key] ||= {} }
238
+ end
239
+
213
240
  # A multipart body is not one JSON object: each part is a form field, and
214
241
  # Rails reads the part NAMES. `user[avatar]` is the avatar of the `user`
215
242
  # hash and a repeated `docs[]` is an array, so the schema's properties
@@ -228,15 +255,15 @@ module Permittable
228
255
  # nullable container's null (`user[docs]=""`), the keys of a :json
229
256
  # field (`user[extra][any]`), and, for a `root:` rule, any part outside
230
257
  # the root, which the runtime never reads; a rootless rule instead gets
231
- # the form keys (FORM_KEYS) the runtime exempts at the top level.
232
- def form_schema(json_schema, rule)
258
+ # the keys (`exempt`, see exempt_keys) the runtime exempts at the top level.
259
+ def form_schema(json_schema, rule, exempt)
233
260
  root = rule[:root] && rule[:root].to_s
234
261
  form = { properties: {}, patterns: {}, required: [], groups: {}, strict: rule[:unknown] == :error }
235
262
  flatten_form(json_schema, [], true, form, skip_group: root)
236
263
  if form[:strict] && root
237
264
  form[:patterns]["^(?!#{form_escape(root)}(?:\\[|$))"] = {}
238
265
  elsif form[:strict]
239
- form[:patterns]["^(?:#{Permittable::FORM_KEYS.map { |key| form_escape(key) }.join('|')})$"] = {}
266
+ form[:patterns]["^(?:#{exempt.map { |key| form_escape(key) }.join('|')})$"] = {}
240
267
  end
241
268
  schema = { "type" => "object", "properties" => form[:properties] }
242
269
  schema["patternProperties"] = form[:patterns] unless form[:patterns].empty?
@@ -386,7 +413,7 @@ module Permittable
386
413
  # dup: the stored desc is frozen, and a caller editing the generated
387
414
  # document in place must neither raise nor reach the contract.
388
415
  operation["description"] = rule[:desc].dup if rule[:desc]
389
- operation["requestBody"] = rule_request_body(rule)
416
+ operation["requestBody"] = rule_request_body(rule, controller)
390
417
  operation["responses"] = responses_for(rule)
391
418
  # The docs must not promise a 422 the server doesn't yet send. Only
392
419
  # the rule's own declaration is contract data — the app-wide
@@ -573,7 +600,7 @@ module Permittable
573
600
  else
574
601
  free.each do |path, verb, template, defaults|
575
602
  holder = (paths[path] ||= {})
576
- placed = with_path_parameters(operation, path, controller.permit_rule_for(action), template || path, defaults)
603
+ placed = with_path_parameters(operation, path, controller.permit_rule_for(action), controller, [template || path, defaults])
577
604
  holder[verb] = template ? placed.merge("x-permittable-path" => template) : placed
578
605
  slots << OperationSlot.new(holder, verb, verb, operation["operationId"], owner, path)
579
606
  end
@@ -692,6 +719,9 @@ module Permittable
692
719
  # otherwise: a rootless contract field named like one is that parameter's
693
720
  # schema (path_fields), and leaves the body schema.
694
721
  #
722
+ # `route` is `[own, defaults]`; `controller` supplies the keys the body
723
+ # exempts (see exempt_keys).
724
+ #
695
725
  # `own` is the route's own spelling of the template, which differs
696
726
  # from `path` when collapse_shapes placed the route under another
697
727
  # route's spelling: the same shape, so the names pair up by position.
@@ -702,7 +732,8 @@ module Permittable
702
732
  # not carry them and they are no path parameter. A name the path
703
733
  # templates is that parameter instead: `get "o(/:id)", defaults:
704
734
  # { id: 5 }` is a parameter on `/o/{id}` and a default only on `/o`.
705
- def with_path_parameters(operation, path, rule, own = path, defaults = [])
735
+ def with_path_parameters(operation, path, rule, controller, route)
736
+ own, defaults = route
706
737
  # A name the path templates twice (a mount and an engine route both
707
738
  # name `:id`) is declared once: OpenAPI forbids two parameters with
708
739
  # one name and location.
@@ -721,7 +752,7 @@ module Permittable
721
752
  operation.each_with_object({}) do |(key, value), out|
722
753
  if key == "requestBody"
723
754
  out["parameters"] = parameters unless parameters.empty?
724
- value = body_without(rule, fields.values + default_fields(rule, supplied), own_names + supplied)
755
+ value = body_without(rule, controller, fields.values + default_fields(rule, supplied), own_names + supplied)
725
756
  end
726
757
  out[key] = value unless value.nil?
727
758
  end
@@ -782,11 +813,11 @@ module Permittable
782
813
  # nothing in the body can make the request wrong. A name the route's
783
814
  # `defaults:` fill is treated the same. A rooted contract
784
815
  # needs none of this: its wrapper object is permissive already.
785
- def body_without(rule, moved, templated)
816
+ def body_without(rule, controller, moved, templated)
786
817
  rest = rule[:fields] - moved
787
818
  return if rest.empty? && !moved.empty?
788
819
 
789
- body = rule_request_body(moved.empty? ? rule : rule.merge(fields: rest))
820
+ body = rule_request_body(moved.empty? ? rule : rule.merge(fields: rest), controller)
790
821
  return body unless rule[:unknown] == :error && !rule[:root]
791
822
 
792
823
  properties = body["content"].values.first["schema"]["properties"]
@@ -1013,13 +1044,15 @@ module Permittable
1013
1044
  # The names a route fills from its `defaults:` (`scope`, `resources` and
1014
1045
  # `mount` take them too), as strings. The router puts them into
1015
1046
  # path_parameters beside the URL's own, so the client cannot set them;
1016
- # `controller`, `action` and `format` are the router's own keys and
1017
- # are left out, as the runtime leaves them out of `unknown:`. A
1047
+ # `controller` and `action` are the router's own keys and are left
1048
+ # out, as the runtime leaves them out of `unknown:`. `format` is left in:
1049
+ # a `defaults: { format: ... }` lands in path_parameters like any other
1050
+ # default, so a contract field of that name is filled by it. A
1018
1051
  # hand-built route that cannot say fills nothing.
1019
1052
  def route_defaults(route)
1020
1053
  return [] unless route.respond_to?(:defaults)
1021
1054
 
1022
- route.defaults.keys.map(&:to_s) - %w[controller action format]
1055
+ route.defaults.keys.map(&:to_s) - %w[controller action]
1023
1056
  end
1024
1057
 
1025
1058
  # The Prefix of each variant of a mount's path under each of `prefixes`,
@@ -469,6 +469,8 @@ module Permittable
469
469
  end
470
470
 
471
471
  def cast_json_default(field, expected)
472
+ # Absent on a nullable field is the nil a contract stores for it.
473
+ return nil if field[:nullable] && Coercion.absent_value?(expected)
472
474
  return expected unless expected.is_a?(Hash)
473
475
 
474
476
  status, value = AuthoredValues.read_json(field, expected)
@@ -634,7 +636,7 @@ module Permittable
634
636
  # `not_to ...returning(x)` would pass both on a rejection and on an
635
637
  # acceptance returning anything else.
636
638
  def does_not_match?(subject) # rubocop:disable Naming/PredicatePrefix -- the RSpec protocol name
637
- raise ArgumentError, negated_qualifier_message if @returning || !@expected_violations.empty?
639
+ raise ArgumentError, negated_qualifier_message(subject) if @returning || !@expected_violations.empty?
638
640
 
639
641
  rule = locate(subject)
640
642
  return false unless rule
@@ -649,9 +651,12 @@ module Permittable
649
651
  if @expect_accepted
650
652
  return "expected #{subject_name} to #{description}, but it rejected them: #{summary(@violations)}" unless @violations.empty?
651
653
 
652
- "expected #{subject_name} to #{description}, but it accepted them but returned #{@result.to_h.inspect}"
654
+ "expected #{subject_name} to #{description}, but it accepted them but returned " \
655
+ "#{printable(@result.to_h)}#{differs_note}"
653
656
  else
654
- return "expected #{subject_name} to #{description}, but it accepted them, returning #{@result.to_h.inspect}" if @violations.empty?
657
+ if @violations.empty?
658
+ return "expected #{subject_name} to #{description}, but it accepted them, returning #{printable(@result.to_h)}"
659
+ end
655
660
 
656
661
  "expected #{subject_name} to #{description}, but the violations were: #{summary(@violations)}"
657
662
  end
@@ -668,7 +673,7 @@ module Permittable
668
673
  label = @expect_accepted ? "accept those params" : "reject those params"
669
674
  label += action_label
670
675
  label += " with #{summary(@expected_violations)}" unless @expected_violations.empty?
671
- label += " returning #{@returning.inspect}" if @returning
676
+ label += " returning #{printable(@returning)}" if @returning
672
677
  label
673
678
  end
674
679
 
@@ -682,15 +687,27 @@ module Permittable
682
687
  # first so a matcher object reused across examples (or re-chained
683
688
  # with another for_action) never reports a previous run's problem.
684
689
  def locate(subject)
685
- @problem = @violations = @result = nil
690
+ @problem = @violations = @result = @root = nil
686
691
  @subject = resolve_subject(subject)
687
- resolve_rule
688
- end
689
-
690
- def negated_qualifier_message
692
+ rule = resolve_rule
693
+ @root = rule[:root] if rule
694
+ rule
695
+ end
696
+
697
+ def negated_qualifier_message(subject = nil)
698
+ # Raised before the rule is located, so the root a scoped filter
699
+ # needs is looked up here, best effort: a subject or an ambiguity
700
+ # that locate would refuse (a BasicObject answers no respond_to?) is not
701
+ # this message's to report, and leaves the filter unscoped (the
702
+ # shallow one) rather than masking the qualifier error.
703
+ begin
704
+ locate(subject) if subject
705
+ rescue StandardError
706
+ @root = nil
707
+ end
691
708
  call = @expect_accepted ? "accept_params" : "reject_params"
692
709
  qualifiers = []
693
- qualifiers << "returning #{@returning.inspect}" if @returning
710
+ qualifiers << "returning #{printable(@returning)}" if @returning
694
711
  qualifiers << "with_violation #{summary(@expected_violations)}" unless @expected_violations.empty?
695
712
  "#{LABEL}: `not_to #{call}(...)` cannot take qualifiers (here: #{qualifiers.join(', ')}) — " \
696
713
  "negating one is ambiguous, since it would pass both when the params are " \
@@ -788,36 +805,40 @@ module Permittable
788
805
  # payload belongs to the spec. The copy stringifies Symbol keys, which
789
806
  # the normalizer's HashWithIndifferentAccess does anyway but only after
790
807
  # it has read a Symbol :tempfile key as an upload, a key a request
791
- # (String keys only) never has. A Parameters payload is unwrapped first
792
- # so it is normalized like the Hash it wraps.
808
+ # (String keys only) never has, and Symbol values (see copy_payload).
809
+ # A Parameters payload is unwrapped first so it is normalized like the
810
+ # Hash it wraps. Without actionpack's request code only the normalizer
811
+ # is skipped; the copy (and its Symbol stringifying) still happens.
793
812
  def request_params
813
+ payload = copy_payload(@params.respond_to?(:to_unsafe_h) ? @params.to_unsafe_h : @params)
794
814
  utils = request_utils
795
- return @params unless utils
796
-
797
- payload = @params.respond_to?(:to_unsafe_h) ? @params.to_unsafe_h : @params
798
- utils.normalize_encode_params(copy_payload(payload))
815
+ utils ? utils.normalize_encode_params(payload) : payload
799
816
  end
800
817
 
801
818
  # Rails main moved the class from ActionDispatch::Request::Utils to
802
- # ActionDispatch::Http::Utils. nil only when ActionDispatch is not
803
- # loaded at all (a non-Rails stand-in controller); once it is, a rename
804
- # this does not know raises instead of silently skipping the
805
- # normalization, which would bring the false greens back.
819
+ # ActionDispatch::Http::Utils. nil when actionpack's request code is not
820
+ # loaded (a non-Rails stand-in controller, or a bare ActionDispatch module
821
+ # that something else namespaced): there is nothing to normalize with, and
822
+ # nothing was renamed. Once ActionDispatch::Request exists, a rename this
823
+ # does not know raises instead of silently skipping the normalization,
824
+ # which would bring the false greens back.
806
825
  def request_utils
807
- return unless defined?(ActionDispatch)
808
-
809
826
  if defined?(ActionDispatch::Http::Utils) then ActionDispatch::Http::Utils
810
827
  elsif defined?(ActionDispatch::Request::Utils) then ActionDispatch::Request::Utils
811
- else
828
+ elsif defined?(ActionDispatch::Request)
812
829
  raise "Permittable's matchers cannot find Rails' parameter normalizer: neither " \
813
830
  "ActionDispatch::Http::Utils nor ActionDispatch::Request::Utils is defined"
814
831
  end
815
832
  end
816
833
 
834
+ # Symbol keys become Strings, and so do Symbol values: a request carries
835
+ # every scalar as a String (or a JSON number, boolean or null), never a
836
+ # Symbol, so `status: :active` is the "active" the contract would read.
817
837
  def copy_payload(value)
818
838
  case value
819
839
  when Hash then value.to_h { |key, item| [key.is_a?(Symbol) ? key.to_s : key, copy_payload(item)] }
820
840
  when Array then value.map { |item| copy_payload(item) }
841
+ when Symbol then value.to_s
821
842
  else value
822
843
  end
823
844
  end
@@ -874,6 +895,116 @@ module Permittable
874
895
  raise ArgumentError, "#{LABEL}: the subject of accept_params/reject_params must include Permittable (got #{subject.inspect})"
875
896
  end
876
897
 
898
+ # A hash echoed into a failure message, which lands in CI logs, goes out
899
+ # redacted the way a request log is: by the names `sensitive:` fields
900
+ # registered, and by the app's config.filter_parameters when a Rails
901
+ # application is booted. Only the printed copy is filtered; the matching
902
+ # itself reads the real values.
903
+ def printable(hash)
904
+ redacted(hash).inspect
905
+ end
906
+
907
+ # The redacted structure itself, which differs_note walks beside the
908
+ # real one. A rule with a root: has its hash put back under that root
909
+ # for the app's filter, as a request logs it, so a scoped filter
910
+ # ("payment.number", /\Apayment\.code\z/) matches; the registry pass
911
+ # runs on the hash alone, as it does at runtime.
912
+ def redacted(hash)
913
+ inner = redact_sensitive(hash)
914
+ filter = parameter_filter
915
+ return inner unless filter
916
+ return filter.filter(inner) unless @root
917
+
918
+ key = @root.to_s
919
+ filter.filter(key => inner)[key]
920
+ end
921
+
922
+ # The registry's names, by whatever it can answer: #include?, else its
923
+ # #names matched the way the registry matches (case-insensitive
924
+ # substring). One with neither is left to its #to_proc in
925
+ # parameter_filter. A masked value is replaced whole, a Hash included.
926
+ # An ActionController::Parameters is unwrapped, so its nested values
927
+ # are reached too.
928
+ def redact_sensitive(value)
929
+ value = value.to_unsafe_h if value.respond_to?(:to_unsafe_h)
930
+ return value.map { |item| redact_sensitive(item) } if value.is_a?(Array)
931
+ return value unless value.is_a?(Hash)
932
+
933
+ registry = Permittable.filter_parameter_registry
934
+ value.to_h do |key, item|
935
+ [key, sensitive_name?(registry, key) ? Permittable::FilterParameterRegistry::FILTERED : redact_sensitive(item)]
936
+ end
937
+ end
938
+
939
+ def sensitive_name?(registry, key)
940
+ if registry.respond_to?(:include?)
941
+ registry.include?(key)
942
+ elsif registry.respond_to?(:names)
943
+ name = key.to_s.downcase
944
+ registry.names.any? { |sensitive| name.include?(sensitive.to_s.downcase) }
945
+ else
946
+ false
947
+ end
948
+ end
949
+
950
+ # Rails' ParameterFilter over the app's config.filter_parameters, plus
951
+ # the registry's own #to_proc when it can answer neither #include? nor
952
+ # #names (a proc masks only Strings, but it is all such a registry
953
+ # offers). ActiveSupport::ParameterFilter is not loaded by
954
+ # `require "active_support"` before 7.0, and a Rails 6.1 app without
955
+ # ActionDispatch::Request has not loaded it either; it has been
956
+ # requirable since 6.0.
957
+ def parameter_filter
958
+ filters = []
959
+ app = defined?(Rails) && Rails.respond_to?(:application) && Rails.application
960
+ filters.concat(Array(app.config.filter_parameters)) if app
961
+ registry = Permittable.filter_parameter_registry
962
+ filters << registry.to_proc unless registry.respond_to?(:include?) || registry.respond_to?(:names)
963
+ return if filters.empty?
964
+
965
+ begin
966
+ require "active_support/parameter_filter"
967
+ rescue LoadError
968
+ return
969
+ end
970
+ ActiveSupport::ParameterFilter.new(filters)
971
+ end
972
+
973
+ # The registry matches by substring, like Rails (`pin` also redacts
974
+ # `shipping`), so an expected and an actual hash that differ only under
975
+ # redacted names print the same. Say where they differ, by name only.
976
+ def differs_note
977
+ return "" unless @returning
978
+
979
+ expected = ActiveSupport::HashWithIndifferentAccess.new(@returning).to_h
980
+ actual = @result.to_h
981
+ return "" if expected == actual || printable(expected) != printable(actual)
982
+
983
+ # A filter on the root itself masks the whole hash, leaving the root as the one path.
984
+ paths = differing_paths(expected, actual, redacted(expected), redacted(actual)).map { |path| path || @root.to_s }
985
+ paths.empty? ? "" : "; differs at: #{paths.join(', ')}"
986
+ end
987
+
988
+ # Walks the real hashes beside their redacted copies and stops at the
989
+ # redaction boundary: a node the copy shows as masked is named by its
990
+ # path alone when the real values differ, and not entered, since a key
991
+ # or an index below it is as secret as its value.
992
+ def differing_paths(expected, actual, expected_masked, actual_masked, prefix = nil)
993
+ filtered = Permittable::FilterParameterRegistry::FILTERED
994
+ masked = expected_masked == filtered || actual_masked == filtered
995
+ if !masked && expected.is_a?(Hash) && actual.is_a?(Hash)
996
+ (expected.keys | actual.keys).flat_map do |key|
997
+ differing_paths(expected[key], actual[key], expected_masked[key], actual_masked[key], [prefix, key].compact.join("."))
998
+ end
999
+ elsif !masked && expected.is_a?(Array) && actual.is_a?(Array) && expected.length == actual.length
1000
+ expected.each_index.flat_map do |i|
1001
+ differing_paths(expected[i], actual[i], expected_masked[i], actual_masked[i], "#{prefix}[#{i}]")
1002
+ end
1003
+ else
1004
+ expected == actual ? [] : [prefix]
1005
+ end
1006
+ end
1007
+
877
1008
  def summary(violations)
878
1009
  violations.map { |v| v[:code] ? "#{v[:param]} (#{v[:code]})" : v[:param] }.join(", ")
879
1010
  end
@@ -13,9 +13,10 @@ namespace :permittable do
13
13
 
14
14
  # Every controller, not just the ones including Permittable — a controller
15
15
  # that never included it is unguarded, which is exactly the finding. Bare
16
- # ActionController::Metal ones included; see Audit.rails_controllers.
17
- controllers = Permittable::Audit.rails_controllers
16
+ # ActionController::Metal ones included, and autoloaded framework ones
17
+ # (Rails::HealthController) the routes name; see Audit.rails_controllers.
18
18
  routes = Permittable::OpenAPI.rails_routes(Rails.application)
19
+ controllers = Permittable::Audit.rails_controllers(routes)
19
20
 
20
21
  entries = Permittable::Audit.entries(controllers: controllers, routes: routes)
21
22
  stale = Permittable::Audit.stale(controllers: controllers, routes: routes)
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.14.0".freeze
2
+ VERSION = "0.15.1".freeze
3
3
  end