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.
@@ -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