@deployowl/guard 2.2.2
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.
- package/README.md +77 -0
- package/adapters/cloudflare-kv.js +57 -0
- package/adapters/redis.js +61 -0
- package/dist/index.js +100 -0
- package/package.json +47 -0
- package/types/adapters.d.ts +64 -0
- package/types/index.d.ts +233 -0
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# @deployowl/guard
|
|
2
|
+
|
|
3
|
+
Enterprise-grade, platform-agnostic bot detection and security middleware.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npm install @deployowl/guard
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { OwlGuard } from "@deployowl/guard";
|
|
15
|
+
|
|
16
|
+
const owlguard = new OwlGuard({
|
|
17
|
+
apiKey: "owl_live_xxx", // Optional: enables enrichment signals
|
|
18
|
+
blockScore: 80, // Bot score threshold for 403 block
|
|
19
|
+
challengeScore: 50, // Bot score threshold for JS challenge
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const result = await owlguard.decision(request);
|
|
23
|
+
|
|
24
|
+
if (result.blocked) {
|
|
25
|
+
return new Response(result.reason, { status: result.status });
|
|
26
|
+
}
|
|
27
|
+
// Continue to your handler
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Configuration Options
|
|
31
|
+
|
|
32
|
+
| Option | Type | Default | Description |
|
|
33
|
+
|---|---|---|---|
|
|
34
|
+
| `apiKey` | `string` | — | OwlGuard install key. Enables enrichment signals. |
|
|
35
|
+
| `blockScore` | `number` | `80` | Bot score threshold for hard 403 block. |
|
|
36
|
+
| `challengeScore` | `number` | `50` | Bot score threshold for JS proof-of-work challenge. |
|
|
37
|
+
| `rateLimit` | `object` | `{windowMs: 60000, max: 120}` | Per-IP rate limiting. |
|
|
38
|
+
| `bypassPaths` | `string[]` | `[]` | Path prefixes that skip all checks. |
|
|
39
|
+
| `allowlist` | `object` | — | IP/apiKey allowlist. |
|
|
40
|
+
| `denylist` | `object` | — | IP/ASN denylist. |
|
|
41
|
+
| `geo` | `object` | — | Country-level geo fencing. |
|
|
42
|
+
| `validation` | `object` | — | Body size, methods, content-types. |
|
|
43
|
+
| `challenge` | `object` | `{difficulty: "000"}` | SHA-256 proof-of-work config. |
|
|
44
|
+
| `log` | `string` | `"warn"` | Verbosity: `none`/`block`/`warn`/`info`. |
|
|
45
|
+
| `reporting` | `boolean` | `false` | **Opt-in**: send blocked-decision events to reporting worker. |
|
|
46
|
+
| `reportingSecret` | `string` | — | **Required when `reporting: true`**. Obtain from dashboard. |
|
|
47
|
+
|
|
48
|
+
## What Data Is Sent
|
|
49
|
+
|
|
50
|
+
### Enrichment — to `enrich.deployowl.com/enrich` (when `apiKey` is set)
|
|
51
|
+
|
|
52
|
+
- End-user IP address (for threat scoring)
|
|
53
|
+
- Request headers (when running inside an edge worker)
|
|
54
|
+
- **Authentication:** apiKey only
|
|
55
|
+
- **Purpose:** Obtain threatScore, country, ASN, Tor/VPN flags
|
|
56
|
+
|
|
57
|
+
### Decision Reporting — to `security.deployowl.com` (only when `reporting: true`)
|
|
58
|
+
|
|
59
|
+
- IP, country, pathname, method, block reason, score
|
|
60
|
+
- **Authentication:** `reportingSecret` (config-provided)
|
|
61
|
+
- **Purpose:** Analytics, billing, dashboard visibility
|
|
62
|
+
|
|
63
|
+
### Remote Config — to `api.deployowl.com/api/v1/config` (when `apiKey` is set)
|
|
64
|
+
|
|
65
|
+
- API key sent via `X-API-Key` header only (not URL)
|
|
66
|
+
- **Purpose:** Fetch WAF rules, rate limits, custom routes
|
|
67
|
+
|
|
68
|
+
## Privacy & Security
|
|
69
|
+
|
|
70
|
+
- **No environment variables collected.**
|
|
71
|
+
- **No dependency manifests collected.**
|
|
72
|
+
- Reporting is **opt-in** and requires a user-provided secret.
|
|
73
|
+
- API keys are sent via headers, never URL query strings.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
Proprietary © DeployOwl. All rights reserved.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* owlguard — adapters/cloudflare-kv.js
|
|
3
|
+
* Pre-built storage adapter for Cloudflare KV namespaces.
|
|
4
|
+
*
|
|
5
|
+
* Usage (Cloudflare Worker):
|
|
6
|
+
* import { CloudflareKVAdapter } from "owlguard/adapters/cloudflare-kv";
|
|
7
|
+
*
|
|
8
|
+
* // In your wrangler.toml, bind a KV namespace:
|
|
9
|
+
* // [[kv_namespaces]]
|
|
10
|
+
* // binding = "OWL_RATE_LIMIT"
|
|
11
|
+
* // id = "your-kv-namespace-id"
|
|
12
|
+
*
|
|
13
|
+
* const owl = new OwlGuard({
|
|
14
|
+
* storage: new CloudflareKVAdapter(env.OWL_RATE_LIMIT),
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* This gives you globally-consistent rate limiting across all Cloudflare
|
|
18
|
+
* Worker isolates — every edge location shares the same KV counter.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export class CloudflareKVAdapter {
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} namespace — A Cloudflare KV namespace binding.
|
|
24
|
+
*/
|
|
25
|
+
constructor(namespace) {
|
|
26
|
+
this._kv = namespace;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} key
|
|
31
|
+
* @returns {Promise<{ ts: number, count: number } | null>}
|
|
32
|
+
*/
|
|
33
|
+
async get(key) {
|
|
34
|
+
try {
|
|
35
|
+
const value = await this._kv.get(key, { type: "json" });
|
|
36
|
+
return value ?? null;
|
|
37
|
+
} catch (_) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} key
|
|
44
|
+
* @param {{ ts: number, count: number }} value
|
|
45
|
+
* @param {number} ttlSeconds
|
|
46
|
+
* @returns {Promise<void>}
|
|
47
|
+
*/
|
|
48
|
+
async put(key, value, ttlSeconds) {
|
|
49
|
+
try {
|
|
50
|
+
await this._kv.put(key, JSON.stringify(value), {
|
|
51
|
+
expirationTtl: Math.ceil(ttlSeconds),
|
|
52
|
+
});
|
|
53
|
+
} catch (_) {
|
|
54
|
+
// Write failure — degrade gracefully
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* owlguard — adapters/redis.js
|
|
3
|
+
* Pre-built storage adapter for Redis.
|
|
4
|
+
*
|
|
5
|
+
* Compatible with both:
|
|
6
|
+
* - node-redis (v4+): client.setEx(key, ttl, value)
|
|
7
|
+
* - ioredis: client.setex(key, ttl, value) [lowercase]
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* import { createClient } from "redis";
|
|
11
|
+
* import { RedisAdapter } from "owlguard/adapters/redis";
|
|
12
|
+
*
|
|
13
|
+
* const redis = createClient({ url: process.env.REDIS_URL });
|
|
14
|
+
* await redis.connect();
|
|
15
|
+
*
|
|
16
|
+
* const owl = new OwlGuard({
|
|
17
|
+
* storage: new RedisAdapter(redis),
|
|
18
|
+
* });
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export class RedisAdapter {
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} client — A redis or ioredis client instance.
|
|
24
|
+
*/
|
|
25
|
+
constructor(client) {
|
|
26
|
+
this._client = client;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} key
|
|
31
|
+
* @returns {Promise<{ ts: number, count: number } | null>}
|
|
32
|
+
*/
|
|
33
|
+
async get(key) {
|
|
34
|
+
try {
|
|
35
|
+
const raw = await this._client.get(key);
|
|
36
|
+
return raw ? JSON.parse(raw) : null;
|
|
37
|
+
} catch (_) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} key
|
|
44
|
+
* @param {{ ts: number, count: number }} value
|
|
45
|
+
* @param {number} ttlSeconds
|
|
46
|
+
* @returns {Promise<void>}
|
|
47
|
+
*/
|
|
48
|
+
async put(key, value, ttlSeconds) {
|
|
49
|
+
try {
|
|
50
|
+
const serialized = JSON.stringify(value);
|
|
51
|
+
// Support both node-redis (setEx) and ioredis (setex)
|
|
52
|
+
if (typeof this._client.setEx === "function") {
|
|
53
|
+
await this._client.setEx(key, ttlSeconds, serialized);
|
|
54
|
+
} else {
|
|
55
|
+
await this._client.setex(key, ttlSeconds, serialized);
|
|
56
|
+
}
|
|
57
|
+
} catch (_) {
|
|
58
|
+
// Write failure — degrade gracefully, rate limiting continues in-memory
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
var Z=Object.defineProperty;var q=(e,t)=>()=>(e&&(t=e(e=0)),t);var ee=(e,t)=>{for(var n in t)Z(e,n,{get:t[n],enumerable:!0})};var X={};ee(X,{computeSignature:()=>N,verifyHandshake:()=>ue});async function N(e,t){let n=new TextEncoder,o=n.encode(t),a=n.encode(e),s=await crypto.subtle.importKey("raw",o,{name:"HMAC",hash:"SHA-256"},!1,["sign"]),r=await crypto.subtle.sign("HMAC",s,a);return Array.from(new Uint8Array(r)).map(l=>l.toString(16).padStart(2,"0")).join("")}async function ue(e,t){let n=e["x-owlguard-signature"],o=e["x-owlguard-timestamp"];if(!n||!o)return!1;let a=parseInt(o,10);if(isNaN(a)||Math.abs(Date.now()-a)>5*60*1e3)return!1;try{let r=await N(o,t);return n===r}catch{return!1}}var U=q(()=>{});function M(e){if(e&&typeof e.ip=="string"&&typeof e.method=="string")return{ip:e.ip??"unknown",country:e.country??null,asn:e.asn??null,userAgent:e.userAgent??"",method:e.method??"GET",pathname:e.pathname??"/",headers:A(e.headers??{}),bodySize:e.bodySize??0,cookie:e.cookie??""};if(e&&typeof e.cf<"u"){let t=new URL(e.url),n=Object.fromEntries(e.headers);return{ip:e.headers.get("CF-Connecting-IP")??"unknown",country:e.cf?.country??null,asn:e.cf?.asn??null,userAgent:e.headers.get("user-agent")??"",method:e.method,pathname:t.pathname,headers:A(n),bodySize:Number(e.headers.get("content-length")??0),cookie:e.headers.get("cookie")??"",cfRaw:{"cf-ipcountry":e.cf?.country??null,"cf-threat-score":e.cf?.threatScore!=null?String(e.cf.threatScore):null,"cf-asn":e.cf?.asn!=null?String(e.cf.asn):null,"cf-bot-score":e.cf?.botManagement?.score!=null?String(e.cf.botManagement.score):null}}}if(e&&typeof e.headers?.get=="function"){let t=new URL(e.url),n=Object.fromEntries(e.headers);return{ip:e.headers.get("x-forwarded-for")?.split(",")[0]?.trim()??e.headers.get("x-real-ip")??"unknown",country:e.headers.get("cf-ipcountry")??null,asn:null,userAgent:e.headers.get("user-agent")??"",method:e.method,pathname:t.pathname,headers:A(n),bodySize:Number(e.headers.get("content-length")??0),cookie:e.headers.get("cookie")??"",cfRaw:null}}if(e&&e.socket!==void 0){let t=e.headers.host??"localhost",n=new URL(e.url??"/",`http://${t}`);return{ip:e.headers["x-forwarded-for"]?.split(",")[0]?.trim()??e.socket?.remoteAddress??"unknown",country:e.headers["cf-ipcountry"]??null,asn:null,userAgent:e.headers["user-agent"]??"",method:e.method??"GET",pathname:n.pathname,headers:A(e.headers),bodySize:Number(e.headers["content-length"]??0),cookie:e.headers.cookie??"",cfRaw:null}}return console.warn("[OwlGuard] Could not detect request platform. Pass a Cloudflare Request, Node IncomingMessage, or a normalized context object."),{ip:"unknown",country:null,asn:null,userAgent:"",method:"GET",pathname:"/",headers:{},bodySize:0,cookie:"",cfRaw:null}}function A(e){let t={};for(let[n,o]of Object.entries(e))t[n.toLowerCase()]=o;return t}var v=class{constructor(){this._store=new Map}async get(t){return this._store.get(t)??null}async put(t,n,o){this._store.set(t,n),this._prune(o*1e3)}_prune(t){let n=Date.now()-t;for(let[o,a]of this._store.entries())a.ts<n&&this._store.delete(o)}};var k={none:0,block:1,warn:2,info:3},C={block:"\u{1F6AB} [OwlGuard] BLOCKED",challenge:"\u{1F989} [OwlGuard] CHALLENGED",allow:"\u2705 [OwlGuard] ALLOWED",skip:"\u23ED\uFE0F [OwlGuard] SKIPPED"},T=class{constructor(t="warn"){this.level=k[t]??k.warn}block(t,n,o){this.level<k.block||console.warn(`${C.block} | reason=${t} ip=${n.ip} method=${n.method} path=${n.pathname} score=${o}`)}challenge(t,n,o){this.level<k.warn||console.warn(`${C.challenge} | reason=${t} ip=${n.ip} method=${n.method} path=${n.pathname} score=${o}`)}allow(t,n,o){this.level<k.info||console.info(`${C.allow} | reason=${t} ip=${n.ip} method=${n.method} path=${n.pathname} score=${o}`)}skip(t,n){this.level<k.info||console.info(`${C.skip} | reason=${t} ip=${n.ip} path=${n.pathname}`)}};async function j(e,t,n={}){let o=n.windowMs??6e4,a=n.max??120,s=`owlguard:rl:${e}`,r=Date.now(),i=Math.ceil(o/1e3),l=null;try{l=await t.get(s)}catch{return!1}let d=1,c=r;l&&r-l.ts<o&&(d=l.count+1,c=l.ts);try{await t.put(s,{ts:c,count:d},i)}catch{return!1}return d>a}function F(e){return e.split(".").reduce((t,n)=>(t<<8)+parseInt(n,10),0)>>>0}function te(e,t){if(!t.includes("/"))return e===t;let[n,o]=t.split("/"),a=parseInt(o,10);if(isNaN(a)||a<0||a>32)return!1;let s=a===0?0:-1<<32-a>>>0,r=F(e),i=F(n);return(r&s)===(i&s)}function x(e){if(e=e.split("%")[0],e.includes(".")){let r=e.lastIndexOf(":"),i=e.slice(r+1),l=e.slice(0,r),c=F(i).toString(16).padStart(8,"0");e=l+":"+c.slice(0,4)+":"+c.slice(4)}let t=e.split("::");if(t.length>2)return null;let n=t[0]?t[0].split(":"):[],o=t[1]?t[1].split(":"):[],a=8-n.length-o.length;if(a<0)return null;let s=[...n,...Array(a).fill("0"),...o];return s.length!==8?null:s.map(r=>r.padStart(4,"0")).join("")}function ne(e,t){if(!t.includes("/")){let c=x(e),u=x(t);return c!==null&&u!==null&&c===u}let[n,o]=t.split("/"),a=parseInt(o,10);if(isNaN(a)||a<0||a>128)return!1;let s=x(e),r=x(n);if(!s||!r)return!1;let i=BigInt("0x"+s),l=BigInt("0x"+r),d=a===0?0n:~0n<<BigInt(128-a)&0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFn;return(i&d)===(l&d)}function oe(e,t){return!e||!t?!1:e.includes(":")?ne(e,t):te(e,t)}function E(e,t){return!e||!Array.isArray(t)||t.length===0?!1:t.some(n=>oe(e,n))}function B(e,t={}){if(!t||Object.keys(t).length===0)return{allowed:!1,reason:""};if(t.ips?.length&&E(e.ip,t.ips))return{allowed:!0,reason:"allowlist_ip"};let n=t.apiKeys;if(n?.header&&n?.values?.length){let o=e.headers[n.header.toLowerCase()];if(o&&n.values.includes(o))return{allowed:!0,reason:"allowlist_apikey"}}return{allowed:!1,reason:""}}function R(e,t={}){return!t||Object.keys(t).length===0?{denied:!1,reason:""}:t.ips?.length&&E(e.ip,t.ips)?{denied:!0,reason:"denylist_ip"}:t.asns?.length&&e.asn&&t.asns.includes(Number(e.asn))?{denied:!0,reason:"denylist_asn"}:{denied:!1,reason:""}}function I(e,t={}){return!t||Object.keys(t).length===0?{blocked:!1,reason:""}:t.deny?.length&&e&&t.deny.includes(e)?{blocked:!0,reason:"geo_deny"}:t.allow?.length&&(!e||!t.allow.includes(e))?{blocked:!0,reason:"geo_allow"}:{blocked:!1,reason:""}}var ae=["GET","POST","PUT","DELETE","PATCH","HEAD","OPTIONS"],re=["POST","PUT","PATCH"];function G(e,t={}){if(!t||Object.keys(t).length===0)return{valid:!0,status:200,reason:""};if(!(t.allowedMethods??ae).includes(e.method?.toUpperCase()))return{valid:!1,status:405,reason:"method_not_allowed"};if(t.maxBodySize!=null&&e.bodySize>t.maxBodySize)return{valid:!1,status:413,reason:"body_too_large"};if(t.allowedContentTypes?.length&&re.includes(e.method?.toUpperCase())){let o=(e.headers["content-type"]??"").split(";")[0].trim().toLowerCase();if(o&&!t.allowedContentTypes.map(s=>s.toLowerCase().trim()).some(s=>o.startsWith(s)))return{valid:!1,status:415,reason:"content_type_not_allowed"}}return{valid:!0,status:200,reason:""}}var se=[/googlebot/i,/bingbot/i,/slurp/i,/duckduckbot/i,/facebookexternalhit/i,/twitterbot/i,/linkedinbot/i,/whatsapp/i,/applebot/i],ie=[/bot/i,/crawl/i,/spider/i,/scrape/i,/curl/i,/python-requests/i,/axios/i,/wget/i,/go-http-client/i,/headlesschrome/i,/phantomjs/i,/selenium/i,/puppeteer/i,/playwright/i,/java\//i,/ruby/i,/libwww/i],le=[/\/\.env/i,/\/wp-admin/i,/\/wp-login/i,/\/phpmyadmin/i,/\/xmlrpc\.php/i,/\/config\./i,/\/\.git/i,/\/admin/i,/\/shell/i,/\/passwd/i];function K(e,t={},n=null){let o=e.userAgent??"",a=e.headers.accept??"",s=e.headers["accept-language"]??"",r=e.pathname??"",i=t.goodBots??se,l=t.badUaPatterns??ie,d=t.suspiciousPaths??le;if(i.some(u=>u.test(o)))return-1;let c=0;return o?l.some(u=>u.test(o))&&(c+=60):c+=40,s||(c+=15),(!a||a==="*/*")&&(c+=10),d.some(u=>u.test(r))&&(c+=50),n&&(n.threatScore!=null&&(c+=Math.round(n.threatScore/100*60)),n.botScore!=null&&(c+=Math.round((1-n.botScore/99)*60)),n.isTor&&(c+=40),n.isVpn&&(c+=20)),Math.min(c,100)}var ce="https://enrich.deployowl.com/enrich";async function z(e,t,n=50){let{ip:o,cfRaw:a}=e;if(!t||!o||o==="unknown")return null;let s=new AbortController,r=setTimeout(()=>s.abort(),n);try{let i=await fetch(ce,{method:"POST",signal:s.signal,headers:{"content-type":"application/json","x-owl-api-key":t},body:JSON.stringify({ip:o,cfHeaders:a??{}})});if(!i.ok)return null;let l=await i.json();return{threatScore:l.threatScore??null,country:l.country??null,asn:l.asn??null,isTor:l.isTor??!1,isVpn:l.isVpn??!1,botScore:l.botScore??null}}catch{return null}finally{clearTimeout(r)}}async function H(e,t){let n=new TextEncoder,o=await crypto.subtle.importKey("raw",n.encode(t),{name:"HMAC",hash:"SHA-256"},!1,["sign"]),a=await crypto.subtle.sign("HMAC",o,n.encode(e));return Array.prototype.map.call(new Uint8Array(a),s=>("00"+s.toString(16)).slice(-2)).join("")}async function V(e={}){let t=e.difficulty??"000",n=e.title??"Checking your browser...",o=e.message??"Please wait while we verify your request.",a=e.signingSecret??"default_waf_secret",s=e.ip??"",r=crypto.randomUUID?crypto.randomUUID():Math.random().toString(36).slice(2)+Date.now().toString(36),i=Date.now(),l=`${r}:${i}:${s}`,d=await H(l,a),c=`${r}.${i}.${d}`;return`<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>${O(n)}</title>
|
|
7
|
+
<style>
|
|
8
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
body {
|
|
10
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
11
|
+
display: flex;
|
|
12
|
+
flex-direction: column;
|
|
13
|
+
align-items: center;
|
|
14
|
+
justify-content: center;
|
|
15
|
+
min-height: 100vh;
|
|
16
|
+
background: #f9fafb;
|
|
17
|
+
color: #374151;
|
|
18
|
+
}
|
|
19
|
+
.card {
|
|
20
|
+
background: #fff;
|
|
21
|
+
border: 1px solid #e5e7eb;
|
|
22
|
+
border-radius: 12px;
|
|
23
|
+
padding: 2.5rem 3rem;
|
|
24
|
+
text-align: center;
|
|
25
|
+
max-width: 420px;
|
|
26
|
+
width: 90%;
|
|
27
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
|
28
|
+
}
|
|
29
|
+
.owl { font-size: 3rem; margin-bottom: 1rem; }
|
|
30
|
+
h1 { font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; }
|
|
31
|
+
p { font-size: 0.9rem; color: #6b7280; margin-bottom: 1.5rem; }
|
|
32
|
+
.progress {
|
|
33
|
+
height: 4px;
|
|
34
|
+
background: #e5e7eb;
|
|
35
|
+
border-radius: 2px;
|
|
36
|
+
overflow: hidden;
|
|
37
|
+
}
|
|
38
|
+
.progress-bar {
|
|
39
|
+
height: 100%;
|
|
40
|
+
width: 0%;
|
|
41
|
+
background: #6366f1;
|
|
42
|
+
border-radius: 2px;
|
|
43
|
+
transition: width 0.3s ease;
|
|
44
|
+
animation: pulse 1.5s ease-in-out infinite;
|
|
45
|
+
}
|
|
46
|
+
@keyframes pulse {
|
|
47
|
+
0%, 100% { opacity: 1; }
|
|
48
|
+
50% { opacity: 0.6; }
|
|
49
|
+
}
|
|
50
|
+
</style>
|
|
51
|
+
</head>
|
|
52
|
+
<body>
|
|
53
|
+
<div class="card">
|
|
54
|
+
<div class="owl">\u{1F989}</div>
|
|
55
|
+
<h1>${O(n)}</h1>
|
|
56
|
+
<p>${O(o)}</p>
|
|
57
|
+
<div class="progress"><div class="progress-bar" id="bar"></div></div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<script>
|
|
61
|
+
(async () => {
|
|
62
|
+
const challenge = ${JSON.stringify(c)};
|
|
63
|
+
const difficulty = ${JSON.stringify(t)};
|
|
64
|
+
const bar = document.getElementById("bar");
|
|
65
|
+
const maxAttempts = 2_000_000;
|
|
66
|
+
|
|
67
|
+
for (let n = 0; n < maxAttempts; n++) {
|
|
68
|
+
// Yield to browser every 500 iterations to keep UI responsive
|
|
69
|
+
if (n % 500 === 0) {
|
|
70
|
+
bar.style.width = Math.min((n / 50000) * 100, 95) + "%";
|
|
71
|
+
await new Promise(r => setTimeout(r, 0));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const buf = await crypto.subtle.digest(
|
|
75
|
+
"SHA-256",
|
|
76
|
+
new TextEncoder().encode(challenge + n)
|
|
77
|
+
);
|
|
78
|
+
const hex = Array.from(new Uint8Array(buf))
|
|
79
|
+
.map(b => b.toString(16).padStart(2, "0"))
|
|
80
|
+
.join("");
|
|
81
|
+
|
|
82
|
+
if (hex.startsWith(difficulty)) {
|
|
83
|
+
bar.style.width = "100%";
|
|
84
|
+
// SameSite=Lax: safe for top-level nav, blocked on cross-site subrequests
|
|
85
|
+
document.cookie =
|
|
86
|
+
"_owl_cleared=" + btoa(challenge + ":" + n + ":" + difficulty) +
|
|
87
|
+
"; path=/; SameSite=Lax";
|
|
88
|
+
// Small delay so progress bar visually completes
|
|
89
|
+
await new Promise(r => setTimeout(r, 300));
|
|
90
|
+
location.reload();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Exhausted attempts without solving \u2014 reload anyway (rare edge case)
|
|
96
|
+
location.reload();
|
|
97
|
+
})();
|
|
98
|
+
</script>
|
|
99
|
+
</body>
|
|
100
|
+
</html>`}async function W(e,t="000",n="default_waf_secret"){let a=(e.cookie??"").match(/(?:^|;)\s*_owl_cleared=([^;]+)/);if(!a)return!1;let s;try{s=atob(a[1])}catch{return!1}let r=s.split(":");if(r.length<3)return!1;let i=r[r.length-1],l=r[r.length-2],d=r.slice(0,r.length-2).join(":");if(i!==t)return!1;let c=d.split(".");if(c.length!==3)return!1;let[u,b,w]=c,g=parseInt(b);if(isNaN(g))return!1;let h=Date.now();if(h-g>3e5||h-g<-6e4)return!1;let f=e.ip??"",P=`${u}:${g}:${f}`,D=await H(P,n);if(w!==D){let p=`${u}:${g}:`,m=await H(p,n);if(w!==m)return!1}try{let p=new TextEncoder,m=await crypto.subtle.digest("SHA-256",p.encode(d+l));return Array.prototype.map.call(new Uint8Array(m),S=>("00"+S.toString(16)).slice(-2)).join("").startsWith(t)}catch{return!1}}function O(e){return String(e).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}async function Y(e,t,n,o){if((t.bypassPaths??[]).some(p=>e.pathname.startsWith(p)))return o.skip("bypass",e),$("bypass");if(await j(e.ip,n,t.rateLimit))return o.block("rate_limited",e,0),y(429,"rate_limited",0);let{allowed:r,reason:i}=B(e,t.allowlist);if(r)return o.allow(i,e,0),$(i);let{denied:l,reason:d}=R(e,t.denylist);if(l)return o.block(d,e,0),y(403,d,0);let{blocked:c,reason:u}=I(e.country,t.geo);if(c)return o.block(u,e,0),y(403,u,0);let{valid:b,status:w,reason:g}=G(e,t.validation);if(!b)return o.block(g,e,0),y(w,g,0);let h=null;if(t.apiKey){let p=t.enrichmentTimeout??50;if(h=await z(e,t.apiKey,p),h?.country&&(e.country=h.country),h?.asn&&t.denylist?.asns?.length){let m={...e,asn:h.asn},{denied:_,reason:S}=R(m,t.denylist);if(_)return o.block(S,e,0),y(403,S,0)}if(h?.country&&h.country!==e.country){let{blocked:m,reason:_}=I(h.country,t.geo);if(m)return o.block(_,e,0),y(403,_,0)}}let f=K(e,t.scorer,h);if(f===-1)return o.allow("good_bot",e,-1),$("good_bot",-1);let P=t.blockScore??80;if(f>=P)return o.block("bot_score_block",e,f),y(403,"bot_score_block",f);let D=t.challengeScore??50;if(f>=D){let p=t.challenge?.difficulty??"000",m=t.challenge?.signingSecret||t.apiKey||"default_waf_secret";if(!await W(e,p,m))return o.challenge("bot_score_challenge",e,f),await de(f,t.challenge,e.ip,m)}return o.allow("allow",e,f),$("allow",f)}function $(e,t=0){return{blocked:!1,status:200,reason:e,score:t,html:null}}function y(e,t,n){return{blocked:!0,status:e,reason:t,score:n,html:null}}async function de(e,t={},n="",o=""){return{blocked:!0,status:200,reason:"challenge",score:e,html:await V({...t,ip:n,signingSecret:o})}}var L="https://security.deployowl.com";async function J(e,t,n,o){if(!n||!o||!L||L.startsWith("__"))return;let a=new AbortController,s=setTimeout(()=>a.abort(),2e3);try{await fetch(L,{method:"POST",signal:a.signal,headers:{"content-type":"application/json","x-owl-secret":o,"x-owl-api-key":n},body:JSON.stringify({ts:Date.now(),apiKey:n,ip:t.ip,country:t.country,pathname:t.pathname,method:t.method,blocked:e.blocked,reason:e.reason,score:e.score})})}catch{}finally{clearTimeout(s)}}U();var Q=class{constructor(t={}){this._config=t,this._store=t.storage??new v,this._logger=new T(t.log??"warn"),this._callbacks=[],this._remoteConfig=null,this._remoteConfigAt=0,this._remoteConfigTtl=15*60*1e3,this._quotaExceeded=!1,t.apiKey||(t.log??"warn")!=="none"&&console.info("[OwlGuard] No apiKey provided. Enrichment signals (threatScore, Tor, VPN, ASN) will be skipped. Get a key at https://deployowl.com")}async _fetchRemoteConfig(){if(this._config.apiKey)try{let n=await fetch("https://api.deployowl.com/api/v1/config",{method:"GET",headers:{"X-API-Key":this._config.apiKey}});if(n.status===402){this._quotaExceeded=!0,this._remoteConfigAt=Date.now();return}if(n.ok){let o=await n.json();this._remoteConfig=o,this._quotaExceeded=!!o.quotaExceeded,this._remoteConfigAt=Date.now()}}catch(t){this._logger.info(`[OwlGuard] Failed to fetch remote config: ${t.message}`)}}onDecision(t){return typeof t=="function"&&this._callbacks.push(t),this}async decision(t,n=null){let o=M(t),a=n,s=Date.now();!this._quotaExceeded&&this._config.remoteConfig===!0&&s-this._remoteConfigAt>=this._remoteConfigTtl&&await this._fetchRemoteConfig();let r={...this._config,...this._remoteConfig||{}};if(this._quotaExceeded&&(r.apiKey=null),r.apiKey)try{let{verifyHandshake:l}=await Promise.resolve().then(()=>(U(),X));if(await l(o.headers,r.apiKey)){let c=o.headers["x-owlguard-decision"]||"allow",u=o.headers["x-owlguard-reason"]||"allowed",b=parseInt(o.headers["x-owlguard-score"]||"0",10),w={blocked:c==="block",status:c==="block"?403:200,reason:u,score:isNaN(b)?0:b,html:null};return this._logger.info(`[HANDSHAKE BYPASS] Trusted Edge verdict: ${c} (score: ${w.score})`,o),w}}catch(l){this._logger.info(`[HANDSHAKE BYPASS] Handshake verification skipped: ${l.message}`)}let i=await Y(o,r,this._store,this._logger);return r.dryRun&&i.blocked&&(this._logger.info(`[DRY RUN] Would have blocked: ${i.reason} (score: ${i.score})`,o),i.blocked=!1,i.simulated=!0),this._fireCallbacks(i,o,a,r.apiKey),i}_fireCallbacks(t,n,o,a){let s=this._callbacks.map(l=>Promise.resolve().then(()=>l(t,n)).catch(()=>{})),r=a&&t.blocked&&this._config.reporting&&this._config.reportingSecret?J(t,n,a,this._config.reportingSecret).catch(()=>{}):Promise.resolve(),i=Promise.allSettled([...s,r]);o&&typeof o.waitUntil=="function"&&o.waitUntil(i)}};export{Q as OwlGuard,N as computeSignature};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deployowl/guard",
|
|
3
|
+
"version": "2.2.2",
|
|
4
|
+
"description": "Enterprise-grade, platform-agnostic bot detection and security middleware",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "types/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./types/index.d.ts"
|
|
12
|
+
},
|
|
13
|
+
"./adapters/redis": {
|
|
14
|
+
"import": "./adapters/redis.js",
|
|
15
|
+
"types": "./types/adapters.d.ts"
|
|
16
|
+
},
|
|
17
|
+
"./adapters/cloudflare-kv": {
|
|
18
|
+
"import": "./adapters/cloudflare-kv.js",
|
|
19
|
+
"types": "./types/adapters.d.ts"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist/",
|
|
24
|
+
"adapters/",
|
|
25
|
+
"types/"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "esbuild src/index.js --bundle --format=esm --outfile=dist/index.js --minify --platform=neutral",
|
|
29
|
+
"dev": "esbuild src/index.js --bundle --format=esm --outfile=dist/index.js --platform=neutral --watch"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"security",
|
|
33
|
+
"bot-detection",
|
|
34
|
+
"middleware",
|
|
35
|
+
"rate-limiting",
|
|
36
|
+
"geo-blocking",
|
|
37
|
+
"waf"
|
|
38
|
+
],
|
|
39
|
+
"author": "OwlGuard",
|
|
40
|
+
"license": "Proprietary",
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18.0.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"esbuild": "^0.21.5"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* owlguard — TypeScript type declarations for storage adapters.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { StorageAdapter } from "./index.js";
|
|
6
|
+
|
|
7
|
+
// ── Redis Adapter ─────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Minimal interface for a Redis client.
|
|
11
|
+
* Compatible with both `ioredis` and `node-redis` (v4+).
|
|
12
|
+
*/
|
|
13
|
+
export interface RedisLike {
|
|
14
|
+
get(key: string): Promise<string | null>;
|
|
15
|
+
setEx(key: string, ttlSeconds: number, value: string): Promise<unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Pre-built storage adapter for Redis.
|
|
20
|
+
* Works with ioredis and node-redis (v4+).
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* import { createClient } from "redis";
|
|
24
|
+
* import { RedisAdapter } from "owlguard/adapters/redis";
|
|
25
|
+
*
|
|
26
|
+
* const redis = createClient({ url: process.env.REDIS_URL });
|
|
27
|
+
* await redis.connect();
|
|
28
|
+
*
|
|
29
|
+
* const owl = new OwlGuard({
|
|
30
|
+
* storage: new RedisAdapter(redis),
|
|
31
|
+
* });
|
|
32
|
+
*/
|
|
33
|
+
export declare class RedisAdapter implements StorageAdapter {
|
|
34
|
+
constructor(client: RedisLike);
|
|
35
|
+
get(key: string): Promise<{ ts: number; count: number } | null>;
|
|
36
|
+
put(key: string, value: { ts: number; count: number }, ttlSeconds: number): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Cloudflare KV Adapter ─────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Minimal interface for a Cloudflare KV namespace.
|
|
43
|
+
*/
|
|
44
|
+
export interface CloudflareKVNamespace {
|
|
45
|
+
get(key: string, options: { type: "json" }): Promise<unknown>;
|
|
46
|
+
put(key: string, value: string, options: { expirationTtl: number }): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Pre-built storage adapter for Cloudflare KV.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* // In your Cloudflare Worker:
|
|
54
|
+
* import { CloudflareKVAdapter } from "owlguard/adapters/cloudflare-kv";
|
|
55
|
+
*
|
|
56
|
+
* const owl = new OwlGuard({
|
|
57
|
+
* storage: new CloudflareKVAdapter(env.OWL_RATE_LIMIT),
|
|
58
|
+
* });
|
|
59
|
+
*/
|
|
60
|
+
export declare class CloudflareKVAdapter implements StorageAdapter {
|
|
61
|
+
constructor(namespace: CloudflareKVNamespace);
|
|
62
|
+
get(key: string): Promise<{ ts: number; count: number } | null>;
|
|
63
|
+
put(key: string, value: { ts: number; count: number }, ttlSeconds: number): Promise<void>;
|
|
64
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* owlguard — TypeScript type declarations
|
|
3
|
+
* Full types for the OwlGuard npm package public API.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ── Storage Adapter ───────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface StorageAdapter {
|
|
9
|
+
/** Retrieve a stored value by key. Returns null if not found. */
|
|
10
|
+
get(key: string): Promise<{ ts: number; count: number } | null>;
|
|
11
|
+
/** Store a value with a TTL in seconds. */
|
|
12
|
+
put(key: string, value: { ts: number; count: number }, ttlSeconds: number): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ── Configuration ─────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface AllowlistApiKeysConfig {
|
|
18
|
+
/** The header name to check (e.g. "x-api-key"). */
|
|
19
|
+
header: string;
|
|
20
|
+
/** Values that are unconditionally trusted. */
|
|
21
|
+
values: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AllowlistConfig {
|
|
25
|
+
/** IPv4, IPv6, or CIDR ranges to unconditionally allow (e.g. "10.0.0.0/8"). */
|
|
26
|
+
ips?: string[];
|
|
27
|
+
/** API key header config for unconditional trust. */
|
|
28
|
+
apiKeys?: AllowlistApiKeysConfig;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DenylistConfig {
|
|
32
|
+
/** IPv4, IPv6, or CIDR ranges to unconditionally block. */
|
|
33
|
+
ips?: string[];
|
|
34
|
+
/** ASN numbers to block entire ISPs or datacenters. */
|
|
35
|
+
asns?: number[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GeoConfig {
|
|
39
|
+
/**
|
|
40
|
+
* If set, ONLY these ISO 3166-1 alpha-2 country codes are allowed.
|
|
41
|
+
* Checked after geo.deny.
|
|
42
|
+
*/
|
|
43
|
+
allow?: string[];
|
|
44
|
+
/** ISO 3166-1 alpha-2 country codes to always block. Checked first. */
|
|
45
|
+
deny?: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ScorerConfig {
|
|
49
|
+
/** UA patterns for verified good bots — always allowed (score = -1). */
|
|
50
|
+
goodBots?: RegExp[];
|
|
51
|
+
/** UA patterns that raise the bot suspicion score. */
|
|
52
|
+
badUaPatterns?: RegExp[];
|
|
53
|
+
/** Path patterns that raise the bot suspicion score. */
|
|
54
|
+
suspiciousPaths?: RegExp[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface RateLimitConfig {
|
|
58
|
+
/** Sliding window duration in milliseconds. Default: 60000 (1 minute). */
|
|
59
|
+
windowMs?: number;
|
|
60
|
+
/** Maximum requests per window per IP. Default: 120. */
|
|
61
|
+
max?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ValidationConfig {
|
|
65
|
+
/** Maximum allowed body size in bytes. */
|
|
66
|
+
maxBodySize?: number;
|
|
67
|
+
/** Allowed HTTP methods. Default: GET, POST, PUT, DELETE, PATCH. */
|
|
68
|
+
allowedMethods?: string[];
|
|
69
|
+
/** Allowed Content-Type values (checked on POST, PUT, PATCH). */
|
|
70
|
+
allowedContentTypes?: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ChallengeConfig {
|
|
74
|
+
/**
|
|
75
|
+
* SHA-256 leading-zero difficulty.
|
|
76
|
+
* "00" ≈ 10ms, "000" ≈ 200ms (default), "0000" ≈ 3s.
|
|
77
|
+
*/
|
|
78
|
+
difficulty?: string;
|
|
79
|
+
/** Title text shown on the challenge page. */
|
|
80
|
+
title?: string;
|
|
81
|
+
/** Body message shown on the challenge page. */
|
|
82
|
+
message?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface OwlGuardConfig {
|
|
86
|
+
/**
|
|
87
|
+
* Your OwlGuard install key (owl_live_xxx).
|
|
88
|
+
* Optional — enrichment signals are skipped without it.
|
|
89
|
+
*/
|
|
90
|
+
apiKey?: string;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* External storage adapter for globally-consistent rate limiting.
|
|
94
|
+
* If omitted, OwlGuard uses an in-memory store (per-instance).
|
|
95
|
+
* For multi-instance deployments, provide a Redis or Cloudflare KV adapter.
|
|
96
|
+
*/
|
|
97
|
+
storage?: StorageAdapter;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Path prefixes that skip ALL security checks.
|
|
101
|
+
* Use for webhooks, health probes, etc.
|
|
102
|
+
* e.g. ["/api/webhook", "/healthz"]
|
|
103
|
+
*/
|
|
104
|
+
bypassPaths?: string[];
|
|
105
|
+
|
|
106
|
+
/** Requests matching any allowlist entry are unconditionally allowed. */
|
|
107
|
+
allowlist?: AllowlistConfig;
|
|
108
|
+
|
|
109
|
+
/** Requests matching any denylist entry are hard-blocked (403). */
|
|
110
|
+
denylist?: DenylistConfig;
|
|
111
|
+
|
|
112
|
+
/** Country-level geo fencing using ISO 3166-1 alpha-2 codes. */
|
|
113
|
+
geo?: GeoConfig;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Bot score threshold for a hard 403 block.
|
|
117
|
+
* Range: 0–100. Default: 80.
|
|
118
|
+
*/
|
|
119
|
+
blockScore?: number;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Bot score threshold for a JS proof-of-work challenge.
|
|
123
|
+
* Range: 0–100. Default: 50.
|
|
124
|
+
*/
|
|
125
|
+
challengeScore?: number;
|
|
126
|
+
|
|
127
|
+
/** Override the default bot scoring patterns. */
|
|
128
|
+
scorer?: ScorerConfig;
|
|
129
|
+
|
|
130
|
+
/** Rate limiting configuration. */
|
|
131
|
+
rateLimit?: RateLimitConfig;
|
|
132
|
+
|
|
133
|
+
/** Request shape validation. */
|
|
134
|
+
validation?: ValidationConfig;
|
|
135
|
+
|
|
136
|
+
/** JS proof-of-work challenge page configuration. */
|
|
137
|
+
challenge?: ChallengeConfig;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Console log verbosity.
|
|
141
|
+
* - "none" — silent
|
|
142
|
+
* - "block" — hard blocks only
|
|
143
|
+
* - "warn" — blocks + challenges (default)
|
|
144
|
+
* - "info" — everything
|
|
145
|
+
*/
|
|
146
|
+
log?: "none" | "block" | "warn" | "info";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Decision Result ───────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/** All possible machine-readable reason keys returned in DecisionResult. */
|
|
152
|
+
export type DecisionReason =
|
|
153
|
+
| "bypass"
|
|
154
|
+
| "allowlist_ip"
|
|
155
|
+
| "allowlist_apikey"
|
|
156
|
+
| "denylist_ip"
|
|
157
|
+
| "denylist_asn"
|
|
158
|
+
| "geo_deny"
|
|
159
|
+
| "geo_allow"
|
|
160
|
+
| "method_not_allowed"
|
|
161
|
+
| "body_too_large"
|
|
162
|
+
| "content_type_not_allowed"
|
|
163
|
+
| "rate_limited"
|
|
164
|
+
| "good_bot"
|
|
165
|
+
| "bot_score_block"
|
|
166
|
+
| "challenge"
|
|
167
|
+
| "allow";
|
|
168
|
+
|
|
169
|
+
export interface DecisionResult {
|
|
170
|
+
/** True if the request should be stopped. */
|
|
171
|
+
blocked: boolean;
|
|
172
|
+
/** HTTP status to return (200, 403, 429, 405, 413, 415). */
|
|
173
|
+
status: number;
|
|
174
|
+
/** Machine-readable reason key. */
|
|
175
|
+
reason: DecisionReason;
|
|
176
|
+
/**
|
|
177
|
+
* Bot suspicion score at time of decision.
|
|
178
|
+
* Range: 0–100. -1 means verified good bot.
|
|
179
|
+
*/
|
|
180
|
+
score: number;
|
|
181
|
+
/**
|
|
182
|
+
* Challenge page HTML. Only present when reason === "challenge".
|
|
183
|
+
* Return this as an HTML response with status 200.
|
|
184
|
+
*/
|
|
185
|
+
html: string | null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Normalized Request Context (internal, exposed for onDecision) ─────────────
|
|
189
|
+
|
|
190
|
+
export interface OwlContext {
|
|
191
|
+
ip: string;
|
|
192
|
+
country: string | null;
|
|
193
|
+
asn: number | null;
|
|
194
|
+
userAgent: string;
|
|
195
|
+
method: string;
|
|
196
|
+
pathname: string;
|
|
197
|
+
headers: Record<string, string>;
|
|
198
|
+
bodySize: number;
|
|
199
|
+
cookie: string;
|
|
200
|
+
cfRaw: Record<string, string | null> | null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── OwlGuard Class ─────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
/** Callback invoked after every security decision. Never blocks the response. */
|
|
206
|
+
export type OnDecisionCallback = (result: DecisionResult, ctx: OwlContext) => void | Promise<void>;
|
|
207
|
+
|
|
208
|
+
export declare class OwlGuard {
|
|
209
|
+
constructor(config?: OwlGuardConfig);
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Register a callback invoked after every security decision.
|
|
213
|
+
* Fires asynchronously — never blocks or delays the decision result.
|
|
214
|
+
* Use for logging, analytics, or billing integrations.
|
|
215
|
+
*
|
|
216
|
+
* @example
|
|
217
|
+
* owl.onDecision((result, ctx) => {
|
|
218
|
+
* if (result.blocked) console.log(`Blocked ${ctx.ip}: ${result.reason}`);
|
|
219
|
+
* });
|
|
220
|
+
*/
|
|
221
|
+
onDecision(fn: OnDecisionCallback): this;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Evaluates a request and returns a security decision.
|
|
225
|
+
*
|
|
226
|
+
* Accepts any of:
|
|
227
|
+
* - Cloudflare Worker Request
|
|
228
|
+
* - Node.js http.IncomingMessage
|
|
229
|
+
* - Generic fetch-API Request
|
|
230
|
+
* - Pre-normalized OwlContext object
|
|
231
|
+
*/
|
|
232
|
+
decision(request: Request | import("http").IncomingMessage | OwlContext | object): Promise<DecisionResult>;
|
|
233
|
+
}
|