castle-rb 3.6.2 → 4.0.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 +41 -5
- data/lib/castle.rb +3 -1
- data/lib/castle/client.rb +20 -15
- data/lib/castle/configuration.rb +30 -3
- 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 +49 -5
- data/lib/castle/header_filter.rb +35 -0
- data/lib/castle/header_formatter.rb +3 -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/build_spec.rb +4 -2
- data/spec/lib/castle/api/request_spec.rb +1 -0
- 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 +18 -2
- data/spec/lib/castle/context/default_spec.rb +10 -11
- 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 +66 -49
- data/spec/lib/castle/extractors/ip_spec.rb +56 -12
- data/spec/lib/castle/header_filter_spec.rb +38 -0
- data/spec/lib/castle/header_formatter_spec.rb +1 -1
- 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
- metadata +31 -3
data/lib/castle/extractors/ip.rb
CHANGED
@@ -4,14 +4,58 @@ 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 Client-Ip Remote-Addr].freeze
|
9
|
+
# default header fallback when ip is not found
|
10
|
+
FALLBACK = 'Remote-Addr'
|
11
|
+
|
12
|
+
private_constant :FALLBACK, :DEFAULT
|
13
|
+
|
14
|
+
# @param headers [Hash]
|
15
|
+
def initialize(headers)
|
16
|
+
@headers = headers
|
17
|
+
@ip_headers = Castle.config.ip_headers + DEFAULT
|
18
|
+
@proxies = Castle.config.trusted_proxies + Castle::Configuration::TRUSTED_PROXIES
|
9
19
|
end
|
10
20
|
|
21
|
+
# Order of headers:
|
22
|
+
# .... list of headers defined by ip_headers
|
23
|
+
# X-Forwarded-For
|
24
|
+
# Client-Ip is
|
25
|
+
# Remote-Addr
|
26
|
+
# @return [String]
|
11
27
|
def call
|
12
|
-
|
13
|
-
|
14
|
-
|
28
|
+
@ip_headers.each do |ip_header|
|
29
|
+
ip_value = calculate_ip(ip_header)
|
30
|
+
return ip_value if ip_value
|
31
|
+
end
|
32
|
+
|
33
|
+
@headers[FALLBACK]
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
# @param header [String]
|
39
|
+
# @return [String]
|
40
|
+
def calculate_ip(header)
|
41
|
+
ips = ips_from(header)
|
42
|
+
remove_proxies(ips).first
|
43
|
+
end
|
44
|
+
|
45
|
+
# @param ips [Array<String>]
|
46
|
+
# @return [Array<String>]
|
47
|
+
def remove_proxies(ips)
|
48
|
+
ips.reject { |ip| @proxies.any? { |proxy| proxy.match(ip) } }
|
49
|
+
end
|
50
|
+
|
51
|
+
# @param header [String]
|
52
|
+
# @return [Array<String>]
|
53
|
+
def ips_from(header)
|
54
|
+
value = @headers[header]
|
55
|
+
|
56
|
+
return [] unless value
|
57
|
+
|
58
|
+
value.strip.split(/[,\s]+/).reverse
|
15
59
|
end
|
16
60
|
end
|
17
61
|
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castle
|
4
|
+
# used for preparing valuable headers list
|
5
|
+
class HeaderFilter
|
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
|
+
)$/x.freeze
|
15
|
+
|
16
|
+
private_constant :VALUABLE_HEADERS
|
17
|
+
|
18
|
+
# @param request [Rack::Request]
|
19
|
+
def initialize(request)
|
20
|
+
@request_env = request.env
|
21
|
+
@formatter = HeaderFormatter.new
|
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
|
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
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'action_controller/railtie'
|
4
|
+
|
5
|
+
class TestApp < Rails::Application
|
6
|
+
secrets.secret_token = 'secret_token'
|
7
|
+
secrets.secret_key_base = 'secret_key_base'
|
8
|
+
|
9
|
+
config.logger = Logger.new($stdout)
|
10
|
+
Rails.logger = config.logger
|
11
|
+
|
12
|
+
routes.draw do
|
13
|
+
get '/' => 'home#index'
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class HomeController < ActionController::Base
|
4
|
+
def index
|
5
|
+
request_context = ::Castle::Client.to_context(request)
|
6
|
+
track_options = ::Castle::Client.to_options(
|
7
|
+
event: '$login.succeeded',
|
8
|
+
user_id: '123',
|
9
|
+
properties: {
|
10
|
+
key: 'value'
|
11
|
+
},
|
12
|
+
user_traits: {
|
13
|
+
key: 'value'
|
14
|
+
}
|
15
|
+
)
|
16
|
+
client = ::Castle::Client.new(request_context)
|
17
|
+
client.track(track_options)
|
18
|
+
|
19
|
+
render inline: 'hello'
|
20
|
+
end
|
21
|
+
end
|
@@ -21,11 +21,13 @@ describe Castle::API::Request::Build do
|
|
21
21
|
|
22
22
|
context 'when post' do
|
23
23
|
let(:time) { Time.now.utc.iso8601(3) }
|
24
|
-
let(:command)
|
24
|
+
let(:command) do
|
25
|
+
Castle::Commands::Track.new({}).build(event: '$login.succeeded', name: "\xC4")
|
26
|
+
end
|
25
27
|
let(:expected_body) do
|
26
28
|
{
|
27
29
|
event: '$login.succeeded',
|
28
|
-
name:
|
30
|
+
name: '�',
|
29
31
|
context: {},
|
30
32
|
sent_at: time
|
31
33
|
}
|
@@ -244,11 +244,13 @@ describe Castle::Client do
|
|
244
244
|
it { expect(request_response[:action]).to be_eql('allow') }
|
245
245
|
it { expect(request_response[:user_id]).to be_eql('1234') }
|
246
246
|
it { expect(request_response[:failover]).to be true }
|
247
|
-
it { expect(request_response[:failover_reason]).to be_eql('Castle set to do not track.') }
|
247
|
+
it { expect(request_response[:failover_reason]).to be_eql('Castle is set to do not track.') }
|
248
248
|
end
|
249
249
|
|
250
250
|
context 'when request with fail' do
|
251
|
-
before
|
251
|
+
before do
|
252
|
+
allow(Castle::API).to receive(:request).and_raise(Castle::RequestError.new(Timeout::Error))
|
253
|
+
end
|
252
254
|
|
253
255
|
context 'with request error and throw strategy' do
|
254
256
|
before { allow(Castle.config).to receive(:failover_strategy).and_return(:throw) }
|
@@ -274,7 +276,7 @@ describe Castle::Client do
|
|
274
276
|
it { expect { request_response }.to raise_error(Castle::InternalServerError) }
|
275
277
|
end
|
276
278
|
|
277
|
-
|
279
|
+
describe 'not throw on eg deny strategy' do
|
278
280
|
it { assert_not_requested :post, 'https://:secret@api.castle.io/v1/authenticate' }
|
279
281
|
it { expect(request_response[:action]).to be_eql('allow') }
|
280
282
|
it { expect(request_response[:user_id]).to be_eql('1234') }
|
@@ -31,14 +31,25 @@ describe Castle::Configuration do
|
|
31
31
|
|
32
32
|
describe 'api_secret' do
|
33
33
|
context 'with env' do
|
34
|
+
let(:secret_key_env) { 'secret_key_env' }
|
35
|
+
let(:secret_key) { 'secret_key' }
|
36
|
+
|
34
37
|
before do
|
35
38
|
allow(ENV).to receive(:fetch).with(
|
36
39
|
'CASTLE_API_SECRET', ''
|
37
|
-
).and_return(
|
40
|
+
).and_return(secret_key_env)
|
38
41
|
end
|
39
42
|
|
40
43
|
it do
|
41
|
-
expect(config.api_secret).to be_eql(
|
44
|
+
expect(config.api_secret).to be_eql(secret_key_env)
|
45
|
+
end
|
46
|
+
|
47
|
+
context 'when key is overwritten' do
|
48
|
+
before { config.api_secret = secret_key }
|
49
|
+
|
50
|
+
it do
|
51
|
+
expect(config.api_secret).to be_eql(secret_key)
|
52
|
+
end
|
42
53
|
end
|
43
54
|
end
|
44
55
|
|
@@ -48,6 +59,7 @@ describe Castle::Configuration do
|
|
48
59
|
before do
|
49
60
|
config.api_secret = value
|
50
61
|
end
|
62
|
+
|
51
63
|
it do
|
52
64
|
expect(config.api_secret).to be_eql(value)
|
53
65
|
end
|
@@ -69,6 +81,7 @@ describe Castle::Configuration do
|
|
69
81
|
before do
|
70
82
|
config.request_timeout = value
|
71
83
|
end
|
84
|
+
|
72
85
|
it do
|
73
86
|
expect(config.request_timeout).to be_eql(value)
|
74
87
|
end
|
@@ -84,6 +97,7 @@ describe Castle::Configuration do
|
|
84
97
|
before do
|
85
98
|
config.whitelisted = ['header']
|
86
99
|
end
|
100
|
+
|
87
101
|
it do
|
88
102
|
expect(config.whitelisted).to be_eql(['Header'])
|
89
103
|
end
|
@@ -99,6 +113,7 @@ describe Castle::Configuration do
|
|
99
113
|
before do
|
100
114
|
config.blacklisted = ['header']
|
101
115
|
end
|
116
|
+
|
102
117
|
it do
|
103
118
|
expect(config.blacklisted).to be_eql(['Header'])
|
104
119
|
end
|
@@ -114,6 +129,7 @@ describe Castle::Configuration do
|
|
114
129
|
before do
|
115
130
|
config.failover_strategy = :deny
|
116
131
|
end
|
132
|
+
|
117
133
|
it do
|
118
134
|
expect(config.failover_strategy).to be_eql(:deny)
|
119
135
|
end
|
@@ -16,24 +16,23 @@ describe Castle::Context::Default do
|
|
16
16
|
let(:request) { Rack::Request.new(env) }
|
17
17
|
let(:default_context) { subject.call }
|
18
18
|
let(:version) { '2.2.0' }
|
19
|
-
|
20
|
-
|
21
|
-
stub_const('Castle::VERSION', version)
|
22
|
-
end
|
23
|
-
|
24
|
-
it { expect(default_context[:active]).to be_eql(true) }
|
25
|
-
it { expect(default_context[:origin]).to be_eql('web') }
|
26
|
-
|
27
|
-
it do
|
28
|
-
expect(default_context[:headers]).to be_eql(
|
19
|
+
let(:result_headers) do
|
20
|
+
{
|
29
21
|
'X-Forwarded-For' => '1.2.3.4',
|
30
22
|
'Accept-Language' => 'en',
|
31
23
|
'User-Agent' => 'test',
|
32
24
|
'Content-Length' => '0',
|
33
25
|
'Cookie' => true
|
34
|
-
|
26
|
+
}
|
35
27
|
end
|
36
28
|
|
29
|
+
before do
|
30
|
+
stub_const('Castle::VERSION', version)
|
31
|
+
end
|
32
|
+
|
33
|
+
it { expect(default_context[:active]).to be_eql(true) }
|
34
|
+
it { expect(default_context[:origin]).to be_eql('web') }
|
35
|
+
it { expect(default_context[:headers]).to be_eql(result_headers) }
|
37
36
|
it { expect(default_context[:ip]).to be_eql(ip) }
|
38
37
|
it { expect(default_context[:library][:name]).to be_eql('castle-rb') }
|
39
38
|
it { expect(default_context[:library][:version]).to be_eql(version) }
|
@@ -1,8 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
describe Castle::Extractors::ClientId do
|
4
|
-
subject(:extractor) { described_class.new(
|
4
|
+
subject(:extractor) { described_class.new(formatted_headers, cookies) }
|
5
5
|
|
6
|
+
let(:formatted_headers) { Castle::HeaderFilter.new(request).call }
|
6
7
|
let(:client_id_cookie) { 'abcd' }
|
7
8
|
let(:client_id_header) { 'abcde' }
|
8
9
|
let(:cookies) { request.cookies }
|