castle-rb 3.6.2 → 4.3.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +57 -5
  3. data/lib/castle.rb +5 -3
  4. data/lib/castle/api.rb +12 -6
  5. data/lib/castle/api/request.rb +15 -10
  6. data/lib/castle/api/session.rb +39 -0
  7. data/lib/castle/client.rb +23 -18
  8. data/lib/castle/configuration.rb +49 -6
  9. data/lib/castle/context/default.rb +36 -18
  10. data/lib/castle/context/sanitizer.rb +1 -0
  11. data/lib/castle/events.rb +49 -0
  12. data/lib/castle/extractors/client_id.rb +7 -3
  13. data/lib/castle/extractors/headers.rb +24 -30
  14. data/lib/castle/extractors/ip.rb +69 -5
  15. data/lib/castle/headers_filter.rb +35 -0
  16. data/lib/castle/headers_formatter.rb +22 -0
  17. data/lib/castle/validators/not_supported.rb +1 -0
  18. data/lib/castle/validators/present.rb +1 -0
  19. data/lib/castle/version.rb +1 -1
  20. data/spec/integration/rails/rails_spec.rb +61 -0
  21. data/spec/integration/rails/support/all.rb +6 -0
  22. data/spec/integration/rails/support/application.rb +15 -0
  23. data/spec/integration/rails/support/home_controller.rb +21 -0
  24. data/spec/lib/castle/api/request_spec.rb +43 -30
  25. data/spec/lib/castle/api/session_spec.rb +47 -0
  26. data/spec/lib/castle/api_spec.rb +4 -4
  27. data/spec/lib/castle/client_spec.rb +5 -3
  28. data/spec/lib/castle/commands/authenticate_spec.rb +1 -0
  29. data/spec/lib/castle/commands/identify_spec.rb +1 -0
  30. data/spec/lib/castle/commands/impersonate_spec.rb +1 -0
  31. data/spec/lib/castle/commands/track_spec.rb +1 -0
  32. data/spec/lib/castle/configuration_spec.rb +21 -4
  33. data/spec/lib/castle/context/default_spec.rb +13 -13
  34. data/spec/lib/castle/events_spec.rb +5 -0
  35. data/spec/lib/castle/extractors/client_id_spec.rb +2 -1
  36. data/spec/lib/castle/extractors/headers_spec.rb +67 -51
  37. data/spec/lib/castle/extractors/ip_spec.rb +89 -12
  38. data/spec/lib/castle/headers_filter_spec.rb +38 -0
  39. data/spec/lib/castle/{header_formatter_spec.rb → headers_formatter_spec.rb} +3 -3
  40. data/spec/lib/castle/utils/cloner_spec.rb +1 -0
  41. data/spec/lib/castle/utils/timestamp_spec.rb +3 -4
  42. data/spec/lib/castle/utils_spec.rb +1 -1
  43. data/spec/lib/castle/validators/not_supported_spec.rb +1 -3
  44. data/spec/spec_helper.rb +1 -2
  45. metadata +38 -10
  46. data/lib/castle/api/request/build.rb +0 -27
  47. data/lib/castle/header_formatter.rb +0 -9
  48. data/spec/lib/castle/api/request/build_spec.rb +0 -44
@@ -4,36 +4,54 @@ module Castle
4
4
  module Context
5
5
  class Default
6
6
  def initialize(request, cookies = nil)
7
- @client_id = Extractors::ClientId.new(request, cookies || request.cookies).call
8
- @headers = Extractors::Headers.new(request).call
9
- @request_ip = Extractors::IP.new(request).call
7
+ @pre_headers = HeadersFilter.new(request).call
8
+ @cookies = cookies || request.cookies
9
+ @request = request
10
10
  end
11
11
 
12
12
  def call
