camada 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 86d9659607a031e6d4802b62fa614a63c5cf682c8b25ad753e08dad5d5890f04
4
- data.tar.gz: 3837b8f2e624efdca08154e3dabe053c4e924af155688be40a2980c59d9370c6
3
+ metadata.gz: 0d3fbef4ecb9e7381ff18ff7f00298f155db47809898b7a2f62ee2d0a3d8d90f
4
+ data.tar.gz: 25e2f5efe68f77b02d8903d04b59752ea25587c1ac9834c930ea28d24c14eb98
5
5
  SHA512:
6
- metadata.gz: 4b5eaee46ab80bf9d7b8af4fb2499461c4a2ad3a3ba604f691d77bef227eceb97d9698a1a37688bbc2e87949059f86569103cb43b83e96331012d22bae9bf0ee
7
- data.tar.gz: 99709f2521ed91d8be8dd8714396e7184667814581c401031f33ba9d1b55ad5edda46a5d65139f5fdac44cc918974c768f544919e5ecb9f2fceeb6c6cfde8cf3
6
+ metadata.gz: d2f3c3bb076396b25ced5e13ad2371b53709cbb8c73f7a48fb6553ebdb4e576736b88ca22731fc18c2abbd45cddc65b43861eb99f5073f8bb94af7f92b23b6e3
7
+ data.tar.gz: 61947d1b869a9b5ea49fdaca8e8c8e62c66d9677c52889756a369fae54356e51927bc04767fa9b0c250a3bf128bb24d07971ddef7aecb8a5c6936ac3d8a40259
data/README.md CHANGED
@@ -94,8 +94,9 @@ use Camada::Rack, engine
94
94
  A block answers `403 Forbidden` with `x-block-reason`, `x-block-version` and, when a rule
95
95
  decided, `x-block-rule`; its event ships with `blk` (and `rl`). A `warn` rule passes and stamps
96
96
  `wrn`; a `skip` rule passes with nothing stamped. Cold (no snapshot yet) passes: fail open.
