camada 0.1.3 → 0.1.5
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 +22 -2
- 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/match.rb +3 -3
- data/lib/camada/snapshot/parse.rb +66 -9
- 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: 86d9659607a031e6d4802b62fa614a63c5cf682c8b25ad753e08dad5d5890f04
|
|
4
|
+
data.tar.gz: 3837b8f2e624efdca08154e3dabe053c4e924af155688be40a2980c59d9370c6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4b5eaee46ab80bf9d7b8af4fb2499461c4a2ad3a3ba604f691d77bef227eceb97d9698a1a37688bbc2e87949059f86569103cb43b83e96331012d22bae9bf0ee
|
|
7
|
+
data.tar.gz: 99709f2521ed91d8be8dd8714396e7184667814581c401031f33ba9d1b55ad5edda46a5d65139f5fdac44cc918974c768f544919e5ecb9f2fceeb6c6cfde8cf3
|
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
|
|
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)
|
|
@@ -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
|
|
@@ -191,10 +191,54 @@ module Camada
|
|
|
191
191
|
|
|
192
192
|
# A regex test that never raises: a value in an encoding the pattern cannot read (a binary
|
|
193
193
|
# PATH_INFO with high bytes, say) reads as "no match" rather than an exception on the request path.
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
#
|
|
195
|
+
# `on_error` is what a value the engine cannot read answers. The legacy block-side path regexes
|
|
196
|
+
# keep false; a custom `matches` rule passes `deny` (regex-cap-sdk v1: an unevaluable value is a
|
|
197
|
+
# hit for block/challenge/warn and a miss for skip, the same as an over-cap one, camada-all-cwb4).
|
|
198
|
+
def self.regex_hit?(rx, value, on_error = false)
|
|
199
|
+
rx.match?(utf8_view(value))
|
|
196
200
|
rescue StandardError
|
|
197
|
-
|
|
201
|
+
on_error
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# A value tagged ASCII-8BIT (Rack) whose bytes are valid UTF-8 is read as UTF-8, so a non-ASCII
|
|
205
|
+
# pattern neither raises nor flips a deny/skip outcome. Genuinely invalid bytes stay as they are
|
|
206
|
+
# and take the on_error path.
|
|
207
|
+
def self.utf8_view(v)
|
|
208
|
+
return v if v.encoding == Encoding::UTF_8
|
|
209
|
+
|
|
210
|
+
s = v.dup.force_encoding(Encoding::UTF_8)
|
|
211
|
+
s.valid_encoding? ? s : v
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# regex-cap-sdk v1 S1: the longest value a `matches` regex runs on, in UTF-16 code units.
|
|
215
|
+
MAX_MATCH_INPUT = 2048
|
|
216
|
+
|
|
217
|
+
# Length in UTF-16 code units (what edge's `v.length` counts: an astral code point is 2). A String
|
|
218
|
+
# that is not valid UTF-8 (Rack hands over ASCII-8BIT) counts its bytes.
|
|
219
|
+
def self.utf16_len(v)
|
|
220
|
+
s = v.dup.force_encoding(Encoding::UTF_8)
|
|
221
|
+
return v.bytesize unless s.valid_encoding?
|
|
222
|
+
|
|
223
|
+
s.length + s.each_char.count { |c| c.ord > 0xFFFF }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def self.over_cap?(v)
|
|
227
|
+
v.bytesize > MAX_MATCH_INPUT && utf16_len(v) > MAX_MATCH_INPUT
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# regex-cap-sdk S2: delete [\x00-\x1f\x7f] from a ua/header value before a `matches` regex runs.
|
|
231
|
+
# Bytewise, so it stays safe on invalid UTF-8.
|
|
232
|
+
def self.strip_control(v)
|
|
233
|
+
v.b.delete("\x00-\x1f\x7f").force_encoding(v.encoding)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# regex-cap-sdk S3: a raw control character in the raw path form is spelled %xx, as canon_path does.
|
|
237
|
+
def self.encode_control(p)
|
|
238
|
+
b = p.b
|
|
239
|
+
return p unless b.match?(/[\x00-\x1f\x7f]/n)
|
|
240
|
+
|
|
241
|
+
b.gsub(/[\x00-\x1f\x7f]/n) { |c| format("%%%02x", c.ord) }.force_encoding(p.encoding)
|
|
198
242
|
end
|
|
199
243
|
|
|
200
244
|
# The same envelope for the plain string ops: Puma hands every env value to Rack as
|
|
@@ -275,7 +319,7 @@ module Camada
|
|
|
275
319
|
p = strip_query(raw)
|
|
276
320
|
return [p, p, p] if canonical?(p)
|
|
277
321
|
|
|
278
|
-
[p, canon_path(p, dots: false), canon_path(p)]
|
|
322
|
+
[encode_control(p), canon_path(p, dots: false), canon_path(p)]
|
|
279
323
|
end
|
|
280
324
|
|
|
281
325
|
def self.path_dir(p) = p.end_with?("/") ? p : "#{p}/"
|
|
@@ -308,12 +352,14 @@ module Camada
|
|
|
308
352
|
deny ? (pred.call(forms[0]) || pred.call(forms[1]) || pred.call(forms[2])) : (pred.call(forms[1]) && pred.call(forms[2]))
|
|
309
353
|
end
|
|
310
354
|
|
|
311
|
-
# One path condition -> a predicate over a single path form.
|
|
312
|
-
|
|
355
|
+
# One path condition -> a predicate over a single path form. A `matches` form over MAX_MATCH_INPUT
|
|
356
|
+
# never runs the regex: it answers `deny` (a hit for block/challenge/warn, a miss for skip), and so
|
|
357
|
+
# does a form the regex engine cannot read.
|
|
358
|
+
def self.path_pred(op, values, deny = true)
|
|
313
359
|
case op
|
|
314
360
|
when "matches"
|
|
315
361
|
rx = compile_regex(values[0], Regexp::IGNORECASE)
|
|
316
|
-
->(p) { !rx.nil? && regex_hit?(rx, p) }
|
|
362
|
+
->(p) { !rx.nil? && (over_cap?(p) ? deny : regex_hit?(rx, p, deny)) }
|
|
317
363
|
when "starts_with"
|
|
318
364
|
v = values[0]
|
|
319
365
|
key = v.end_with?("/") ? dir_key(v) : canon_path(v)
|
|
@@ -345,7 +391,7 @@ module Camada
|
|
|
345
391
|
op = c["op"].to_s
|
|
346
392
|
if f == "path" # every path op reads the canonical forms (see "paths" above)
|
|
347
393
|
raw = c["v"]
|
|
348
|
-
pred = path_pred(op, raw.is_a?(Array) ? raw.map(&:to_s) : [raw.to_s])
|
|
394
|
+
pred = path_pred(op, raw.is_a?(Array) ? raw.map(&:to_s) : [raw.to_s], deny)
|
|
349
395
|
return ->(r) { path_hit?(r.paths, deny, &pred) }
|
|
350
396
|
end
|
|
351
397
|
negate = op == "is_not" || op == "not_in"
|
|
@@ -386,9 +432,20 @@ module Camada
|
|
|
386
432
|
case op
|
|
387
433
|
when "matches"
|
|
388
434
|
rx = compile_regex(values[0])
|
|
435
|
+
strip = f == "ua" || f == "header"
|
|
389
436
|
lambda do |r|
|
|
390
437
|
v = read.call(r)
|
|
391
|
-
|
|
438
|
+
next false if v.nil? || rx.nil?
|
|
439
|
+
|
|
440
|
+
# The cap comes before regex_hit?, whose rescue would turn an encoding error into a miss.
|
|
441
|
+
hit = ->(s) { over_cap?(s) ? deny : regex_hit?(rx, s, deny) }
|
|
442
|
+
next hit.call(v) unless strip
|
|
443
|
+
|
|
444
|
+
stripped = strip_control(v) # one scan (S5)
|
|
445
|
+
next hit.call(v) if stripped.bytesize == v.bytesize
|
|
446
|
+
|
|
447
|
+
# S5: control chars never widen a skip and never neuter a deny regex
|
|
448
|
+
deny && (hit.call(v) || hit.call(stripped))
|
|
392
449
|
end
|
|
393
450
|
when "contains"
|
|
394
451
|
needle = values[0]
|
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.5"
|
|
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.5
|
|
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-19 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,
|