13
- defaults.merge!(additional_defaults)
14
- end
15
-
16
- private
17
-
18
- def defaults
19
13
  {
20
- client_id: @client_id,
14
+ client_id: client_id,
21
15
  active: true,
22
16
  origin: 'web',
23
- headers: @headers,
24
- ip: @request_ip,
17
+ headers: headers,
18
+ ip: ip,
25
19
  library: {
26
20
  name: 'castle-rb',
27
21
  version: Castle::VERSION
28
22
  }
29
- }
23
+ }.tap do |result|
24
+ result[:locale] = locale if locale
25
+ result[:user_agent] = user_agent if user_agent
26
+ end
30
27
  end
31
28
 
32
- def additional_defaults
33
- {}.tap do |result|
34
- result[:locale] = @headers['Accept-Language'] if @headers['Accept-Language']
35
- result[:user_agent] = @headers['User-Agent'] if @headers['User-Agent']
36
- end
29
+ private
30
+
31
+ # @return [String]
32
+ def locale
33
+ @pre_headers['Accept-Language']
34
+ end
35
+
36
+ # @return [String]
37
+ def user_agent
38
+ @pre_headers['User-Agent']
39
+ end
40
+
41
+ # @return [String]
42
+ def ip
43
+ Extractors::IP.new(@pre_headers).call
44
+ end
45
+
46
+ # @return [String]
47
+ def client_id
48
+ Extractors::ClientId.new(@pre_headers, @cookies).call
49
+ end
50
+
51
+ # formatted and filtered headers
52
+ # @return [Hash]
53
+ def headers
54
+ Extractors::Headers.new(@pre_headers).call
37
55
  end
38
56
  end
39
57
  end
@@ -15,6 +15,7 @@ module Castle
15
15
  return unless context
16
16
  return context unless context.key?(:active)
17
17
  return context if [true, false].include?(context[:active])
18
+
18
19
  context.reject { |key| key == :active }
19
20
  end
20
21
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ # list of events based on https://docs.castle.io/api_reference/#list-of-recognized-events
5
+ module Events
6
+ # Record when a user succesfully logs in.
7
+ LOGIN_SUCCEEDED = '$login.succeeded'
8
+ # Record when a user failed to log in.
9
+ LOGIN_FAILED = '$login.failed'
10
+ # Record when a user logs out.
11
+ LOGOUT_SUCCEEDED = '$logout.succeeded'
12
+ # Record when a user updated their profile (including password, email, phone, etc).
13
+ PROFILE_UPDATE_SUCCEEDED = '$profile_update.succeeded'
14
+ # Record errors when updating profile.
15
+ PROFILE_UPDATE_FAILED = '$profile_update.failed'
16
+ # Capture account creation, both when a user signs up as well as when created manually
17
+ # by an administrator.
18
+ REGISTRATION_SUCCEEDED = '$registration.succeeded'
19
+ # Record when an account failed to be created.
20
+ REGISTRATION_FAILED = '$registration.failed'
21
+ # The user completed all of the steps in the password reset process and the password was
22
+ # successfully reset.Password resets do not required knowledge of the current password.
23
+ PASSWORD_RESET_SUCCEEDED = '$password_reset.succeeded'
24
+ # Use to record when a user failed to reset their password.
25
+ PASSWORD_RESET_FAILED = '$password_reset.failed'
26
+ # The user successfully requested a password reset.
27
+ PASSWORD_RESET_REQUEST_SUCCCEEDED = '$password_reset_request.succeeded'
28
+ # The user failed to request a password reset.
29
+ PASSWORD_RESET_REQUEST_FAILED = '$password_reset_request.failed'
30
+ # User account has been reset.
31
+ INCIDENT_MITIGATED = '$incident.mitigated'
32
+ # User confirmed malicious activity.
33
+ REVIEW_ESCALATED = '$review.escalated'
34
+ # User confirmed safe activity.
35
+ REVIEW_RESOLVED = '$review.resolved'
36
+ # Record when a user is prompted with additional verification, such as two-factor
37
+ # authentication or a captcha.
38
+ CHALLENGE_REQUESTED = '$challenge.requested'
39
+ # Record when additional verification was successful.
40
+ CHALLENGE_SUCCEEDED = '$challenge.succeeded'
41
+ # Record when additional verification failed.
42
+ CHALLENGE_FAILED = '$challenge.failed'
43
+ # Record when a user attempts an in-app transaction, such as a purchase or withdrawal.
44
+ TRANSACTION_ATTEMPTED = '$transaction.attempted'
45
+ # Record when a user session is extended, or use any time you want
46
+ # to re-authenticate a user mid-session.
47
+ SESSION_EXTENDED = '$session.extended'
48
+ end
49
+ end
@@ -4,13 +4,17 @@ module Castle
4
4
  module Extractors
