fingerly 0.1.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 +7 -0
- data/LICENSE +202 -0
- data/README.md +402 -0
- data/lib/fingerly/client.rb +251 -0
- data/lib/fingerly/config.rb +146 -0
- data/lib/fingerly/errors.rb +95 -0
- data/lib/fingerly/models.rb +182 -0
- data/lib/fingerly/proxy.rb +430 -0
- data/lib/fingerly/redacted.rb +52 -0
- data/lib/fingerly/transport.rb +344 -0
- data/lib/fingerly/version.rb +6 -0
- data/lib/fingerly/webhooks.rb +126 -0
- data/lib/fingerly.rb +27 -0
- data/sig/fingerly.rbs +213 -0
- metadata +61 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
|
|
5
|
+
module Fingerly
|
|
6
|
+
# What +client_ip+ is given: one incoming request, already parsed out of the
|
|
7
|
+
# Rack environment.
|
|
8
|
+
#
|
|
9
|
+
# Rack spells headers +HTTP_CF_CONNECTING_IP+; this spells them the way they
|
|
10
|
+
# arrived, matched without regard to case.
|
|
11
|
+
class ProxyRequest
|
|
12
|
+
include Redacted
|
|
13
|
+
|
|
14
|
+
# @return [String] the HTTP method. Named for Rack's own +request_method+
|
|
15
|
+
# rather than +method+, which every Ruby object already answers to.
|
|
16
|
+
attr_reader :request_method
|
|
17
|
+
# @return [String] the request path as the application sees it
|
|
18
|
+
attr_reader :path
|
|
19
|
+
# @return [String, nil] the TCP peer's address (+REMOTE_ADDR+), when the
|
|
20
|
+
# server reports one. This is your load balancer or CDN, not the
|
|
21
|
+
# visitor, whenever one is in front of you.
|
|
22
|
+
attr_reader :peer_host
|
|
23
|
+
|
|
24
|
+
# @api private
|
|
25
|
+
def initialize(env, path)
|
|
26
|
+
@request_method = env["REQUEST_METHOD"].to_s
|
|
27
|
+
@path = path
|
|
28
|
+
@peer_host = env["REMOTE_ADDR"]
|
|
29
|
+
@headers = ProxyRequest.headers_from(env)
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# One header, matched without regard to case.
|
|
34
|
+
#
|
|
35
|
+
# @param name [String] the header name, as HTTP spells it
|
|
36
|
+
# @return [String, nil] the value, or nil when the request carried none
|
|
37
|
+
def header(name)
|
|
38
|
+
@headers[name.to_s.downcase]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Hash{String=>String}] every header, lowercased, with repeats
|
|
42
|
+
# joined by ", " as HTTP defines
|
|
43
|
+
attr_reader :headers
|
|
44
|
+
|
|
45
|
+
# @api private
|
|
46
|
+
def self.headers_from(env)
|
|
47
|
+
headers = {}
|
|
48
|
+
env.each do |key, value|
|
|
49
|
+
next unless value.is_a?(String)
|
|
50
|
+
|
|
51
|
+
name =
|
|
52
|
+
if key.start_with?("HTTP_") then key[5..].downcase.tr("_", "-")
|
|
53
|
+
elsif %w[CONTENT_TYPE CONTENT_LENGTH].include?(key) then key.downcase.tr("_", "-")
|
|
54
|
+
end
|
|
55
|
+
headers[name] = value if name
|
|
56
|
+
end
|
|
57
|
+
headers.freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# The visitor's cookies and authorization header are in +headers+; a
|
|
63
|
+
# client_ip that logs the request must not print them.
|
|
64
|
+
def redacted_fields
|
|
65
|
+
[["method", @request_method], ["path", @path], ["peer_host", @peer_host.inspect]]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Same-origin proxy for the browser SDK, as a Rack application or middleware.
|
|
70
|
+
#
|
|
71
|
+
# Content blockers refuse browser requests to third-party APIs, and a refused
|
|
72
|
+
# identification is a visitor never identified. Mounting this on a path of
|
|
73
|
+
# your own site makes those requests first-party. The proxy key is what lets
|
|
74
|
+
# that hop declare the visitor's real address, origin and user agent; a
|
|
75
|
+
# public key alone never can, so nobody can spoof them.
|
|
76
|
+
#
|
|
77
|
+
# Rack is the one interface every Ruby server and framework shares, so there
|
|
78
|
+
# is one adapter and no per-framework variants:
|
|
79
|
+
#
|
|
80
|
+
# # config/application.rb
|
|
81
|
+
# config.middleware.use Fingerly::Proxy,
|
|
82
|
+
# proxy_key: ENV.fetch("FINGERLY_PROXY_KEY"),
|
|
83
|
+
# path: "/k7x2qm4a",
|
|
84
|
+
# client_ip: ->(request) { request.header("CF-Connecting-IP") }
|
|
85
|
+
#
|
|
86
|
+
# The browser SDK posts both of its calls to that one path, and each is
|
|
87
|
+
# forwarded to Fingerly's one ingest URL, never anywhere else and never to
|
|
88
|
+
# event reads. Customer cookies and authorization headers are never passed
|
|
89
|
+
# on, and nothing that names Fingerly is relayed back.
|
|
90
|
+
class Proxy
|
|
91
|
+
include Redacted
|
|
92
|
+
|
|
93
|
+
DEFAULT_MAX_BODY_BYTES = 1 << 20
|
|
94
|
+
DEFAULT_TIMEOUT = 5
|
|
95
|
+
USER_AGENT = "fingerly-ruby/#{VERSION}".freeze
|
|
96
|
+
# What this proxy reports itself as upstream; shown per proxy in the
|
|
97
|
+
# dashboard.
|
|
98
|
+
PROXY_VERSION = "ruby/#{VERSION}".freeze
|
|
99
|
+
|
|
100
|
+
# The answer when client_ip returns anything but one IP address.
|
|
101
|
+
NO_CLIENT_IP = "The client IP callback returned no valid address"
|
|
102
|
+
|
|
103
|
+
# Statuses that must not carry a body.
|
|
104
|
+
BODILESS = [204, 205, 304].freeze
|
|
105
|
+
|
|
106
|
+
# The response headers relayed to the browser, and nothing else. A content
|
|
107
|
+
# blocker can match a response header by name, so an allowlist is what
|
|
108
|
+
# keeps anything naming Fingerly off the page, whatever the API adds in
|
|
109
|
+
# future. It also leaves out every header that describes the upstream
|
|
110
|
+
# connection rather than the bytes handed back, and +set-cookie+, the one
|
|
111
|
+
# header whose values cannot be joined into the string Rack 2 and Rack 3
|
|
112
|
+
# both accept.
|
|
113
|
+
RELAYED_HEADERS = %w[
|
|
114
|
+
content-type x-request-id ratelimit-limit ratelimit-remaining retry-after
|
|
115
|
+
].freeze
|
|
116
|
+
|
|
117
|
+
# @return [Integer] the request body limit in bytes
|
|
118
|
+
attr_reader :max_body_bytes
|
|
119
|
+
# @return [String, nil] the one path answered, without a trailing slash, or
|
|
120
|
+
# nil to answer whatever the framework routes here
|
|
121
|
+
attr_reader :path
|
|
122
|
+
# @return [String] the API route version forwarded upstream
|
|
123
|
+
attr_reader :api_version
|
|
124
|
+
|
|
125
|
+
# @param app [#call, nil] the rest of the Rack stack. Rack passes this
|
|
126
|
+
# when the proxy is used as middleware, which then needs +path+ to know
|
|
127
|
+
# which request is its own; without one, a path the proxy does not serve
|
|
128
|
+
# gets 404.
|
|
129
|
+
# @param client_ip [#call] resolves the visitor's address from a
|
|
130
|
+
# {ProxyRequest}. Read it from a header your own infrastructure sets,
|
|
131
|
+
# such as your load balancer's or CDN's. Never use the left-most
|
|
132
|
+
# +X-Forwarded-For+ value: the visitor writes it.
|
|
133
|
+
# @param proxy_key [String, nil] a write-only credential, +fly_px_…+, held
|
|
134
|
+
# only on your server
|
|
135
|
+
# @param secret_key [String, nil] the deprecated alternative to
|
|
136
|
+
# +proxy_key+. Secret read keys will be refused in the next major
|
|
137
|
+
# version; do not pass both.
|
|
138
|
+
# @param path [String, nil] the path the dashboard issued for this proxy,
|
|
139
|
+
# such as +/k7x2qm4a+, matched exactly. Omit it only where the framework
|
|
140
|
+
# already routes that path here, as a Rails +mount+ or a +Rack::URLMap+
|
|
141
|
+
# entry does; the proxy then answers whatever it is handed.
|
|
142
|
+
# @param api_version [String] the version of the upstream ingest URL
|
|
143
|
+
# @param upstream [String, nil] overrides the API origin implied by the key
|
|
144
|
+
# @param max_body_bytes [Integer] larger request bodies are refused with 413
|
|
145
|
+
# @param timeout [Numeric] how long to wait for Fingerly, in seconds
|
|
146
|
+
# @param configure_http [Proc, nil] receives the +Net::HTTP+ before it connects
|
|
147
|
+
# @param logger [#warn, nil] receives one line per failed upstream call.
|
|
148
|
+
# Without one, +rack.logger+ is used when the server provides it, and
|
|
149
|
+
# standard error otherwise.
|
|
150
|
+
# @raise [ConfigurationError] if a credential, path, limit or callback is
|
|
151
|
+
# invalid, or if the proxy is middleware without a path. A proxy is wired
|
|
152
|
+
# up once at start-up, where an error is natural to handle.
|
|
153
|
+
def initialize(app = nil, client_ip:, proxy_key: nil, secret_key: nil,
|
|
154
|
+
path: nil, api_version: "v1", upstream: nil,
|
|
155
|
+
max_body_bytes: DEFAULT_MAX_BODY_BYTES, timeout: DEFAULT_TIMEOUT,
|
|
156
|
+
configure_http: nil, logger: nil)
|
|
157
|
+
raise ConfigurationError, "A Fingerly proxy key is required" if !proxy_key && !secret_key
|
|
158
|
+
if proxy_key && secret_key
|
|
159
|
+
raise ConfigurationError, "Pass proxy_key or deprecated secret_key, not both"
|
|
160
|
+
end
|
|
161
|
+
raise ConfigurationError, "client_ip is required" unless client_ip.respond_to?(:call)
|
|
162
|
+
# Middleware sees every request in the stack. Without a path it would
|
|
163
|
+
# take all of them from the application behind it.
|
|
164
|
+
raise ConfigurationError, "path is required when the proxy is middleware" if app && !path
|
|
165
|
+
|
|
166
|
+
if logger && !logger.respond_to?(:warn)
|
|
167
|
+
raise ConfigurationError, "logger must respond to warn"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
@app = app
|
|
171
|
+
@logger = logger
|
|
172
|
+
# Set once the client_ip misconfiguration has been logged, so a broken
|
|
173
|
+
# callback costs one log line per process rather than one per visitor.
|
|
174
|
+
@misconfigured = []
|
|
175
|
+
@misconfigured_lock = Mutex.new
|
|
176
|
+
@client_ip = client_ip
|
|
177
|
+
@uses_proxy_key = !proxy_key.nil?
|
|
178
|
+
@key = proxy_key || secret_key
|
|
179
|
+
@scope = Config.parse_key(@key, @uses_proxy_key ? Config::PROXY : Config::SECRET)
|
|
180
|
+
@path = path.nil? ? nil : Config.normalize_path(path)
|
|
181
|
+
@api_version = Config.normalize_api_version(api_version)
|
|
182
|
+
@ingest = "/api/#{@api_version}/ingest".freeze
|
|
183
|
+
@max_body_bytes = Config.positive_int(max_body_bytes, "max_body_bytes")
|
|
184
|
+
@transport = Transport.new(
|
|
185
|
+
origin: Config.normalize_endpoint(upstream || Config::DEFAULT_ENDPOINT),
|
|
186
|
+
timeout: Config.positive_seconds(timeout, "timeout"),
|
|
187
|
+
configure_http: configure_http
|
|
188
|
+
)
|
|
189
|
+
freeze
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Whether a path is one this proxy serves.
|
|
193
|
+
#
|
|
194
|
+
# Asked before the body is read. A middleware that called {#call}'s
|
|
195
|
+
# forwarding path to find out would have consumed +rack.input+ doing it,
|
|
196
|
+
# and under Rack 3 that stream is not required to rewind, so the
|
|
197
|
+
# application it passed the request along to would receive nothing.
|
|
198
|
+
#
|
|
199
|
+
# The match is exact and case-sensitive: no trailing or doubled slash, no
|
|
200
|
+
# dot segment, no encoded slash, nothing under the path. Without a
|
|
201
|
+
# configured +path+ the framework routed the request here, and {#call}
|
|
202
|
+
# checks that the mount was hit exactly.
|
|
203
|
+
#
|
|
204
|
+
# @param path [String] the request path
|
|
205
|
+
# @return [Boolean]
|
|
206
|
+
def handles?(path)
|
|
207
|
+
@path.nil? || path.to_s == @path
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Serves one Rack request.
|
|
211
|
+
#
|
|
212
|
+
# @param env [Hash] the Rack environment
|
|
213
|
+
# @return [Array(Integer, Hash, Array<String>)] a Rack response
|
|
214
|
+
def call(env)
|
|
215
|
+
# PATH_INFO alone, never SCRIPT_NAME + PATH_INFO. Under a sub-URI
|
|
216
|
+
# deployment SCRIPT_NAME is a detail the configured path knows nothing
|
|
217
|
+
# about, so the application-relative path is the one that means the
|
|
218
|
+
# same thing wherever the application is deployed.
|
|
219
|
+
path = env["PATH_INFO"].to_s
|
|
220
|
+
return pass(env) unless @path ? handles?(path) : mounted_exactly?(env)
|
|
221
|
+
|
|
222
|
+
serve(env, ProxyRequest.new(env, path))
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
# Handles a request that is this proxy's own.
|
|
228
|
+
#
|
|
229
|
+
# Separate from {#call} so the rescue below can never reach the application
|
|
230
|
+
# behind the middleware: an +Errno::ENOENT+ or an +IOError+ raised by the
|
|
231
|
+
# host application is that application's error, not a visitor hanging up.
|
|
232
|
+
def serve(env, request)
|
|
233
|
+
return refuse(405, "Method Not Allowed", "allow" => "POST") unless
|
|
234
|
+
request.request_method.casecmp("POST").zero?
|
|
235
|
+
|
|
236
|
+
api_key = upstream_key_for(request.header("x-api-key"))
|
|
237
|
+
return refuse(401, "Unauthorized") if api_key.nil?
|
|
238
|
+
|
|
239
|
+
# Refuse on the declared size first; the read below enforces the real one.
|
|
240
|
+
declared = request.header("content-length").to_i
|
|
241
|
+
return refuse(413, "Payload Too Large") if declared > @max_body_bytes
|
|
242
|
+
|
|
243
|
+
body = read_bounded(env["rack.input"])
|
|
244
|
+
return refuse(413, "Payload Too Large") if body.nil?
|
|
245
|
+
|
|
246
|
+
forward(env, request, api_key, body)
|
|
247
|
+
rescue IOError, SystemCallError
|
|
248
|
+
# The visitor went away while the body was arriving. Nobody is left to
|
|
249
|
+
# read an answer, and there is nothing worth logging.
|
|
250
|
+
refuse(400, "Bad request")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def redacted_fields
|
|
254
|
+
[["path", @path.inspect], ["api_version", @api_version],
|
|
255
|
+
[@uses_proxy_key ? "proxy_key" : "secret_key", Redacted::MARK]]
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Whether a mount routed exactly its own path here.
|
|
259
|
+
#
|
|
260
|
+
# A Rails +mount+ or +Rack::URLMap+ matches a prefix and moves it into
|
|
261
|
+
# SCRIPT_NAME, so +/k7x2qm4a/admin+ arrives with PATH_INFO +/admin+. Rails
|
|
262
|
+
# also hands the mount PATH_INFO +/+ for both +/k7x2qm4a+ and
|
|
263
|
+
# +/k7x2qm4a/+, so where the server reports the raw request line in
|
|
264
|
+
# REQUEST_URI, as Puma and Falcon do, that is compared too.
|
|
265
|
+
def mounted_exactly?(env)
|
|
266
|
+
return false unless ["", "/"].include?(env["PATH_INFO"].to_s)
|
|
267
|
+
|
|
268
|
+
raw = env["REQUEST_URI"]
|
|
269
|
+
return true unless raw.is_a?(String) && !raw.empty?
|
|
270
|
+
|
|
271
|
+
raw = raw.sub(%r{\A[a-z][a-z0-9+.-]*://[^/]*}i, "") # an absolute-form target
|
|
272
|
+
raw.split("?", 2).first == env["SCRIPT_NAME"].to_s
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def pass(env)
|
|
276
|
+
return @app.call(env) if @app
|
|
277
|
+
|
|
278
|
+
refuse(404, "Not Found")
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def forward(env, request, api_key, body)
|
|
282
|
+
address = client_address(env, request)
|
|
283
|
+
return refuse(500, NO_CLIENT_IP) if address.nil?
|
|
284
|
+
|
|
285
|
+
headers = upstream_headers(request, api_key, address)
|
|
286
|
+
|
|
287
|
+
# Both of the browser SDK's calls go to the one ingest URL, which tells
|
|
288
|
+
# them apart by their bodies. The whole answer is read, within its cap,
|
|
289
|
+
# before anything is written back, so a response cut short upstream is
|
|
290
|
+
# a 502 rather than a truncated success.
|
|
291
|
+
result = @transport.call(Net::HTTP::Post, @ingest, headers, body)
|
|
292
|
+
relay(result)
|
|
293
|
+
rescue TimeoutError => e
|
|
294
|
+
upstream_failed(env, 504, "Gateway timeout", e)
|
|
295
|
+
rescue Error => e
|
|
296
|
+
# Refused, reset, unresolvable, malformed, truncated, or over the cap.
|
|
297
|
+
upstream_failed(env, 502, "Bad gateway", e)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# What the callback returned, when it is exactly one IP address.
|
|
301
|
+
#
|
|
302
|
+
# Anything else means client_ip is wired up wrongly, and forwarding a
|
|
303
|
+
# wrong address would mis-score every visitor, so nothing is sent.
|
|
304
|
+
def client_address(env, request)
|
|
305
|
+
address = begin
|
|
306
|
+
@client_ip.call(request)
|
|
307
|
+
rescue StandardError => e
|
|
308
|
+
e
|
|
309
|
+
end
|
|
310
|
+
return address if Config.ip_literal?(address)
|
|
311
|
+
|
|
312
|
+
misconfigured(env, address)
|
|
313
|
+
nil
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def misconfigured(env, returned)
|
|
317
|
+
first = @misconfigured_lock.synchronize do
|
|
318
|
+
next false unless @misconfigured.empty?
|
|
319
|
+
|
|
320
|
+
@misconfigured << true
|
|
321
|
+
end
|
|
322
|
+
return unless first
|
|
323
|
+
|
|
324
|
+
shown = returned.is_a?(Exception) ? "raised #{returned.class}" : "returned #{returned.class}"
|
|
325
|
+
log(env, "fingerly proxy: client_ip #{shown}, not one IP address; " \
|
|
326
|
+
"no identification is forwarded until it does (logged once)")
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Answers a failed upstream call and logs one line about it: no stack
|
|
330
|
+
# trace, nothing from the request, and nothing in the response that names
|
|
331
|
+
# Fingerly.
|
|
332
|
+
def upstream_failed(env, status, message, error)
|
|
333
|
+
log(env, "fingerly proxy: #{status} #{error.message}")
|
|
334
|
+
refuse(status, message)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def log(env, line)
|
|
338
|
+
logger = @logger || env["rack.logger"]
|
|
339
|
+
logger ? logger.warn(line) : warn(line)
|
|
340
|
+
rescue StandardError
|
|
341
|
+
# A broken logger must not turn a gateway error into a crash.
|
|
342
|
+
nil
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Decides which key to present upstream.
|
|
346
|
+
#
|
|
347
|
+
# With a proxy key the browser must supply a public key for the same
|
|
348
|
+
# environment, which the proxy forwards alongside its own. The deprecated
|
|
349
|
+
# secret-key mode authenticates on its own.
|
|
350
|
+
#
|
|
351
|
+
# @return [String, nil] the key to forward, or nil when unauthorised
|
|
352
|
+
def upstream_key_for(presented)
|
|
353
|
+
return @key unless @uses_proxy_key
|
|
354
|
+
|
|
355
|
+
browser = Config.scope_for(presented, Config::PUBLIC)
|
|
356
|
+
return nil if browser.nil?
|
|
357
|
+
|
|
358
|
+
browser == @scope ? presented : nil
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Builds the headers for the forwarded request.
|
|
362
|
+
#
|
|
363
|
+
# Nothing is copied wholesale: only the body's content type, the visitor
|
|
364
|
+
# metadata Fingerly needs, and an idempotency key when the SDK sent one.
|
|
365
|
+
# The visitor writes the origin, user agent and content type, so each is
|
|
366
|
+
# checked before it is repeated into a header of ours.
|
|
367
|
+
#
|
|
368
|
+
# @return [Hash]
|
|
369
|
+
def upstream_headers(request, api_key, address)
|
|
370
|
+
headers = {
|
|
371
|
+
"content-type" => safe(request.header("content-type")) || "application/json",
|
|
372
|
+
"x-api-key" => api_key,
|
|
373
|
+
"x-fingerly-client-ip" => address,
|
|
374
|
+
"x-fingerly-origin" => safe(request.header("origin")).to_s,
|
|
375
|
+
"x-fingerly-user-agent" => safe(request.header("user-agent")).to_s,
|
|
376
|
+
"x-fingerly-proxy-version" => PROXY_VERSION,
|
|
377
|
+
"user-agent" => USER_AGENT
|
|
378
|
+
}
|
|
379
|
+
headers["x-fingerly-proxy-key"] = @key if @uses_proxy_key
|
|
380
|
+
idempotency = safe(request.header("idempotency-key"))
|
|
381
|
+
headers["idempotency-key"] = idempotency if idempotency && !idempotency.empty?
|
|
382
|
+
headers
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Drops a visitor-supplied value that could not go in a header.
|
|
386
|
+
def safe(value)
|
|
387
|
+
Config.header_safe?(value) ? value : nil
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Returns the upstream response, uncacheable and with only the allowlisted
|
|
391
|
+
# headers.
|
|
392
|
+
#
|
|
393
|
+
# Header names are lowercase and repeats joined with ", ", which is the
|
|
394
|
+
# one shape Rack 2 and Rack 3 both accept: Rack 3 refuses an uppercase name
|
|
395
|
+
# outright, and Rack 2 refuses the array Rack 3 would rather have.
|
|
396
|
+
def relay(result)
|
|
397
|
+
headers = { "cache-control" => "no-store" }
|
|
398
|
+
result.headers.each do |name, values|
|
|
399
|
+
key = name.downcase
|
|
400
|
+
headers[key] = Array(values).join(", ") if RELAYED_HEADERS.include?(key)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# No chunk at all, not one empty chunk: some servers warn on, or count,
|
|
404
|
+
# an empty string in the body of a status that must not have one.
|
|
405
|
+
return [result.status, headers, []] if BODILESS.include?(result.status)
|
|
406
|
+
|
|
407
|
+
[result.status, headers, [result.body]]
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Reads a body without buffering more than the limit.
|
|
411
|
+
#
|
|
412
|
+
# @return [String, nil] the bytes, or nil as soon as the limit is passed
|
|
413
|
+
def read_bounded(input)
|
|
414
|
+
body = "".b
|
|
415
|
+
return body if input.nil?
|
|
416
|
+
|
|
417
|
+
while (chunk = input.read(8192))
|
|
418
|
+
body << chunk
|
|
419
|
+
return nil if body.bytesize > @max_body_bytes
|
|
420
|
+
end
|
|
421
|
+
body
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# A plain-text refusal that never reached Fingerly.
|
|
425
|
+
def refuse(status, message, extra = {})
|
|
426
|
+
headers = { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store" }
|
|
427
|
+
[status, headers.merge(extra), [message]]
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fingerly
|
|
4
|
+
# Keeps credentials out of every way Ruby has of showing or saving an object.
|
|
5
|
+
#
|
|
6
|
+
# The default +inspect+ lists instance variables, so a client in a console,
|
|
7
|
+
# a +pp+ in a log line, or an error tracker that records locals would print
|
|
8
|
+
# its secret key. ActiveSupport's +as_json+ does the same through
|
|
9
|
+
# +instance_values+, and Marshal and YAML would write it to disk. Each class
|
|
10
|
+
# that holds a credential, or a visitor's cookies, includes this and names
|
|
11
|
+
# only what is safe to show.
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
module Redacted
|
|
15
|
+
# What replaces a credential wherever one would have been shown.
|
|
16
|
+
MARK = "[redacted]"
|
|
17
|
+
|
|
18
|
+
def inspect
|
|
19
|
+
fields = redacted_fields.map { |name, value| "#{name}=#{value}" }
|
|
20
|
+
"#{["#<#{self.class.name}", *fields].join(" ")}>"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_s
|
|
24
|
+
inspect
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def pretty_print(printer)
|
|
28
|
+
printer.text(inspect)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def as_json(*)
|
|
32
|
+
inspect
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_json(*)
|
|
36
|
+
inspect.to_json(*)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# YAML: the object is written as its redacted description only.
|
|
40
|
+
def encode_with(coder)
|
|
41
|
+
coder.scalar = inspect
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def marshal_dump
|
|
45
|
+
raise TypeError, "#{self.class.name} holds a credential and cannot be serialized"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def _dump(_level)
|
|
49
|
+
marshal_dump
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|