camada 0.1.2 → 0.1.4
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 +4 -4
- data/README.md +25 -3
- data/lib/camada/beacon_js.rb +3 -3
- data/lib/camada/challenge/format.rb +0 -4
- data/lib/camada/challenge/page.rb +41 -15
- data/lib/camada/engine.rb +9 -2
- data/lib/camada/events/build.rb +2 -1
- data/lib/camada/ip.rb +99 -9
- data/lib/camada/rack.rb +19 -0
- data/lib/camada/redact.rb +69 -8
- data/lib/camada/snapshot/client.rb +63 -16
- data/lib/camada/snapshot/match.rb +3 -3
- data/lib/camada/transport.rb +2 -2
- data/lib/camada/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 67d03ce5257d9e94c2b52c35d9c612077918e82dbb1ccd81de6399300368773d
|
|
4
|
+
data.tar.gz: 867e0652bc76b81a367b679b16e52ffa8e8e1248307d0e435d74197224433980
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 61724d07eb1e27c2709f5f56c5e13e82497a1c21896dc57dc434c09b26a40bdc74f7485151b6d4e692dc3c71fae3f6d1ec03ca003fd57877e2b7ca08dd4ff8da
|
|
7
|
+
data.tar.gz: 3236651e617c201a84d7c68d17c267f564f3ec2519fbe9b20359ff354efa2619bffc891eab86af36205aa2ac5e787ddd9382bd61a52bdd6d2a94521328825508
|
data/README.md
CHANGED
|
@@ -72,6 +72,23 @@ use Camada::Rack, engine
|
|
|
72
72
|
header on its own is never the ip: any caller can set it. **Never `request.ip`**:
|
|
73
73
|
`Rack::Request#ip` trusts `X-Forwarded-For` from anyone, which is exactly the spoof camada
|
|
74
74
|
refuses; the middleware reads `env["REMOTE_ADDR"]` and applies your trusted-proxy rules itself.
|
|
75
|
+
Entries may carry a port (`ip:port`, `[v6]:port`, as Azure Application Gateway writes them) or
|
|
76
|
+
the `::ffff:a.b.c.d` form, and are trimmed of spaces and tabs only. The resolved ip is always a
|
|
77
|
+
valid address or none. Under `cidrs` (and the dashboard's Cloudflare option, stored as `cidrs`)
|
|
78
|
+
the socket peer must itself be one of the trusted ranges or a loopback address (a local front end
|
|
79
|
+
or sidecar); otherwise the peer is the client and `X-Forwarded-For` is ignored, so a direct hit on
|
|
80
|
+
your origin cannot pick its own ip. A `REMOTE_ADDR` that is not an ip (a socket path, `ip:port`)
|
|
81
|
+
could not be read: the ip is then none and `X-Forwarded-For` is never walked. Only an empty or
|
|
82
|
+
absent `REMOTE_ADDR` (Puma or Unicorn bound to a unix socket path) means no peer: the walk then
|
|
83
|
+
runs over `X-Forwarded-For` alone and trusts your network edge, so the app must not be reachable
|
|
84
|
+
around it.
|
|
85
|
+
|
|
86
|
+
**Servers that rewrite the peer from a forwarded header.** camada needs the real socket peer in
|
|
87
|
+
`REMOTE_ADDR`. Puma's `set_remote_address header: "X-Forwarded-For"` replaces it with the leftmost
|
|
88
|
+
`X-Forwarded-For` entry, which the client wrote. camada then takes that forged value as the peer,
|
|
89
|
+
and so as the client ip. Leave Puma's default (`set_remote_address :socket`, the real peer) and
|
|
90
|
+
declare your proxy as camada's trusted proxy instead. A Rack middleware that rewrites
|
|
91
|
+
`REMOTE_ADDR` must sit inside camada (after it in the stack), so camada reads the address first.
|
|
75
92
|
3. Enforces before anything else, beacon endpoints included: your ordered custom rules first (first
|
|
76
93
|
match wins; they read ip, path, user-agent and request headers), then allow → block → challenge.
|
|
77
94
|
A block answers `403 Forbidden` with `x-block-reason`, `x-block-version` and, when a rule
|
|
@@ -79,13 +96,16 @@ use Camada::Rack, engine
|
|
|
79
96
|
`wrn`; a `skip` rule passes with nothing stamped. Cold (no snapshot yet) passes: fail open.
|
|
80
97
|
4. Challenge: a `challenge` verdict gets the self-contained proof-of-work page (or 403 JSON for a
|
|
81
98
|
non-HTML request); `POST /__camada/challenge` verifies the solution, sets `_cch` (bound to the
|
|
82
|
-
ip, one hour) and 302s back. A request whose ip cannot be resolved is never challenged.
|
|
99
|
+
ip, one hour) and 302s back. A request whose ip cannot be resolved is never challenged. The page
|
|
100
|
+
carries its own `Content-Security-Policy` (its inline solver and style by hash, nothing else)
|
|
101
|
+
and replaces any policy your app set on it (Rails' `content_security_policy`,
|
|
102
|
+
`secure_headers`), so a strict site CSP cannot block it.
|
|
83
103
|
5. Serves the beacon: `GET /_cam/b.js` (the `@camada/browser` build, vendored) and `POST /_cam/fp`
|
|
84
104
|
(≤ 32 KB, relayed onto the event batch as a `sig: 1` row with the ip camada resolved). Both
|
|
85
105
|
fall through to your app when the tenant switched the beacon off.
|
|
86
106
|
6. Runs your app with `x-rid` (the rid of the request's event row; never on a 101 handshake) and
|
|
87
107
|
the `_sfp` session cookie on its response, and when the server closes the response body ships
|
|
88
|
-
one redacted event: method, host, path, scrubbed query, status, latency, header
|
|
108
|
+
one redacted event: method, host, path, scrubbed query (and `Referer` query and fragment), status, latency, header
|
|
89
109
|
names/sizes/order, the auth scheme (never the credential), cookie count (never values), the
|
|
90
110
|
matched route pattern when the framework names it (`sinatra.route`, Rails' `route_uri_pattern`). An exception in your app ships as `st: 500` and propagates unchanged.
|
|
91
111
|
|
|
@@ -176,7 +196,9 @@ here (and never raises), while it does at the edge.
|
|
|
176
196
|
|
|
177
197
|
Every entry point runs inside the fail-open envelope: a dead ingest drops telemetry (logged at
|
|
178
198
|
most once a minute, one line, no backtrace), a corrupt snapshot keeps the previous one, a bug in
|
|
179
|
-
the gem costs the request its join, never its response.
|
|
199
|
+
the gem costs the request its join, never its response. A failed snapshot poll (401, 5xx, no
|
|
200
|
+
answer) keeps the rules in place and paces the next one: `Retry-After` in seconds is honoured,
|
|
201
|
+
floored at 5 s and capped at the refresh interval. `CAMADA_DISABLED=1` bypasses everything.
|
|
180
202
|
|
|
181
203
|
## Development
|
|
182
204
|
|
data/lib/camada/beacon_js.rb
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# dist/auto.global.js) vendored as a String literal: served at /_cam/b.js with no runtime file
|
|
5
5
|
# read. spec/beacon_js_spec.rb pins it to the sibling build byte for byte.
|
|
6
6
|
module Camada
|
|
7
|
-
BEACON_VERSION = "0.3.
|
|
8
|
-
BEACON_SHA256 = "
|
|
9
|
-
BEACON_JS = "\"use strict\";(()=>{var k=\"0.3.
|
|
7
|
+
BEACON_VERSION = "0.3.2"
|
|
8
|
+
BEACON_SHA256 = "3a9d99002ad5bae090043b26646378287f179cc1f1fd65934ebd00e9d1fd05cf"
|
|
9
|
+
BEACON_JS = "\"use strict\";(()=>{var k=\"0.3.2\";var _=\"@camada/browser\";var b=`${_}/${k}`;function t(n){try{return n()}catch{return null}}function x(n){let e=2166136261;for(let i=0;i<n.length;i++)e^=n.charCodeAt(i),e=Math.imul(e,16777619);return(e>>>0).toString(16)}function M(){let n=document.createElement(\"canvas\");n.width=240,n.height=60;let e=n.getContext(\"2d\");return e?(e.textBaseline=\"alphabetic\",e.fillStyle=\"#f60\",e.fillRect(120,5,60,20),e.fillStyle=\"#069\",e.font=\"15px Arial\",e.fillText(\"Sfp\\u2603 1.0\",2,15),e.fillStyle=\"rgba(102,204,0,0.7)\",e.font=\"17px Times\",e.fillText(\"Sfp\\u2603 1.0\",4,45),x(n.toDataURL())):null}function R(){let n=document.createElement(\"canvas\"),e=n.getContext(\"webgl\")||n.getContext(\"experimental-webgl\");if(!e)return null;let i=e.getExtension(\"WEBGL_debug_renderer_info\");return{v:i?e.getParameter(i.UNMASKED_VENDOR_WEBGL):e.getParameter(e.VENDOR),r:i?e.getParameter(i.UNMASKED_RENDERER_WEBGL):e.getParameter(e.RENDERER)}}function S(){return x([Math.tan(-1e300),Math.sinh(1),Math.expm1(1),Math.cbrt(100),Math.log1p(10),Math.atanh(.5)].join(\"|\"))}var N=/^\\$?cdc_|^__webdriver|^__selenium|^__nightmare|^__playwright|^callPhantom/,L=/^\\$?cdc_|^__webdriver|^__selenium|^__nightmare|^__playwright|^callPhantom|^_phantom/;function j(){let n=document,e=navigator,i=window,o=0;for(let s in n)N.test(s)&&o++;for(let s in i)L.test(s)&&o++;return{wd:e.webdriver===!0?1:0,cdc:o,chrome:typeof i.chrome<\"u\"?1:0,plugins:e.plugins?e.plugins.length:-1,langs:e.languages?e.languages.length:-1,outer:window.outerWidth===0&&window.outerHeight===0?1:0,perm:t(()=>Notification.permission),pdf:typeof e.pdfViewerEnabled==\"boolean\"?e.pdfViewerEnabled?1:0:-1,err:t(()=>{try{null.x}catch(s){let c=s.stack;return c?c.split(`\n`).length:0}})}}function T({endpoint:n,rid:e}){let i=e||null,o=performance.now(),s=0,c=0,m=0,f=0,h=0,u=null;function l(){u===null&&(u=Math.round(performance.now()-o))}addEventListener(\"mousemove\",()=>{s++,l()},{passive:!0}),addEventListener(\"scroll\",()=>{c++,l()},{passive:!0}),addEventListener(\"keydown\",()=>{m++,l()},{passive:!0}),addEventListener(\"touchstart\",()=>{f++,l()},{passive:!0}),addEventListener(\"click\",()=>{h++,l()},{passive:!0});function w(){let r=navigator,a=t(()=>performance.getEntriesByType(\"navigation\")[0]),D=t(()=>{let p=performance.getEntriesByType(\"paint\");return p.length?Math.round(p[p.length-1].startTime):null}),d=t(()=>r.connection)||{};return{sdk:b,rid:i,ts:Date.now(),scr:t(()=>[screen.width,screen.height,screen.availWidth,screen.availHeight,screen.colorDepth]),win:t(()=>[window.innerWidth,window.innerHeight,window.outerWidth,window.outerHeight]),dpr:t(()=>window.devicePixelRatio),tz:t(()=>Intl.DateTimeFormat().resolvedOptions().timeZone),tzo:t(()=>new Date().getTimezoneOffset()),lang:t(()=>r.language),langs:t(()=>r.languages?Array.prototype.slice.call(r.languages,0,5):null),plat:t(()=>r.platform),cores:t(()=>r.hardwareConcurrency),mem:t(()=>r.deviceMemory),touch:t(()=>r.maxTouchPoints),ua:t(()=>r.userAgent),dnt:t(()=>r.doNotTrack),cookies:t(()=>r.cookieEnabled?1:0),conn:t(()=>[d.effectiveType,d.rtt,d.downlink,d.saveData?1:0]),gl:t(R),cv:t(M),mf:t(S),auto:t(j),vis:t(()=>document.visibilityState),focus:t(()=>document.hasFocus()?1:0),timing:a?[Math.round(a.domContentLoadedEventEnd),Math.round(a.loadEventEnd),Math.round(a.responseEnd-a.requestStart)]:null,paint:D,input:{moves:s,scrolls:c,keys:m,touches:f,clicks:h,first:u,at:Math.round(performance.now()-o)}}}function v(r){let a=JSON.stringify(r?Object.assign(w(),{hi:r}):w());navigator.sendBeacon&&navigator.sendBeacon(n,new Blob([a],{type:\"application/json\"}))||fetch(n,{method:\"POST\",body:a,keepalive:!0,headers:{\"content-type\":\"application/json\"}}).catch(()=>{})}let g=navigator.userAgentData,E=g&&g.getHighEntropyValues?g.getHighEntropyValues([\"architecture\",\"bitness\",\"model\",\"platformVersion\",\"fullVersionList\"]).catch(()=>null):Promise.resolve(null);setTimeout(()=>{E.then(v)},2e3),addEventListener(\"pagehide\",()=>{E.then(r=>{v(r)})},{once:!0})}function O(n){let e=new URL(n,\"http://_\"),i=e.pathname.split(\"/\");return i[i.length-1]=\"fp\",{endpoint:i.join(\"/\"),rid:e.searchParams.get(\"r\")}}(function(){try{let n=document.currentScript,e=n&&n.src;if(!e)return;let{endpoint:i,rid:o}=O(e);T({endpoint:i,rid:o})}catch{}})();})();\n"
|
|
10
10
|
end
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
3
|
require "openssl"
|
|
5
4
|
|
|
6
5
|
module Camada
|
|
@@ -83,9 +82,6 @@ module Camada
|
|
|
83
82
|
s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """).gsub("'", "'")
|
|
84
83
|
end
|
|
85
84
|
|
|
86
|
-
# Safe to drop inside an inline <script>: `<` is escaped so no value can close the element early.
|
|
87
|
-
def self.escape_script(s) = JSON.generate(s).gsub("<", "\\u003c")
|
|
88
|
-
|
|
89
85
|
# application/x-www-form-urlencoded, last value wins. Never raises on junk: an invalid
|
|
90
86
|
# %-escape is kept as is, invalid UTF-8 is replaced.
|
|
91
87
|
def self.parse_form_body(body)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "openssl"
|
|
3
4
|
require_relative "format"
|
|
4
5
|
|
|
5
6
|
module Camada
|
|
@@ -13,6 +14,11 @@ module Camada
|
|
|
13
14
|
#
|
|
14
15
|
# The page carries its own SHA-256 rather than calling crypto.subtle: subtle is undefined on
|
|
15
16
|
# non-secure origins (plain http on a LAN host), and 65k awaited digests would be slow anyway.
|
|
17
|
+
#
|
|
18
|
+
# CSP (camada-all-y38o, core's mwpo): the inline script and style depend on `bits` alone (the solver reads the
|
|
19
|
+
# nonce from the form), so their hashes are fixed per difficulty and challenge_page_csp(bits) can allow exactly
|
|
20
|
+
# them. The engine sends that policy with the page and the Rack middleware replaces any policy the app set on a
|
|
21
|
+
# served challenge, so a strict app CSP (Rails' content_security_policy, secure_headers) cannot block the solver.
|
|
16
22
|
|
|
17
23
|
# FIPS 180-4 round constants (cube roots of the first 64 primes).
|
|
18
24
|
K = "0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5," \
|
|
@@ -66,6 +72,25 @@ module Camada
|
|
|
66
72
|
"@keyframes camada-slide{0%{transform:translateX(-120%)}100%{transform:translateX(400%)}}\n"
|
|
67
73
|
].join.freeze
|
|
68
74
|
|
|
75
|
+
# The page's one inline script. Depends on `bits` only: the nonce comes from the form.
|
|
76
|
+
def self.solver_script(bits)
|
|
77
|
+
[
|
|
78
|
+
SOLVER, "\n",
|
|
79
|
+
"(function(){\n",
|
|
80
|
+
" if(typeof window==='undefined'||window.__camadaAutostart===false)return;\n",
|
|
81
|
+
" var nonce=document.getElementById('camada-n').value,shift=#{32 - bits};\n",
|
|
82
|
+
" function go(){\n",
|
|
83
|
+
" for(var n=0;n<5000000;n++){\n",
|
|
84
|
+
" if((__camadaSha256Words(nonce+'.'+n)[0]>>>shift)===0){\n",
|
|
85
|
+
" document.getElementById('camada-s').value=String(n);\n",
|
|
86
|
+
" document.getElementById('camada-f').submit();\n",
|
|
87
|
+
" return}}\n",
|
|
88
|
+
" document.getElementById('camada-msg').textContent='Could not complete the check. Please reload.'}\n",
|
|
89
|
+
" setTimeout(go,30);\n",
|
|
90
|
+
"})();\n"
|
|
91
|
+
].join
|
|
92
|
+
end
|
|
93
|
+
|
|
69
94
|
# `action` is the verify endpoint path; `to` has already been through safe_return_to.
|
|
70
95
|
def self.challenge_page(nonce:, action:, to:, bits: POW_BITS)
|
|
71
96
|
bits = bits.clamp(1, 32) # shift 32-bits must stay in range
|
|
@@ -82,26 +107,27 @@ module Camada
|
|
|
82
107
|
"<div class=\"bar\"><i></i></div>\n",
|
|
83
108
|
"</main>\n",
|
|
84
109
|
"<form id=\"camada-f\" method=\"POST\" action=\"#{escape_attr(action)}\">\n",
|
|
85
|
-
"<input type=\"hidden\" name=\"nonce\" value=\"#{escape_attr(nonce)}\">\n",
|
|
110
|
+
"<input type=\"hidden\" name=\"nonce\" value=\"#{escape_attr(nonce)}\" id=\"camada-n\">\n",
|
|
86
111
|
"<input type=\"hidden\" name=\"solution\" id=\"camada-s\">\n",
|
|
87
112
|
"<input type=\"hidden\" name=\"to\" value=\"#{escape_attr(to)}\">\n",
|
|
88
113
|
"</form>\n",
|
|
89
|
-
"<script>#{
|
|
90
|
-
"(function(){\n",
|
|
91
|
-
" if(typeof window==='undefined'||window.__camadaAutostart===false)return;\n",
|
|
92
|
-
" var nonce=#{escape_script(nonce)},shift=#{32 - bits};\n",
|
|
93
|
-
" function go(){\n",
|
|
94
|
-
" for(var n=0;n<5000000;n++){\n",
|
|
95
|
-
" if((__camadaSha256Words(nonce+'.'+n)[0]>>>shift)===0){\n",
|
|
96
|
-
" document.getElementById('camada-s').value=String(n);\n",
|
|
97
|
-
" document.getElementById('camada-f').submit();\n",
|
|
98
|
-
" return}}\n",
|
|
99
|
-
" document.getElementById('camada-msg').textContent='Could not complete the check. Please reload.'}\n",
|
|
100
|
-
" setTimeout(go,30);\n",
|
|
101
|
-
"})();\n",
|
|
102
|
-
"</script>\n",
|
|
114
|
+
"<script>#{solver_script(bits)}</script>\n",
|
|
103
115
|
"</body></html>"
|
|
104
116
|
].join
|
|
105
117
|
end
|
|
118
|
+
|
|
119
|
+
def self.sha256_src(text) = "'sha256-#{[OpenSSL::Digest::SHA256.digest(text)].pack("m0")}'"
|
|
120
|
+
|
|
121
|
+
# The Content-Security-Policy to send with challenge_page(bits:): it allows only the page's own inline script and
|
|
122
|
+
# style (by 'sha256-...' hash) and nothing else. There is no form-action: browsers apply it to the redirects that
|
|
123
|
+
# follow the POST too, so an app whose page 302s on to an external sign-in after verify's 302 would be blocked.
|
|
124
|
+
# Send it as the response's only CSP, removing any Content-Security-Policy or Content-Security-Policy-Report-Only
|
|
125
|
+
# the app set.
|
|
126
|
+
def self.challenge_page_csp(bits = POW_BITS)
|
|
127
|
+
bits = bits.clamp(1, 32)
|
|
128
|
+
@csp_cache ||= {} # bits -> policy; a racing first fill computes the same string twice, harmlessly
|
|
129
|
+
@csp_cache[bits] ||= "default-src 'none'; script-src #{sha256_src(solver_script(bits))}; " \
|
|
130
|
+
"style-src #{sha256_src("\n#{STYLE}")}; base-uri 'none'; frame-ancestors 'self'"
|
|
131
|
+
end
|
|
106
132
|
end
|
|
107
133
|
end
|
data/lib/camada/engine.rb
CHANGED
|
@@ -212,7 +212,10 @@ module Camada
|
|
|
212
212
|
# custom rules read the user agent and the request headers (§D3).
|
|
213
213
|
v = @snap.verdict(Snapshot::MatchInput.new(ip: ip, path: req.path, ua: req.header("user-agent"), header: ->(n) { req.header(n) }))
|
|
214
214
|
if v.block
|
|
215
|
-
headers = {
|
|
215
|
+
headers = {
|
|
216
|
+
"content-type" => "text/plain", "cache-control" => "no-store", # per-visitor: no shared cache keeps it (camada-all-8lp6)
|
|
217
|
+
"x-block-reason" => v.reason || "", "x-block-version" => v.version || ""
|
|
218
|
+
}
|
|
216
219
|
headers["x-block-rule"] = v.rule if v.rule # a custom rule blocked: name it, so the customer knows which row to edit
|
|
217
220
|
ev = event(req, SecureRandom.uuid, nil, false, ip)
|
|
218
221
|
ev["st"] = 403 # blocked requests always ship: silent expiry makes blocks oscillate
|
|
@@ -308,7 +311,11 @@ module Camada
|
|
|
308
311
|
|
|
309
312
|
def page(ip, to)
|
|
310
313
|
html = Challenge.challenge_page(nonce: @kit.nonce(ip, now_ms), action: @challenge_path, to: to)
|
|
311
|
-
|
|
314
|
+
headers = CHALLENGE_HEADERS.merge(
|
|
315
|
+
"content-type" => "text/html; charset=utf-8",
|
|
316
|
+
"content-security-policy" => Challenge.challenge_page_csp # only its own inline solver and style (camada-all-y38o)
|
|
317
|
+
)
|
|
318
|
+
Answer.new(status: 403, headers: headers, body: html)
|
|
312
319
|
end
|
|
313
320
|
|
|
314
321
|
# 403 + the proof-of-work page (HTML navigations) or 403 JSON (everything else), plus the
|
data/lib/camada/events/build.rb
CHANGED
|
@@ -68,7 +68,8 @@ module Camada
|
|
|
68
68
|
"chmob" => first["sec-ch-ua-mobile"], "chplat" => first["sec-ch-ua-platform"],
|
|
69
69
|
"acc" => first["accept"], "lang" => first["accept-language"],
|
|
70
70
|
"fs" => first["sec-fetch-site"], "fm" => first["sec-fetch-mode"],
|
|
71
|
-
"fd" => first["sec-fetch-dest"], "fu" => first["sec-fetch-user"],
|
|
71
|
+
"fd" => first["sec-fetch-dest"], "fu" => first["sec-fetch-user"],
|
|
72
|
+
"ref" => Redact.scrub_url(first["referer"]), "org" => first["origin"], # ref: scrubbed like q (camada-all-ijsa)
|
|
72
73
|
"xrw" => first["x-requested-with"], "auth" => auth_scheme(first["authorization"]), # scheme only, never the credential
|
|
73
74
|
"hm" => mask, "hn" => hn, "hb" => hb, "ck" => cookie.empty? ? 0 : cookie.split(";", -1).length,
|
|
74
75
|
"hord" => names.join(",")[0, 2048] # header order as this host reports it (the env's order under Rack)
|
data/lib/camada/ip.rb
CHANGED
|
@@ -9,8 +9,72 @@ module Camada
|
|
|
9
9
|
module Ip
|
|
10
10
|
Cidr = Struct.new(:base4, :base6, :bits) # base4: v4 base or -1; base6: words or nil
|
|
11
11
|
|
|
12
|
+
# A plain IPv4 or IPv6 address (C6 v3), by our own grammar, never IPAddr (which accepts zone ids, brackets and
|
|
13
|
+
# embedded dotted quads). Stricter than IpParse.parse_ip6, which mirrors edge-analyst and must not drift: a single
|
|
14
|
+
# leading or trailing `:` (`:8:8:8`, `2001:db8::1:`) is rejected here only.
|
|
12
15
|
def self.valid_ip?(s)
|
|
13
|
-
|
|
16
|
+
return IpParse.parse_ip4(s) >= 0 unless s.include?(":")
|
|
17
|
+
|
|
18
|
+
n = s.bytesize
|
|
19
|
+
return false if n < 2 || (s.getbyte(0) == 58 && s.getbyte(1) != 58) || (s.getbyte(n - 1) == 58 && s.getbyte(n - 2) != 58)
|
|
20
|
+
|
|
21
|
+
!IpParse.parse_ip6(s).nil?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# `::ffff:a.b.c.d` (any case) -> `a.b.c.d`, only when a valid dotted quad follows; anything else comes back
|
|
25
|
+
# unchanged (`::ffff:7f00:1` stays an ordinary IPv6 address). C6 v2.
|
|
26
|
+
def self.unmap(s)
|
|
27
|
+
return s if s.bytesize < 14 || !s.byteslice(0, 7).casecmp?("::ffff:")
|
|
28
|
+
|
|
29
|
+
v4 = s.byteslice(7..)
|
|
30
|
+
IpParse.parse_ip4(v4) >= 0 ? v4 : s
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# HTTP optional whitespace only (SP, HTAB), never String#strip (which also removes CR, LF, VT, FF and NUL). C6 v3.
|
|
34
|
+
def self.trim_ows(s)
|
|
35
|
+
a = 0
|
|
36
|
+
b = s.bytesize
|
|
37
|
+
a += 1 while a < b && (s.getbyte(a) == 32 || s.getbyte(a) == 9)
|
|
38
|
+
b -= 1 while b > a && (s.getbyte(b - 1) == 32 || s.getbyte(b - 1) == 9)
|
|
39
|
+
s.byteslice(a, b - a)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Split on `,`, each entry OWS-trimmed, port-stripped and unmapped; empty entries dropped before `hops` counts.
|
|
43
|
+
def self.entries(xff)
|
|
44
|
+
xff.split(",", -1).map { |e| unmap(strip_port(trim_ows(e))) }.reject(&:empty?)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A trusted hop: a valid ip inside a configured range. An entry that is not an ip never is (C6 v3).
|
|
48
|
+
def self.trusted?(ip, cidrs)
|
|
49
|
+
valid_ip?(ip) && cidrs.any? { |t| in_cidr?(ip, t) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
PORT_RE = /\A:[0-9]{1,5}\z/
|
|
53
|
+
|
|
54
|
+
# One X-Forwarded-For entry without its port (camada-all-0vho): `[v6]:port` and `[v6]` -> `v6`,
|
|
55
|
+
# `a.b.c.d:port` -> `a.b.c.d`. A bare IPv6 literal comes back whole (its last group is never a port); anything
|
|
56
|
+
# else malformed comes back unchanged, so validation still rejects it and the peer wins.
|
|
57
|
+
def self.strip_port(e)
|
|
58
|
+
if e.start_with?("[")
|
|
59
|
+
close = e.index("]")
|
|
60
|
+
return e if close.nil?
|
|
61
|
+
|
|
62
|
+
inner = e[1...close]
|
|
63
|
+
rest = e[(close + 1)..]
|
|
64
|
+
return inner.include?(":") && (rest.empty? || PORT_RE.match?(rest)) ? inner : e
|
|
65
|
+
end
|
|
66
|
+
return e if e.count(":") != 1 # no colon, or a bare IPv6 literal
|
|
67
|
+
|
|
68
|
+
host, port = e.split(":", 2)
|
|
69
|
+
IpParse.parse_ip4(host) >= 0 && PORT_RE.match?(":#{port}") ? host : e
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# 127.0.0.0/8 or ::1 (camada-all-yfc6, decision D1), compared by value, never IPAddr#loopback?. The peer's
|
|
73
|
+
# `::ffff:127.x.x.x` form is already reduced to the dotted quad by unmap; `::ffff:7f00:1` is not loopback.
|
|
74
|
+
def self.loopback?(ip)
|
|
75
|
+
return IpParse.parse_ip4(ip) >> 24 == 127 unless ip.include?(":")
|
|
76
|
+
|
|
77
|
+
IpParse.parse_ip6(ip) == [0, 0, 0, 1]
|
|
14
78
|
end
|
|
15
79
|
|
|
16
80
|
def self.parse_cidr(c)
|
|
@@ -54,27 +118,53 @@ module Camada
|
|
|
54
118
|
true
|
|
55
119
|
end
|
|
56
120
|
|
|
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).
|
|
121
|
+
# The client IP from the socket peer and X-Forwarded-For per the trusted-proxy config. The result is always a
|
|
122
|
+
# valid ip or nil, in every mode (C6 v3). Anything unresolvable falls back to the peer (fail safe).
|
|
123
|
+
#
|
|
124
|
+
# XFF entries are trimmed of SP/HTAB only, read without a port (`ip:port`, `[v6]:port`, as Azure writes them)
|
|
125
|
+
# and with `::ffff:a.b.c.d` reduced to `a.b.c.d`, in every mode. The peer is unmap(peer) when that is a valid ip,
|
|
126
|
+
# else nil: a non-empty peer that is not an ip (a socket path, `@`, `ip:port`, a zone id) could not be read, and
|
|
127
|
+
# the result falls back to nil, never to the XFF walk. The peer is never trimmed or port-stripped.
|
|
128
|
+
#
|
|
129
|
+
# `cidrs` (also what the dashboard's Cloudflare option stores) walks the chain `[...xff, peer]` from the right: if
|
|
130
|
+
# the peer is not a trusted proxy, the request did not come through one and the peer IS the client; XFF is
|
|
131
|
+
# ignored, since anyone hitting the origin directly can write it. A loopback peer (127.0.0.0/8, ::1,
|
|
132
|
+
# ::ffff:127.x.x.x) counts as a trusted hop: it is a local front end or sidecar (App Engine's is always
|
|
133
|
+
# 127.0.0.1), and no remote client can be one. Loopback is trusted only as the peer, never as an XFF entry; an
|
|
134
|
+
# XFF entry that is not an ip is never a trusted hop. With no peer at all (nil or "": a host that exposes no
|
|
135
|
+
# socket, or a unix-socket listener whose REMOTE_ADDR is empty) the walk runs over XFF alone: such a host trusts
|
|
136
|
+
# its network edge to have written XFF, and the app must not be reachable around that edge. `hops` and `vercel`
|
|
137
|
+
# trust XFF by definition and ignore the peer.
|
|
59
138
|
def self.resolve_client_ip(peer, xff, cfg)
|
|
60
|
-
|
|
139
|
+
ip = resolve(peer.nil? || peer.empty? ? nil : peer.b, xff&.b, cfg) # bytewise: valid ips are ASCII
|
|
140
|
+
ip&.force_encoding(Encoding::UTF_8)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def self.resolve(raw_peer, xff, cfg)
|
|
144
|
+
mapped = raw_peer && unmap(raw_peer) # dual-stack v4-mapped form
|
|
145
|
+
sock = mapped && valid_ip?(mapped) ? mapped : nil
|
|
61
146
|
return sock if cfg.nil? || cfg["mode"] == "none" || xff.nil? || xff.empty?
|
|
62
147
|
|
|
63
|
-
|
|
64
|
-
return sock if
|
|
148
|
+
list = entries(xff)
|
|
149
|
+
return sock if list.empty?
|
|
65
150
|
|
|
66
151
|
candidate = nil
|
|
67
152
|
case cfg["mode"]
|
|
68
153
|
when "hops"
|
|
69
154
|
hops = cfg["hops"].to_i
|
|
70
|
-
candidate =
|
|
155
|
+
candidate = list[list.length - hops] if hops.between?(1, list.length)
|
|
71
156
|
when "vercel"
|
|
72
|
-
candidate =
|
|
157
|
+
candidate = list.last # Vercel overwrites XFF, so its rightmost entry is trustworthy
|
|
73
158
|
when "cidrs"
|
|
74
159
|
trusted = (cfg["cidrs"] || []).filter_map { |x| parse_cidr(x.to_s) }
|
|
75
|
-
|
|
160
|
+
# the peer is the first hop: if it is not a trusted proxy, it is the client. An unreadable peer gives nil,
|
|
161
|
+
# never the walk; only no peer at all walks.
|
|
162
|
+
return sock if mapped && (sock.nil? || (!loopback?(sock) && !trusted?(sock, trusted)))
|
|
163
|
+
|
|
164
|
+
candidate = list.reverse_each.find { |entry| !trusted?(entry, trusted) }
|
|
76
165
|
end
|
|
77
166
|
candidate && valid_ip?(candidate) ? candidate : sock
|
|
78
167
|
end
|
|
168
|
+
private_class_method :resolve
|
|
79
169
|
end
|
|
80
170
|
end
|
data/lib/camada/rack.rb
CHANGED
|
@@ -127,6 +127,8 @@ module Camada
|
|
|
127
127
|
sinatra&.to_s&.sub(/\A[A-Z]+ /, "")
|
|
128
128
|
end
|
|
129
129
|
|
|
130
|
+
CSP_HEADERS = %w[content-security-policy content-security-policy-report-only].freeze
|
|
131
|
+
|
|
130
132
|
# `Camada::Rack.new(app)` wires the lazy default from the environment on the first request;
|
|
131
133
|
# `Camada::Rack.new(app, engine)` uses the one you built.
|
|
132
134
|
def initialize(app, engine = nil, **opts)
|
|
@@ -166,6 +168,7 @@ module Camada
|
|
|
166
168
|
raise
|
|
167
169
|
end
|
|
168
170
|
headers = stamp(status, headers, passed)
|
|
171
|
+
headers = own_challenge_csp(headers) if passed.ctx && passed.ctx["challenged"]
|
|
169
172
|
finish = passed.on_finish
|
|
170
173
|
return [status, headers, body] if finish.nil?
|
|
171
174
|
|
|
@@ -194,6 +197,22 @@ module Camada
|
|
|
194
197
|
headers
|
|
195
198
|
end
|
|
196
199
|
|
|
200
|
+
# A challenge page the app returned (serve_challenge) keeps its own CSP: a policy the app or its middleware put on
|
|
201
|
+
# that response (Rails' content_security_policy, secure_headers) is dropped and the page's is set, or the solver
|
|
202
|
+
# would be blocked (camada-all-y38o). Any other response comes back unchanged.
|
|
203
|
+
# The headers are changed in place, so a Rack::Headers (or any Hash subclass) keeps its class.
|
|
204
|
+
def own_challenge_csp(headers)
|
|
205
|
+
value = ->(name) { Array(headers.find { |k, _| k.to_s.downcase == name }&.last).first.to_s }
|
|
206
|
+
return headers unless value.call("x-camada-challenge") == "1" && value.call("content-type").start_with?("text/html")
|
|
207
|
+
|
|
208
|
+
headers.delete_if { |k, _| CSP_HEADERS.include?(k.to_s.downcase) }
|
|
209
|
+
headers["content-security-policy"] = Challenge.challenge_page_csp
|
|
210
|
+
headers
|
|
211
|
+
rescue StandardError => e
|
|
212
|
+
Guarded.log_rate_limited(e)
|
|
213
|
+
headers
|
|
214
|
+
end
|
|
215
|
+
|
|
197
216
|
def join_cookies(existing, cookie)
|
|
198
217
|
return cookie if existing.nil? || (existing.respond_to?(:empty?) && existing.empty?)
|
|
199
218
|
|
data/lib/camada/redact.rb
CHANGED
|
@@ -9,33 +9,94 @@ module Camada
|
|
|
9
9
|
# credentials, or raw user identifiers (HMAC-hashed here, inside the SDK, before anything
|
|
10
10
|
# reaches the queue). Ported from @camada/core src/redact.ts.
|
|
11
11
|
module Redact
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
# Exact patterns of C6 v3, matched bytewise on a binary copy (decoding yields any byte; every pattern is ASCII,
|
|
13
|
+
# so a byte >= 0x80 never matches). Whole-string anchors are \A...\z (Ruby's ^/$ are line anchors). Ruby's /i
|
|
14
|
+
# folds Unicode (U+212A Kelvin K matches k), so the name test is the lowercase-A-Z-then-substring form and hex
|
|
15
|
+
# spells out [a-fA-F0-9].
|
|
16
|
+
NAME_WORDS = %w[pass tok secret key auth sess sig code jwt bearer credential].freeze
|
|
17
|
+
JWT_RE = /\AeyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/n # prefix match: anything may follow
|
|
18
|
+
HEX_RE = /\A[a-fA-F0-9]{32,}\z/n
|
|
19
|
+
B64_RE = %r{\A[A-Za-z0-9+/_-]{40,}={0,2}\z}n
|
|
20
|
+
ESC_RE = /%([0-9a-fA-F]{2})/n
|
|
16
21
|
|
|
17
22
|
REDACT_ALLOWLIST = %w[plan role locale ab_variant].freeze # additions only, never narrowing
|
|
18
23
|
|
|
19
24
|
def self.suspect_value?(v)
|
|
25
|
+
v = v.b
|
|
20
26
|
JWT_RE.match?(v) || HEX_RE.match?(v) || B64_RE.match?(v)
|
|
21
27
|
end
|
|
22
28
|
|
|
23
|
-
#
|
|
29
|
+
# core's NAME_RE (`pass(word)?|tok(en)?|secret|key|api[-_]?key|auth|sess(ion)?|sig(nature)?|code|jwt|bearer|
|
|
30
|
+
# credential`, unanchored, case-insensitive) in its ASCII-only form.
|
|
31
|
+
def self.name_hit?(n)
|
|
32
|
+
lower = n.b.tr("A-Z", "a-z")
|
|
33
|
+
NAME_WORDS.any? { |w| lower.include?(w) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Percent-decoding that never raises: bytewise, each `%HH` (ASCII hex) becomes that byte, any other `%` stays,
|
|
37
|
+
# `+` stays. Up to 4 passes, so double (or triple) encoding cannot hide a name or a nested query; text that still
|
|
38
|
+
# holds a `%HH` after 4 passes returns nil, which the caller scrubs. The cap keeps a crafted `%252525...` linear.
|
|
39
|
+
# Only ever used to TEST a part; the output keeps the raw text.
|
|
40
|
+
def self.decode(s)
|
|
41
|
+
s = s.b
|
|
42
|
+
4.times do
|
|
43
|
+
break unless ESC_RE.match?(s)
|
|
44
|
+
|
|
45
|
+
s = s.gsub(ESC_RE) { ::Regexp.last_match(1).hex.chr }
|
|
46
|
+
end
|
|
47
|
+
ESC_RE.match?(s) ? nil : s
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# One `name=value` or bare segment of a decoded nested value (no further decoding, no recursion).
|
|
51
|
+
def self.cred_segment?(seg)
|
|
52
|
+
eq = seg.index("=")
|
|
53
|
+
eq.nil? ? suspect_value?(seg) : name_hit?(seg[0, eq]) || suspect_value?(seg[(eq + 1)..])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A decoded value that carries a nested query (`?`, `&` or `=`) is a credential when any of its `[?&]`-separated
|
|
57
|
+
# segments is one (C6 v3). `q=a=b` or `q=1' or '1'='1` is not; `next=/r?token=x` is.
|
|
58
|
+
def self.nested_cred?(d)
|
|
59
|
+
/[?&=]/n.match?(d) && d.split(/[?&]/n, -1).any? { |seg| cred_segment?(seg) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Replaces credential-looking query parts with ~r, preserving structure and order (C6 v3). The string is split on
|
|
63
|
+
# `&`, never URL-parsed. Each part's name and value are percent-decoded (decode) before testing; the output keeps
|
|
64
|
+
# the raw text. `name=value` -> `name=~r` when the name looks like a credential, the value looks like one, or the
|
|
65
|
+
# value holds a nested query with a credential segment (replaced whole, so payloads like `q=1' or '1'='1` stay
|
|
66
|
+
# visible to the scorer). A bare part -> `~r` when it looks like a credential value or holds a credential
|
|
67
|
+
# segment. Either half still encoded after 4 decode passes counts as a credential.
|
|
24
68
|
def self.scrub_query(query)
|
|
25
69
|
return query || "" if query.nil? || query.length <= 1
|
|
26
70
|
|
|
27
71
|
lead = query.start_with?("?") ? "?" : ""
|
|
28
72
|
out = (lead.empty? ? query : query[1..]).split("&", -1).map do |p|
|
|
29
73
|
eq = p.index("=")
|
|
30
|
-
|
|
74
|
+
if eq.nil?
|
|
75
|
+
d = decode(p)
|
|
76
|
+
next d.nil? || suspect_value?(d) || nested_cred?(d) ? "~r" : p
|
|
77
|
+
end
|
|
31
78
|
|
|
32
79
|
name = p[0, eq]
|
|
33
|
-
|
|
34
|
-
|
|
80
|
+
dn = decode(name)
|
|
81
|
+
dv = decode(p[(eq + 1)..])
|
|
82
|
+
dn.nil? || dv.nil? || name_hit?(dn) || suspect_value?(dv) || nested_cred?(dv) ? "#{name}=~r" : p
|
|
35
83
|
end
|
|
36
84
|
lead + out.join("&")
|
|
37
85
|
end
|
|
38
86
|
|
|
87
|
+
# A URL (a Referer) with its query and fragment run through scrub_query (camada-all-ijsa); the scheme, host and
|
|
88
|
+
# path are kept as they are. The path is left alone on purpose: it gets the same treatment as the request's own
|
|
89
|
+
# path `p` (none). A same-origin Referer's path already shipped as `p` on that page's own event, and a
|
|
90
|
+
# cross-origin one carries only the origin under the browsers' default Referrer-Policy.
|
|
91
|
+
def self.scrub_url(url)
|
|
92
|
+
return nil if url.nil?
|
|
93
|
+
|
|
94
|
+
head, hash, frag = url.partition("#")
|
|
95
|
+
path, q, query = head.partition("?")
|
|
96
|
+
scrubbed = q.empty? ? head : path + scrub_query(q + query)
|
|
97
|
+
hash.empty? ? scrubbed : "#{scrubbed}##{scrub_query(frag)}"
|
|
98
|
+
end
|
|
99
|
+
|
|
39
100
|
# Body shape only: field names and byte sizes, never values. One level deep.
|
|
40
101
|
def self.body_shape(obj)
|
|
41
102
|
return nil unless obj.is_a?(Hash)
|
|
@@ -23,6 +23,21 @@ module Camada
|
|
|
23
23
|
# Process.pid with the pid that built the client and starts over in a forked worker.
|
|
24
24
|
COLD = MatchResult.new(reason: "cold") # never loaded yet: fail open, mirrors the collector
|
|
25
25
|
NONE = MatchResult.new
|
|
26
|
+
POLL_FLOOR_S = 5.0
|
|
27
|
+
|
|
28
|
+
# Seconds to wait before the next self-initiated poll after a poll answered `status`, or nil
|
|
29
|
+
# when the answer was 200/204/304 (not paced: the normal cadence governs). A Retry-After in
|
|
30
|
+
# delta-seconds is honoured, floored at 5 s and capped at the current cadence (the cap wins).
|
|
31
|
+
# HTTP-dates and anything else that is not all digits read as absent (edge-analyst only sends
|
|
32
|
+
# delta-seconds). status 0 = no answer. Internal.
|
|
33
|
+
def self.next_poll_delay(status, retry_after, refresh_s)
|
|
34
|
+
return nil if [200, 204, 304].include?(status)
|
|
35
|
+
|
|
36
|
+
ra = retry_after.to_s.gsub(/\A[ \t]+|[ \t]+\z/, "")
|
|
37
|
+
secs = ra.match?(/\A[0-9]+\z/) ? [ra.to_i, 1_000_000_000].min : 0 # overflow reads as huge, not invalid
|
|
38
|
+
cap = [refresh_s.to_f, 0.0].max
|
|
39
|
+
[[secs, POLL_FLOOR_S].max, cap].min.to_f # rubocop:disable Style/ComparableClamp -- not #clamp: it raises when the cap is under the floor
|
|
40
|
+
end
|
|
26
41
|
|
|
27
42
|
class Client
|
|
28
43
|
attr_accessor :transport
|
|
@@ -46,6 +61,7 @@ module Camada
|
|
|
46
61
|
@pinned = !refresh_s.nil?
|
|
47
62
|
@etag = nil
|
|
48
63
|
@loaded_at = nil
|
|
64
|
+
@not_before = 0.0 # a failed poll gates the next self-initiated one until this monotonic time
|
|
49
65
|
fresh_state!
|
|
50
66
|
end
|
|
51
67
|
|
|
@@ -81,24 +97,55 @@ module Camada
|
|
|
81
97
|
@loaded_at.nil? || monotonic - @loaded_at > @refresh_s * 0.9
|
|
82
98
|
end
|
|
83
99
|
|
|
84
|
-
# Kicks a refresh when
|
|
100
|
+
# Kicks a refresh when due; never blocks the request path, never raises.
|
|
85
101
|
def ensure_fresh
|
|
86
102
|
check_fork!
|
|
87
|
-
return if !
|
|
103
|
+
return if !due? || @loading.locked?
|
|
88
104
|
|
|
89
|
-
t = Thread.new {
|
|
105
|
+
t = Thread.new { refresh_if_due }
|
|
90
106
|
t.name = "camada-snapshot-load"
|
|
91
107
|
t.report_on_exception = false
|
|
92
108
|
nil
|
|
93
109
|
end
|
|
94
110
|
|
|
95
|
-
# One synchronous poll (single in-flight): what
|
|
111
|
+
# One synchronous poll (single in-flight): what tests and warm-ups call directly, ignoring the gate.
|
|
96
112
|
def refresh
|
|
113
|
+
poll { true }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Cold (never loaded) and no-snapshot both fail open, mirroring the edge collector.
|
|
117
|
+
def verdict(i)
|
|
118
|
+
return COLD if @loaded_at.nil?
|
|
119
|
+
|
|
120
|
+
m = @matcher
|
|
121
|
+
m ? m.match(i) : NONE
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
# What the background kick calls: take the slot, then re-check, so a second kick that lost the
|
|
127
|
+
# race to a poll that has just finished (and gated the next one) does nothing.
|
|
128
|
+
def refresh_if_due
|
|
129
|
+
poll { due? }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Stale and past the failure gate: what every self-initiated poll (request path, timer tick)
|
|
133
|
+
# asks. A gate further away than one cadence means the clock stepped; treat it as open.
|
|
134
|
+
def due?
|
|
135
|
+
return false unless stale?
|
|
136
|
+
|
|
137
|
+
now = monotonic
|
|
138
|
+
@not_before <= now || @not_before - now > @refresh_s
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def poll
|
|
97
142
|
check_fork!
|
|
98
143
|
lock = @loading # bound once: after_fork! swaps the attribute
|
|
99
144
|
return unless lock.try_lock
|
|
100
145
|
|
|
101
146
|
begin
|
|
147
|
+
return unless yield
|
|
148
|
+
|
|
102
149
|
load_once
|
|
103
150
|
rescue StandardError => e # a poll that can never succeed must not be silent, nor fatal
|
|
104
151
|
Guarded.log_rate_limited(e)
|
|
@@ -107,16 +154,6 @@ module Camada
|
|
|
107
154
|
end
|
|
108
155
|
end
|
|
109
156
|
|
|
110
|
-
# Cold (never loaded) and no-snapshot both fail open, mirroring the edge collector.
|
|
111
|
-
def verdict(i)
|
|
112
|
-
return COLD if @loaded_at.nil?
|
|
113
|
-
|
|
114
|
-
m = @matcher
|
|
115
|
-
m ? m.match(i) : NONE
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
private
|
|
119
|
-
|
|
120
157
|
def fresh_state!
|
|
121
158
|
@pid = Process.pid
|
|
122
159
|
@loading = Mutex.new
|
|
@@ -150,8 +187,18 @@ module Camada
|
|
|
150
187
|
headers["x-camada-sdk"] = @sdk if @sdk
|
|
151
188
|
# a tenant without that container is answered with the next one down
|
|
152
189
|
headers["x-camada-snapshot"] = @snapshot_version.to_s if @snapshot_version > 3
|
|
153
|
-
res =
|
|
154
|
-
|
|
190
|
+
res = begin
|
|
191
|
+
@transport.call(HttpRequest.new(method: "GET", url: @url, headers: headers, body: nil, timeout_s: @timeout_s))
|
|
192
|
+
rescue StandardError => e
|
|
193
|
+
Guarded.log_rate_limited(e) # still logged (rate-limited), and gated below as status 0
|
|
194
|
+
HttpResponse.new(status: 0, headers: {}, body: "".b) # a transport that raises is no answer
|
|
195
|
+
end
|
|
196
|
+
delay = Snapshot.next_poll_delay(res.status, res.headers["retry-after"], @refresh_s)
|
|
197
|
+
unless delay.nil? # 401/5xx/network: keep what we have (cold stays cold) and back off
|
|
198
|
+
@not_before = monotonic + delay # written before the slot is released
|
|
199
|
+
return
|
|
200
|
+
end
|
|
201
|
+
@not_before = 0.0
|
|
155
202
|
|
|
156
203
|
# loaded_at is stamped last (even when the body turns out corrupt): "not cold" is what Puma's
|
|
157
204
|
# request threads read as "rules in place", so it must not be visible before the matcher and
|
|
@@ -97,7 +97,7 @@ module Camada
|
|
|
97
97
|
def blocked4?(n)
|
|
98
98
|
s = @snap
|
|
99
99
|
b = n >> 8
|
|
100
|
-
return false if (s.bm4[b >> 5] >> (b & 31))
|
|
100
|
+
return false if (s.bm4[b >> 5] >> (b & 31)).nobits?(1)
|
|
101
101
|
|
|
102
102
|
hi = n >> 16
|
|
103
103
|
left = s.idx4[hi]
|
|
@@ -122,7 +122,7 @@ module Camada
|
|
|
122
122
|
def blocked6?(w)
|
|
123
123
|
s = @snap
|
|
124
124
|
b = w[0] >> 8
|
|
125
|
-
return false if (s.bm6[b >> 5] >> (b & 31))
|
|
125
|
+
return false if (s.bm6[b >> 5] >> (b & 31)).nobits?(1)
|
|
126
126
|
|
|
127
127
|
left = 0
|
|
128
128
|
right = s.n6 - 1
|
|
@@ -143,7 +143,7 @@ module Camada
|
|
|
143
143
|
|
|
144
144
|
def blocked_asn?(asn)
|
|
145
145
|
s = @snap
|
|
146
|
-
return (s.asn_bm[asn >> 5] >> (asn & 31))
|
|
146
|
+
return (s.asn_bm[asn >> 5] >> (asn & 31)).anybits?(1) if asn < 4_194_304
|
|
147
147
|
|
|
148
148
|
extra = s.asn_extra
|
|
149
149
|
left = 0
|
data/lib/camada/transport.rb
CHANGED
|
@@ -42,11 +42,11 @@ module Camada
|
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
def self.response(status, headers, body)
|
|
45
|
-
if headers["content-encoding"].to_s.downcase == "gzip"
|
|
45
|
+
if !body.empty? && headers["content-encoding"].to_s.downcase == "gzip"
|
|
46
46
|
begin
|
|
47
47
|
body = Zlib::GzipReader.new(StringIO.new(body)).read.b
|
|
48
48
|
rescue StandardError
|
|
49
|
-
return HttpResponse.new(status: 0, headers:
|
|
49
|
+
return HttpResponse.new(status: 0, headers: {}, body: "".b) # a body we cannot read is no answer at all: no retry-after either
|
|
50
50
|
end
|
|
51
51
|
headers.delete("content-encoding")
|
|
52
52
|
end
|
data/lib/camada/version.rb
CHANGED
|
@@ -5,6 +5,6 @@ module Camada
|
|
|
5
5
|
# source: camada.gemspec reads it at build, and the sibling drift guards (camada-backend,
|
|
6
6
|
# camada-web, camada-mkt) parse this file with /^\s*VERSION = "([^"]+)"/m the way they parse
|
|
7
7
|
# a package.json. Plain X.Y.Z only: the analyst's SDK_RE drops anything else.
|
|
8
|
-
VERSION = "0.1.
|
|
8
|
+
VERSION = "0.1.4"
|
|
9
9
|
SDK_ID = "@camada/ruby/#{VERSION}".freeze
|
|
10
10
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: camada
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- camada
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: 'camada SDK for Ruby: inline enforcement of your snapshot (ordered custom
|
|
14
14
|
rules, then allow, block, challenge), first-party beacon and proof-of-work challenge,
|