aikido-zen 1.0.2.beta.2-aarch64-linux

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.
Files changed (116) hide show
  1. checksums.yaml +7 -0
  2. data/.aikido +6 -0
  3. data/.ruby-version +1 -0
  4. data/.simplecov +26 -0
  5. data/.standard.yml +3 -0
  6. data/LICENSE +674 -0
  7. data/README.md +146 -0
  8. data/Rakefile +67 -0
  9. data/benchmarks/README.md +23 -0
  10. data/benchmarks/rails7.1_sql_injection.js +70 -0
  11. data/docs/banner.svg +202 -0
  12. data/docs/config.md +125 -0
  13. data/docs/proxy.md +10 -0
  14. data/docs/rails.md +114 -0
  15. data/lib/aikido/zen/actor.rb +116 -0
  16. data/lib/aikido/zen/agent/heartbeats_manager.rb +66 -0
  17. data/lib/aikido/zen/agent.rb +179 -0
  18. data/lib/aikido/zen/api_client.rb +145 -0
  19. data/lib/aikido/zen/attack.rb +207 -0
  20. data/lib/aikido/zen/background_worker.rb +52 -0
  21. data/lib/aikido/zen/capped_collections.rb +68 -0
  22. data/lib/aikido/zen/collector/hosts.rb +15 -0
  23. data/lib/aikido/zen/collector/routes.rb +66 -0
  24. data/lib/aikido/zen/collector/sink_stats.rb +95 -0
  25. data/lib/aikido/zen/collector/stats.rb +111 -0
  26. data/lib/aikido/zen/collector/users.rb +30 -0
  27. data/lib/aikido/zen/collector.rb +144 -0
  28. data/lib/aikido/zen/config.rb +282 -0
  29. data/lib/aikido/zen/context/rack_request.rb +24 -0
  30. data/lib/aikido/zen/context/rails_request.rb +44 -0
  31. data/lib/aikido/zen/context.rb +112 -0
  32. data/lib/aikido/zen/detached_agent/agent.rb +78 -0
  33. data/lib/aikido/zen/detached_agent/front_object.rb +37 -0
  34. data/lib/aikido/zen/detached_agent/server.rb +78 -0
  35. data/lib/aikido/zen/detached_agent.rb +2 -0
  36. data/lib/aikido/zen/errors.rb +107 -0
  37. data/lib/aikido/zen/event.rb +71 -0
  38. data/lib/aikido/zen/internals.rb +103 -0
  39. data/lib/aikido/zen/libzen-v0.1.39-aarch64-linux.so +0 -0
  40. data/lib/aikido/zen/middleware/check_allowed_addresses.rb +26 -0
  41. data/lib/aikido/zen/middleware/middleware.rb +11 -0
  42. data/lib/aikido/zen/middleware/rack_throttler.rb +48 -0
  43. data/lib/aikido/zen/middleware/request_tracker.rb +192 -0
  44. data/lib/aikido/zen/middleware/set_context.rb +26 -0
  45. data/lib/aikido/zen/outbound_connection.rb +45 -0
  46. data/lib/aikido/zen/outbound_connection_monitor.rb +23 -0
  47. data/lib/aikido/zen/package.rb +22 -0
  48. data/lib/aikido/zen/payload.rb +50 -0
  49. data/lib/aikido/zen/rails_engine.rb +56 -0
  50. data/lib/aikido/zen/rate_limiter/breaker.rb +61 -0
  51. data/lib/aikido/zen/rate_limiter/bucket.rb +76 -0
  52. data/lib/aikido/zen/rate_limiter/result.rb +31 -0
  53. data/lib/aikido/zen/rate_limiter.rb +50 -0
  54. data/lib/aikido/zen/request/heuristic_router.rb +115 -0
  55. data/lib/aikido/zen/request/rails_router.rb +77 -0
  56. data/lib/aikido/zen/request/schema/auth_discovery.rb +86 -0
  57. data/lib/aikido/zen/request/schema/auth_schemas.rb +54 -0
  58. data/lib/aikido/zen/request/schema/builder.rb +121 -0
  59. data/lib/aikido/zen/request/schema/definition.rb +107 -0
  60. data/lib/aikido/zen/request/schema/empty_schema.rb +28 -0
  61. data/lib/aikido/zen/request/schema.rb +87 -0
  62. data/lib/aikido/zen/request.rb +122 -0
  63. data/lib/aikido/zen/route.rb +39 -0
  64. data/lib/aikido/zen/runtime_settings/endpoints.rb +49 -0
  65. data/lib/aikido/zen/runtime_settings/ip_set.rb +36 -0
  66. data/lib/aikido/zen/runtime_settings/protection_settings.rb +62 -0
  67. data/lib/aikido/zen/runtime_settings/rate_limit_settings.rb +47 -0
  68. data/lib/aikido/zen/runtime_settings.rb +65 -0
  69. data/lib/aikido/zen/scan.rb +75 -0
  70. data/lib/aikido/zen/scanners/path_traversal/helpers.rb +65 -0
  71. data/lib/aikido/zen/scanners/path_traversal_scanner.rb +63 -0
  72. data/lib/aikido/zen/scanners/shell_injection/helpers.rb +159 -0
  73. data/lib/aikido/zen/scanners/shell_injection_scanner.rb +64 -0
  74. data/lib/aikido/zen/scanners/sql_injection_scanner.rb +93 -0
  75. data/lib/aikido/zen/scanners/ssrf/dns_lookups.rb +27 -0
  76. data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +97 -0
  77. data/lib/aikido/zen/scanners/ssrf_scanner.rb +265 -0
  78. data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +49 -0
  79. data/lib/aikido/zen/scanners.rb +7 -0
  80. data/lib/aikido/zen/sink.rb +118 -0
  81. data/lib/aikido/zen/sinks/action_controller.rb +83 -0
  82. data/lib/aikido/zen/sinks/async_http.rb +80 -0
  83. data/lib/aikido/zen/sinks/curb.rb +113 -0
  84. data/lib/aikido/zen/sinks/em_http.rb +83 -0
  85. data/lib/aikido/zen/sinks/excon.rb +118 -0
  86. data/lib/aikido/zen/sinks/file.rb +112 -0
  87. data/lib/aikido/zen/sinks/http.rb +93 -0
  88. data/lib/aikido/zen/sinks/httpclient.rb +95 -0
  89. data/lib/aikido/zen/sinks/httpx.rb +78 -0
  90. data/lib/aikido/zen/sinks/kernel.rb +33 -0
  91. data/lib/aikido/zen/sinks/mysql2.rb +31 -0
  92. data/lib/aikido/zen/sinks/net_http.rb +101 -0
  93. data/lib/aikido/zen/sinks/patron.rb +103 -0
  94. data/lib/aikido/zen/sinks/pg.rb +72 -0
  95. data/lib/aikido/zen/sinks/resolv.rb +62 -0
  96. data/lib/aikido/zen/sinks/socket.rb +78 -0
  97. data/lib/aikido/zen/sinks/sqlite3.rb +46 -0
  98. data/lib/aikido/zen/sinks/trilogy.rb +31 -0
  99. data/lib/aikido/zen/sinks/typhoeus.rb +78 -0
  100. data/lib/aikido/zen/sinks.rb +36 -0
  101. data/lib/aikido/zen/sinks_dsl.rb +250 -0
  102. data/lib/aikido/zen/synchronizable.rb +24 -0
  103. data/lib/aikido/zen/system_info.rb +84 -0
  104. data/lib/aikido/zen/version.rb +10 -0
  105. data/lib/aikido/zen/worker.rb +87 -0
  106. data/lib/aikido/zen.rb +246 -0
  107. data/lib/aikido-zen.rb +3 -0
  108. data/placeholder/.gitignore +4 -0
  109. data/placeholder/README.md +11 -0
  110. data/placeholder/Rakefile +75 -0
  111. data/placeholder/lib/placeholder.rb.template +3 -0
  112. data/placeholder/placeholder.gemspec.template +20 -0
  113. data/tasklib/bench.rake +94 -0
  114. data/tasklib/libzen.rake +133 -0
  115. data/tasklib/wrk.rb +88 -0
  116. metadata +205 -0
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "auth_schemas"
4
+
5
+ module Aikido::Zen
6
+ class Request::Schema
7
+ class AuthDiscovery
8
+ def initialize(context)
9
+ @context = context
10
+ end
11
+
12
+ def schemas
13
+ schemas = []
14
+ schemas << extract_from_authorization_header if headers["Authorization"]
15
+ schemas.concat(extract_from_headers)
16
+ schemas.concat(extract_from_cookies)
17
+
18
+ AuthSchemas.new(schemas)
19
+ end
20
+
21
+ private
22
+
23
+ def extract_from_authorization_header
24
+ type, _ = headers["Authorization"].to_s.split(/\s+/, 2)
25
+
26
+ if AUTHORIZATION_SCHEMES.include?(type.to_s.downcase)
27
+ AuthSchemas::Authorization.new(type)
28
+ else
29
+ AuthSchemas::ApiKey.new(:header, "Authorization")
30
+ end
31
+ end
32
+
33
+ def extract_from_headers
34
+ (headers.keys & COMMON_API_KEY_HEADERS)
35
+ .map { |header| AuthSchemas::ApiKey.new(:header, header) }
36
+ end
37
+
38
+ def extract_from_cookies
39
+ cookie_names = @context.payload_sources[:cookie].keys.map(&:downcase)
40
+
41
+ (cookie_names & COMMON_COOKIE_NAMES)
42
+ .map { |cookie| AuthSchemas::ApiKey.new(:cookie, cookie) }
43
+ end
44
+
45
+ def headers
46
+ @context.request.normalized_headers
47
+ end
48
+
49
+ AUTHORIZATION_SCHEMES = %w[
50
+ basic
51
+ bearer
52
+ digest
53
+ dpop
54
+ gnap
55
+ hoba
56
+ mutal
57
+ negotiate
58
+ privatetoken
59
+ scram-sha-1
60
+ scram-sha-256
61
+ vapid
62
+ ].freeze
63
+
64
+ COMMON_API_KEY_HEADERS = %w[
65
+ Apikey
66
+ Api-Key
67
+ Token
68
+ X-Api-Key
69
+ X-Token
70
+ ]
71
+
72
+ COMMON_COOKIE_NAMES = %w[
73
+ user_id
74
+ auth
75
+ session
76
+ jwt
77
+ token
78
+ sid
79
+ connect.sid
80
+ auth_token
81
+ access_token
82
+ refresh_token
83
+ ]
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ class Request::Schema
5
+ class AuthSchemas
6
+ attr_reader :schemas
7
+
8
+ def initialize(schemas)
9
+ @schemas = schemas
10
+ end
11
+
12
+ def merge(other)
13
+ self.class.new((schemas + other.schemas).uniq)
14
+ end
15
+ alias_method :|, :merge
16
+
17
+ def as_json
18
+ @schemas.map(&:as_json) unless @schemas.empty?
19
+ end
20
+
21
+ def self.from_json(schemas_array)
22
+ return NONE if !schemas_array || schemas_array.empty?
23
+
24
+ AuthSchemas.new(schemas_array.map do |schema|
25
+ if schema[:type] == "http"
26
+ Authorization.new(schema[:scheme])
27
+ elsif schema[:type] == "apiKey"
28
+ ApiKey.new(schema[:in], schema[:name])
29
+ else
30
+ raise "Invalid schema type: #{schema[:type]}"
31
+ end
32
+ end)
33
+ end
34
+
35
+ def ==(other)
36
+ other.is_a?(self.class) && schemas == other.schemas
37
+ end
38
+
39
+ NONE = new([])
40
+
41
+ Authorization = Struct.new(:scheme) do
42
+ def as_json
43
+ {type: "http", scheme: scheme.downcase}
44
+ end
45
+ end
46
+
47
+ ApiKey = Struct.new(:location, :name) do
48
+ def as_json
49
+ {type: "apiKey", in: location, name: name}.compact
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "definition"
4
+ require_relative "empty_schema"
5
+ require_relative "auth_discovery"
6
+
7
+ module Aikido::Zen
8
+ class Request::Schema
9
+ # @api private
10
+ class Builder
11
+ def initialize(context: Aikido::Zen.current_context, config: Aikido::Zen.config)
12
+ @context = context
13
+ @config = config
14
+ @max_depth = @config.api_schema_collection_max_depth
15
+ @max_props = @config.api_schema_collection_max_properties
16
+ end
17
+
18
+ def schema
19
+ Request::Schema.new(
20
+ content_type: body_data_type,
21
+ body_schema: body_schema,
22
+ query_schema: query_schema,
23
+ auth_schema: AuthDiscovery.new(@context).schemas
24
+ )
25
+ end
26
+
27
+ private
28
+
29
+ def new(definition)
30
+ Aikido::Zen::Request::Schema::Definition.new(definition)
31
+ end
32
+
33
+ def request
34
+ @context.request
35
+ end
36
+
37
+ def body_data_type
38
+ media_type = request.media_type.to_s
39
+
40
+ # If the media type includes any tree other than the standard (vnd., prs.,
41
+ # x., etc) and a suffix, then remove that bit and just keep the suffix,
42
+ # which should tell us what the underlying data structure is.
43
+ #
44
+ # application/json => application/json
45
+ # application/vnd.github.v3+json => application/json
46
+ media_type = media_type.sub(%r{/.*\+}, "/") if media_type.include?("+")
47
+
48
+ DATA_TYPES.fetch(media_type, nil)
49
+ end
50
+
51
+ def query_schema
52
+ return EMPTY_SCHEMA if request.query_string.to_s.empty?
53
+
54
+ discover_schema(@context.payload_sources[:query])
55
+ end
56
+
57
+ def body_schema
58
+ return EMPTY_SCHEMA if request.content_length.to_i.zero?
59
+
60
+ discover_schema(sanitize_data(@context.payload_sources[:body]))
61
+ end
62
+
63
+ def discover_schema(object, depth: 0)
64
+ case object
65
+ when nil
66
+ new(type: "null")
67
+ when true, false
68
+ new(type: "boolean")
69
+ when String
70
+ new(type: "string")
71
+ when Numeric
72
+ new(type: "number")
73
+ when Array
74
+ # If the array has at least one item, we assume it's homogeneous for
75
+ # performance reasons, and so only inspect the type of the first one.
76
+ sub_schema = {items: discover_schema(object.first, depth: depth + 1)} unless object.empty?
77
+ new({type: "array"}.merge(sub_schema.to_h))
78
+ when Hash
79
+ object
80
+ .take(@max_props)
81
+ .each_with_object({type: "object", properties: {}}) { |(key, value), schema|
82
+ break schema if depth >= @max_depth
83
+ schema[:properties][key] = discover_schema(value, depth: depth + 1)
84
+ }
85
+ .then { |dfn| new(dfn) }
86
+ end
87
+ end
88
+
89
+ # By default, Rails' automatic decoding wraps non Hash inputs in a Hash
90
+ # with a _json key, so that the "params" object is always a Hash. So, for
91
+ # example, the request body: '["this","is","json"]' is transformed to
92
+ # '{"_json": ["this","is","json"]}' before being passed to the controller.
93
+ #
94
+ # We want to make sure to avoid this extra key when building the schema,
95
+ # since we won't be able to play back requests with it.
96
+ def sanitize_data(data)
97
+ return data unless @context.request.framework == "rails"
98
+ return data unless data.is_a?(Hash)
99
+
100
+ if data.is_a?(Hash) && data.keys == ["_json"]
101
+ data["_json"]
102
+ else
103
+ data
104
+ end
105
+ end
106
+
107
+ DATA_TYPES = {
108
+ "application/csp-report" => :json,
109
+ "application/x-json" => :json,
110
+ "application/json" => :json,
111
+
112
+ "application/x-www-form-urlencoded" => :"form-urlencoded",
113
+
114
+ "multipart/form-data" => :"form-data",
115
+
116
+ "application/xml" => :xml,
117
+ "text/xml" => :xml
118
+ }
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+ require_relative "empty_schema"
5
+
6
+ module Aikido::Zen
7
+ class Request::Schema
8
+ # @api private
9
+ #
10
+ # The "JSON Schema"-like implementation that we extract from looking at the
11
+ # request body and/or query string.
12
+ class Definition
13
+ extend Forwardable
14
+ def_delegators :definition, :deconstruct_keys
15
+
16
+ def initialize(definition)
17
+ @definition = definition
18
+ end
19
+
20
+ # Recursively merges this schema definition with another one.
21
+ #
22
+ # * Properties missing in one or the other schemas are treated as optional.
23
+ # * Merging any property with a null schema results in an optional schema.
24
+ # * Number and Integer schemas are merged into Number schemas, since it's
25
+ # the more permissive of the types.
26
+ #
27
+ # Other than that, everything else just results in additive merging,
28
+ # resulting in combining types together.
29
+ #
30
+ # @param other [Aikido::Zen::Request::Schema::Definition, nil]
31
+ # @return [Aikido::Zen::Request::Schema::Definition]
32
+ #
33
+ # @see https://cswr.github.io/JsonSchema/spec/introduction/
34
+ def merge(other)
35
+ case [self, other]
36
+
37
+ # Merging with itself or with nil results in just a copy
38
+ in [obj, ^obj | nil | EMPTY_SCHEMA]
39
+ dup
40
+
41
+ # objects where at least one of them has properties
42
+ in [{type: "object", properties: _}, {type: "object", properties: _}] |
43
+ [{type: "object", properties: _}, {type: "object"}] |
44
+ [{type: "object"}, {type: "object", properties: _}]
45
+ left, right = definition[:properties], other.definition[:properties]
46
+ merged_props = (left.keys.to_a | right.keys.to_a)
47
+ .map { |key| [key, left.fetch(key, NULL).merge(right.fetch(key, NULL))] }
48
+ .to_h
49
+ new(definition.merge(other.definition).merge(properties: merged_props))
50
+
51
+ # arrays where at least one of them has items
52
+ in [{type: "array", items: _}, {type: "array", items: _}] |
53
+ [{type: "array", items: _}, {type: "array"}] |
54
+ [{type: "array"}, {type: "array", items: _}]
55
+ items = [definition[:items], other.definition[:items]].compact.reduce(:merge)
56
+ new(definition.merge(other.definition).merge({items: items}.compact))
57
+
58
+ # x | x => x
59
+ in {type: type}, {type: ^type}
60
+ new(definition.merge(other.definition))
61
+
62
+ # any | null => any?
63
+ in {type: "null"}, {type: _}
64
+ new(other.definition.merge(optional: true))
65
+ in {type: _}, {type: "null"}
66
+ new(definition.merge(optional: true))
67
+
68
+ # x | y => [x, y] if x != y
69
+ else
70
+ left_type, right_type = definition[:type], other.definition[:type]
71
+ types = [left_type, right_type].flatten.uniq.sort
72
+ new(definition.merge(other.definition).merge(type: types))
73
+
74
+ end
75
+ end
76
+ alias_method :|, :merge
77
+
78
+ def ==(other)
79
+ as_json == other.as_json
80
+ end
81
+
82
+ def as_json
83
+ definition
84
+ .transform_keys(&:to_s)
85
+ .transform_values do |val|
86
+ val.respond_to?(:as_json) ? val.as_json : val
87
+ end
88
+ end
89
+
90
+ def inspect
91
+ format("#<%s %p>", self.class, definition)
92
+ end
93
+
94
+ protected
95
+
96
+ attr_reader :definition
97
+
98
+ private
99
+
100
+ def new(definition)
101
+ self.class.new(definition)
102
+ end
103
+
104
+ NULL = new(type: "null") # used as a stand-in to merge missing object props
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ class Request::Schema
5
+ # @!visibility private
6
+ #
7
+ # Singleton used as a placeholder until we get a schema for a request.
8
+ # When "merged" it waits until a non-nil value is given and returns that.
9
+ EMPTY_SCHEMA = Object.new
10
+
11
+ class << EMPTY_SCHEMA
12
+ # @!visibility private
13
+ def merge(schema)
14
+ schema || self
15
+ end
16
+ alias_method :|, :merge
17
+
18
+ # @!visibility private
19
+ def as_json
20
+ nil
21
+ end
22
+
23
+ def inspect
24
+ "#<Aikido::Zen::Schema EMPTY>"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Aikido::Zen
6
+ # Defines the shape of a request received by your application as seen by Zen.
7
+ # This is used to understand how requests are made against your app, so
8
+ # dynamic security testing on your API endpoints can take place.
9
+ #
10
+ # @see Aikido::Zen::Config#api_schema_collection_enabled?
11
+ class Request::Schema
12
+ # @return [Symbol, nil] an identifier for the Content-Type header of the
13
+ # request, if sent.
14
+ attr_reader :content_type
15
+
16
+ # @return [Aikido::Zen::Request::Schema::Definition]
17
+ attr_reader :body_schema
18
+
19
+ # @return [Aikido::Zen::Request::Schema::Definition]
20
+ attr_reader :query_schema
21
+
22
+ # @return [Aikido::Zen::Request::Schema::AuthSchemas]
23
+ attr_reader :auth_schema
24
+
25
+ # Extracts the request information from the current Context (if configured)
26
+ # and builds a Schema out of it.
27
+ #
28
+ # @param context [Aikido::Zen::Context, nil]
29
+ # @return [Aikido::Zen::Request::Schema, nil]
30
+ def self.build(context = Aikido::Zen.current_context)
31
+ return if context.nil?
32
+
33
+ Request::Schema::Builder.new(context: context).schema
34
+ end
35
+
36
+ def initialize(content_type:, body_schema:, query_schema:, auth_schema:)
37
+ @content_type = content_type
38
+ @query_schema = query_schema
39
+ @body_schema = body_schema
40
+ @auth_schema = auth_schema
41
+ end
42
+
43
+ # @return [Hash]
44
+ def as_json
45
+ body = {type: content_type, schema: body_schema.as_json}.compact
46
+ body = nil if body.empty?
47
+
48
+ {body: body, query: query_schema.as_json, auth: auth_schema.as_json}.compact
49
+ end
50
+
51
+ def self.from_json(data)
52
+ if data.empty?
53
+ return Request::Schema.new(
54
+ content_type: nil,
55
+ body_schema: EMPTY_SCHEMA,
56
+ query_schema: EMPTY_SCHEMA,
57
+ auth_schema: Aikido::Zen::Request::Schema::AuthSchemas.new([])
58
+ )
59
+ end
60
+
61
+ Request::Schema.new(
62
+ content_type: data[:body].nil? ? nil : data[:body][:type],
63
+ body_schema: data[:body].nil? ? EMPTY_SCHEMA : Aikido::Zen::Request::Schema::Definition.new(data[:body][:schema]),
64
+ query_schema: data[:query].nil? ? EMPTY_SCHEMA : Aikido::Zen::Request::Schema::Definition.new(data[:query]),
65
+ auth_schema: Aikido::Zen::Request::Schema::AuthSchemas.from_json(data[:auth])
66
+ )
67
+ end
68
+
69
+ # Merges the request specification with another request's specification.
70
+ #
71
+ # @param other [Aikido::Zen::Request::Schema, nil]
72
+ # @return [Aikido::Zen::Request::Schema]
73
+ def merge(other)
74
+ return self if other.nil?
75
+
76
+ self.class.new(
77
+ content_type: other.content_type,
78
+ body_schema: body_schema.merge(other.body_schema),
79
+ query_schema: query_schema.merge(other.query_schema),
80
+ auth_schema: auth_schema.merge(other.auth_schema)
81
+ )
82
+ end
83
+ alias_method :|, :merge
84
+ end
85
+ end
86
+
87
+ require_relative "schema/builder"
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module Aikido::Zen
6
+ # Wrapper around Rack::Request-like objects to add some behavior.
7
+ class Request < SimpleDelegator
8
+ # @return [String] identifier of the framework handling this HTTP request.
9
+ attr_reader :framework
10
+
11
+ # @return [Aikido::Zen::Router]
12
+ attr_reader :router
13
+
14
+ # The current user, if set by the host app.
15
+ #
16
+ # @return [Aikido::Zen::Actor, nil]
17
+ # @see Aikido::Zen.track_user
18
+ attr_accessor :actor
19
+
20
+ def initialize(delegate, config = Aikido::Zen.config, framework:, router:)
21
+ super(delegate)
22
+ @config = config
23
+ @framework = framework
24
+ @router = router
25
+ @body_read = false
26
+ end
27
+
28
+ def __setobj__(delegate) # :nodoc:
29
+ super
30
+ @body_read = false
31
+ @route = @normalized_header = @truncated_body = nil
32
+ end
33
+
34
+ # @return [Aikido::Zen::Route] the framework route being requested.
35
+ def route
36
+ @route ||= @router.recognize(self)
37
+ end
38
+
39
+ # @return [Aikido::Zen::Request::Schema, nil]
40
+ def schema
41
+ @schema ||= Aikido::Zen::Request::Schema.build
42
+ end
43
+
44
+ # @api private
45
+ #
46
+ # @return [String] the IP address of the client making the request.
47
+ def client_ip
48
+ return @client_ip if @client_ip
49
+
50
+ if @config.client_ip_header
51
+ value = env[@config.client_ip_header]
52
+ if Resolv::AddressRegex.match?(value)
53
+ @client_ip = value
54
+ else
55
+ @config.logger.warn("Invalid IP address in custom client IP header `#{@config.client_ip_header}`: `#{value}`")
56
+ end
57
+ end
58
+
59
+ @client_ip ||= respond_to?(:remote_ip) ? remote_ip : ip
60
+ end
61
+
62
+ # Map the CGI-style env Hash into "pretty-looking" headers, preserving the
63
+ # values as-is. For example, HTTP_ACCEPT turns into "Accept", CONTENT_TYPE
64
+ # turns into "Content-Type", and HTTP_X_FORWARDED_FOR turns into
65
+ # "X-Forwarded-For".
66
+ #
67
+ # @return [Hash<String, String>]
68
+ def normalized_headers
69
+ @normalized_headers ||= env.slice(*BLESSED_CGI_HEADERS)
70
+ .merge(env.select { |key, _| key.start_with?("HTTP_") })
71
+ .transform_keys { |header|
72
+ name = header.sub(/^HTTP_/, "").downcase
73
+ name.split("_").map { |part| part[0].upcase + part[1..] }.join("-")
74
+ }
75
+ end
76
+
77
+ # @api private
78
+ #
79
+ # Reads the first 16KiB of the request body, to include in attack reports
80
+ # back to the Aikido server. This method should only be called if an attack
81
+ # is detected during the current request.
82
+ #
83
+ # If the underlying IO object has been partially (or fully) read before,
84
+ # this will attempt to restore the previous cursor position after reading it
85
+ # if possible, or leave if rewund if not.
86
+ #
87
+ # @param max_size [Integer] number of bytes to read at most.
88
+ #
89
+ # @return [String]
90
+ def truncated_body(max_size: 16384)
91
+ return @truncated_body if @body_read
92
+ return nil if body.nil?
93
+
94
+ begin
95
+ initial_pos = body.pos if body.respond_to?(:pos)
96
+ body.rewind
97
+ @truncated_body = body.read(max_size)
98
+ ensure
99
+ @body_read = true
100
+ body.rewind
101
+ body.seek(initial_pos) if initial_pos && body.respond_to?(:seek)
102
+ end
103
+ end
104
+
105
+ def as_json
106
+ {
107
+ method: request_method.downcase,
108
+ url: url,
109
+ ipAddress: client_ip,
110
+ userAgent: user_agent,
111
+ headers: normalized_headers.reject { |_, val| val.to_s.empty? },
112
+ body: truncated_body,
113
+ source: framework,
114
+ route: route&.path
115
+ }
116
+ end
117
+
118
+ BLESSED_CGI_HEADERS = %w[CONTENT_TYPE CONTENT_LENGTH]
119
+ end
120
+ end
121
+
122
+ require_relative "request/schema"
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ # Routes keep information about the mapping defined in the current web
5
+ # framework to go from a given HTTP request to the code that handles said
6
+ # request.
7
+ class Route
8
+ # @return [String] the HTTP verb used to request this route.
9
+ attr_reader :verb
10
+
11
+ # @return [String] the URL pattern used to match request paths. For
12
+ # example "/users/:id".
13
+ attr_reader :path
14
+
15
+ def initialize(verb:, path:)
16
+ @verb = verb
17
+ @path = path
18
+ end
19
+
20
+ def as_json
21
+ {method: verb, path: path}
22
+ end
23
+
24
+ def ==(other)
25
+ other.is_a?(Route) &&
26
+ other.verb == verb &&
27
+ other.path == path
28
+ end
29
+ alias_method :eql?, :==
30
+
31
+ def hash
32
+ [verb, path].hash
33
+ end
34
+
35
+ def inspect
36
+ "#<#{self.class.name} #{verb} #{path.inspect}>"
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../route"
4
+ require_relative "protection_settings"
5
+
6
+ module Aikido::Zen
7
+ # Wraps the list of endpoint protection settings, providing an interface for
8
+ # checking the settings for any given route. If the route has no configured
9
+ # settings, that will return the singleton
10
+ # {RuntimeSettings::ProtectionSettings.none}.
11
+ #
12
+ # @example
13
+ # endpoint = runtime_settings.endpoints[request.route]
14
+ # block_request unless endpoint.allows?(request.ip)
15
+ class RuntimeSettings::Endpoints
16
+ # @param data [Array<Hash>]
17
+ # @return [Aikido::Zen::RuntimeSettings::Endpoints]
18
+ def self.from_json(data)
19
+ data = Array(data).map { |item|
20
+ route = Route.new(verb: item["method"], path: item["route"])
21
+ settings = RuntimeSettings::ProtectionSettings.from_json(item)
22
+ [route, settings]
23
+ }.to_h
24
+
25
+ new(data)
26
+ end
27
+
28
+ def initialize(data = {})
29
+ @endpoints = data
30
+ @endpoints.default = RuntimeSettings::ProtectionSettings.none
31
+ end
32
+
33
+ # @param route [Aikido::Zen::Route]
34
+ # @return [Aikido::Zen::RuntimeSettings::ProtectionSettings]
35
+ def [](route)
36
+ @endpoints[route]
37
+ end
38
+
39
+ # @!visibility private
40
+ def ==(other)
41
+ other.is_a?(RuntimeSettings::Endpoints) && to_h == other.to_h
42
+ end
43
+
44
+ # @!visibility private
45
+ protected def to_h
46
+ @endpoints
47
+ end
48
+ end
49
+ end