5
5
  # used for extraction of cookies and headers from the request
6
6
  class ClientId
7
- def initialize(request, cookies)
8
- @request = request
7
+ # @param headers [Hash]
8
+ # @param cookies [NilClass|Hash]
9
+ def initialize(headers, cookies)
10
+ @headers = headers
9
11
  @cookies = cookies || {}
10
12
  end
11
13
 
14
+ # extracts client id
15
+ # @return [String]
12
16
  def call
13
- @request.env['HTTP_X_CASTLE_CLIENT_ID'] || @cookies['__cid'] || ''
17
+ @headers['X-Castle-Client-Id'] || @cookies['__cid'] || ''
14
18
  end
15
19
  end
16
20
  end
@@ -5,47 +5,41 @@ module Castle
5
5
  # used for extraction of cookies and headers from the request
6
6
  class Headers
7
7
  # Headers that we will never scrub, even if they land on the configuration blacklist.
8
- ALWAYS_INCLUDED_HEADERS = %w[User-Agent].freeze
8
+ ALWAYS_WHITELISTED = %w[User-Agent].freeze
9
9
 
10
10
  # Headers that will always be scrubbed, even if whitelisted.
11
- ALWAYS_SCRUBBED_HEADERS = %w[Cookie Authorization].freeze
11
+ ALWAYS_BLACKLISTED = %w[Cookie Authorization].freeze
12
12
 
13
- # Rack does not add the HTTP_ prefix to Content-Length for some reason
14
- CONTENT_LENGTH = 'CONTENT_LENGTH'
13
+ private_constant :ALWAYS_WHITELISTED, :ALWAYS_BLACKLISTED
15
14
 
16
- # Prefix that Rack adds for HTTP headers
17
- HTTP_HEADER_PREFIX = 'HTTP_'
18
-
19
- private_constant :ALWAYS_INCLUDED_HEADERS, :ALWAYS_SCRUBBED_HEADERS,
20
- :CONTENT_LENGTH, :HTTP_HEADER_PREFIX
21
-
22
- # @param request [Rack::Request]
23
- def initialize(request)
24
- @request_env = request.env
25
- @formatter = HeaderFormatter.new
15
+ # @param headers [Hash]
16
+ def initialize(headers)
17
+ @headers = headers
18
+ @no_whitelist = Castle.config.whitelisted.empty?
26
19
  end
27
20
 
28
21
  # Serialize HTTP headers
29
22
  # @return [Hash]
30
23
  def call
31
- @request_env.keys.each_with_object({}) do |env_header, acc|
32
- next unless env_header.to_s.start_with?(HTTP_HEADER_PREFIX) || env_header == CONTENT_LENGTH
33
-
34
- header = @formatter.call(env_header)
35
-
36
- if ALWAYS_SCRUBBED_HEADERS.include?(header)
37
- acc[header] = true
38
- elsif ALWAYS_INCLUDED_HEADERS.include?(header)
39
- acc[header] = @request_env[env_header]
40
- elsif Castle.config.blacklisted.include?(header)
41
- acc[header] = true
42
- elsif Castle.config.whitelisted.empty? || Castle.config.whitelisted.include?(header)
43
- acc[header] = @request_env[env_header]
44
- else
45
- acc[header] = true
46
- end
24
+ @headers.each_with_object({}) do |(name, value), acc|
25
+ acc[name] = header_value(name, value)
47
26
  end
48
27
  end
