Skip to content

Commit 1fb6370

Browse files
authored
Merge pull request #51 from PerimeterX/dev-first-party
first party
2 parents b403714 + 38630f6 commit 1fb6370

File tree

10 files changed

+301
-42
lines changed

10 files changed

+301
-42
lines changed

changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
## [2.2.0] - 2020-09-15
9+
### Added
10+
- First Party
11+
812
## [2.1.0] - 2020-09-01
913
### Added
1014
- Added option to set a different px configuration on each request

lib/perimeter_x.rb

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,23 @@
1212
require 'perimeterx/internal/validators/perimeter_x_s2s_validator'
1313
require 'perimeterx/internal/validators/perimeter_x_cookie_validator'
1414
require 'perimeterx/internal/exceptions/px_config_exception'
15+
require 'perimeterx/internal/first_party/px_first_party'
1516

1617
module PxModule
1718
# Module expose API
1819
def px_verify_request(request_config={})
1920
begin
2021
px_instance = PerimeterX.new(request_config)
21-
px_ctx = px_instance.verify(request.env)
22+
req = ActionDispatch::Request.new(request.env)
23+
24+
# handle first party requests
25+
if px_instance.first_party.is_first_party_request(req)
26+
render_first_party_response(req, px_instance)
27+
return true
28+
end
29+
30+
# verify request
31+
px_ctx = px_instance.verify(req)
2232
px_config = px_instance.px_config
2333

2434
msg_title = 'PxModule[px_verify_request]'
@@ -46,12 +56,21 @@ def px_verify_request(request_config={})
4656
end
4757

4858
is_mobile = px_ctx.context[:cookie_origin] == 'header' ? '1' : '0'
49-
action = px_ctx.context[:block_action][0,1]
59+
action = px_ctx.context[:block_action][0,1]
5060

51-
px_template_object = {
61+
if px_config[:first_party_enabled]
62+
px_template_object = {
63+
js_client_src: "/#{px_config[:app_id][2..-1]}/init.js",
64+
block_script: "/#{px_config[:app_id][2..-1]}/captcha/#{px_config[:app_id]}/captcha.js?a=#{action}&u=#{px_ctx.context[:uuid]}&v=#{px_ctx.context[:vid]}&m=#{is_mobile}",
65+
host_url: "/#{px_config[:app_id][2..-1]}/xhr"
66+
}
67+
else
68+
px_template_object = {
69+
js_client_src: "//#{PxModule::CLIENT_HOST}/#{px_config[:app_id]}/main.min.js",
5270
block_script: "//#{PxModule::CAPTCHA_HOST}/#{px_config[:app_id]}/captcha.js?a=#{action}&u=#{px_ctx.context[:uuid]}&v=#{px_ctx.context[:vid]}&m=#{is_mobile}",
53-
js_client_src: "//#{PxModule::CLIENT_HOST}/#{px_config[:app_id]}/main.min.js"
54-
}
71+
host_url: "https://collector-#{px_config[:app_id]}.perimeterx.net"
72+
}
73+
end
5574

5675
html = PxTemplateFactory.get_template(px_ctx, px_config, px_template_object)
5776

