sitegoose 0.3.3
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +342 -0
- data/lib/sitegoose/analytics.rb +113 -0
- data/lib/sitegoose/challenge.rb +177 -0
- data/lib/sitegoose/core.rb +523 -0
- data/lib/sitegoose/crawlers.rb +195 -0
- data/lib/sitegoose/errors.rb +456 -0
- data/lib/sitegoose/firewall.rb +417 -0
- data/lib/sitegoose/inventory.rb +131 -0
- data/lib/sitegoose/ip.rb +159 -0
- data/lib/sitegoose/middleware.rb +52 -0
- data/lib/sitegoose/performance.rb +114 -0
- data/lib/sitegoose/platform.rb +557 -0
- data/lib/sitegoose/posture.rb +272 -0
- data/lib/sitegoose/proxy.rb +260 -0
- data/lib/sitegoose/queries.rb +399 -0
- data/lib/sitegoose/query_text.rb +215 -0
- data/lib/sitegoose/railtie.rb +124 -0
- data/lib/sitegoose/request_rules.rb +390 -0
- data/lib/sitegoose/resources.rb +449 -0
- data/lib/sitegoose/sidekiq.rb +95 -0
- data/lib/sitegoose/small_modules.rb +250 -0
- data/lib/sitegoose/version.rb +5 -0
- data/lib/sitegoose.rb +261 -0
- metadata +92 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SiteGoose
|
|
4
|
+
MODULES = %w[proxy firewall errors analytics performance crawlers forms headers audit flags maintenance inventory
|
|
5
|
+
posture resources].freeze
|
|
6
|
+
|
|
7
|
+
# The site, its secret and the modules that are on. Each module is
|
|
8
|
+
# separate: one failing to start never stops the others, and the firewall
|
|
9
|
+
# never waits on reporting. A loaded module can also be switched off in the
|
|
10
|
+
# dashboard (Security -> Server package).
|
|
11
|
+
class Platform
|
|
12
|
+
attr_reader :core
|
|
13
|
+
|
|
14
|
+
# site_id, secret (site settings; keep it on the server), endpoint (the
|
|
15
|
+
# collector's URL); trust_proxy (how to find the visitor's address: nil
|
|
16
|
+
# uses the connection's, a number of proxies in X-Forwarded-For,
|
|
17
|
+
# "cloudflare"); debug (print send problems); server_name (this
|
|
18
|
+
# service's name); queries (false: Server speed without the database
|
|
19
|
+
# queries); per module true / false / {options}.
|
|
20
|
+
def initialize(site_id: nil, secret: nil, endpoint: nil, trust_proxy: nil, debug: false, server_name: nil,
|
|
21
|
+
logger: nil, queries: true, **modules)
|
|
22
|
+
unknown = modules.keys.map(&:to_s) - MODULES
|
|
23
|
+
warn "[analytics-platform] unknown options ignored: #{unknown.join(', ')}" unless unknown.empty?
|
|
24
|
+
@core = Core.new(site_id, secret, endpoint, debug: debug, server_name: server_name, logger: logger)
|
|
25
|
+
unless @core.valid?
|
|
26
|
+
warn "[analytics-platform] site_id, secret and endpoint are required; nothing is sent and every request passes"
|
|
27
|
+
end
|
|
28
|
+
@modules = {}
|
|
29
|
+
MODULES.each do |name|
|
|
30
|
+
setting = modules.key?(name.to_sym) ? modules[name.to_sym] : true
|
|
31
|
+
next if setting == false || setting.nil?
|
|
32
|
+
|
|
33
|
+
opts = setting.is_a?(Hash) ? SiteGoose.symbolize(setting) : {}
|
|
34
|
+
opts[:trust_proxy] = trust_proxy if name == "firewall" && !opts.key?(:trust_proxy)
|
|
35
|
+
# server_name names this service for every module that sends one, so
|
|
36
|
+
# its errors, timings and resources can be told apart.
|
|
37
|
+
if server_name && %w[errors performance crawlers resources].include?(name)
|
|
38
|
+
opts[:server_name] = server_name unless opts.key?(:server_name)
|
|
39
|
+
end
|
|
40
|
+
# Inventory and posture call it host, and are stored one per site and
|
|
41
|
+
# host: without it two services on one machine overwrite each other's.
|
|
42
|
+
opts[:host] = server_name if server_name && %w[inventory posture].include?(name) && !opts.key?(:host)
|
|
43
|
+
begin
|
|
44
|
+
@modules[name] = load(name, opts)
|
|
45
|
+
rescue StandardError, ScriptError => e
|
|
46
|
+
warn "[analytics-platform] the #{name} module could not start and is off: #{e.message}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
@queries = nil
|
|
50
|
+
performance = @modules["performance"]
|
|
51
|
+
if performance && queries != false
|
|
52
|
+
begin
|
|
53
|
+
@queries = Queries.new(@core, server_name: performance.server_name)
|
|
54
|
+
rescue StandardError, ScriptError => e
|
|
55
|
+
warn "[analytics-platform] database queries could not start and are off: #{e.message}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
@core.start(@modules.keys)
|
|
59
|
+
@closed = false
|
|
60
|
+
SiteGoose.created(self)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def load(name, opts)
|
|
64
|
+
case name
|
|
65
|
+
when "firewall" then Firewall.new(@core, **opts)
|
|
66
|
+
when "errors" then Errors.new(@core, **opts)
|
|
67
|
+
when "analytics" then Analytics.new(@core, **opts)
|
|
68
|
+
when "performance" then Performance.new(@core, **opts)
|
|
69
|
+
when "crawlers" then Crawlers.new(@core, **opts)
|
|
70
|
+
when "forms" then Forms.new(@core, **opts)
|
|
71
|
+
when "headers" then Headers.new
|
|
72
|
+
when "audit" then Audit.new(@core, **opts)
|
|
73
|
+
when "flags" then Flags.new(@core, **opts)
|
|
74
|
+
when "maintenance" then Maintenance.new(@core, **opts)
|
|
75
|
+
when "inventory" then Inventory.new(@core, **opts)
|
|
76
|
+
when "posture" then Posture.new(@core, **opts)
|
|
77
|
+
when "resources" then Resources.new(@core, **opts)
|
|
78
|
+
when "proxy" then Proxy.new(@core, **opts)
|
|
79
|
+
else raise ArgumentError, name
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
private :load
|
|
83
|
+
|
|
84
|
+
# ---- Modules ----
|
|
85
|
+
|
|
86
|
+
# The modules that are on.
|
|
87
|
+
def modules
|
|
88
|
+
@modules.keys
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def module(name)
|
|
92
|
+
@modules[name.to_s]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def firewall
|
|
96
|
+
@modules["firewall"]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def errors
|
|
100
|
+
@modules["errors"]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The database queries Server speed records (nil with queries: false or
|
|
104
|
+
# performance off).
|
|
105
|
+
attr_reader :queries
|
|
106
|
+
|
|
107
|
+
# The first-party proxy: proxy.script_url and proxy.collect_url are the
|
|
108
|
+
# two addresses the page's script tag needs, on this site's own domain.
|
|
109
|
+
def proxy
|
|
110
|
+
@modules["proxy"]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# [status, headers, body] when the address belongs to the proxy, else nil.
|
|
114
|
+
# Called first: a visitor's beacon is our traffic, not a page of the site.
|
|
115
|
+
def proxy_response(path, method: "GET", query: "", headers: {}, body: nil, remote: "")
|
|
116
|
+
proxy = @modules["proxy"]
|
|
117
|
+
return nil unless proxy
|
|
118
|
+
|
|
119
|
+
found = proxy.match(path)
|
|
120
|
+
return nil unless found
|
|
121
|
+
|
|
122
|
+
ip = firewall ? firewall.client_ip(remote, headers) : remote
|
|
123
|
+
# A denied or banned address gets nothing from us either; the rate
|
|
124
|
+
# limit and the browser check aren't for beacons.
|
|
125
|
+
if firewall
|
|
126
|
+
decision = firewall.check_address(ip, method, path, SiteGoose.header(headers, "user-agent"))
|
|
127
|
+
return [403, { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store" }, "Forbidden"] if decision[:action] == "block"
|
|
128
|
+
end
|
|
129
|
+
body = body.call if body.respond_to?(:call)
|
|
130
|
+
proxy.handle(found[0], found[1], method: method, query: query, headers: headers, body: body, client_ip: ip)
|
|
131
|
+
rescue StandardError => e
|
|
132
|
+
@core.log("proxy", "failed:", e.message)
|
|
133
|
+
[502, {}, ""]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def capture_exception(error, request: nil, status: 0, kind: "handled")
|
|
137
|
+
errors&.capture_exception(error, request: request, status: status, kind: kind)
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# A step of your own in the trail shown before an error in this request or job.
|
|
142
|
+
def add_breadcrumb(text)
|
|
143
|
+
Context.add_breadcrumb(text) if errors
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A background job (a queue consumer, a cron task): its errors are sent
|
|
147
|
+
# with the job's name as their route, and each run is timed (Health ->
|
|
148
|
+
# Server speed -> Jobs); a run that raises counts as failed, and the
|
|
149
|
+
# exception is raised on.
|
|
150
|
+
#
|
|
151
|
+
# platform.job("nightly-import") { ... }
|
|
152
|
+
#
|
|
153
|
+
# report: ->(error) { true } when this error should be sent (Sidekiq: only
|
|
154
|
+
# the last attempt); default every error.
|
|
155
|
+
def job(name, report: nil)
|
|
156
|
+
route = "/(job) #{SiteGoose.text(name.to_s.empty? ? 'job' : name, 180)}"
|
|
157
|
+
fields = { "method" => "JOB", "path" => route, "route" => route, "session_id" => "" }
|
|
158
|
+
started = SiteGoose.clock
|
|
159
|
+
status = nil
|
|
160
|
+
Context.with(fields) do
|
|
161
|
+
result = yield
|
|
162
|
+
status = 200
|
|
163
|
+
result
|
|
164
|
+
rescue SignalException, SystemExit
|
|
165
|
+
# Interrupted or stopped: not a run that failed, and not timed.
|
|
166
|
+
status = nil
|
|
167
|
+
raise
|
|
168
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- raised on
|
|
169
|
+
status = 500
|
|
170
|
+
begin
|
|
171
|
+
capture_exception(e, status: 500, kind: "error", request: fields) if report.nil? || report.call(e)
|
|
172
|
+
rescue StandardError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
raise
|
|
176
|
+
ensure
|
|
177
|
+
time_request("JOB", route, status, started, route) if status
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# A failed login (bot protection): the address, or a Rack request.
|
|
182
|
+
def login_failed(request_or_ip)
|
|
183
|
+
return unless firewall
|
|
184
|
+
|
|
185
|
+
ip = request_or_ip.is_a?(String) ? request_or_ip : request_ip(request_or_ip)
|
|
186
|
+
firewall.login_failed(ip) unless ip.to_s.empty?
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The visitor's address for a Rack (or Rails) request or env.
|
|
190
|
+
def request_ip(request)
|
|
191
|
+
env = SiteGoose.env_of(request)
|
|
192
|
+
return "" unless env
|
|
193
|
+
|
|
194
|
+
remote = env["REMOTE_ADDR"].to_s
|
|
195
|
+
firewall ? firewall.client_ip(remote, SiteGoose.headers_of(env)) : remote
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# A feature flag from the dashboard (user: for rollouts and user lists;
|
|
199
|
+
# default: until the flag is known, or with the module off).
|
|
200
|
+
def enabled?(key, user: nil, default: false)
|
|
201
|
+
flags = @modules["flags"]
|
|
202
|
+
flags ? flags.enabled?(key, user, default) : default
|
|
203
|
+
end
|
|
204
|
+
alias is_enabled enabled?
|
|
205
|
+
|
|
206
|
+
# Maintenance mode: nil to let the request through, [:pass, headers] to let
|
|
207
|
+
# it through and remember the preview link, or [status, headers, body].
|
|
208
|
+
def maintenance_response(remote, headers, path, query, secure: false)
|
|
209
|
+
maintenance = @modules["maintenance"]
|
|
210
|
+
return nil unless maintenance
|
|
211
|
+
|
|
212
|
+
ip = firewall ? firewall.client_ip(remote, headers) : remote
|
|
213
|
+
maintenance.decide(ip, path, query, SiteGoose.header(headers, "cookie"), secure: secure)
|
|
214
|
+
rescue StandardError => e
|
|
215
|
+
@core.log("maintenance", "check failed, request passed:", e.message)
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Records who did what (Security -> Audit log). action: e.g.
|
|
220
|
+
# "user.role_changed"; request adds the actor's address and visit.
|
|
221
|
+
def audit(action, actor: "", target: "", details: nil, request: nil)
|
|
222
|
+
audit = @modules["audit"]
|
|
223
|
+
return unless audit
|
|
224
|
+
|
|
225
|
+
ip = request ? request_ip(request) : ""
|
|
226
|
+
audit.record(action, actor, target, details, ip, request ? session_id(request) : "")
|
|
227
|
+
rescue StandardError => e
|
|
228
|
+
@core.log("audit", "could not record:", e.message)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Form protection: checks a form before you accept it. The form is the
|
|
232
|
+
# request's POST parameters (Rack, Rails), else pass fields.
|
|
233
|
+
# -> {ok:, spam:, reasons:, checked:}; spam goes to Leads -> Spam.
|
|
234
|
+
def check_form(request = nil, fields: nil, email: nil, name: nil, form: nil)
|
|
235
|
+
forms = @modules["forms"]
|
|
236
|
+
return { ok: true, spam: false, reasons: [], checked: false } unless forms
|
|
237
|
+
|
|
238
|
+
if fields.nil? && request
|
|
239
|
+
fields = if request.respond_to?(:request_parameters) then request.request_parameters
|
|
240
|
+
elsif request.respond_to?(:POST) then request.POST
|
|
241
|
+
end
|
|
242
|
+
fields = fields.to_unsafe_h if fields.respond_to?(:to_unsafe_h)
|
|
243
|
+
end
|
|
244
|
+
forms.check(fields || {}, request ? request_ip(request) : "", email, name, form)
|
|
245
|
+
rescue StandardError => e
|
|
246
|
+
@core.log("forms", "could not check a form:", e.message)
|
|
247
|
+
{ ok: true, spam: false, reasons: ["unchecked"], checked: false }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def track(name, fields = nil, session_id: "")
|
|
251
|
+
analytics = @modules["analytics"]
|
|
252
|
+
analytics ? analytics.track(name, fields, session_id) : ""
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def identify(user_id, session_id: "")
|
|
256
|
+
analytics = @modules["analytics"]
|
|
257
|
+
analytics ? analytics.identify(user_id, session_id) : false
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def lead(fields, session_id: "")
|
|
261
|
+
analytics = @modules["analytics"]
|
|
262
|
+
analytics&.lead(fields, session_id)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# The dashboard's security headers the response doesn't set itself
|
|
266
|
+
# (present: the header names it has).
|
|
267
|
+
def security_headers(present = [])
|
|
268
|
+
return [] unless @modules.key?("headers")
|
|
269
|
+
|
|
270
|
+
have = present.map { |name| name.to_s.downcase }
|
|
271
|
+
@core.security_headers.reject { |name, _| have.include?(name.downcase) }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Records a finished request for Server speed (started: SiteGoose.clock).
|
|
275
|
+
# The request's database queries go under the same method and route.
|
|
276
|
+
def time_request(method, route, status, started, path = "")
|
|
277
|
+
performance = @modules["performance"]
|
|
278
|
+
recorded = performance&.record(method, route, status, (SiteGoose.clock - started) * 1000, path)
|
|
279
|
+
@queries&.finish(*recorded) if recorded
|
|
280
|
+
rescue StandardError => e
|
|
281
|
+
@core.log("performance", "could not time a request:", e.message)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Records a finished request when it came from a crawler or AI bot
|
|
285
|
+
# (Health -> Crawlers). started: SiteGoose.clock when it arrived.
|
|
286
|
+
def record_crawler(user_agent, path, status, started = nil, size = 0, ip = "")
|
|
287
|
+
crawlers = @modules["crawlers"]
|
|
288
|
+
return unless crawlers
|
|
289
|
+
|
|
290
|
+
ms = started ? (SiteGoose.clock - started) * 1000 : 0
|
|
291
|
+
crawlers.middleware(user_agent, path, status, ms, size, ip)
|
|
292
|
+
rescue StandardError => e
|
|
293
|
+
@core.log("crawlers", "could not count a crawler request:", e.message)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# The browser's visit, from a Rack or Rails request (the X-Analytics-Session header).
|
|
297
|
+
def session_id(request)
|
|
298
|
+
env = SiteGoose.env_of(request)
|
|
299
|
+
value = env && env["HTTP_X_ANALYTICS_SESSION"]
|
|
300
|
+
value.is_a?(String) ? value : ""
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Exceptions a Logger is given at ERROR or above are sent too (kind
|
|
304
|
+
# "logged"); the logger still writes them. Rails.logger is hooked on its
|
|
305
|
+
# own with errors: {capture_logging: true}.
|
|
306
|
+
def capture_logger(logger)
|
|
307
|
+
return logger unless errors && logger
|
|
308
|
+
|
|
309
|
+
targets = logger.respond_to?(:broadcasts) ? [logger, *logger.broadcasts] : [logger]
|
|
310
|
+
targets.uniq.each do |target|
|
|
311
|
+
next unless target.respond_to?(:add)
|
|
312
|
+
|
|
313
|
+
target.singleton_class.prepend(LoggerCapture) unless target.singleton_class.include?(LoggerCapture)
|
|
314
|
+
target.instance_variable_set(:@__sitegoose_capture, lambda { |error|
|
|
315
|
+
name = target.respond_to?(:progname) ? target.progname.to_s : ""
|
|
316
|
+
capture_exception(error, kind: "logged") unless name.start_with?("analytics")
|
|
317
|
+
})
|
|
318
|
+
end
|
|
319
|
+
logger
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Sends everything waiting (serverless, a job runner: before returning).
|
|
323
|
+
def flush
|
|
324
|
+
[*@modules.values, @queries].compact.each do |mod|
|
|
325
|
+
mod.flush
|
|
326
|
+
rescue StandardError
|
|
327
|
+
nil
|
|
328
|
+
end
|
|
329
|
+
nil
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Stops background work and sends what's waiting.
|
|
333
|
+
def close
|
|
334
|
+
return if @closed
|
|
335
|
+
|
|
336
|
+
@closed = true
|
|
337
|
+
@core.stop
|
|
338
|
+
[*@modules.values, @queries].compact.each do |mod|
|
|
339
|
+
mod.close
|
|
340
|
+
rescue StandardError
|
|
341
|
+
nil
|
|
342
|
+
end
|
|
343
|
+
nil
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def closed?
|
|
347
|
+
@closed
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# ---- Requests ----
|
|
351
|
+
|
|
352
|
+
# [status, headers, body] when the firewall turns the request away, else
|
|
353
|
+
# nil. headers: a hash with lowercase names. target: the request target
|
|
354
|
+
# as sent, query string and all (SiteGoose.request_target).
|
|
355
|
+
def blocked(remote, headers, method, target)
|
|
356
|
+
fw = firewall
|
|
357
|
+
return nil unless fw
|
|
358
|
+
|
|
359
|
+
begin
|
|
360
|
+
ip = fw.client_ip(remote, headers)
|
|
361
|
+
decision = fw.check(ip, method, target, SiteGoose.header(headers, "user-agent"),
|
|
362
|
+
SiteGoose.header(headers, "accept"), SiteGoose.header(headers, "cookie"))
|
|
363
|
+
return fw.challenge_page(ip, method) if decision[:action] == "block" && decision[:rule] == "challenge"
|
|
364
|
+
rescue StandardError => e
|
|
365
|
+
@core.log("firewall", "check failed, request passed:", e.message)
|
|
366
|
+
return nil
|
|
367
|
+
end
|
|
368
|
+
return nil unless decision[:action] == "block"
|
|
369
|
+
|
|
370
|
+
extra = { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store" }
|
|
371
|
+
if decision[:status] == 429
|
|
372
|
+
extra["retry-after"] = fw.retry_after
|
|
373
|
+
return [429, extra, "Too Many Requests"]
|
|
374
|
+
end
|
|
375
|
+
[403, extra, "Forbidden"]
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# The Rack integration: everything a request goes through, in order. The
|
|
379
|
+
# block runs the app. Used by SiteGoose::Middleware.
|
|
380
|
+
def rack_call(env)
|
|
381
|
+
Forking.check
|
|
382
|
+
started = SiteGoose.clock
|
|
383
|
+
headers = SiteGoose.headers_of(env)
|
|
384
|
+
method = env["REQUEST_METHOD"].to_s
|
|
385
|
+
remote = env["REMOTE_ADDR"].to_s
|
|
386
|
+
raw_path = "#{env['SCRIPT_NAME']}#{env['PATH_INFO']}"
|
|
387
|
+
path = SiteGoose.decode_path(raw_path)
|
|
388
|
+
query = env["QUERY_STRING"].to_s
|
|
389
|
+
# The tracker's own addresses, answered before anything else.
|
|
390
|
+
answered = proxy_response(raw_path, method: method, query: query, headers: headers, remote: remote,
|
|
391
|
+
body: -> { SiteGoose.read_body(env) })
|
|
392
|
+
return SiteGoose.rack_response(answered, method) if answered
|
|
393
|
+
|
|
394
|
+
ip = nil
|
|
395
|
+
refused = blocked(remote, headers, method, SiteGoose.request_target(env))
|
|
396
|
+
if refused
|
|
397
|
+
ip = firewall ? firewall.client_ip(remote, headers) : remote
|
|
398
|
+
# A crawler the firewall turned away is still a crawler, and counted
|
|
399
|
+
# with the status it got.
|
|
400
|
+
record_crawler(headers["user-agent"], path, refused[0], started, 0, ip)
|
|
401
|
+
return SiteGoose.rack_response(refused, method)
|
|
402
|
+
end
|
|
403
|
+
closed = maintenance_response(remote, headers, path, query, secure: SiteGoose.https?(env))
|
|
404
|
+
return SiteGoose.rack_response(closed, method) if closed && closed[0] != :pass
|
|
405
|
+
|
|
406
|
+
remember = closed ? closed[1] : {}
|
|
407
|
+
fields = -> { SiteGoose.request_fields(env, method, path) }
|
|
408
|
+
status = 500
|
|
409
|
+
response = nil
|
|
410
|
+
Context.with(fields) do
|
|
411
|
+
response = yield
|
|
412
|
+
status = response[0].to_i
|
|
413
|
+
# Rails and Sinatra turn an exception into an error page themselves,
|
|
414
|
+
# so it never reaches here raised; they leave it in the env.
|
|
415
|
+
error = env["action_dispatch.exception"] || env["sinatra.error"]
|
|
416
|
+
capture_exception(error, status: status, kind: "error", request: fields.call) if error.is_a?(Exception) && status >= 500
|
|
417
|
+
response
|
|
418
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- raised on
|
|
419
|
+
unless e.is_a?(SignalException) || e.is_a?(SystemExit)
|
|
420
|
+
capture_exception(e, status: 500, kind: "error", request: fields.call)
|
|
421
|
+
end
|
|
422
|
+
raise
|
|
423
|
+
ensure
|
|
424
|
+
begin
|
|
425
|
+
route = SiteGoose.route_of(env, path)
|
|
426
|
+
time_request(method, route, status, started, path)
|
|
427
|
+
size = response ? SiteGoose.content_length(response[1]) : 0
|
|
428
|
+
record_crawler(headers["user-agent"], path, status, started, size, ip || (firewall ? firewall.client_ip(remote, headers) : remote))
|
|
429
|
+
rescue StandardError
|
|
430
|
+
nil
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
SiteGoose.add_headers(response, security_headers(SiteGoose.header_names(response[1])), remember)
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# ---- Rack helpers ----
|
|
438
|
+
|
|
439
|
+
class << self
|
|
440
|
+
# Whether the visitor's request came over HTTPS (a TLS-ending proxy says
|
|
441
|
+
# so in X-Forwarded-Proto, as Rack reads it).
|
|
442
|
+
def https?(env)
|
|
443
|
+
return true if env["HTTPS"].to_s.downcase == "on" || env["rack.url_scheme"] == "https"
|
|
444
|
+
|
|
445
|
+
env["HTTP_X_FORWARDED_PROTO"].to_s.split(",").first.to_s.strip.downcase == "https"
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# The env of a Rack::Request, an ActionDispatch::Request, or an env itself.
|
|
449
|
+
def env_of(request)
|
|
450
|
+
return request if request.is_a?(Hash)
|
|
451
|
+
return request.env if request.respond_to?(:env)
|
|
452
|
+
|
|
453
|
+
nil
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# The request's headers as a hash with lowercase names.
|
|
457
|
+
def headers_of(env)
|
|
458
|
+
headers = {}
|
|
459
|
+
env.each do |key, value|
|
|
460
|
+
next unless key.is_a?(String) && value.is_a?(String)
|
|
461
|
+
|
|
462
|
+
if key.start_with?("HTTP_")
|
|
463
|
+
headers[key[5..].tr("_", "-").downcase] = value
|
|
464
|
+
elsif key == "CONTENT_TYPE" || key == "CONTENT_LENGTH"
|
|
465
|
+
headers[key.tr("_", "-").downcase] = value
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
headers
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def read_body(env)
|
|
472
|
+
input = env["rack.input"]
|
|
473
|
+
return "" unless input
|
|
474
|
+
|
|
475
|
+
body = input.read(Proxy::MAX_BODY + 1).to_s
|
|
476
|
+
input.rewind if input.respond_to?(:rewind)
|
|
477
|
+
body
|
|
478
|
+
rescue StandardError
|
|
479
|
+
""
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# The route as the framework names it: Rails 7.1+ keeps the matched
|
|
483
|
+
# pattern (/users/:id), Sinatra its route ("GET /users/:id"); an older
|
|
484
|
+
# Rails gives the path parameters, put back into the path; else "" (the
|
|
485
|
+
# path is normalized).
|
|
486
|
+
def route_of(env, path)
|
|
487
|
+
pattern = env["action_dispatch.route_uri_pattern"]
|
|
488
|
+
return pattern.sub(/\(\.:format\)\z/, "")[0, 200] if pattern.is_a?(String) && !pattern.empty?
|
|
489
|
+
|
|
490
|
+
route = env["sinatra.route"]
|
|
491
|
+
return route.split(" ", 2)[1].to_s[0, 200] if route.is_a?(String) && !route.empty?
|
|
492
|
+
|
|
493
|
+
params = env["action_dispatch.request.path_parameters"]
|
|
494
|
+
return "" unless params.is_a?(Hash) && params.key?(:controller)
|
|
495
|
+
|
|
496
|
+
format = params[:format].to_s
|
|
497
|
+
parts = path.split("/", -1)
|
|
498
|
+
parts[-1] = parts[-1].delete_suffix(".#{format}") if !format.empty? && parts[-1]
|
|
499
|
+
params.each do |key, value|
|
|
500
|
+
next if %i[controller action format].include?(key) || !value.is_a?(String) || value.empty?
|
|
501
|
+
|
|
502
|
+
index = parts.index(value)
|
|
503
|
+
parts[index] = ":#{key}" if index
|
|
504
|
+
end
|
|
505
|
+
route = parts.join("/")
|
|
506
|
+
route.empty? ? "/" : route[0, 200]
|
|
507
|
+
rescue StandardError
|
|
508
|
+
""
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def request_fields(env, method, path)
|
|
512
|
+
session = env["HTTP_X_ANALYTICS_SESSION"]
|
|
513
|
+
{ "method" => method, "path" => path, "route" => route_of(env, path), "session_id" => session.is_a?(String) ? session : "" }
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def header_names(headers)
|
|
517
|
+
headers.respond_to?(:keys) ? headers.keys.map { |k| k.to_s.downcase } : []
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def content_length(headers)
|
|
521
|
+
return 0 unless headers.respond_to?(:each)
|
|
522
|
+
|
|
523
|
+
headers.each { |k, v| return v.to_s.to_i if k.to_s.casecmp?("content-length") }
|
|
524
|
+
0
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# Our own answer (the check page, a refusal, the proxy) as a Rack response.
|
|
528
|
+
def rack_response(answer, method)
|
|
529
|
+
status, headers, body = answer
|
|
530
|
+
body = "" if method.to_s.upcase == "HEAD"
|
|
531
|
+
[status, headers.to_h { |k, v| [k.to_s.downcase, v.to_s] }, [body.to_s]]
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# The dashboard's security headers and the maintenance cookie, onto the
|
|
535
|
+
# app's own response.
|
|
536
|
+
def add_headers(response, extra, remember)
|
|
537
|
+
return response if extra.empty? && remember.empty?
|
|
538
|
+
|
|
539
|
+
status, headers, body = response
|
|
540
|
+
headers = headers.dup if headers.frozen?
|
|
541
|
+
extra.each { |name, value| headers[name.downcase] = value }
|
|
542
|
+
remember.each do |name, value|
|
|
543
|
+
existing = headers[name]
|
|
544
|
+
headers[name] = if existing.nil? || existing.empty? then value
|
|
545
|
+
elsif existing.is_a?(Array) then existing + [value]
|
|
546
|
+
elsif rack3? then [existing, value]
|
|
547
|
+
else "#{existing}\n#{value}"
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
[status, headers, body]
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def rack3?
|
|
554
|
+
defined?(::Rack::RELEASE) && ::Rack::RELEASE.to_s >= "3"
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
end
|