28
+
29
+ private
30
+
31
+ # scrub header value
32
+ # @param name [String]
33
+ # @param value [String]
34
+ # @return [TrueClass | FalseClass | String]
35
+ def header_value(name, value)
36
+ return true if ALWAYS_BLACKLISTED.include?(name)
37
+ return value if ALWAYS_WHITELISTED.include?(name)
38
+ return true if Castle.config.blacklisted.include?(name)
39
+ return value if @no_whitelist || Castle.config.whitelisted.include?(name)
40
+
41
+ true
42
+ end
49
43
  end
50
44
  end
51
45
  end
@@ -4,14 +4,78 @@ module Castle
4
4
  module Extractors
5
5
  # used for extraction of ip from the request
6
6
  class IP
7
- def initialize(request)
8
- @request = request
7
+ # ordered list of ip headers for ip extraction
8
+ DEFAULT = %w[X-Forwarded-For Remote-Addr].freeze
9
+ # list of header which are used with proxy depth setting
10
+ DEPTH_RELATED = %w[X-Forwarded-For].freeze
11
+
12
+ private_constant :DEFAULT
13
+
14
+ # @param headers [Hash]
15
+ def initialize(headers)
16
+ @headers = headers
17
+ @ip_headers = Castle.config.ip_headers.empty? ? DEFAULT : Castle.config.ip_headers
18
+ @proxies = Castle.config.trusted_proxies + Castle::Configuration::TRUSTED_PROXIES
19
+ @trust_proxy_chain = Castle.config.trust_proxy_chain
20
+ @trusted_proxy_depth = Castle.config.trusted_proxy_depth
9
21
  end
10
22
 
23
+ # Order of headers:
24
+ # .... list of headers defined by ip_headers
25
+ # X-Forwarded-For
26
+ # Remote-Addr
27
+ # @return [String]
11
28
  def call
12
- return @request.env['HTTP_CF_CONNECTING_IP'] if @request.env['HTTP_CF_CONNECTING_IP']
13
- return @request.remote_ip if @request.respond_to?(:remote_ip)
14
- @request.ip
29
+ all_ips = []
30
+
31
+ @ip_headers.each do |ip_header|
32
+ ips = ips_from(ip_header)
33
+ ip_value = remove_proxies(ips)
34
+
35
+ return ip_value if ip_value
36
+
37
+ all_ips.push(*ips)
38
+ end
39
+
40
+ # fallback to first listed ip
41
+ all_ips.first
42
+ end
43
+
44
+ private
45
+
46
+ # @param ips [Array<String>]
47
+ # @return [Array<String>]
48
+ def remove_proxies(ips)
49
+ return ips.first if @trust_proxy_chain
50
+
51
+ ips.reject { |ip| proxy?(ip) }.last
52
+ end
53
+
54
+ # @param ip [String]
55
+ # @return [Boolean]
56
+ def proxy?(ip)
57
+ @proxies.any? { |proxy| proxy.match(ip) }
58
+ end
59
+
60
+ # @param header [String]
61
+ # @return [Array<String>]
62
+ def ips_from(header)
63
+ value = @headers[header]
64
+
65
+ return [] unless value
66
+
67
+ ips = value.strip.split(/[,\s]+/)
68
+
69
+ limit_proxy_depth(ips, header)
70
+ end
71
+
72
+ # @param ips [Array<String>]
73
+ # @param ip_header [String]
74
+ # @return [Array<String>]
75
+ def limit_proxy_depth(ips, ip_header)
76
+ ips.pop(@trusted_proxy_depth) if DEPTH_RELATED.include?(ip_header)
77
+
78
+ ips
15
79
  end
16
80
  end