@@ -68,7 +87,7 @@ def px_verify_request(request_config={})
6887
hash_json = {
6988
:appId => px_config[:app_id],
7089
:jsClientSrc => px_template_object[:js_client_src],
71-
:firstPartyEnabled => false,
90+
:firstPartyEnabled => px_ctx.context[:first_party_enabled],
7291
:uuid => px_ctx.context[:uuid],
7392
:vid => px_ctx.context[:vid],
7493
:hostUrl => "https://collector-#{px_config[:app_id]}.perimeterx.net",
@@ -110,6 +129,29 @@ def px_verify_request(request_config={})
110129
end
111130
end
112131

132+
def render_first_party_response(req, px_instance)
133+
fp = px_instance.first_party
134+
px_config = px_instance.px_config
135+
136+
if px_config[:first_party_enabled]
137+
# first party enabled - proxy response
138+
fp_response = fp.send_first_party_request(req)
139+
response.status = fp_response.code
140+
fp_response.to_hash.each do |header_name, header_value_arr|
141+
if header_name!="content-length"
142+
response.headers[header_name] = header_value_arr[0]
143+
end
144+
end
145+
res_type = fp.get_response_content_type(req)
146+
render res_type => fp_response.body
147+
else
148+
# first party disabled - return empty response
149+
response.status = 200
150+
res_type = fp.get_response_content_type(req)
151+
render res_type => ""
152+
end
153+
end
154+
113155
def self.configure(basic_config)
114156
PerimeterX.set_basic_config(basic_config)
115157
end
@@ -119,6 +161,7 @@ def self.configure(basic_config)
119161
class PerimeterX
120162

121163
attr_reader :px_config
164+
attr_reader :first_party
122165
attr_accessor :px_http_client
123166
attr_accessor :px_activity_client
124167

@@ -128,7 +171,7 @@ def self.set_basic_config(basic_config)
128171
end
129172

130173
#Instance Methods
131-
def verify(env)
174+
def verify(req)
132175
begin
133176

134177
# check module_enabled
@@ -137,13 +180,11 @@ def verify(env)
137180
@logger.warn('Module is disabled')
138181
return nil
139182
end
140-
141-
req = ActionDispatch::Request.new(env)
142183

143184
# filter whitelist routes
144185
url_path = URI.parse(req.original_url).path
145186
if url_path && !url_path.empty?
146-
if check_whitelist_routes(px_config[:whitelist_routes], url_path)
187+
if check_whitelist_routes(px_config[:whitelist_routes], url_path)
147188
@logger.debug("PerimeterX[pxVerify]: whitelist route: #{url_path}")
148189
return nil
149190
end
@@ -176,6 +217,7 @@ def initialize(request_config)
176217
@px_http_client = PxHttpClient.new(@px_config)
177218

178219
@px_activity_client = PerimeterxActivitiesClient.new(@px_config, @px_http_client)
220+
@first_party = FirstPartyManager.new(@px_config, @px_http_client, @logger)
179221

180222
@px_cookie_validator = PerimeterxCookieValidator.new(@px_config)
181223
@px_s2s_validator = PerimeterxS2SValidator.new(@px_config, @px_http_client)

lib/perimeterx/configuration.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ class Configuration
3030
:ip_headers => [],
3131
:ip_header_function => nil,
3232
:bypass_monitor_header => nil,
33-
:risk_cookie_max_iterations => 5000
33+
:risk_cookie_max_iterations => 5000,
34+
:first_party_enabled => true
3435
}
3536

3637
CONFIG_SCHEMA = {
@@ -60,7 +61,9 @@ class Configuration
6061
:custom_logo => {types: [String], required: false},
6162
:css_ref => {types: [String], required: false},
6263
:js_ref => {types: [String], required: false},
63-
:custom_uri => {types: [Proc], required: false}
64+
:custom_uri => {types: [Proc], required: false},
65+
:first_party_enabled => {types: [FalseClass, TrueClass], required: false}
66+
6467
}
6568

