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.
- checksums.yaml +4 -4
- data/README.md +57 -5
- data/lib/castle.rb +5 -3
- data/lib/castle/api.rb +12 -6
- data/lib/castle/api/request.rb +15 -10
- data/lib/castle/api/session.rb +39 -0
- data/lib/castle/client.rb +23 -18
- data/lib/castle/configuration.rb +49 -6
- data/lib/castle/context/default.rb +36 -18
- data/lib/castle/context/sanitizer.rb +1 -0
- data/lib/castle/events.rb +49 -0
- data/lib/castle/extractors/client_id.rb +7 -3
- data/lib/castle/extractors/headers.rb +24 -30
- data/lib/castle/extractors/ip.rb +69 -5
- data/lib/castle/headers_filter.rb +35 -0
- data/lib/castle/headers_formatter.rb +22 -0
- data/lib/castle/validators/not_supported.rb +1 -0
- data/lib/castle/validators/present.rb +1 -0
- data/lib/castle/version.rb +1 -1
- data/spec/integration/rails/rails_spec.rb +61 -0
- data/spec/integration/rails/support/all.rb +6 -0
- data/spec/integration/rails/support/application.rb +15 -0
- data/spec/integration/rails/support/home_controller.rb +21 -0
- data/spec/lib/castle/api/request_spec.rb +43 -30
- data/spec/lib/castle/api/session_spec.rb +47 -0
- data/spec/lib/castle/api_spec.rb +4 -4
- data/spec/lib/castle/client_spec.rb +5 -3
- data/spec/lib/castle/commands/authenticate_spec.rb +1 -0
- data/spec/lib/castle/commands/identify_spec.rb +1 -0
- data/spec/lib/castle/commands/impersonate_spec.rb +1 -0
- data/spec/lib/castle/commands/track_spec.rb +1 -0
- data/spec/lib/castle/configuration_spec.rb +21 -4
- data/spec/lib/castle/context/default_spec.rb +13 -13
- data/spec/lib/castle/events_spec.rb +5 -0
- data/spec/lib/castle/extractors/client_id_spec.rb +2 -1
- data/spec/lib/castle/extractors/headers_spec.rb +67 -51
- data/spec/lib/castle/extractors/ip_spec.rb +89 -12
- data/spec/lib/castle/headers_filter_spec.rb +38 -0
- data/spec/lib/castle/{header_formatter_spec.rb → headers_formatter_spec.rb} +3 -3
- data/spec/lib/castle/utils/cloner_spec.rb +1 -0
- data/spec/lib/castle/utils/timestamp_spec.rb +3 -4
- data/spec/lib/castle/utils_spec.rb +1 -1
- data/spec/lib/castle/validators/not_supported_spec.rb +1 -3
- data/spec/spec_helper.rb +1 -2
- metadata +38 -10
- data/lib/castle/api/request/build.rb +0 -27
- data/lib/castle/header_formatter.rb +0 -9
- 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
|
-
@
|
8
|
-
@
|
9
|
-
@
|
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:
|
14
|
+
client_id: client_id,
|
21
15
|
active: true,
|
22
16
|
origin: 'web',
|
23
|
-
headers:
|
24
|
-
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
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
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
|
@@ -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
|
-
|
8
|
-
|
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
|
-
@
|
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
|
-
|
8
|
+
ALWAYS_WHITELISTED = %w[User-Agent].freeze
|
9
9
|
|
10
10
|
# Headers that will always be scrubbed, even if whitelisted.
|
11
|
-
|
11
|
+
ALWAYS_BLACKLISTED = %w[Cookie Authorization].freeze
|
12
12
|
|
13
|
-
|
14
|
-
CONTENT_LENGTH = 'CONTENT_LENGTH'
|
13
|
+
private_constant :ALWAYS_WHITELISTED, :ALWAYS_BLACKLISTED
|
15
14
|
|
16
|
-
#
|
17
|
-
|
18
|
-
|
19
|
-
|
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
|
-
@
|
32
|
-
|
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
|
data/lib/castle/extractors/ip.rb
CHANGED
@@ -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
|
-
|
8
|
-
|
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
|
-
|
13
|
-
|
14
|
-
@
|
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
|
data/lib/castle/version.rb
CHANGED
@@ -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
|