17
81
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ # used for preparing valuable headers list
5
+ class HeadersFilter
6
+ # headers filter
7
+ # HTTP_ - this is how Rack prefixes incoming HTTP headers
8
+ # CONTENT_LENGTH - for responses without Content-Length or Transfer-Encoding header
9
+ # REMOTE_ADDR - ip address header returned by web server
10
+ VALUABLE_HEADERS = /^
11
+ HTTP(?:_|-).*|
12
+ CONTENT(?:_|-)LENGTH|
13
+ REMOTE(?:_|-)ADDR
14
+ $/xi.freeze
15
+
16
+ private_constant :VALUABLE_HEADERS
17
+
18
+ # @param request [Rack::Request]
19
+ def initialize(request)
20
+ @request_env = request.env
21
+ @formatter = HeadersFormatter
22
+ end
23
+
24
+ # Serialize HTTP headers
25
+ # @return [Hash]
26
+ def call
27
+ @request_env.keys.each_with_object({}) do |header_name, acc|
28
+ next unless header_name.match(VALUABLE_HEADERS)
29
+
30
+ formatted_name = @formatter.call(header_name)
31
+ acc[formatted_name] = @request_env[header_name]
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ # formats header name
5
+ class HeadersFormatter
6
+ class << self
7
+ # @param header [String]
8
+ # @return [String]
9
+ def call(header)
10
+ format(header.to_s.gsub(/^HTTP(?:_|-)/i, ''))
11
+ end
12
+
13
+ private
14
+
15
+ # @param header [String]
16
+ # @return [String]
17
+ def format(header)
18
+ header.split(/_|-/).map(&:capitalize).join('-')
19
+ end
20
+ end
21
+ end
22
+ end
@@ -7,6 +7,7 @@ module Castle
7
7
  def call(options, keys)
8
8
  keys.each do |key|
9
9
  next unless options.key?(key)
10
+
10
11
  raise Castle::InvalidParametersError, "#{key} is/are not supported"
11
12
  end
12
13
  end
@@ -7,6 +7,7 @@ module Castle
7
7
  def call(options, keys)
8
8
  keys.each do |key|
9
9
  next unless options[key].to_s.empty?
10
+
10
11
  raise Castle::InvalidParametersError, "#{key} is missing or empty"
11
12
  end
12
13
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Castle
4
- VERSION = '3.6.2'
4
+ VERSION = '4.3.0'
5
5
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative 'support/all'
5
+
6
+ RSpec.describe HomeController, type: :request do
7
+ describe '#index' do
8
+ let(:request) do
9
+ {
10
+ 'event' => '$login.succeeded',
11
+ 'user_id' => '123',
12
+ 'properties' => { 'key' => 'value' },
13
+ 'user_traits' => { 'key' => 'value' },
14
+ 'timestamp' => now.utc.iso8601(3),
15
+ 'sent_at' => now.utc.iso8601(3),
16
+ 'context' => {
17
+ 'client_id' => '',
18
+ 'active' => true,
19
+ 'origin' => 'web',
20
+ 'headers' => {
21
+ 'Accept' => 'text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5',
22
+ 'Authorization' => true,
23
+ 'Content-Length' => '0',
24
+ 'Cookie' => true,
25
+ 'Host' => 'www.example.com',
26
+ 'X-Forwarded-For' => '5.5.5.5, 1.2.3.4',
27
+ 'Remote-Addr' => '127.0.0.1'
28
+ },
29
+ 'ip' => '1.2.3.4',
30
+ 'library' => {
31
+ 'name' => 'castle-rb',
32
+ 'version' => Castle::VERSION
33
+ }
34
+ }
35
+ }
36
+ end
37
+ let(:now) { Time.now }
38
+ let(:headers) do
39
+ {
40
+ 'HTTP_AUTHORIZATION' => 'Basic 123',
41
+ 'HTTP_X_FORWARDED_FOR' => '5.5.5.5, 1.2.3.4'
42
+ }
43
+ end
44
+
45
+ before do
46
+ Timecop.freeze(now)
47
+ stub_request(:post, 'https://api.castle.io/v1/track')
48
+ get '/', headers: headers
49
+ end
50
+
51
+ after { Timecop.return }
52
+
53
+ it do
54
+ assert_requested :post, 'https://api.castle.io/v1/track', times: 1 do |req|
55
+ JSON.parse(req.body) == request
56
+ end
57
+ end
58
+
59
+ it { expect(response).to be_successful }
60
+ end
61
+ end