6669
def self.set_basic_config(basic_config)
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
require 'perimeterx/internal/perimeter_x_context'
2+
module PxModule
3+
class FirstPartyManager
4+
def initialize(px_config, px_http_client, logger)
5+
@px_config = px_config
6+
@app_id = px_config[:app_id]
7+
@px_http_client = px_http_client
8+
@logger = logger
9+
@from = [
10+
"/#{@app_id[2..-1]}/init.js",
11+
"/#{@app_id[2..-1]}/captcha",
12+
"/#{@app_id[2..-1]}/xhr"
13+
]
14+
end
15+
16+
def send_first_party_request(req)
17+
uri = URI.parse(req.original_url)
18+
url_path = uri.path
19+
20+
headers = extract_headers(req)
21+
headers["x-px-first-party"] = "1"
22+
headers["x-px-enforcer-true-ip"] = PerimeterXContext.extract_ip(req, @px_config)
23+
24+
if url_path.start_with?(@from[0])
25+
return get_client(req, uri, headers)
26+
elsif url_path.start_with?(@from[1])
27+
return get_captcha(req, uri, headers)
28+
elsif url_path.start_with?(@from[2])
29+
return send_xhr(req, uri, headers)
30+
else
31+
return nil
32+
end
33+
end
34+
35+
def get_client(req, uri, headers)
36+
@logger.debug("FirstPartyManager[get_client]")
37+
38+
# define host
39+
headers["host"] = PxModule::CLIENT_HOST
40+
41+
# define request url
42+
url = "#{uri.scheme}://#{PxModule::CLIENT_HOST}/#{@app_id}/main.min.js"
43+
44+
# send request
45+
return @px_http_client.get(url, headers)
46+
end
47+
48+
def get_captcha(req, uri, headers)
49+
@logger.debug("FirstPartyManager[get_captcha]")
50+
51+
# define host
52+
headers["host"] = PxModule::CAPTCHA_HOST
53+
54+
# define request url
55+
path_and_query = uri.request_uri
56+
uri_suffix = path_and_query.sub "/#{@app_id[2..-1]}/captcha", ""
57+
url = "#{uri.scheme}://#{PxModule::CAPTCHA_HOST}#{uri_suffix}"
58+
59+
# send request
60+
return @px_http_client.get(url, headers)
61+
end
62+
63+
def send_xhr(req, uri, headers)
64+
@logger.debug("FirstPartyManager[send_xhr]")
65+
66+
# handle vid cookies
67+
if !req.cookies.nil?
68+
if req.cookies.key?("_pxvid")
69+
vid = PerimeterXContext.force_utf8(req.cookies["_pxvid"])
70+
if headers.key?('cookie')
71+
headers['cookie'] += "; pxvid=#{vid}";
72+
else
73+
headers['cookie'] = "pxvid=#{vid}";
74+
end
75+
end
76+
end
77+
78+
# define host
79+
headers["host"] = "collector-#{@app_id.downcase}.perimeterx.net"
80+
81+
# define request url
82+
path_and_query = uri.request_uri
83+
path_suffix = path_and_query.sub "/#{@app_id[2..-1]}/xhr", ""
84+
url = "#{uri.scheme}://collector-#{@app_id.downcase}.perimeterx.net#{path_suffix}"
85+
86+
# send request
87+
return @px_http_client.post_xhr(url, req.body.string, headers)
88+
end
89+
90+
def extract_headers(req)
91+
headers = Hash.new
92+
req.headers.each do |k, v|
93+
if (k.start_with? 'HTTP_') && (!@px_config[:sensitive_headers].include? k)
94+
header = k.to_s.gsub('HTTP_', '')
95+
header = header.gsub('_', '-').downcase
96+
headers[header] = PerimeterXContext.force_utf8(v)
97+
end
98+
end
99+
return headers
100+
end
101+
102+
# -1 - not first party request
103+
# 0 - /init.js
104+
# 1 - /captcha
105+
# 2 - /xhr
106+
def get_first_party_request_type(req)
107+
url_path = URI.parse(req.original_url).path
108+
@from.each_with_index do |val,index|
109+
if url_path.start_with?(val)
110+
return index
111+
end
112+
end
113+
return -1
114+
end
115+
116+
def is_first_party_request(req)
117+
return get_first_party_request_type(req) != -1
118+
end
119+
120+
def get_response_content_type(req)
121+
return get_first_party_request_type(req) == 2 ? :json : :js
122+
end
123+
end
124+
end

lib/perimeterx/internal/perimeter_x_context.rb

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,28 @@ class PerimeterXContext
66

77
attr_accessor :context
88
attr_accessor :px_config
9+
10+
# class methods
11+
12+
def self.extract_ip(req, px_config)
13+
# Get IP from header/custom function
14+
if px_config[:ip_headers].length() > 0
15+
px_config[:ip_headers].each do |ip_header|
16+
if req.headers[ip_header]
17+
return PerimeterXContext.force_utf8(req.headers[ip_header])
18+
end
19+
end
20+
elsif px_config[:ip_header_function] != nil
21+
return px_config[:ip_header_function].call(req)
22+
end
23+
return req.ip
24+
end
25+
26+
def self.force_utf8(str)
27+
return str.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '')
28+
end
29+
30+
# instance methods
931

