camada 0.1.3 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 66753d06b68709a8360519bc64f67cc2278112b804da0d83bebfbe74ab211ae6
4
- data.tar.gz: fc1423d70058d49223d3a3462d882fc6898013266c130d4be2835bb5696e9f5b
3
+ metadata.gz: 67d03ce5257d9e94c2b52c35d9c612077918e82dbb1ccd81de6399300368773d
4
+ data.tar.gz: 867e0652bc76b81a367b679b16e52ffa8e8e1248307d0e435d74197224433980
5
5
  SHA512:
6
- metadata.gz: 94a9c22c64a0f100c47c6312bc81cd9d4cf0b38a73d30f901a7951815b42b9f111d617f69acd2fa5541945b9626a4259c9ad39e5a2021fd7c8a6a03d38d30809
7
- data.tar.gz: 07d7bfc337bc64e69781e507efcd1269acbd79e626608e27631aec91004a4de820ad80bc5ba3fbe02d3192005a8f1278fd7825a66df31ea6b7063f87c13c5f9a
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
 
@@ -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.0"
8
- BEACON_SHA256 = "0abf522f9d0004ae798ba22fec74e935f545f787847574c4800662d00d522e95"
9
- BEACON_JS = "\"use strict\";(()=>{var k=\"0.3.0\";var _=\"@camada/browser\";var b=`${_}/${k}`;function n(t){try{return t()}catch{return null}}function x(t){let e=2166136261;for(let i=0;i<t.length;i++)e^=t.charCodeAt(i),e=Math.imul(e,16777619);return(e>>>0).toString(16)}function M(){let t=document.createElement(\"canvas\");t.width=240,t.height=60;let e=t.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(t.toDataURL())):null}function R(){let t=document.createElement(\"canvas\"),e=t.getContext(\"webgl\")||t.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 t=document,e=navigator,i=window,o=0;for(let s in t)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:n(()=>Notification.permission),pdf:typeof e.pdfViewerEnabled==\"boolean\"?e.pdfViewerEnabled?1:0:-1,err:n(()=>{try{null.x}catch(s){let c=s.stack;return c?c.split(`\n`).length:0}})}}function T({endpoint:t,rid:e}){let i=e||null,o=performance.now(),s=0,c=0,m=0,h=0,f=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\",()=>{h++,l()},{passive:!0}),addEventListener(\"click\",()=>{f++,l()},{passive:!0});function w(){let r=navigator,a=n(()=>performance.getEntriesByType(\"navigation\")[0]),D=n(()=>{let p=performance.getEntriesByType(\"paint\");return p.length?Math.round(p[p.length-1].startTime):null}),d=n(()=>r.connection)||{};return{sdk:b,rid:i,ts:Date.now(),scr:n(()=>[screen.width,screen.height,screen.availWidth,screen.availHeight,screen.colorDepth]),win:n(()=>[window.innerWidth,window.innerHeight,window.outerWidth,window.outerHeight]),dpr:n(()=>window.devicePixelRatio),tz:n(()=>Intl.DateTimeFormat().resolvedOptions().timeZone),tzo:n(()=>new Date().getTimezoneOffset()),lang:n(()=>r.language),langs:n(()=>r.languages?Array.prototype.slice.call(r.languages,0,5):null),plat:n(()=>r.platform),cores:n(()=>r.hardwareConcurrency),mem:n(()=>r.deviceMemory),touch:n(()=>r.maxTouchPoints),ua:n(()=>r.userAgent),dnt:n(()=>r.doNotTrack),cookies:n(()=>r.cookieEnabled?1:0),conn:n(()=>[d.effectiveType,d.rtt,d.downlink,d.saveData?1:0]),gl:n(R),cv:n(M),mf:n(S),auto:n(j),vis:n(()=>document.visibilityState),focus:n(()=>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:h,clicks:f,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(t,new Blob([a],{type:\"application/json\"}))||fetch(t,{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(t){let e=new URL(t,\"http://_\"),i=e.pathname.split(\"/\");return i[i.length-1]=\"fp\",{endpoint:i.join(\"/\"),rid:e.searchParams.get(\"r\")}}(function(){try{let t=document.currentScript,e=t&&t.src;if(!e)return;let{endpoint:i,rid:o}=O(e);T({endpoint:i,rid:o})}catch{}})();})();\n"
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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;").gsub("'", "&#39;")
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>#{SOLVER}\n",
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 = { "content-type" => "text/plain", "x-block-reason" => v.reason || "", "x-block-version" => v.version || "" }
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
- Answer.new(status: 403, headers: CHALLENGE_HEADERS.merge("content-type" => "text/html; charset=utf-8"), body: html)
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
@@ -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"], "ref" => first["referer"], "org" => first["origin"],
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
- s.include?(":") ? !IpParse.parse_ip6(s).nil? : IpParse.parse_ip4(s) >= 0
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
- sock = peer&.start_with?("::ffff:") ? peer[7..] : peer # dual-stack v4-mapped form
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
- entries = xff.split(",").map(&:strip).reject(&:empty?)
64
- return sock if entries.empty?
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 = entries[entries.length - hops] if hops.between?(1, entries.length)
155
+ candidate = list[list.length - hops] if hops.between?(1, list.length)
71
156
  when "vercel"
72
- candidate = entries.last # Vercel overwrites XFF, so its rightmost entry is trustworthy
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
- candidate = entries.reverse_each.find { |entry| trusted.none? { |t| in_cidr?(entry, t) } }
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
- 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}
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
- # Replaces credential-looking query values with ~r, preserving structure and order.
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
- next p if eq.nil?
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
- value = p[(eq + 1)..]
34
- NAME_RE.match?(name) || suspect_value?(value) ? "#{name}=~r" : p
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)) & 1 == 0
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)) & 1 == 0
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)) & 1 != 0 if asn < 4_194_304
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
@@ -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.3"
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.3
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-09 00:00:00.000000000 Z
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,