97
- 4. Challenge: a `challenge` verdict gets the self-contained proof-of-work page (or 403 JSON for a
98
- non-HTML request); `POST /__camada/challenge` verifies the solution, sets `_cch` (bound to the
97
+ 4. Challenge: a `challenge` verdict gets the self-contained proof-of-work page on a page load. Any other
98
+ request passes to your app signalled: `x-camada-challenge: 1`, `cache-control: no-store` and the CDN cache headers (replacing
99
+ yours) and `chs: 1` on its event, which ships whatever `sample` and `exclude` say; `POST /__camada/challenge` verifies the solution, sets `_cch` (bound to the
99
100
  ip, one hour) and 302s back. A request whose ip cannot be resolved is never challenged. The page
100
101
  carries its own `Content-Security-Policy` (its inline solver and style by hash, nothing else)
101
102
  and replaces any policy your app set on it (Rails' `content_security_policy`,
@@ -161,7 +162,8 @@ event name is free-form; the analyst's app-context rules read this vocabulary:
161
162
 
162
163
  A route you gate yourself: `Camada.serve_challenge(env)` returns a Rack triple
163
164
  `[status, headers, [body]]` to answer with (Sinatra: `halt(*answer)`) until the browser holds a
164
- valid `_cch`, then `nil`.
165
+ valid `_cch`, then `nil`. A non-page (non-HTML) request also gets `nil` and is signalled
166
+ (`x-camada-challenge: 1`, `no-store`, `chs: 1`).
165
167
 
166
168
  ## What this tap can see
167
169
 
@@ -175,7 +177,11 @@ at this position covers ip, path, user-agent and header conditions — ASN, coun
175
177
  fail open in-app. `matches` patterns are JS regexes read by Ruby's Onigmo: named groups are
176
178
  native, `[^]` and `\cX` are translated, `^`/`$` become `\A`/`\z` (Ruby's are line anchors,
177
179
  JS's are not), `\d`/`\w`/`\b` are ASCII as in JS; a spelling Ruby still rejects never matches
178
- here (and never raises), while it does at the edge.
180
+ here (and never raises), while it does at the edge. An astral character (an emoji, say) is one
181
+ character here and two UTF-16 units at the edge, so a pattern that counts characters around one
182
+ (`^.$`, `.{2}`, `[^a]`, `\S`) or puts one in a class or before a quantifier can disagree; `.*`
183
+ and a bare emoji agree. A repeated capture group whose body can match empty (as in `((x?[ab]*)+?[ab]){2}`) can
184
+ match differently here than at the edge: that is an Onigmo engine difference.
179
185
 
180
186
  ## Deploying it
181
187
 
@@ -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.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"
7
+ BEACON_VERSION = "0.4.0"
8
+ BEACON_SHA256 = "59233fa412e6f2f1b09a851e3ccf8021c8dd550476004bf1e280bf1511d7bf7e"
9
+ BEACON_JS = "\"use strict\";(()=>{var k=\"0.4.0\";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
@@ -19,6 +19,9 @@ module Camada
19
19
  FP_MAX = 32 * 1024 # matches the server's /fp cap: never accept what ingest will 413
20
20
  CHALLENGE_PATH = "/__camada/challenge"
21
21
  BODY_MAX = 4 * 1024 # the verify form is ~120 bytes; anything larger is not ours
22
+ # Row keys only the SDK can attest (TLS/network facts, outcome, session, track()). A beacon body is the
23
+ # visitor's, so these never ride a sig:1 row; the analyst joins them from the beacon's request row.
24
+ REQUEST_ROW_KEYS = %w[tlsx ja4 asn cc sid ns st dur rt blk rl wrn chs ch et uid].freeze
22
25
 
23
26
  SESSION_COOKIE = "_sfp" # same cookie as the edge collector: sid/ns comparable across taps
24
27
  SESSION_MAX_AGE = 2_592_000 # 30 days
data/lib/camada/engine.rb CHANGED
@@ -83,6 +83,8 @@ module Camada
83
83
  # done. Everything runs inside the fail-open envelope: a camada bug must never 5xx the customer,
84
84
  # and CAMADA_DISABLED=1 bypasses the SDK entirely.
85
85
  class Engine
86
+ NO_STORE_HEADERS = %w[cache-control cdn-cache-control surrogate-control cloudflare-cdn-cache-control
87
+ vercel-cdn-cache-control netlify-cdn-cache-control].freeze
86
88
  CHALLENGE_HEADERS = { "cache-control" => "no-store", "x-camada-challenge" => "1" }.freeze
87
89
 
88
90
  attr_reader :env, :snap, :queue, :kit, :script_path, :fp_path, :challenge_path, :challenge_on
@@ -148,8 +150,9 @@ module Camada
148
150
  end
149
151
 
150
152
  # Serve the challenge for this request on demand — for a route the app wants to gate itself.
151
- # nil when the client already holds a valid _cch (render your own page), or when the client
152
- # cannot be identified (fail open).
153
+ # nil when the client already holds a valid _cch (render your own page), when the client
154
+ # cannot be identified (fail open), or when it is not a page load: that request is signalled
155
+ # instead (`x-camada-challenge: 1`, `chs: 1`) and the app renders.
153
156
  def serve_challenge(ctx)
154
157
  return nil if disabled? || @kit.nil? || ctx.nil?
155
158
 
@@ -157,6 +160,11 @@ module Camada
157
160
  ip = ctx["ip"]
158
161
  return nil if req.nil? || ip.nil? || ip.empty? || challenge_passed?(req, ip)
159
162
 
163
+ unless Challenge.wants_html?(req.header("accept"), req.header("sec-fetch-dest"))
164
+ ctx["signalled"] = true
165
+ return nil
166
+ end
167
+
160
168
  ctx["challenged"] = true
161
169
  serve_challenge_answer(req, ip, ctx["sid"])
162
170
  rescue StandardError => e
@@ -230,11 +238,18 @@ module Camada
230
238
  # A challenge needs a resolved client IP: the nonce and the _cch cookie are bound to it,
231
239
  # so without one a single solve would mint a cookie every unidentified client could
232
240
  # present. No ip -> no challenge (fail open), the same stance ip rules take.
241
+ signalled = false
233
242
  if @challenge_on && ip && !ip.empty?
234
243
  # The verify endpoint answers first: a challenged client must be able to reach it.
235
244
  return verify(req, body, ip) if req.method == "POST" && req.path == @challenge_path
245
+
236
246
  if v.challenge && !challenge_passed?(req, ip)
237
- return serve_challenge_answer(req, ip, Camada.cookie_value(req.header("cookie"), SESSION_COOKIE))
247
+ # Only a page load can be challenged; anything else passes, signalled (camada-all-7cgs).
248
+ if Challenge.wants_html?(req.header("accept"), req.header("sec-fetch-dest"))
249
+ return serve_challenge_answer(req, ip, Camada.cookie_value(req.header("cookie"), SESSION_COOKIE))
250
+ end
251
+
252
+ signalled = true
238
253
  end
239
254
  end
240
255
 
@@ -256,6 +271,7 @@ module Camada
256
271
  set_cookie += "; Secure" if secure?(req)
257
272
  end
258
273
  ctx = { "rid" => rid, "sid" => sid, "ip" => ip, "_req" => req, "_engine" => self }
274
+ ctx["signalled"] = true if signalled
259
275
 
260
276
  cfg = @snap.config || {}
261
277
  excluded = (cfg["exclude"] || []).any? { |x| req.path.start_with?(x.to_s) }
@@ -266,9 +282,12 @@ module Camada
266
282
  on_finish = lambda do |status|
267
283
  # serve_challenge may have answered from inside the app, and it already shipped the
268
284
  # `blk: "challenge"` row — one request, one event.
269
- next if ctx["challenged"] || excluded || !sampled
285
+ next if ctx["challenged"]
286
+ # a signalled row ships whatever sample and exclude say, like the 403 row it replaces
287
+ next if !ctx["signalled"] && (excluded || !sampled)
270
288
 
271
289
  ev = event(req, rid, sid, new_session, ip)
290
+ ev["chs"] = 1 if ctx["signalled"] # challenge signalled (contract §A3)
272
291
  ev["ts"] = ts0
273
292
  ev["st"] = status
274
293
  ev["dur"] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).to_i
@@ -299,7 +318,8 @@ module Camada
299
318
  parsed = Camada.parse_json(body)
300
319
  return answer unless parsed.is_a?(Hash)
301
320
 
302
- @queue.push(parsed.merge("sig" => 1, "ip" => ip, "tap" => TAP)) # spread first: ip and tap are the server's word
321
+ # spread first: ip, tap and rts (the relay clock) are the server's word
322
+ @queue.push(parsed.except(*REQUEST_ROW_KEYS).merge!("sig" => 1, "ip" => ip, "tap" => TAP, "rts" => now_ms))
303
323
  answer
304
324
  end
305
325
 
@@ -318,17 +338,10 @@ module Camada
318
338
  Answer.new(status: 403, headers: headers, body: html)
319
339
  end
320
340
 
321
- # 403 + the proof-of-work page (HTML navigations) or 403 JSON (everything else), plus the
322
- # `blk: "challenge"` event — a served challenge is reported like a block (contract §D2).
341
+ # 403 + the proof-of-work page, plus the `blk: "challenge"` event — a served challenge is
342
+ # reported like a block (contract §D2). Callers pass page loads only.
323
343
  def serve_challenge_answer(req, ip, sid)
324
- to = Challenge.safe_return_to(req.path + req.query)
325
- answer =
326
- if Challenge.wants_html?(req.header("accept"), req.header("sec-fetch-dest"))
327
- page(ip, to)
328
- else
329
- Answer.new(status: 403, headers: CHALLENGE_HEADERS.merge("content-type" => "application/json"),
330
- body: '{"error":"challenge_required"}')
331
- end
344
+ answer = page(ip, Challenge.safe_return_to(req.path + req.query))
332
345
  begin
333
346
  ev = event(req, SecureRandom.uuid, sid, false, ip)
334
347
  ev["st"] = 403
data/lib/camada/rack.rb CHANGED
@@ -168,6 +168,7 @@ module Camada
168
168
  raise
169
169
  end
170
170
  headers = stamp(status, headers, passed)
171
+ headers = signal(headers) if passed.ctx && passed.ctx["signalled"]
171
172
  headers = own_challenge_csp(headers) if passed.ctx && passed.ctx["challenged"]
172
173
  finish = passed.on_finish
173
174
  return [status, headers, body] if finish.nil?
@@ -197,6 +198,20 @@ module Camada
197
198
  headers
198
199
  end
199
200
 
201
+ # A request that passed under a challenge (handle() or serve_challenge() marked the ctx) carries
202
+ # `x-camada-challenge: 1`, and `no-store` in place of the app's cache-control and CDN cache headers: the signal
203
+ # is per client. CDNs read their own cache header ahead of cache-control, so all of these end up no-store.
204
+ def signal(headers)
205
+ headers = headers.to_h
206
+ headers.delete_if { |k, _| Engine::NO_STORE_HEADERS.include?(k.to_s.downcase) }
207
+ Engine::NO_STORE_HEADERS.each { |h| headers[h] = "no-store" }
208
+ headers["x-camada-challenge"] = "1"
209
+ headers
210
+ rescue StandardError => e
211
+ Guarded.log_rate_limited(e)
212
+ headers
213
+ end
214
+
200
215
  # A challenge page the app returned (serve_challenge) keeps its own CSP: a policy the app or its middleware put on
201
216
  # that response (Rails' content_security_policy, secure_headers) is dropped and the page's is set, or the solver
202
217
  # would be blocked (camada-all-y38o). Any other response comes back unchanged.
@@ -60,7 +60,12 @@ module Camada
60
60
  @snap = snap
61
61
  end
62
62
 
63
- def match(i)
63
+ # The whole decision (rules, allow, block, challenge) runs in one request regex budget.
64
+ def match(i) = Snapshot.with_regex_budget { decide(i) }
65
+
66
+ private
67
+
68
+ def decide(i)
64
69
  s = @snap
65
70
  ip = i.ip || ""
66
71
  n4 = -1
@@ -76,9 +81,8 @@ module Camada
76
81
  unless s.rules.empty?
77
82
  r = RuleRequest.new(n4: n4, ip6: w, asn: i.asn, country: i.country, tlsx: i.tlsx,
78
83
  paths: forms, ua: i.ua, header: i.header)
79
- s.rules.each do |rule| # the order IS the precedence (§A4): first match wins
80
- return Snapshot.rule_result(rule, s.version) if rule.conds.all? { |cond| cond.call(r) }
81
- end
84
+ hit = s.rules.find { |rule| rule.conds.all? { |cond| cond.call(r) } } # the order IS the precedence (§A4): first match wins
85
+ return Snapshot.rule_result(hit, s.version) if hit
82
86
  end
83
87
  reason = side(s.allow, i, n4, w, forms, false) # an exemption: every canonical spelling must agree
84
88
  return MatchResult.new(allowed: true, reason: reason, version: s.version) if reason
@@ -92,8 +96,6 @@ module Camada
92
96
  MatchResult.new(version: s.version)
93
97
  end
94
98
 
95
- private
96
-
97
99
  def blocked4?(n)
98
100
  s = @snap
99
101
  b = n >> 8
@@ -134,46 +134,419 @@ module Camada
134
134
  cmp_words(r, o, w) <= 0 && cmp_words(r, o + 4, w) >= 0 # rubocop:disable Style/ComparableBetween -- two different comparisons
135
135
  end
136
136
 
137
+ # regex-limit v1: the longest one customer regex may run, and what one request's rule walk may spend on all of them.
138
+ MATCH_BUDGET_MS = 50
139
+ REQUEST_REGEX_BUDGET_MS = 100
140
+ MATCH_TIMEOUT = Regexp.respond_to?(:timeout) ? MATCH_BUDGET_MS / 1000.0 : nil
141
+
142
+ # Runs the block with a fresh per-request regex budget (seconds left, in request-scoped state), reset in ensure.
143
+ def self.with_regex_budget
144
+ Thread.current[:camada_regex_budget] = REQUEST_REGEX_BUDGET_MS / 1000.0
145
+ yield
146
+ ensure
147
+ Thread.current[:camada_regex_budget] = nil
148
+ end
149
+
137
150
  # A pattern this runtime rejects never matches, and never throws (fail open). Patterns are
138
151
  # authored as JS regexes (the analyst validates them with `new RegExp`), so the JS spellings
139
152
  # Onigmo reads differently are translated first — see js_to_onigmo; \d \w \b are ASCII in
140
- # Ruby by default, as JS reads them.
153
+ # Ruby by default, as JS reads them. An astral char is one code point here and two UTF-16 units
154
+ # in JS, so `.`, `[^a]` and `\S` count it once (an accepted gap, camada-all-vuvo).
155
+ # A quantifier right after a raw astral char repeats the whole char here but only its low unit in JS,
156
+ # so js_to_onigmo answers as V8 does: the char when the count allows 1, else `(?!)` (camada-all-5lra, see astral_count).
157
+ # Where a lookbehind ends up holding `(?!)`, Onigmo cannot compile it, so compile_regex returns nil and the rule never matches.
158
+ # V8's zero-low-unit reading is not emulated.
159
+ # Onigmo also diverges from JS on a
160
+ # repeated capture group whose body can match empty, even with no backreference: `((x?[ab]*)+?[ab]){2}` on 'aa'
161
+ # matches in JS, not here (an accepted gap, camada-all-mx0s). Under IGNORECASE Onigmo also applies
162
+ # multi-char case folds JS /i (one char at a time) does not: `\A(?:ss)\z` matches "ß", `[ß]` and `[a\S]` match "SS", `ff` matches
163
+ # "ff", and an ASCII-only pattern does the same once Ruby recompiles it for a non-ASCII value. Extra matches only, so a rule hits a
164
+ # value JS would not and never misses one; splitting every literal run would slow string search (an accepted gap, camada-all-wdii).
165
+ # Inside a lookbehind js_to_onigmo splits adjacent literals (camada-all-ccjo) so it stays fixed-length, which stops ss folding to ß,
166
+ # but a lone ß or ff there still matches "SS" / "FF".
167
+ # A backreference JS and Onigmo read differently is rewritten or, when it cannot be, fails open (scan_groups).
168
+ # A `(?` or `(*` group JS lacks (`(?i)`, `(?>`, `(*FAIL)`, ...) fails open too: JS rejects it or V8 mis-evaluates it (camada-all-lsl8).
169
+ # Only a UTF-8 or ASCII pattern is walked: snapshot patterns arrive as UTF-8 JSON, and the binary walk
170
+ # misreads other encodings.
141
171
  def self.compile_regex(pattern, options = 0)
142
- Regexp.new(js_to_onigmo(pattern), options)
172
+ return nil unless pattern.is_a?(String) && (pattern.encoding == Encoding::UTF_8 || pattern.ascii_only?)
173
+
174
+ bin = pattern.b # the one binary copy both walks read (O(1) indexing; the syntax is ASCII)
175
+ scan = scan_groups(bin)
176
+ return nil if scan[1]
177
+
178
+ source = js_to_onigmo(bin, scan).force_encoding(pattern.encoding)
179
+ verbose = $VERBOSE
180
+ begin
181
+ $VERBOSE = nil # `[a\S]` expands to a class with a duplicated range; Onigmo warns under -w, matching is right
182
+ # Ruby >= 3.2: a per-regex match bound (regex-limit v1). Never the global Regexp.timeout, that is the host app's.
183
+ # Ruby 3.1 has no match-time bound and rejects the keyword, so it compiles without it.
184
+ MATCH_TIMEOUT ? Regexp.new(source, options, timeout: MATCH_TIMEOUT) : Regexp.new(source, options)
185
+ ensure
186
+ $VERBOSE = verbose
187
+ end
143
188
  rescue RegexpError, TypeError, ArgumentError, EncodingError
144
189
  nil
145
190
  end
146
191
 
147
- # The JS-only spellings a tenant is likely to author: `[^]` (any char) -> `[\s\S]`, `\cX` ->
192
+ # One escape/class-aware walk: [the capture groups of a JS pattern in number order (the name, or
193
+ # nil when unnamed), whether the pattern fails open, {reference position => [end position, :empty or :cond]}].
194
+ # JS reads a backreference to a group that has not captured as empty and clears captures each
195
+ # iteration; Onigmo fails it and keeps the last capture. A reference to a group not closed before
196
+ # it (forward or self) is always empty in JS: kind :empty, written `(?:)`. One to a group that may not have
197
+ # captured (it or an enclosing group is followed by a min-0 quantifier or sits in a negative lookaround, or
198
+ # it lies in an alternation branch: a `|` in an enclosing group or at top level) becomes the conditional
199
+ # `(?(N)\N)` (kind :cond; unset reads as empty), unless the group or an ancestor repeats (an empty last iteration
200
+ # makes Onigmo overwrite the capture, JS rejects that iteration and keeps it and the per-iteration reset
201
+ # diverges) or the group sits inside a positive lookahead (a lookahead keeps its first successful path, and JS tries the
202
+ # paths in another order and rejects an empty optional iteration): fail open. So does a plain reference (the group
203
+ # closed before it, always captured) to a group that sits in, or is, a repeated body that can match empty
204
+ # (`(b*)+x\1`): the walk tracks per frame whether its body must consume input (solid) or can match empty (nullable).
205
+ # A reference inside that repeat reads its own iteration's capture and stays compiled. So does a backward reference to a
206
+ # name two groups share (JS allows that in separate
207
+ # branches; Onigmo reads another group). A backreference inside a lookbehind, or to a group with a lookbehind
208
+ # ancestor, fails open too: JS matches a lookbehind right to left ("closed before the reference" is backwards
209
+ # there; a quantified group keeps its leftmost repetition). Beside named groups, a `\k<name>` naming no group
210
+ # is a JS SyntaxError: fail open (Onigmo aborts the process on it).
211
+ # So does a plain reference to a group in a positive lookahead that holds a quantified frame with optional
212
+ # iterations and a body that can match empty (JS rejects the empty iteration, so the lookahead's first success differs).
213
+ # Walks a binary copy (O(1) indexing; the syntax is ASCII), so the names come back binary.
214
+ # After the walk one forward pass over the frames (a parent precedes its children) settles, per
215
+ # frame, what its ancestors imply, so each reference costs O(1) however deeply its group nests.
216
+ def self.scan_groups(pattern)
217
+ pattern = binary(pattern)
218
+ return [[], false, {}] unless pattern.match?(/\\[1-9k]/) # no reference: nothing reads the names or can diverge
219
+
220
+ cur = { parent: nil }
221
+ frames = [cur] # every group, capturing or not, in `(` order
222
+ groups = [] # capture groups in number order
223
+ refs = [] # [position, number or name]
224
+ in_class = false
225
+ i = 0
226
+ while i < pattern.length
227
+ ch = pattern[i]
228
+ if ch == "\\"
229
+ if in_class
230
+ refs << [i, :bad, cur, i + 2] if pattern[i + 1] == "k" # `[\k]` is a SyntaxError beside named groups
231
+ elsif (m = pattern.match(/\G(?:k<([^<>]*)>|([1-9][0-9]*)|(k))/, i + 1))
232
+ refs << [i, m[3] ? :bad : m[1] || m[2].to_i, cur, m.end(0)]
233
+ i = m.end(0) - 2 # the reference is no atom: skip its name or digits (the loop's step and the one below)
234
+ elsif (m = ESC_ATOM.match(pattern, i))
235
+ cur[:solid] = true unless min_zero?(pattern, m.end(0))
236
+ i = m.end(0) - 2
237
+ end
238
+ i += 1
239
+ elsif in_class
240
+ if ch == "]"
241
+ in_class = false
242
+ cur[:solid] = true unless min_zero?(pattern, i + 1)
243
+ end
244
+ elsif ch == "["
245
+ in_class = true
246
+ elsif ch == "("
247
+ cur = { parent: cur, neg: pattern.match?(/\G\(\?<?!/, i), lb: pattern.match?(/\G\(\?<[=!]/, i), la: pattern.match?(/\G\(\?=/, i) }
248
+ frames << cur
249
+ if pattern[i + 1] != "?"
250
+ groups << cur
251
+ elsif (m = pattern.match(/\G\(\?<([^=!<>][^<>]*)>/, i))
252
+ groups << cur.merge!(name: m[1])
253
+ end
254
+ if (m = pattern.match(/\G\(\?(?:<[=!]|<[^<>]*>|[A-Za-z-]*:|[=!])/, i))
255
+ i = m.end(0) - 1 # the group's prefix is no atom
256
+ end
257
+ elsif ch == ")" && cur[:parent]
258
+ cur[:end] = i
259
+ b = pattern.match(JS_BRACE, i + 1)
260
+ cur[:zero] = min_zero?(pattern, i + 1)
261
+ cur[:nullable] ||= !cur[:solid] || cur[:lb] || cur[:la] || cur[:neg] # a lookaround consumes nothing
262
+ cur[:rep] = pattern.match?(/\G[+*]/, i + 1) || (b && (b[2] ? b[3].empty? || over_one?(b[3]) : over_one?(b[1]))) # repeats
263
+ cur[:holds] ||= (cur[:zero] || cur[:rep]) && cur[:nullable] # it or a frame inside it: an optional nullable repeat
264
+ cur[:parent][:holds] ||= cur[:holds]
265
+ closed = cur
266
+ cur = cur[:parent]
267
+ cur[:solid] = true unless closed[:nullable] || closed[:zero] # the branch now holds an atom that must consume
268
+ elsif ch == "|"
269
+ cur[:alt] = true
270
+ cur[:nullable] ||= !cur[:solid]
271
+ cur[:solid] = false
272
+ elsif ch == "{" && (b = pattern.match(JS_BRACE, i))
273
+ i = b.end(0) - 1 # a quantifier, not an atom
274
+ elsif ch.ord >= 0xC0 || (ch.ord < 0x80 && !"^$?*+".include?(ch))
275
+ e = i + 1
276
+ e += 1 while e < pattern.length && pattern.getbyte(e).between?(0x80, 0xBF)
277
+ cur[:solid] = true unless min_zero?(pattern, e)
278
+ i = e - 1
279
+ end
280
+ i += 1
281
+ end
282
+ return [[], false, {}] if refs.empty? # an escaped backslash or a class `[\1]` is no reference
283
+
284
+ names = groups.map { |g| g[:name] }
285
+ index = names.each_with_index.reverse_each.to_h # name => first group number - 1
286
+ named = names.any?
287
+ frames.drop(1).each do |f|
288
+ p = f[:parent]
289
+ f[:la_div] = (f[:la] && f[:holds]) || p[:la_div]
290
+ f[:in_lb] = f[:lb] || p[:in_lb]
291
+ f[:unset] = f[:zero] || f[:neg] || p[:alt] || p[:unset] # may not have captured when a reference runs
292
+ f[:rep_above] = p[:rep] || p[:rep_above] # a strict ancestor repeats
293
+ f[:in_la] = f[:la] || p[:in_la] # inside a positive lookahead
294
+ f[:rn] = f[:rep] && f[:nullable] ? f : p[:rn] # innermost repeating frame (or ancestor) whose body can match empty
295
+ end
296
+ dups = names.compact.tally.select { |_, c| c > 1 }
297
+ plan = {}
298
+ diverges = refs.any? do |pos, ref, home, stop|
299
+ next named if ref == :bad # a `\k` not followed by `<name>`: a SyntaxError beside named groups
300
+
301
+ k = if ref.is_a?(String)
302
+ index[ref]
303
+ else
304
+ ref <= groups.length ? ref - 1 : nil
305
+ end
306
+ next named && ref.is_a?(String) unless k # a name with no group beside named groups: a JS SyntaxError
307
+
308
+ g = groups[k]
309
+ next true if home[:in_lb] || g[:in_lb]
310
+
311
+ unless g[:end] && g[:end] < pos
312
+ plan[pos] = [stop, :empty] # never captured yet: empty, and a quantifier after it keeps its atom
313
+ next false
314
+ end
315
+ next true if g[:name] && dups[g[:name]] # JS allows a name in two branches; Onigmo reads another group
316
+ # a repeat that can match empty: Onigmo's last empty iteration overwrites the capture, JS keeps it
317
+ next true if (rn = g[:rn]) && rn[:end] < pos
318
+
319
+ next true if g[:la_div]
320
+ next false unless g[:unset]
321
+ next true if g[:rep_above] || g[:rep] || g[:in_la]
322
+
323
+ plan[pos] = [stop, :cond]
324
+ false
325
+ end
326
+ [names, diverges, plan]
327
+ end
328
+
329
+ # Whether the text at j starts a min-0 quantifier (`?`, `*`, `{0}`, `{0,}`, `{0,m}`).
330
+ def self.min_zero?(pattern, j)
331
+ c = pattern.getbyte(j)
332
+ return true if c == 0x3F || c == 0x2A # ? *
333
+ return false unless c == 0x7B # {
334
+
335
+ b = pattern.match(JS_BRACE, j)
336
+ !b.nil? && b[1].delete("0").empty?
337
+ end
338
+
339
+ JS_BRACE = /\G\{([0-9]+)(?:(,)([0-9]*))?\}/
340
+ JS_QUANT = /\G(?:([?*+])|#{JS_BRACE.source.delete_prefix('\\G')})\??/ # a JS quantifier with its lazy `?`
341
+ ONIGMO_REPEAT_MAX = 100_000 # Onigmo rejects a repeat number above this ("too big number for repeat range")
342
+ JS_WS = "\\t-\\r\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff" # JS \s (Ruby's is ASCII)
343
+ JS_SPACE = { "s" => "[#{JS_WS}]", "S" => "[^#{JS_WS}]" }.freeze
344
+ ANY_CHAR = '[\x00-\x7f]|[\xc0-\xff][\x80-\xbf]*|[\s\S]' # one char of a binary UTF-8 string, whole
345
+ JS_ATOM = /\G(?:\\(?:c[A-Za-z0-9_]|x\h{2}|u\h{4}|[0-3][0-7]{0,2}|[4-7][0-7]?|(?!c)(?:#{ANY_CHAR}))|#{ANY_CHAR})/n # one class atom
346
+ # One escape that must consume input (nil where it may match empty: \b \B, \c without a letter, a trailing \).
347
+ # An unsure escape is no match: calling it nullable only fails open, splitting an atom would not.
348
+ ESC_ATOM = /\G\\(?:c[A-Za-z]|x\h{2}|u\h{4}|0[0-7]{0,2}|(?![bBc])(?:#{ANY_CHAR}))/n
349
+ JS_DOT = "[^\\n\\r\\u2028\\u2029]" # JS . (Onigmo's excludes only \n)
350
+ NON_JS_GROUP = /\G\((?:\*|\?(?![:=!<]))/ # a `(?` or `(*` opening none of the JS forms `(?:` `(?=` `(?!` `(?<`
351
+ JS_OPENER = /\G\((?:\?(?:[:=!]|(<)[=!]))?/ # an unnamed group's opener; the capture is set for a lookbehind
352
+ JS_GROUP = /\G\(\?<(?![=!])([^<>]*)>/n # a named group's opener: a JS name holds no `<` or `>`
353
+ JS_NAME = /\A[\p{ID_Start}$_][\p{ID_Continue}$\u200c\u200d]*\z/ # a JS group name
354
+ JS_ESCAPE = /\G(?:[bdDfnrsStvwW]|x\h{2}|u\h{4})/ # the letter escapes JS reads without `u`
355
+
356
+ # The JS-only spellings a tenant is likely to author: `[^]` (any char) -> `(?m:.)`, `\cX` ->
148
357
  # the control character, and the anchors: JS `^`/`$` bind the whole input while Ruby's are
149
- # line anchors, so outside a class they become `\A`/`\z`. `(?<name>` is native to Onigmo.
150
- # Anything else Ruby rejects still fails open.
151
- def self.js_to_onigmo(pattern)
152
- out = +""
358
+ # line anchors, so outside a class they become `\A`/`\z`; a `{` that is no JS quantifier
359
+ # (`{,3}`, `{ 1}`) -> `\{` (JS reads it as text, Onigmo as a bound), and `x{n}?` -> `x{n}` (JS
360
+ # reads it as exact-n, Onigmo as optional). `\<letter>` outside JS's escape set is the bare
361
+ # letter, as JS reads it without `u` (`\p{L}` is "p{L}", `\A` is "A"). `.` -> JS_DOT, `[]` ->
362
+ # `(?!)` (never matches; a `]` right after `[`/`[^` closes the class, a stray `]` -> `\]`), and
363
+ # `\s`/`\S` -> the JS whitespace set; inside a class they nest as a class
364
+ # (`[a\s]` -> `[a[WS]]`: one atom, no ReDoS), a `-` beside one stays a literal dash, as in JS,
365
+ # and so does a `-` right after the atom that follows it.
366
+ # Under IGNORECASE Onigmo folds several chars at once (`ss` ~ ß, and a positive class holding
367
+ # `\S` gains such folds), which makes a lookbehind variable-length and uncompilable; JS folds one
368
+ # char at a time. So inside a lookbehind adjacent literal chars are split with `(?:)` (`ab` -> `a(?:)b`; a
369
+ # lone literal stays a plain string node, which Onigmo can still expand for a multi-char fold) and a positive
370
+ # class with `\S` (or `\W`/`\D`) becomes `(?:[^WS]|[rest])`, and a closed one-char class counts as a literal for
371
+ # that split (camada-all-ccjo, camada-all-fe1r).
372
+ # In a class `\c` is a control char only before a letter, digit or `_` (ClassControlLetter); before
373
+ # anything else (`[\c*]`) it is a literal `\` then `c` (camada-all-mqcb).
374
+ # A `{m,n}` bound above Onigmo's 100000 is clamped: m to 100000, and an n above it drops (`{m,}`,
375
+ # exact for any input shorter than n), where JS accepts it (camada-all-yh2t).
376
+ # A literal `[` or `&` in a class is escaped (no nesting, POSIX class or `&&`, as in JS).
377
+ # `(?<name>` and `\k<name>` are copied verbatim once the name fits JS's grammar (JS_NAME); a name
378
+ # used twice is kept only when the twins sit in different alternatives of one disjunction (JS
379
+ # throws otherwise), and then no backreference may exist (camada-all-7xyl). Anything else Ruby
380
+ # rejects still fails open.
381
+ # Raises RegexpError on a group JS lacks (see compile_regex).
382
+ # `scan` is the scan_groups result (default: scan the pattern). Walks a binary copy (O(1) indexing
383
+ # on a non-ASCII pattern) and returns the pattern's encoding.
384
+ def self.js_to_onigmo(pattern, scan = nil)
385
+ enc = pattern.encoding
386
+ pattern = binary(pattern)
387
+ out = String.new(encoding: Encoding::BINARY)
153
388
  i = 0
154
389
  n = pattern.length
155
390
  in_class = false
391
+ pull_end = solo_end = -1 # where the last in-class \\s/\\S ended; where the atom after its dash ended
392
+ group_names, _, plan = scan || scan_groups(pattern)
393
+ named = group_names.any?
394
+ opens = [] # the unclosed `(` indices outside a class
395
+ bars = [-1] # per open group, the root first: the index of the last `|` in it or an ancestor
396
+ lbs = [false] # per open group, the root first: inside a lookbehind (camada-all-ccjo)
397
+ lit_end = -1 # the pattern index right after the last literal emitted inside a lookbehind
398
+ class_start = nil # the out index of an open positive class's `[` inside a lookbehind
399
+ class_alts = [] # the `\S`/`\W`/`\D` that class held
400
+ last_named = {} # name -> the index of its last opener
156
401
  while i < n
157
402
  ch = pattern[i]
158
403
  if ch == "\\" && i + 1 < n
159
- if pattern[i + 1] == "c" && i + 2 < n && pattern[i + 2].match?(/[A-Za-z]/)
160
- out << Regexp.escape((pattern[i + 2].upcase.ord - 64).chr)
404
+ if !in_class && (r = plan[i])
405
+ if r[1] == :empty
406
+ out << "(?:)"
407
+ else
408
+ ref = pattern[(i + 1)...r[0]]
409
+ nm = ref.start_with?("k") ? ref[2..-2] : (named && group_names[ref.to_i - 1])
410
+ out << (nm ? "(?(<#{nm}>)\\k<#{nm}>)" : "(?(#{ref})\\#{ref})")
411
+ end
412
+ i = r[0]
413
+ next
414
+ end
415
+ l = pattern[i + 1]
416
+ if in_class && class_start && (l == "S" || l == "W" || l == "D")
417
+ class_alts << (l == "S" ? JS_SPACE["S"] : "\\#{l}") # spliced in at the `]`: a positive class holding one gains multi-char folds
418
+ pull_end = i + 2
419
+ i += 2
420
+ next
421
+ end
422
+ if l == "s" || l == "S"
423
+ out << JS_SPACE[l] # in a class too: Onigmo nests classes, one atom
424
+ pull_end = i + 2 if in_class
425
+ i += 2
426
+ next
427
+ end
428
+ if l == "c" && pattern[i + 2]&.match?(in_class ? /[A-Za-z0-9_]/ : /[A-Za-z]/) # JS Annex B: `[\c1]` too
429
+ out << Regexp.escape((pattern[i + 2].ord & 0x1F).chr)
161
430
  i += 3
162
431
  next
163
432
  end
164
- out << pattern[i, 2]
433
+ if named && !in_class && (m = pattern.match(/\G[1-9][0-9]*/, i + 1)) && (m[0].to_i <= group_names.length)
434
+ nm = group_names[m[0].to_i - 1] # Onigmo rejects a numbered backreference beside named groups
435
+ if nm
436
+ out << "\\k<#{nm}>"
437
+ i += 1 + m[0].length
438
+ next
439
+ end
440
+ end
441
+ if named && !in_class && l == "k" && (m = pattern.match(/\G\\k<[^<>]*>/n, i))
442
+ out << m[0] # a `$` in the name is no anchor; scan_groups failed open on a name no group has
443
+ i += m[0].length
444
+ next
445
+ end
446
+ if !in_class && l.ord >= 0xF0 && pattern.match?(JS_QUANT, i + 5) # `\😀?` reads as `😀?` (camada-all-5lra)
447
+ i += 1
448
+ next
449
+ end
450
+ kept = !l.match?(/[A-Za-z]/) || pattern.match?(JS_ESCAPE, i + 1) || (l == "B" && !in_class) ||
451
+ (l == "k" && named)
452
+ l = "\\\\c" if l == "c" # JS reads `\c` before a non-letter as text
453
+ if kept
454
+ e = (pattern.match(/\G\\\d+/n, i) || pattern.match(JS_ATOM, i))[0] # the whole escape: a lookbehind must not split `\x41`
455
+ out << e
456
+ i += e.length
457
+ next
458
+ end
459
+ if !in_class && lbs.last # a bare letter is a literal (2 long): split it from a literal just before
460
+ out << "(?:)" if lit_end == i
461
+ lit_end = i + 2
462
+ end
463
+ out << l
165
464
  i += 2
166
465
  next
167
466
  end
168
467
  if in_class
169
- in_class = ch != "]"
468
+ if ch == "]"
469
+ in_class = false
470
+ if class_alts.any?
471
+ rest = out.slice!(class_start..)[1..]
472
+ rest = "\\#{rest}" if rest.start_with?("^") # `[^..]` would negate the spliced class
473
+ alts = class_alts.join("|")
474
+ alts = "(?:#{alts}" + (rest.empty? ? ")" : "|[#{rest}])") if class_alts.length > 1 || !rest.empty?
475
+ out << alts
476
+ class_alts = []
477
+ else
478
+ out << "]"
479
+ end
480
+ lit_end = i + 1 if class_start # Onigmo merges a one-char class into the literals beside it
481
+ class_start = nil
482
+ i += 1
483
+ next
484
+ end
485
+ if ch == "-" && (pull_end == i || solo_end == i || pattern.match?(class_start ? /\G-\\[sSWD]/ : /\G-\\[sS]/, i))
486
+ if pull_end == i # JS reads the atom after that dash as standalone: a dash right after it is text too
487
+ m = pattern.match(JS_ATOM, i + 1) # nil when the pattern ends on the dash
488
+ solo_end = m ? i + 1 + m[0].length : pattern.length
489
+ end
490
+ ch = "\\-"
491
+ end
492
+ ch = "\\#{ch}" if ch == "[" || ch == "&" # JS: `[[:alpha:]]` is {[ : a l p h} then `]`
493
+ elsif ch == "(" && pattern.match?(NON_JS_GROUP, i)
494
+ raise RegexpError, "non-JS group" # compile_regex fails open
495
+ elsif ch == "(" && (m = pattern.match(JS_GROUP, i))
496
+ nm = m[1].dup.force_encoding(Encoding::UTF_8)
497
+ raise RegexpError, "invalid group name" unless nm.valid_encoding? && nm.match?(JS_NAME)
498
+
499
+ # the twin is exclusive when a group open since before it has seen a `|` since (transitive:
500
+ # checking the last twin is enough)
501
+ if (prev = last_named[nm]) && (named || bars[opens.bsearch_index { |o| o >= prev } || opens.length] < prev)
502
+ raise RegexpError, "duplicate group name"
503
+ end
504
+
505
+ last_named[nm] = i
506
+ opens << i
507
+ bars << bars.last
508
+ lbs << lbs.last
509
+ out << m[0]
510
+ i += m[0].length
511
+ next
512
+ elsif ch == "("
513
+ m = pattern.match(JS_OPENER, i)
514
+ opens << i
515
+ bars << bars.last
516
+ lbs << (lbs.last || !m[1].nil?)
517
+ out << m[0]
518
+ i += m[0].length
519
+ next
520
+ elsif ch == "|"
521
+ bars[-1] = i
522
+ elsif ch == ")" && opens.any?
523
+ opens.pop
524
+ bars.pop
525
+ lbs.pop
526
+ elsif ch == "."
527
+ out << JS_DOT
528
+ i += 1
529
+ next
530
+ elsif ch == "]"
531
+ out << "\\]"
532
+ i += 1
533
+ next
170
534
  elsif ch == "["
171
- if pattern[i, 3] == "[^]"
172
- out << "[\\s\\S]"
173
- i += 3
535
+ neg = pattern[i + 1] == "^"
536
+ j = i + (neg ? 2 : 1)
537
+ if pattern[j] == "]"
538
+ out << (neg ? "(?m:.)" : "(?!)")
539
+ i = j + 1
174
540
  next
175
541
  end
542
+ if !neg && lbs.last
543
+ out << "(?:)" if lit_end == i
544
+ class_start = out.length
545
+ end
546
+ out << pattern[i...j]
547
+ i = j
176
548
  in_class = true
549
+ next
177
550
  elsif ch == "^"
178
551
  out << "\\A"
179
552
  i += 1
@@ -182,23 +555,100 @@ module Camada
182
555
  out << "\\z"
183
556
  i += 1
184
557
  next
558
+ elsif ch == "{"
559
+ m = pattern.match(JS_BRACE, i)
560
+ out << (m ? brace_quantifier(m) : "\\{")
561
+ i += m ? m[0].length : 1
562
+ i += 1 if m && !m[0].include?(",") && pattern[i] == "?"
563
+ next
564
+ end
565
+ if !in_class && ch.ord >= 0xF0 && (q = pattern.match(JS_QUANT, i + 4)) && !(count = astral_count(q)).nil?
566
+ if count && lbs.last # the char alone is a literal
567
+ out << "(?:)" if lit_end == i
568
+ lit_end = q.end(0)
569
+ end
570
+ # `(?!)` inside a lookbehind is not compilable by Onigmo: compile_regex returns nil and the rule never matches
571
+ out << (count ? pattern[i, 4] : "(?!)")
572
+ i = q.end(0)
573
+ next
574
+ end
575
+ # a literal in a lookbehind directly after another gets `(?:)` (never inside a UTF-8 char): Onigmo folds `ss` to ß under /i
576
+ if !in_class && lbs.last && !"()|*+?".include?(ch)
577
+ if ch.ord.between?(0x80, 0xBF)
578
+ lit_end = i + 1 if lit_end == i
579
+ else
580
+ out << "(?:)" if lit_end == i
581
+ lit_end = i + 1
582
+ end
185
583
  end
186
584
  out << ch
187
585
  i += 1
188
586
  end
189
- out
587
+ out.force_encoding(enc)
588
+ end
589
+
590
+ # V8 repeats only the low unit of an astral char: true when the quantifier allows one, false when not, nil when m > n (left as written).
591
+ # V8's zero-low-unit reading (the high unit alone: `x😀{0}` on "x😀", a trailing `x😀?` on "x😁") is not emulated: that branch
592
+ # never matches, as the whole pattern never matches in the linear engines (camada-all-k938).
593
+ def self.astral_count(match)
594
+ sym, lo, comma, hi = match.captures
595
+ lo, hi = { "?" => %w[0 1], "*" => ["0", nil], "+" => ["1", nil] }[sym] || [lo, comma ? hi : lo]
596
+ lo, hi = canonical_bounds(lo, hi)
597
+ return nil unless lo # m > n
598
+
599
+ !over_one?(lo) && hi != "0"
600
+ end
601
+
602
+ # [lo, hi] without leading zeros (hi "" is no upper bound), or nil when m > n: a JS SyntaxError, left as written
603
+ def self.canonical_bounds(lo, hi)
604
+ lo, hi = [lo, hi.to_s].map { |d| d.sub(/\A0+(?=.)/, "") }
605
+ [lo, hi] unless hi != "" && ([lo.length, lo] <=> [hi.length, hi]).positive?
606
+ end
607
+
608
+ def self.over_one?(digits)
609
+ digits = digits.sub(/\A0+/, "") # a digit-string test: to_i on a huge one is slow
610
+ digits.length > 1 || digits > "1"
611
+ end
612
+
613
+ def self.binary(str)
614
+ str.encoding == Encoding::BINARY ? str : str.b
615
+ end
616
+
617
+ def self.brace_quantifier(match)
618
+ lo, hi = canonical_bounds(match[1], match[3])
619
+ return match[0] unless lo # m > n is a JS SyntaxError: leave it for Onigmo to reject
620
+
621
+ lo = ONIGMO_REPEAT_MAX.to_s if over_repeat_max?(lo)
622
+ hi = "" if over_repeat_max?(hi) # an upper bound past it never binds a real input
623
+ "{#{lo}#{match[2]}#{hi}}"
624
+ end
625
+
626
+ def self.over_repeat_max?(digits)
627
+ digits = digits.to_s # nil: `{m}` has no upper bound
628
+ digits.length > 6 || digits.to_i > ONIGMO_REPEAT_MAX # length first: to_i on a huge digit string is slow
190
629
  end
191
630
 
192
631
  # A regex test that never raises: a value in an encoding the pattern cannot read (a binary
193
632
  # PATH_INFO with high bytes, say) reads as "no match" rather than an exception on the request path.
194
633
  #
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
634
+ # `on_error` is what a value the engine cannot read answers. A caller with no side passes false;
635
+ # a `matches` condition (a custom rule or a meta `pathsRegex` entry) passes `deny` (regex-cap-sdk v1: an unevaluable value is a
197
636
  # hit for block/challenge/warn and a miss for skip, the same as an over-cap one, camada-all-cwb4).
637
+ #
638
+ # Inside a match decision the request's regex budget is spent first: once it is gone the regex does not
639
+ # run and answers `on_error`, like an over-limit run (Regexp::TimeoutError is a StandardError).
198
640
  def self.regex_hit?(rx, value, on_error = false)
199
- rx.match?(utf8_view(value))
200
- rescue StandardError
201
- on_error
641
+ left = Thread.current[:camada_regex_budget]
642
+ return on_error if left && left <= 0
643
+
644
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) if left
645
+ begin
646
+ rx.match?(utf8_view(value))
647
+ rescue StandardError
648
+ on_error
649
+ ensure
650
+ Thread.current[:camada_regex_budget] = left - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) if left
651
+ end
202
652
  end
203
653
 
204
654
  # A value tagged ASCII-8BIT (Rack) whose bytes are valid UTF-8 is read as UTF-8, so a non-ASCII
@@ -339,17 +789,23 @@ module Camada
339
789
  false
340
790
  end
341
791
 
342
- # One path form against a side's (or the block side's) exact, prefix and regex entries.
792
+ # One path form against a side's (or the block side's) exact, prefix and regex predicates.
343
793
  def self.path_in?(st, path, regexes = [])
344
794
  return true if st.paths_exact.include?(path)
345
795
  return true if !st.paths_prefix.empty? && prefix_hit?(st.paths_prefix, path)
346
796
 
347
- regexes.any? { |rx| regex_hit?(rx, path) }
797
+ regexes.any? { |pred| pred.call(path) }
348
798
  end
349
799
 
350
800
  # Does the block hold for this request's path? deny = any spelling; an exemption = both canonical forms.
351
801
  def self.path_hit?(forms, deny, &pred)
352
- deny ? (pred.call(forms[0]) || pred.call(forms[1]) || pred.call(forms[2])) : (pred.call(forms[1]) && pred.call(forms[2]))
802
+ if deny
803
+ # a form equal to one already tried is not matched again, so a repeat never spends the request's regex budget twice
804
+ return pred.call(forms[0]) ||
805
+ (forms[1] != forms[0] && pred.call(forms[1])) ||
806
+ (forms[2] != forms[0] && forms[2] != forms[1] && pred.call(forms[2]))
807
+ end
808
+ pred.call(forms[1]) && (forms[2] == forms[1] || pred.call(forms[2]))
353
809
  end
354
810
 
355
811
  # One path condition -> a predicate over a single path form. A `matches` form over MAX_MATCH_INPUT
@@ -389,6 +845,10 @@ module Camada
389
845
  def self.compile_cond(c, sets, deny = true)
390
846
  f = c["f"].to_s
391
847
  op = c["op"].to_s
848
+ # camada-all-zpbv: a stored `matches` value that is "" or not a String never matches (no to_s
849
+ # coercion into a regex; "" would match everything). First, so it also beats the over-cap fail-closed.
850
+ return ->(_r) { false } if op == "matches" && (!c["v"].is_a?(String) || c["v"].empty?)
851
+
392
852
  if f == "path" # every path op reads the canonical forms (see "paths" above)
393
853
  raw = c["v"]
394
854
  pred = path_pred(op, raw.is_a?(Array) ? raw.map(&:to_s) : [raw.to_s], deny)
@@ -531,7 +991,7 @@ module Camada
531
991
  tls: (meta["tls"] || []).to_set,
532
992
  paths_exact: (meta["pathsExact"] || []).to_set { |p| canon_path(p.to_s) },
533
993
  paths_prefix: (meta["pathsPrefix"] || []).to_set { |p| dir_key(p.to_s) },
534
- paths_regex: (meta["pathsRegex"] || []).filter_map { |p| compile_regex(p, Regexp::IGNORECASE) },
994
+ paths_regex: (meta["pathsRegex"] || []).map { |p| path_pred("matches", [p], true) },
535
995
  allow: range_set(sec.fetch(10, EMPTY), sec.fetch(11, EMPTY), meta["allow"]),
536
996
  challenge: range_set(sec.fetch(12, EMPTY), sec.fetch(13, EMPTY), meta["challenge"]),
537
997
  rules: compile_rules(meta, rule4, rule6)
@@ -4,7 +4,8 @@ module Camada
4
4
  # The SDK's wire identity: x-camada-sdk: @camada/ruby/<version>. This literal is the single
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
- # a package.json. Plain X.Y.Z only: the analyst's SDK_RE drops anything else.
8
- VERSION = "0.1.5"
7
+ # a package.json. Plain X.Y.Z only, and @camada/ruby must stay in the analyst's SDK_NAMES:
8
+ # its sdkId drops anything else.
9
+ VERSION = "0.2.0"
9
10
  SDK_ID = "@camada/ruby/#{VERSION}".freeze
10
11
  end
data/lib/camada.rb CHANGED
@@ -83,7 +83,8 @@ module Camada
83
83
  end
84
84
 
85
85
  # A Rack triple [status, headers, [body]] to answer with (Sinatra: `halt(*answer)`) until the
86
- # browser holds a valid _cch, then nil.
86
+ # browser holds a valid _cch, then nil. A non-page (non-HTML) request also gets nil and is
87
+ # signalled (x-camada-challenge: 1, no-store, chs: 1).
87
88
  def serve_challenge(env)
88
89
  ctx = ctx_of(env)
89
90
  engine_for(ctx).serve_challenge(ctx)&.to_rack
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.5
4
+ version: 0.2.0
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-19 00:00:00.000000000 Z
11
+ date: 2026-10-26 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,