1032
def initialize(px_config, req)
1133
@logger = px_config[:logger]
@@ -16,33 +38,22 @@ def initialize(px_config, req)
1638
@context[:headers] = Hash.new
1739
@context[:cookie_origin] = 'cookie'
1840
@context[:made_s2s_risk_api_call] = false
41+
@context[:first_party_enabled] = px_config[:first_party_enabled]
42+
1943
cookies = req.cookies
2044

21-
# Get IP from header/custom function
22-
if px_config[:ip_headers].length() > 0
23-
px_config[:ip_headers].each do |ip_header|
24-
if req.headers[ip_header]
25-
@context[:ip] = force_utf8(req.headers[ip_header])
26-
end
27-
end
28-
elsif px_config[:ip_header_function] != nil
29-
@context[:ip] = px_config[:ip_header_function].call(req)
30-
end
31-
32-
if @context[:ip] == nil
33-
@context[:ip] = req.ip
34-
end
45+
@context[:ip] = PerimeterXContext.extract_ip(req, px_config)
3546

3647
# Get token from header
3748
if req.headers[PxModule::TOKEN_HEADER]
3849
@context[:cookie_origin] = 'header'
39-
token = force_utf8(req.headers[PxModule::TOKEN_HEADER])
50+
token = PerimeterXContext.force_utf8(req.headers[PxModule::TOKEN_HEADER])
4051
if token.match(PxModule::MOBILE_TOKEN_V3_REGEX)
4152
@context[:px_cookie][:v3] = token[2..-1]
4253
elsif token.match(PxModule::MOBILE_ERROR_REGEX)
4354
@context[:mobile_error] = token
4455
if req.headers[PxModule::ORIGINAL_TOKEN_HEADER]
45-
token = force_utf8(req.headers[PxModule::ORIGINAL_TOKEN_HEADER])
56+
token = PerimeterXContext.force_utf8(req.headers[PxModule::ORIGINAL_TOKEN_HEADER])
4657
if token.match(PxModule::MOBILE_TOKEN_V3_REGEX)
4758
@context[:px_cookie][:v3] = token[2..-1]
4859
end
@@ -53,13 +64,13 @@ def initialize(px_config, req)
5364
cookies.each do |k, v|
5465
case k.to_s
5566
when '_px3'
56-
@context[:px_cookie][:v3] = force_utf8(v)
67+
@context[:px_cookie][:v3] = PerimeterXContext.force_utf8(v)
5768
when '_px'
58-
@context[:px_cookie][:v1] = force_utf8(v)
69+
@context[:px_cookie][:v1] = PerimeterXContext.force_utf8(v)
5970
when '_pxvid'
6071
if v.is_a?(String) && v.match(PxModule::VID_REGEX)
6172
@context[:vid_source] = "vid_cookie"
62-
@context[:vid] = force_utf8(v)
73+
@context[:vid] = PerimeterXContext.force_utf8(v)
6374
end
6475
end
6576
end #end case
@@ -69,10 +80,10 @@ def initialize(px_config, req)
6980
if (k.start_with? 'HTTP_')
7081
header = k.to_s.gsub('HTTP_', '')
7182
header = header.gsub('_', '-').downcase
72-
@context[:headers][header.to_sym] = force_utf8(v)
83+
@context[:headers][header.to_sym] = PerimeterXContext.force_utf8(v)
7384
end
7485
end #end headers foreach
75-
86+
7687
@context[:hostname]= req.server_name
7788
@context[:user_agent] = req.user_agent ? req.user_agent : ''
7889
@context[:uri] = px_config[:custom_uri] ? px_config[:custom_uri].call(req) : req.fullpath
@@ -97,10 +108,6 @@ def check_sensitive_route(sensitive_routes, uri)
97108
false
98109
end
99110

100-
def force_utf8(str)
101-
return str.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '')
102-
end
103-
104111
def set_block_action_type(action)
105112
@context[:block_action] = case action
106113
when 'c'

0 commit comments

Comments
 (0)