camada 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,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Camada
4
+ # The fail-open envelope: a camada bug must never 5xx the customer. Every public entry point of
5
+ # the SDK catches, falls back, and reports through log_rate_limited: at most one line a minute.
6
+ module Guarded
7
+ @last_log = 0.0
8
+ @logger = nil
9
+
10
+ class << self
11
+ # Where the one line a minute goes: anything responding to #call(String). Default: $stderr.
12
+ attr_accessor :logger
13
+
14
+ def log_rate_limited(err)
15
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
16
+ return if now - @last_log < 60
17
+
18
+ @last_log = now
19
+ line = "[camada] suppressed error (SDK fails open): #{describe(err)}"
20
+ (@logger || ->(s) { warn(s) }).call(line)
21
+ rescue StandardError
22
+ nil # even logging must not raise
23
+ end
24
+
25
+ private
26
+
27
+ # Exceptions print as "Class: message" on one line — never a backtrace, and never the
28
+ # "(ClassName)" spelling an uncaught Ruby exception uses, so a crash grep stays quiet.
29
+ def describe(err)
30
+ err.is_a?(Exception) ? "#{err.class.name}: #{err.message}".tr("\n", " ") : err.to_s
31
+ end
32
+ end
33
+ end
34
+ end
data/lib/camada/ip.rb ADDED
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ipparse"
4
+
5
+ module Camada
6
+ # Client-IP resolution under the tenant's trusted-proxy config. The default is the socket peer:
7
+ # raw X-Forwarded-For is attacker-writable and is NEVER trusted without explicit configuration;
8
+ # a spoofed XFF must not reach the analysis or the blocklist. Ported from @camada/core src/ip.ts.
9
+ module Ip
10
+ Cidr = Struct.new(:base4, :base6, :bits) # base4: v4 base or -1; base6: words or nil
11
+
12
+ def self.valid_ip?(s)
13
+ s.include?(":") ? !IpParse.parse_ip6(s).nil? : IpParse.parse_ip4(s) >= 0
14
+ end
15
+
16
+ def self.parse_cidr(c)
17
+ slash = c.index("/")
18
+ return nil if slash.nil?
19
+
20
+ addr = c[0, slash]
21
+ bits = Integer(c[(slash + 1)..], 10, exception: false)
22
+ return nil if bits.nil?
23
+
24
+ unless addr.include?(":")
25
+ base = IpParse.parse_ip4(addr)
26
+ return base >= 0 && bits.between?(0, 32) ? Cidr.new(base, nil, bits) : nil
27
+ end
28
+ words = IpParse.parse_ip6(addr)
29
+ words && bits.between?(0, 128) ? Cidr.new(-1, words, bits) : nil
30
+ end
31
+
32
+ def self.in_cidr?(ip, cidr)
33
+ if cidr.base6.nil?
34
+ n = IpParse.parse_ip4(ip)
35
+ return false if n < 0
36
+
37
+ bits = cidr.bits
38
+ mask = bits == 0 ? 0 : (0xFFFFFFFF << (32 - bits)) & 0xFFFFFFFF
39
+ return (n & mask) == (cidr.base4 & mask)
40
+ end
41
+ words = IpParse.parse_ip6(ip)
42
+ return false if words.nil?
43
+
44
+ remaining = cidr.bits
45
+ 4.times do |k|
46
+ break if remaining <= 0
47
+
48
+ take = [32, remaining].min
49
+ mask = take == 32 ? 0xFFFFFFFF : (0xFFFFFFFF << (32 - take)) & 0xFFFFFFFF
50
+ return false if (words[k] & mask) != (cidr.base6[k] & mask)
51
+
52
+ remaining -= take
53
+ end
54
+ true
55
+ end
56
+
57
+ # The client IP from the socket peer and X-Forwarded-For per the trusted-proxy config.
58
+ # Anything unresolvable falls back to the peer (fail safe).
59
+ def self.resolve_client_ip(peer, xff, cfg)
60
+ sock = peer&.start_with?("::ffff:") ? peer[7..] : peer # dual-stack v4-mapped form
61
+ return sock if cfg.nil? || cfg["mode"] == "none" || xff.nil? || xff.empty?
62
+
63
+ entries = xff.split(",").map(&:strip).reject(&:empty?)
64
+ return sock if entries.empty?
65
+
66
+ candidate = nil
67
+ case cfg["mode"]
68
+ when "hops"
69
+ hops = cfg["hops"].to_i
70
+ candidate = entries[entries.length - hops] if hops.between?(1, entries.length)
71
+ when "vercel"
72
+ candidate = entries.last # Vercel overwrites XFF, so its rightmost entry is trustworthy
73
+ when "cidrs"
74
+ trusted = (cfg["cidrs"] || []).filter_map { |x| parse_cidr(x.to_s) }
75
+ candidate = entries.reverse_each.find { |entry| trusted.none? { |t| in_cidr?(entry, t) } }
76
+ end
77
+ candidate && valid_ip?(candidate) ? candidate : sock
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Camada
4
+ # Allocation-free IP parsers, ported 1:1 from edge-analyst src/blocklist.js through
5
+ # @camada/core src/snapshot/ipparse.ts (the reference the conformance fixtures are generated
6
+ # from). Behaviour must not drift: ip4 returns -1 on anything unusual; ip6 rejects zone ids and
7
+ # v4-mapped forms. Ruby integers are unbounded, so the words come back as a 4-element Array
8
+ # instead of being written into a caller's scratch array.
9
+ module IpParse
10
+ DOT = 46
11
+ COLON = 58
12
+ ZERO = 48
13
+ NINE = 57
14
+
15
+ # Dotted-quad IPv4 to a uint32, or -1 when the string is not a plain IPv4 address.
16
+ def self.parse_ip4(s)
17
+ n = part = digits = dots = 0
18
+ s.each_byte do |ch|
19
+ if ch == DOT
20
+ return -1 if digits == 0 || part > 255
21
+
22
+ dots += 1
23
+ return -1 if dots > 3
24
+
25
+ n = (n * 256) + part
26
+ part = digits = 0
27
+ elsif ch.between?(ZERO, NINE)
28
+ part = (part * 10) + (ch - ZERO)
29
+ digits += 1
30
+ return -1 if digits > 3
31
+ else
32
+ return -1
33
+ end
34
+ end
35
+ return -1 if dots != 3 || digits == 0 || part > 255
36
+
37
+ (n * 256) + part
38
+ end
39
+
40
+ # IPv6 text to four big-endian uint32 words, or nil when it is not a plain IPv6 address.
41
+ def self.parse_ip6(s)
42
+ length = s.bytesize
43
+ groups = Array.new(8, 0)
44
+ n = val = digits = 0
45
+ dbl = -1
46
+ i = 0
47
+ if length > 1 && s.getbyte(0) == COLON && s.getbyte(1) == COLON
48
+ dbl = 0
49
+ i = 2
50
+ end
51
+ while i <= length
52
+ c = i < length ? s.getbyte(i) : COLON # a sentinel colon closes the last group
53
+ if c == COLON
54
+ if digits > 0
55
+ return nil if n >= 8
56
+
57
+ groups[n] = val
58
+ n += 1
59
+ val = digits = 0
60
+ elsif i < length
61
+ return nil if dbl != -1
62
+
63
+ dbl = n
64
+ end
65
+ else
66
+ d = hex_digit(c)
67
+ return nil if d.nil?
68
+
69
+ val = (val << 4) | d
70
+ digits += 1
71
+ return nil if digits > 4
72
+ end
73
+ i += 1
74
+ end
75
+ if dbl == -1
76
+ return nil if n != 8
77
+ else
78
+ return nil if n >= 8
79
+
80
+ shift = 8 - n
81
+ 7.downto(dbl + shift) { |k| groups[k] = groups[k - shift] }
82
+ (dbl...(dbl + shift)).each { |k| groups[k] = 0 }
83
+ end
84
+ [
85
+ (groups[0] << 16) | groups[1],
86
+ (groups[2] << 16) | groups[3],
87
+ (groups[4] << 16) | groups[5],
88
+ (groups[6] << 16) | groups[7]
89
+ ]
90
+ end
91
+
92
+ def self.hex_digit(c)
93
+ if c.between?(ZERO, NINE) then c - ZERO
94
+ elsif c.between?(97, 102) then c - 87 # a-f
95
+ elsif c.between?(65, 70) then c - 55 # A-F
96
+ end
97
+ end
98
+ private_class_method :hex_digit
99
+ end
100
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "body_proxy"
5
+ require_relative "engine"
6
+ require_relative "guarded"
7
+
8
+ module Camada
9
+ # Rack middleware: `use Camada::Rack` in config.ru (or the Railtie for Rails). camada answers
10
+ # before routing (block, challenge, beacon), else runs the app and stamps x-rid and the _sfp
11
+ # cookie on its response, shipping the event when the server closes the body. The env keeps no
12
+ # wire header order, so the analyst reads no HEADER_ORDER signal from this tap. Nothing here
13
+ # requires "rack": the host already loaded it, and the gem stays dependency-free.
14
+ class Rack
15
+ # The peer is REMOTE_ADDR — never Rack::Request#ip, which trusts X-Forwarded-For. Every value
16
+ # the engine will match or ship goes through `utf8`: Puma tags env strings ASCII-8BIT, and a
17
+ # binary string with a high byte raises against a UTF-8 rule needle and again in
18
+ # JSON.generate at flush time (the whole batch, blocked rows included). WSGI hands Python
19
+ # str, so the reference has no such seam.
20
+ def self.req_from_env(env)
21
+ headers = []
22
+ env.each do |k, v|
23
+ if k.start_with?("HTTP_")
24
+ headers << [k[5..].downcase.tr("_", "-"), utf8(v)]
25
+ elsif (k == "CONTENT_TYPE" || k == "CONTENT_LENGTH") && v && !v.to_s.empty?
26
+ headers << [k.downcase.tr("_", "-"), utf8(v)]
27
+ end
28
+ end
29
+ query = utf8(env["QUERY_STRING"])
30
+ proto = env["SERVER_PROTOCOL"].to_s
31
+ Req.new(
32
+ method: (env["REQUEST_METHOD"] || "GET").to_s,
33
+ path: Camada.present(utf8(env["PATH_INFO"])) || "/",
34
+ query: query.empty? ? "" : "?#{query}",
35
+ host: Camada.present(utf8(env["HTTP_HOST"])) || utf8(env["SERVER_NAME"]),
36
+ http_version: proto.start_with?("HTTP/") ? proto[5..] : nil,
37
+ peer: Camada.present(utf8(env["REMOTE_ADDR"])),
38
+ https: env["rack.url_scheme"] == "https",
39
+ headers: headers
40
+ )
41
+ end
42
+
43
+ # A valid UTF-8 String for any env value: nil -> "", a binary or malformed string is read as
44
+ # UTF-8 with each invalid byte replaced by U+FFFD (never raises, never mutates the env's own).
45
+ def self.utf8(v)
46
+ s = v.to_s
47
+ return s if s.encoding == Encoding::UTF_8 && s.valid_encoding?
48
+
49
+ s.dup.force_encoding(Encoding::UTF_8).scrub
50
+ end
51
+
52
+ # At most `limit` bytes, or nil when the declared or actual size exceeds it. Whatever was
53
+ # read is put back so an app the request falls through to still sees its whole body (Rack 3
54
+ # inputs need not be rewindable, so the env gets a fresh input either way).
55
+ def self.read_body(env, limit)
56
+ declared = env["CONTENT_LENGTH"].to_i
57
+ return nil if declared > limit
58
+
59
+ input = env["rack.input"]
60
+ data = input&.read(limit + 1) || "".b
61
+ if data.bytesize > limit
62
+ env["rack.input"] = ChainedInput.new(data, input) # over the cap: the prefix, then the unread rest — lazily
63
+ return nil
64
+ end
65
+ env["rack.input"] = StringIO.new(data)
66
+ data
67
+ end
68
+
69
+ # The bytes camada already read, then whatever is left in the stream the app was owed (the
70
+ # port of wsgi.py's _Chained). Nothing past the cap is read until the app asks: a chunked
71
+ # POST of any size costs the process 32 KB, not the body. A Rack 3 input: gets, each, read,
72
+ # close — plus rewind for Rack 2 hosts, which restores the position camada left the stream at.
73
+ class ChainedInput
74
+ def initialize(head, rest)
75
+ @head = StringIO.new(head)
76
+ @rest = rest
77
+ end
78
+
79
+ def read(length = nil, buf = nil)
80
+ out = @head.read(length) || "".b
81
+ if length.nil?
82
+ out << (@rest.read || "".b)
83
+ elsif out.bytesize < length
84
+ more = @rest.read(length - out.bytesize)
85
+ out << more if more
86
+ end
87
+ return nil if length && length > 0 && out.empty? # IO#read: nil at EOF for a positive length
88
+
89
+ buf.nil? ? out : buf.replace(out)
90
+ end
91
+
92
+ def gets
93
+ line = @head.gets
94
+ return @rest.gets if line.nil?
95
+ return line if line.end_with?("\n")
96
+
97
+ line + (@rest.gets || "") # a line that straddles the seam
98
+ end
99
+
100
+ def each
101
+ return enum_for(:each) unless block_given?
102
+
103
+ while (line = gets)
104
+ yield line
105
+ end
106
+ end
107
+
108
+ def close
109
+ @rest.close if @rest.respond_to?(:close)
110
+ end
111
+
112
+ def rewind
113
+ @head.rewind
114
+ return unless @rest.respond_to?(:rewind)
115
+
116
+ @rest.rewind
117
+ @rest.read(@head.string.bytesize) # back to where camada left it: the head already holds these bytes
118
+ end
119
+ end
120
+
121
+ # The matched route pattern, read at finish: Sinatra's "VERB /pattern", Rails' route_uri_pattern.
122
+ def self.route_of(env)
123
+ rails = env["action_dispatch.route_uri_pattern"]
124
+ return rails.to_s if rails
125
+
126
+ sinatra = env["sinatra.route"]
127
+ sinatra&.to_s&.sub(/\A[A-Z]+ /, "")
128
+ end
129
+
130
+ # `Camada::Rack.new(app)` wires the lazy default from the environment on the first request;
131
+ # `Camada::Rack.new(app, engine)` uses the one you built.
132
+ def initialize(app, engine = nil, **opts)
133
+ @app = app
134
+ @engine = engine
135
+ @opts = opts
136
+ end
137
+
138
+ def engine
139
+ @engine ||= Camada.default(**@opts)
140
+ end
141
+
142
+ def call(env)
143
+ eng = engine
144
+ begin
145
+ req = Rack.req_from_env(env)
146
+ limit = eng.wants_body(req.method, req.path)
147
+ body = limit.nil? ? nil : Rack.read_body(env, limit)
148
+ rescue StandardError => e
149
+ Guarded.log_rate_limited(e)
150
+ return @app.call(env)
151
+ end
152
+ result = eng.handle(req, body)
153
+ return result.to_rack if result.is_a?(Answer)
154
+
155
+ run(env, req, result)
156
+ end
157
+
158
+ private
159
+
160
+ def run(env, req, passed)
161
+ env["camada"] = passed.ctx unless passed.ctx.nil?
162
+ begin
163
+ status, headers, body = @app.call(env)
164
+ rescue Exception # rubocop:disable Lint/RescueException -- the server answers 500 for anything that escapes the app
165
+ passed.on_finish&.call(500)
166
+ raise
167
+ end
168
+ headers = stamp(headers, passed)
169
+ finish = passed.on_finish
170
+ return [status, headers, body] if finish.nil?
171
+
172
+ proxy = BodyProxy.new(body) do
173
+ req.route ||= Rack.route_of(env)
174
+ finish.call(status.to_i)
175
+ end
176
+ [status, headers, proxy]
177
+ end
178
+
179
+ # x-rid and the session cookie on the app's response. Rack 3 spells header names in lower
180
+ # case and carries several set-cookie values as an Array; Rack 2 joins them with "\n".
181
+ def stamp(headers, passed)
182
+ return headers if passed.rid.nil? && passed.set_cookie.nil?
183
+
184
+ headers = headers.to_h
185
+ headers["x-rid"] = passed.rid if passed.rid
186
+ if passed.set_cookie
187
+ key = headers.keys.find { |k| k.to_s.downcase == "set-cookie" } || "set-cookie"
188
+ headers[key] = join_cookies(headers[key], passed.set_cookie)
189
+ end
190
+ headers
191
+ rescue StandardError => e
192
+ Guarded.log_rate_limited(e)
193
+ headers
194
+ end
195
+
196
+ def join_cookies(existing, cookie)
197
+ return cookie if existing.nil? || (existing.respond_to?(:empty?) && existing.empty?)
198
+
199
+ arrays = defined?(::Rack::RELEASE) && ::Rack::RELEASE.to_s >= "3"
200
+ return [*existing, cookie] if existing.is_a?(Array) || arrays
201
+
202
+ "#{existing}\n#{cookie}"
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rack"
4
+
5
+ # Rails: the gem inserts Camada::Rack first in the middleware stack, so camada answers before
6
+ # anything else runs. Defined only when Rails is loaded (Bundler.require after rails); the
7
+ # helpers are the module-level ones: Camada.script_tag(request.env), Camada.track(request.env,
8
+ # "login_failed", user: email), Camada.serve_challenge(request.env).
9
+ if defined?(Rails::Railtie)
10
+ module Camada
11
+ class Railtie < Rails::Railtie
12
+ initializer "camada.middleware" do |app|
13
+ app.middleware.insert_before 0, Camada::Rack
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module Camada
7
+ # Redaction, non-configurable-off. The SDK never ships: Authorization/Cookie values (scheme
8
+ # only, events/build.rb), body field values (shape only), query params that look like
9
+ # credentials, or raw user identifiers (HMAC-hashed here, inside the SDK, before anything
10
+ # reaches the queue). Ported from @camada/core src/redact.ts.
11
+ module Redact
12
+ NAME_RE = /(pass(word)?|tok(en)?|secret|key|api[-_]?key|auth|sess(ion)?|sig(nature)?|code|jwt|bearer|credential)/i
13
+ JWT_RE = /\AeyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/
14
+ HEX_RE = /\A[a-f0-9]{32,}\z/i
15
+ B64_RE = %r{\A[A-Za-z0-9+/_-]{40,}={0,2}\z}
16
+
17
+ REDACT_ALLOWLIST = %w[plan role locale ab_variant].freeze # additions only, never narrowing
18
+
19
+ def self.suspect_value?(v)
20
+ JWT_RE.match?(v) || HEX_RE.match?(v) || B64_RE.match?(v)
21
+ end
22
+
23
+ # Replaces credential-looking query values with ~r, preserving structure and order.
24
+ def self.scrub_query(query)
25
+ return query || "" if query.nil? || query.length <= 1
26
+
27
+ lead = query.start_with?("?") ? "?" : ""
28
+ out = (lead.empty? ? query : query[1..]).split("&", -1).map do |p|
29
+ eq = p.index("=")
30
+ next p if eq.nil?
31
+
32
+ name = p[0, eq]
33
+ value = p[(eq + 1)..]
34
+ NAME_RE.match?(name) || suspect_value?(value) ? "#{name}=~r" : p
35
+ end
36
+ lead + out.join("&")
37
+ end
38
+
39
+ # Body shape only: field names and byte sizes, never values. One level deep.
40
+ def self.body_shape(obj)
41
+ return nil unless obj.is_a?(Hash)
42
+
43
+ obj.to_h do |k, v|
44
+ size = case v
45
+ when String then v.length
46
+ when nil then 0
47
+ else
48
+ begin
49
+ JSON.generate(v).length
50
+ rescue StandardError
51
+ 0
52
+ end
53
+ end
54
+ [k.to_s, size]
55
+ end
56
+ end
57
+
58
+ # Stable per-tenant pseudonym: HMAC-SHA256 keyed by the ingest token, labelled so the hash
59
+ # can never double as anything else, truncated to 32 hex chars. The raw identifier never leaves.
60
+ def self.hash_user_id(user_id, ingest_token)
61
+ OpenSSL::HMAC.hexdigest("SHA256", ingest_token, "uid:#{user_id}")[0, 32]
62
+ end
63
+ end
64
+ end