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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +199 -0
- data/lib/camada/beacon_js.rb +10 -0
- data/lib/camada/body_proxy.rb +48 -0
- data/lib/camada/challenge/format.rb +108 -0
- data/lib/camada/challenge/page.rb +107 -0
- data/lib/camada/challenge/verify.rb +68 -0
- data/lib/camada/config.rb +38 -0
- data/lib/camada/constants.rb +35 -0
- data/lib/camada/engine.rb +364 -0
- data/lib/camada/env.rb +47 -0
- data/lib/camada/events/build.rb +82 -0
- data/lib/camada/events/queue.rb +184 -0
- data/lib/camada/guarded.rb +34 -0
- data/lib/camada/ip.rb +80 -0
- data/lib/camada/ipparse.rb +100 -0
- data/lib/camada/rack.rb +205 -0
- data/lib/camada/railtie.rb +17 -0
- data/lib/camada/redact.rb +64 -0
- data/lib/camada/snapshot/client.rb +199 -0
- data/lib/camada/snapshot/match.rb +221 -0
- data/lib/camada/snapshot/parse.rb +363 -0
- data/lib/camada/transport.rb +58 -0
- data/lib/camada/version.rb +10 -0
- data/lib/camada.rb +99 -0
- metadata +74 -0
|
@@ -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
|
data/lib/camada/rack.rb
ADDED
|
@@ -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
|