castle-rb 3.6.2 → 4.3.0

Sign up to get free protection for your applications and to get access to all the features.
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