diff --git a/CHANGELOG.md b/CHANGELOG.md index 772011f..569a709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.20.0] - 2026-10-20 + +### Added + +- **Clearance enforcement on Vercel, for any framework.** WebDecoy's clearance validator, the same decision the Cloudflare, AWS and Netlify validators make, now runs as Vercel middleware with no proxy in front of the site. On the paths you protect in WebDecoy, a visitor needs a clearance token: a real browser earns one on a short check page and is sent back, a client that runs no JavaScript never gets the page. Follows the site's WebDecoy mode, so it blocks nothing until you switch to Enforce, and it fails open whenever WebDecoy cannot be reached. + - `@webdecoy/node/vercel`: Routing Middleware for Astro, SvelteKit, Nuxt, Remix or static sites (`export { default } from '@webdecoy/node/vercel'`). + - `withClearance()` in `@webdecoy/nextjs` for Next.js middleware. + - `createClearanceGate()` in `@webdecoy/node` for other edge adapters. + - Configured by `WEBDECOY_SITE_KEY`, `WEBDECOY_SCANNER_ID` and `WEBDECOY_SENSOR_KEY`, shown on Enforcement, Setup. Verified search crawlers are recognised by forward-confirmed reverse DNS over HTTPS; signed agents (Web Bot Auth) pass. + ## [0.19.1] - 2026-10-15 ### Security diff --git a/README.md b/README.md index 4621195..ea1abd9 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,21 @@ const wd = new WebDecoy({ rules: [webBotAuth()] }); // denies known-agent impers Full guide: [**Verify AI agents with Web Bot Auth in Next.js**](docs/verify-ai-agents-web-bot-auth.md). +## Clearance enforcement on Vercel + +With a WebDecoy account, the paths you protect can require a clearance token, checked in middleware before the page renders. A real browser earns one automatically; a scraper that never runs JavaScript never gets the page. It works with any framework on Vercel, follows the site's WebDecoy mode (monitor until you switch to enforce) and fails open. + +```typescript +// middleware.ts (Astro, SvelteKit, Nuxt, Remix, static) +export { default } from '@webdecoy/node/vercel'; + +// middleware.ts (Next.js) +import { withClearance } from '@webdecoy/nextjs'; +export default withClearance(); +``` + +Set `WEBDECOY_SITE_KEY`, `WEBDECOY_SCANNER_ID` and `WEBDECOY_SENSOR_KEY` from **Enforcement, Setup**. Full guide: [Vercel middleware](https://docs.webdecoy.com/installation/vercel-middleware/). + ## What counts as a false positive? The zero-false-positive claim is about *design*, and it holds if you know the edge cases: diff --git a/eslint.config.mjs b/eslint.config.mjs index 9e7a653..88e67e2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -28,6 +28,10 @@ export default tseslint.config( // Lint findings here are not actionable: the fix belongs in the // generator, and the file is overwritten on every regeneration. '**/*.generated.ts', + // The clearance gate, bundled from WebDecoy's validator source + // (edge/vercel-validator in the main repo). Same reasoning. + '**/*.generated.js', + '**/*.generated.d.ts', ], }, diff --git a/package-lock.json b/package-lock.json index fd02205..b2b8f34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10407,7 +10407,7 @@ }, "packages/client": { "name": "@webdecoy/client", - "version": "0.19.1", + "version": "0.20.0", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.11", @@ -10424,10 +10424,10 @@ }, "packages/express": { "name": "@webdecoy/express", - "version": "0.19.1", + "version": "0.20.0", "license": "MIT", "dependencies": { - "@webdecoy/node": "^0.19.1" + "@webdecoy/node": "^0.20.0" }, "devDependencies": { "@types/express": "^4.17.21", @@ -10448,10 +10448,10 @@ }, "packages/fastify": { "name": "@webdecoy/fastify", - "version": "0.19.1", + "version": "0.20.0", "license": "MIT", "dependencies": { - "@webdecoy/node": "^0.19.1", + "@webdecoy/node": "^0.20.0", "fastify-plugin": "^4.5.1" }, "devDependencies": { @@ -10472,10 +10472,10 @@ }, "packages/hono": { "name": "@webdecoy/hono", - "version": "0.19.1", + "version": "0.20.0", "license": "MIT", "dependencies": { - "@webdecoy/node": "^0.19.1" + "@webdecoy/node": "^0.20.0" }, "devDependencies": { "@types/jest": "^29.5.11", @@ -10495,10 +10495,10 @@ }, "packages/nextjs": { "name": "@webdecoy/nextjs", - "version": "0.19.1", + "version": "0.20.0", "license": "MIT", "dependencies": { - "@webdecoy/node": "^0.19.1" + "@webdecoy/node": "^0.20.0" }, "devDependencies": { "@types/jest": "^29.5.11", @@ -10518,7 +10518,7 @@ }, "packages/webdecoy": { "name": "@webdecoy/node", - "version": "0.19.1", + "version": "0.20.0", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.11", diff --git a/packages/client/package.json b/packages/client/package.json index 7532c8c..5be8261 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/client", - "version": "0.19.1", + "version": "0.20.0", "description": "Web Decoy browser widget - signal collection, proof-of-work, and captcha UI", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/packages/express/package.json b/packages/express/package.json index ac866e0..2c4c48e 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/express", - "version": "0.19.1", + "version": "0.20.0", "description": "Web Decoy middleware for Express.js", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -40,7 +40,7 @@ "url": "https://github.com/WebDecoy/node/issues" }, "dependencies": { - "@webdecoy/node": "^0.19.1" + "@webdecoy/node": "^0.20.0" }, "peerDependencies": { "express": "^4.18.0 || ^5.0.0" diff --git a/packages/fastify/package.json b/packages/fastify/package.json index 69d9178..574d6ab 100644 --- a/packages/fastify/package.json +++ b/packages/fastify/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/fastify", - "version": "0.19.1", + "version": "0.20.0", "description": "Web Decoy plugin for Fastify", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -40,7 +40,7 @@ "url": "https://github.com/WebDecoy/node/issues" }, "dependencies": { - "@webdecoy/node": "^0.19.1", + "@webdecoy/node": "^0.20.0", "fastify-plugin": "^4.5.1" }, "peerDependencies": { diff --git a/packages/hono/package.json b/packages/hono/package.json index 3796ad7..3e70c98 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/hono", - "version": "0.19.1", + "version": "0.20.0", "description": "Web Decoy middleware for Hono \u2014 Cloudflare Workers, Bun, Deno, Node", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -44,7 +44,7 @@ "url": "https://github.com/WebDecoy/node/issues" }, "dependencies": { - "@webdecoy/node": "^0.19.1" + "@webdecoy/node": "^0.20.0" }, "peerDependencies": { "hono": "^4.0.0" diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 6333211..6de0c0c 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/nextjs", - "version": "0.19.1", + "version": "0.20.0", "description": "Web Decoy middleware for Next.js", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -42,7 +42,7 @@ "url": "https://github.com/WebDecoy/node/issues" }, "dependencies": { - "@webdecoy/node": "^0.19.1" + "@webdecoy/node": "^0.20.0" }, "peerDependencies": { "next": ">=13.0.0" diff --git a/packages/nextjs/src/clearance.test.ts b/packages/nextjs/src/clearance.test.ts new file mode 100644 index 0000000..e2d31ba --- /dev/null +++ b/packages/nextjs/src/clearance.test.ts @@ -0,0 +1,90 @@ +import { NextRequest } from 'next/server'; +import { withClearance } from './clearance'; +import { getEdgeVerdict } from './edge-verdict'; + +/** + * withClearance: the gate's decision is @webdecoy/node's and tested there. + * What is tested here is what the application and the visitor receive. + */ + +const ENV = { siteKey: 'org-1', scannerId: 'scanner-1', sensorKey: 'wds1.test' }; +const BROWSER_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0 Safari/537.36'; + +const CONFIG = { + mode: 'enforce', + allow: { search_engines: true, ai_crawlers: true, monitoring: true }, + keys: [], + active_credentials: [], + denied_fps: [], + generated_at: 0, + routes: ['/docs/*'], +}; + +const realFetch = global.fetch; +let hosts = 0; +const site = () => `https://next${++hosts}.example`; + +beforeEach(() => { + global.fetch = jest.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + return url.includes('/api/v1/clearance/config') + ? new Response(JSON.stringify(CONFIG), { status: 200 }) + : new Response('{}', { status: 200 }); + }) as unknown as typeof fetch; +}); + +// The gate's verdict telemetry schedules a delayed flush; let timers advance +// on their own, and clear what is left so it cannot hold jest open. +beforeAll(() => { + jest.useFakeTimers({ advanceTimers: true }); +}); + +afterAll(() => { + jest.clearAllTimers(); + jest.useRealTimers(); +}); + +afterEach(() => { + global.fetch = realFetch; +}); + +describe('withClearance', () => { + it('challenges a protected page without a token, before the page renders', async () => { + const res = await withClearance(ENV)( + new NextRequest(`${site()}/docs/guide`, { headers: { accept: 'text/html', 'user-agent': BROWSER_UA } }) + ); + expect(res.status).toBe(403); + expect(res.headers.get('x-middleware-next')).toBeNull(); + expect(await res.text()).toContain('wd_clearance'); + }); + + it('hands the page the verdict, readable with getEdgeVerdict', async () => { + const res = await withClearance(ENV)( + new NextRequest(`${site()}/pricing`, { + headers: { 'user-agent': BROWSER_UA, 'x-wd-clearance': 'valid' }, + }) + ); + expect(res.headers.get('x-middleware-next')).toBe('1'); + const forwarded = new Headers(); + for (const name of (res.headers.get('x-middleware-override-headers') ?? '').split(',')) { + forwarded.set(name, res.headers.get(`x-middleware-request-${name}`) ?? ''); + } + // The client's forged "valid" is gone; the gate's own verdict is there. + expect(getEdgeVerdict(forwarded).clearance).toBe('unscoped'); + expect(forwarded.get('user-agent')).toBe(BROWSER_UA); + // And the verdict is not sent back to the visitor. + expect(res.headers.get('x-wd-clearance')).toBeNull(); + }); + + it('fails open when WebDecoy cannot be reached', async () => { + global.fetch = jest.fn(async () => { + throw new Error('down'); + }) as unknown as typeof fetch; + const res = await withClearance(ENV)( + new NextRequest(`${site()}/docs/guide`, { headers: { accept: 'text/html', 'user-agent': BROWSER_UA } }) + ); + expect(res.status).toBe(200); + expect(res.headers.get('x-middleware-next')).toBe('1'); + }); +}); diff --git a/packages/nextjs/src/clearance.ts b/packages/nextjs/src/clearance.ts new file mode 100644 index 0000000..2a774dc --- /dev/null +++ b/packages/nextjs/src/clearance.ts @@ -0,0 +1,49 @@ +/** + * WebDecoy clearance in Next.js middleware. + * + * The clearance gate from @webdecoy/node, the same decision WebDecoy's + * Cloudflare, AWS and Netlify validators make, run on Vercel (or any host + * running Next.js middleware) with no proxy in front. Which paths need a + * token, and whether the site monitors or enforces, is set in WebDecoy. + * + * @example + * ```typescript + * // middleware.ts + * import { withClearance } from '@webdecoy/nextjs'; + * + * // Reads WEBDECOY_SITE_KEY, WEBDECOY_SCANNER_ID and WEBDECOY_SENSOR_KEY. + * export default withClearance(); + * + * export const config = { + * // Everything but static assets. Keep /.well-known: the challenge uses it. + * matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], + * }; + * ``` + * + * A page the gate lets through receives the verdict as `x-wd-clearance`; + * `getEdgeVerdict()` reads it in a route handler or server component. + */ + +import { NextResponse } from 'next/server'; +import type { NextRequest, NextFetchEvent } from 'next/server'; +import { createClearanceGate } from '@webdecoy/node'; +import type { ClearanceGateOptions } from '@webdecoy/node'; + +export type { ClearanceGateOptions } from '@webdecoy/node'; + +/** Next.js middleware that runs the clearance gate. Options default to the environment. */ +export function withClearance( + options: ClearanceGateOptions = {} +): (req: NextRequest, event?: NextFetchEvent) => Promise { + const gate = createClearanceGate(options); + return async (req, event) => { + try { + const outcome = await gate(req, { waitUntil: event ? (p) => event.waitUntil(p) : undefined }); + if (outcome.action === 'respond') return outcome.response; + if (!outcome.headers) return NextResponse.next(); + return NextResponse.next({ request: { headers: outcome.headers } }); + } catch { + return NextResponse.next(); // fail open + } + }; +} diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index d24b689..f79725c 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -19,6 +19,11 @@ export { withWebDecoy, withBotProtection } from './middleware'; export type { WebDecoyMiddlewareOptions, WithBotProtectionOptions } from './middleware'; +// The clearance gate, in this middleware: a protected page needs a WebDecoy +// clearance token, with no proxy in front of the site. +export { withClearance } from './clearance'; +export type { ClearanceGateOptions } from './clearance'; + // The edge validator's verdict, readable from a route handler or server // component — not only from middleware. export { getEdgeVerdict } from './edge-verdict'; diff --git a/packages/webdecoy/jest.config.js b/packages/webdecoy/jest.config.js index 57b4da5..fc9f35b 100644 --- a/packages/webdecoy/jest.config.js +++ b/packages/webdecoy/jest.config.js @@ -1,8 +1,13 @@ /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { - preset: 'ts-jest', + preset: 'ts-jest/presets/js-with-ts', testEnvironment: 'node', roots: ['/src'], testMatch: ['**/*.test.ts'], + // js-with-ts: the clearance gate is a generated ES module + // (src/clearance/gate.generated.js), compiled for jest like the sources. + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: { allowJs: true } }], + }, collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'], }; diff --git a/packages/webdecoy/package.json b/packages/webdecoy/package.json index f4ab5de..a141a4e 100644 --- a/packages/webdecoy/package.json +++ b/packages/webdecoy/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/node", - "version": "0.19.1", + "version": "0.20.0", "description": "Web Decoy SDK for Node.js - Bot detection with TLS fingerprinting", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -14,18 +14,23 @@ "require": "./dist/testing.js", "import": "./dist/testing.mjs", "types": "./dist/testing.d.ts" + }, + "./vercel": { + "require": "./dist/vercel.js", + "import": "./dist/vercel.mjs", + "types": "./dist/vercel.d.ts" } }, "files": [ "dist" ], "scripts": { - "build": "tsup src/index.ts src/testing.ts --format cjs,esm --dts --clean", - "dev": "tsup src/index.ts src/testing.ts --format cjs,esm --dts --watch", + "build": "tsup src/index.ts src/testing.ts src/vercel.ts --format cjs,esm --dts --clean", + "dev": "tsup src/index.ts src/testing.ts src/vercel.ts --format cjs,esm --dts --watch", "test": "jest", "lint": "eslint src --max-warnings 9", "clean": "rm -rf dist", - "check:edge": "node ../../scripts/check-edge.mjs src/index.ts" + "check:edge": "node ../../scripts/check-edge.mjs src/index.ts src/vercel.ts" }, "keywords": [ "web-decoy", diff --git a/packages/webdecoy/src/clearance/gate.generated.d.ts b/packages/webdecoy/src/clearance/gate.generated.d.ts new file mode 100644 index 0000000..27f8c78 --- /dev/null +++ b/packages/webdecoy/src/clearance/gate.generated.d.ts @@ -0,0 +1,36 @@ +// GENERATED by WebDecoy edge/vercel-validator/scripts/bundle.mjs. DO NOT EDIT. +/** What the gate needs to know about the site. */ +export interface GateEnv { + /** The organization the site belongs to (WEBDECOY_SITE_KEY). */ + siteKey: string; + /** The site's scanner (WEBDECOY_SCANNER_ID). */ + scannerId: string; + /** Proves the reports come from a validator WebDecoy issued (WEBDECOY_SENSOR_KEY). */ + sensorKey: string; + /** WebDecoy ingest; defaults to DEFAULT_INGEST. */ + ingest?: string; +} +/** What the platform hands the gate. */ +export interface GateContext { + /** The client address. */ + ip: string; + /** Keeps telemetry alive after the response is sent. */ + waitUntil?: (promise: Promise) => void; + /** For tests; the platform's fetch otherwise. */ + fetch?: typeof fetch; +} +/** + * `respond`: send this response instead of the page. + * `continue`: serve the page with exactly these request headers; `null` means untouched. + */ +export type GateOutcome = + | { action: 'respond'; response: Response } + | { action: 'continue'; headers: Headers | null; verdict: string | null }; +/** Decide one request. Never throws; every failure continues the request. */ +export declare function runGate(request: Request, context: GateContext, env: GateEnv): Promise; +/** The client's headers with WebDecoy's verdict headers removed. */ +export declare function cleanHeaders(request: Request): Headers; +export declare const DEFAULT_INGEST: string; +export declare const VERCEL_VALIDATOR_CAPABILITIES: readonly string[]; +/** This bundle's build name, reported with every verdict. */ +export declare const BUILD: string; diff --git a/packages/webdecoy/src/clearance/gate.generated.js b/packages/webdecoy/src/clearance/gate.generated.js new file mode 100644 index 0000000..6ec6bfe --- /dev/null +++ b/packages/webdecoy/src/clearance/gate.generated.js @@ -0,0 +1,210 @@ +// GENERATED by WebDecoy edge/vercel-validator/scripts/bundle.mjs. DO NOT EDIT. +// Build vercel-9971cf5b9746. Regenerate from the validator source; gate-bundle.test.ts fails on a hand edit. +var k={"chat.deepseek.com":"DeepSeek","chat.openai.com":"ChatGPT","chatgpt.com":"ChatGPT","claude.ai":"Claude","copilot.microsoft.com":"Copilot","deepseek.com":"DeepSeek","gemini.google.com":"Gemini","grok.com":"Grok","kagi.com":"Kagi","meta.ai":"Meta AI","perplexity.ai":"Perplexity","phind.com":"Phind","www.deepseek.com":"DeepSeek","www.grok.com":"Grok","www.kagi.com":"Kagi","www.meta.ai":"Meta AI","www.perplexity.ai":"Perplexity","www.phind.com":"Phind","www.you.com":"You.com","you.com":"You.com"};var tt=["utm_source","ref","utm_medium"],oe=500;function rt(e,t){let r=(e??"").trim();if(r)try{return k[new URL(r).hostname.toLowerCase()]??""}catch{return""}let n;try{n=new URL(t)}catch{return""}for(let o of tt)for(let a of n.searchParams.getAll(o)){let i=nt(a);if(i)return i}return""}var ae=e=>e.replace(/[ \-_.]/g,"");function nt(e){let t=e.trim().toLowerCase();if(!t)return"";try{let n=new URL(t).hostname;n&&(t=n.toLowerCase())}catch{}if(t=t.replace(/\.$/,""),k[t])return k[t];let r=ae(t);for(let[n,o]of Object.entries(k))if(r===ae(o.toLowerCase())||r===n.replace(/^www\./,"").replace(/\./g,""))return o;return""}function ie(e,t){if(e.method!=="GET")return null;let r=e.headers;if(r.get("sec-fetch-mode")!=="navigate")return null;let n=r.get("sec-fetch-dest");if(n&&n!=="document")return null;let o=rt(r.get("referer")??"",t.toString());if(!o)return null;let a=t.pathname||"/";return a.length>oe&&(a=a.slice(0,oe)),{platform:o,path:a}}var ot="web-bot-auth";async function se(e,t){let r=e.headers.get("signature-input"),n=e.headers.get("signature");if(!r&&!n)return{status:"none"};if(!r||!n)return{status:"invalid"};if(!t||t.length===0)return{status:"unknown_key"};let o,a;try{o=ut(r),a=gt(n)}catch{return{status:"invalid"}}let i=!1,s=!1;for(let c of o){if(c.params.get("tag")!==ot)continue;i=!0;let u=a.get(c.label);if(!u)continue;let p=c.params.get("keyid");if(typeof p!="string")continue;let w;try{w=at(e,c)}catch{s||=t.some(f=>f.keyid===p);continue}let d=t.find(f=>{if(f.keyid!==p)return!1;try{return ce(f.directory)===w}catch{return!1}});if(!d)continue;s=!0;let y=Date.now()/1e3,b=c.params.get("created"),g=c.params.get("expires");if(typeof b!="number"||typeof g!="number"||y>=g+10||y+101440*60||!c.components.some(f=>f.name==="@authority"||f.name==="@target-uri"))continue;let A;try{A=it(e,c)}catch{continue}let l=!1;try{let f=await crypto.subtle.importKey("raw",pe(d.public_key),{name:"Ed25519"},!1,["verify"]);l=await crypto.subtle.verify({name:"Ed25519"},f,u,new TextEncoder().encode(A))}catch{l=!1}if(l)return{status:"verified",name:d.name,category:d.category,keyid:p}}return s?{status:"invalid"}:i?{status:"unknown_key"}:{status:"none"}}function at(e,t){let r=e.headers.get("signature-agent");if(r===null)throw new Error("missing signature-agent");let n=t.components.find(i=>i.name.toLowerCase()==="signature-agent");if(!n)throw new Error("signature-agent is not covered");let o=r.trim(),a;if(o.startsWith('"')){if(n.keyParam!==void 0)throw new Error("legacy string cannot select a member");let i=new P(o);if(a=fe(i),i.skipSP(),!i.eof())throw new Error("trailing signature-agent data")}else{if(n.keyParam===void 0)throw new Error("dictionary member is not covered");let i=T(o).find(c=>c.key===n.keyParam),s=i?.items[0]?.value;if(!i||i.isList||i.items.length!==1||typeof s!="string")throw new Error("signature-agent member is not a string");a=s}return ce(a)}function ce(e){let t=new URL(e);if(t.protocol!=="https:"||!t.hostname||t.username||t.password)throw new Error("signature-agent must be an https URL");return`https://${H(t)}`}function it(e,t){let r=new URL(e.url),n=[];for(let o of t.components)n.push(`${st(o)}: ${ct(e,r,o)}`);return n.push(`"@signature-params": ${t.raw.trim()}`),n.join(` +`)}function st(e){let t=`"${e.name.toLowerCase()}"`;return e.keyParam!==void 0?`${t};key="${e.keyParam}"`:t}function ct(e,t,r){let n=r.name.toLowerCase();if(n==="@authority")return H(t);if(n==="@target-uri")return`${t.protocol}//${H(t)}${t.pathname}${t.search}`;if(n==="signature-agent"){let o=e.headers.get("signature-agent");if(o===null)throw new Error("signature-agent covered but absent");return r.keyParam!==void 0?lt(o,r.keyParam):o.trim().replace(/\s+/g," ")}throw new Error(`unsupported covered component ${n}`)}function H(e){let t=e.hostname.toLowerCase(),r=e.port;return!r||e.protocol==="https:"&&r==="443"||e.protocol==="http:"&&r==="80"?t:`${t}:${r}`}function lt(e,t){let n=T(e).find(o=>o.key===t);if(!n)throw new Error(`signature-agent has no member ${t}`);return n.raw}function ut(e){return T(e).map(r=>{if(!r.isList)throw new Error(`Signature-Input member ${r.key} is not an inner list`);let n=r.items.map(o=>{if(typeof o.value!="string")throw new Error("component identifier is not a string");let a=o.params.get("key");return{name:o.value,keyParam:typeof a=="string"?a:void 0}});return{label:r.key,components:n,params:r.params,raw:r.raw}})}function gt(e){let t=T(e),r=new Map;for(let n of t){if(n.isList||n.items.length!==1)throw new Error(`Signature member ${n.key} is not a byte sequence`);let o=n.items[0].bytes;if(!o)throw new Error(`Signature member ${n.key} is not a byte sequence`);r.set(n.key,o)}return r}var P=class{constructor(t,r=0){this.s=t;this.i=r}eof(){return this.i>=this.s.length}peek(){return this.s[this.i]}skipSP(){for(;!this.eof()&&(this.peek()===" "||this.peek()===" ");)this.i++}};function T(e){let t=new P(e),r=[];for(t.skipSP();!t.eof();){let o={key:ue(t),raw:"",isList:!1,items:[],params:new Map};if(!t.eof()&&t.peek()==="="){t.i++;let a=t.i;if(!t.eof()&&t.peek()==="(")o.isList=!0,ft(t,o);else{let{value:i,params:s,bytes:c}=le(t);o.items.push({value:i,params:s,...c?{bytes:c}:{}})}o.raw=e.slice(a,t.i)}else o.params=K(t),o.items.push({value:!0,params:new Map});if(r.push(o),t.skipSP(),t.eof())break;if(t.peek()!==",")throw new Error(`expected ',' at ${t.i}`);if(t.i++,t.skipSP(),t.eof())throw new Error("trailing comma")}return r}function ft(e,t){for(e.i++;;){if(e.skipSP(),e.eof())throw new Error("unterminated inner list");if(e.peek()===")"){e.i++;break}let{value:r,params:n}=le(e);if(t.items.push({value:r,params:n}),e.eof())throw new Error("unterminated inner list");let o=e.peek();if(o!==" "&&o!==")")throw new Error(`bad inner list at ${e.i}`)}t.params=K(e)}function le(e){let{value:t,bytes:r}=ge(e),n=K(e);return{value:t,params:n,bytes:r}}function K(e){let t=new Map;for(;!e.eof()&&e.peek()===";";){e.i++,e.skipSP();let r=ue(e),n=!0;if(!e.eof()&&e.peek()==="="){e.i++;let o=ge(e);n=(typeof o.value=="boolean",o.value)}typeof n!="boolean"&&t.set(r,n)}return t}function ue(e){let t=e.peek();if(!t||!/[a-z*]/.test(t))throw new Error(`invalid key at ${e.i}`);let r=e.i;for(;!e.eof()&&/[a-z0-9_\-.*]/.test(e.peek());)e.i++;return e.s.slice(r,e.i)}function ge(e){let t=e.peek();if(t==='"')return{value:fe(e)};if(t===":")return{value:"",bytes:pt(e)};if(t==="?"){e.i++;let r=e.peek();if(e.i++,r==="1")return{value:!0};if(r==="0")return{value:!1};throw new Error("bad boolean")}if(t==="-"||/[0-9]/.test(t))return{value:dt(e)};if(/[a-zA-Z*]/.test(t))return{value:ht(e)};throw new Error(`invalid bare item at ${e.i}`)}function fe(e){e.i++;let t="";for(;!e.eof();){let r=e.s[e.i++];if(r==='"')return t;if(r==="\\"){let n=e.s[e.i++];if(n!=='"'&&n!=="\\")throw new Error("bad escape");t+=n}else{let n=r.charCodeAt(0);if(n<32||n>126)throw new Error("bad char in string");t+=r}}throw new Error("unterminated string")}function pt(e){e.i++;let t=e.i;for(;!e.eof()&&e.peek()!==":";)e.i++;if(e.eof())throw new Error("unterminated byte sequence");let r=e.s.slice(t,e.i);return e.i++,pe(r)}function dt(e){let t=e.i;for(e.peek()==="-"&&e.i++;!e.eof()&&/[0-9]/.test(e.peek());)e.i++;if(!e.eof()&&e.peek()===".")for(e.i++;!e.eof()&&/[0-9]/.test(e.peek());)e.i++;return Number(e.s.slice(t,e.i))}function ht(e){let t=e.i;for(e.i++;!e.eof()&&/[a-zA-Z0-9!#$%&'*+\-.^_`|~:/]/.test(e.peek());)e.i++;return e.s.slice(t,e.i)}function pe(e){let t=atob(e),r=new Uint8Array(new ArrayBuffer(t.length));for(let n=0;n0?Object.keys(z).find(t=>z[t]===e)??"":""}function he(e){return e=wt(e),e=yt(e),e.toLowerCase()}function wt(e){for(let t=0;t<4;t++){let r=bt(e);if(r===e)break;e=r}return e}function bt(e){if(!e.includes("%"))return e;let t="";for(let r=0;r=0&&o>=0){let a=n<<4|o;if(a<128){t+=String.fromCharCode(a),r+=2;continue}}}t+=e[r]}return t}function me(e){return e>=48&&e<=57?e-48:e>=97&&e<=102?e-97+10:e>=65&&e<=70?e-65+10:-1}function yt(e){if(e==="")return e;let t=e.length>1&&e[e.length-1]==="/",r=[];for(let o of e.split("/"))if(!(o===""||o===".")){if(o===".."){r.length>0&&r.pop();continue}r.push(o)}let n="/"+r.join("/");return t&&n!=="/"&&(n+="/"),n}function O(e,t){let r=he(e),n=he(t);if(n.endsWith("/*")){let o=n.slice(0,-2);return o===""?!0:r===o||r.startsWith(o+"/")}return r===n}function B(e){return e.endsWith("/*")?e.length-2:1<<20}function we(e,t,r){let n=new Map,o=new Set,a=[],i=[],s=new Map,c=(l,f)=>{let v=new Set(l);for(let R of f)for(let _ of s.get(R)??[])v.add(_);return v.size===0?void 0:[...v].sort()},u=(l,f,v)=>v?{pattern:l,required_trust:f,refused_behaviors:v}:{pattern:l,required_trust:f};for(let l of t){if(!O(e,l.pattern))continue;if((l.exceptions??[]).some(R=>O(e,R))){i.push(l.pattern);continue}let f=n.get(l.pattern);f===void 0&&a.push(l.pattern);let v=mt(l.min_trust);(f===void 0||v>f)&&n.set(l.pattern,v),l.mode!=="monitor"&&o.add(l.pattern);for(let R of l.refuse_behaviors??[])s.has(l.pattern)||s.set(l.pattern,new Set),s.get(l.pattern)?.add(R)}let p=[];if(a.length===0){let l="";for(let f of i)(l===""||B(f)>B(l))&&(l=f);return{covering:a,deciding_mode:"",attributed:"",required_trust:"",requirement_source:"",previews:p,excepted_by:l,refused_behaviors:[]}}a.sort((l,f)=>B(f)-B(l));let w=r==="monitor",d=a.filter(l=>o.has(l)),y=w?"monitor":"enforce";if(d.length===0){if(!w)for(let l of a)p.push(u(l,V(n.get(l)??0),c([],[l])));d=a,y="monitor"}let b=0,g="";for(let l of d){let f=n.get(l)??0;f>b&&(b=f,g=l)}let A=c([],d)??[];if(y==="enforce")for(let l of a)o.has(l)||p.push(u(l,V(Math.max(b,n.get(l)??0)),c(A,[l])));return{covering:a,deciding_mode:y,attributed:d[0],required_trust:V(b),requirement_source:g,previews:p,excepted_by:"",refused_behaviors:A}}var vt="other",_t={"search engine crawler":"search","ai search":"search","ai assistant":"agent","ai crawler":"training","page preview":"link_preview","feed fetcher":"feeds","monitoring & analytics":"monitoring","search engine optimization":"seo",security:"security_testing"};function I(e){let t=e.trim().toLowerCase();return t?_t[t]??vt:""}function W(e,t){let r=I(e);return r!==""&&t.includes(r)}var xt={search_engines:["search engine crawler"],ai_crawlers:["ai assistant","ai crawler","ai search"],monitoring:["monitoring & analytics"]};function be(e,t){let r=xt[e];return r?r.every(n=>W(n,t)):!1}var At=O;function ye(e,t,r){return e?t?t.some(n=>At(r,n)):!0:!1}var Rt="x-wd-service-token",Ct="wd_clearance";function ve(e,t,r=[]){if(e.graded&&t&&!Bt(e.graded.trust,t))return{pass:!1,label:"insufficient-trust"};if(e.crawler&&r.length>0){if(e.crawler.behavior&&r.includes(e.crawler.behavior))return{pass:!1,label:"crawler-refused"};if(!e.crawler.behavior&&e.crawler.exempted)return{pass:!1,label:"missing"}}return{pass:e.pass,label:e.label}}async function Re(e,t,r){let n=new URL(e.url).pathname,o=we(n,Pt(t),t.mode),a=o.deciding_mode||t.mode,i=await kt(e,t,r,o.covering.length>0,o.excepted_by!==""),s=i.behavior??"",c=ve(i,o.required_trust,o.refused_behaviors),u=c.label==="excepted"?o.excepted_by:c.label==="unscoped"?"":o.attributed,p=!c.pass&&a==="enforce",w=o.previews.map(d=>{let y=ve(i,d.required_trust,d.refused_behaviors??[]);return{pattern:d.pattern,refused:!y.pass&&!p?y.label:""}});return{pass:c.pass,label:c.label,pattern:u,mode:a,previews:w,behavior:c.pass?s:"",crawlerBehavior:i.crawler?.behavior??"",referral:ie(e,new URL(e.url)),outsideReach:i.outsideReach??""}}async function kt(e,t,r,n,o){let a=await St(e,t,r);if(a.grants)return{pass:!0,label:"machine"};let i=await Et(e,t,r,n,o);return a.outsideReach?{...i,outsideReach:a.outsideReach}:i}async function St(e,t,r){let n=e.headers.get(Rt);if(!n)return{grants:!1,outsideReach:""};let o=await ke(n,t.keys,r.siteKey);if(!o||o.typ!=="machine"||!o.sub)return{grants:!1,outsideReach:""};if(t.active_credentials.includes(o.sub))return{grants:!0,outsideReach:""};let a=(t.restricted_credentials??[]).find(s=>s&&s.id===o.sub);if(!a)return{grants:!1,outsideReach:""};let i=new URL(e.url).pathname;return ye(a.here,a.paths,i)?{grants:!0,outsideReach:""}:{grants:!1,outsideReach:o.sub}}async function Et(e,t,r,n,o){let a=await r.verifiedBotCategory(e,n)||"",i=a?{behavior:I(a),exempted:!1}:void 0;if(a&&Ot(a,t)){let p=I(a);return{pass:!0,label:"verified-bot",behavior:p,crawler:{behavior:p,exempted:!0}}}let s=await se(e,t.signed_agents);if(s.status==="verified"&&It(s.category,t))return{pass:!0,label:"signed-agent",behavior:xe(s.category),crawler:{behavior:xe(s.category),exempted:!0}};if(s.status==="invalid"&&n)return{pass:!1,label:"agent-impersonation",crawler:i};if(!n)return o?{pass:!0,label:"excepted",crawler:i}:{pass:!0,label:"unscoped",crawler:i};let c=j(e.headers.get("cookie")??"",Ct);if(!c)return{pass:!1,label:"missing",crawler:i};let u=await ke(c,t.keys,r.siteKey);return!u||(u.typ??"")!==""?{pass:!1,label:"invalid",crawler:i}:t.denied_fps&&t.denied_fps.includes(u.fp)?{pass:!1,label:"revoked",crawler:i}:{pass:!0,label:"valid",graded:{trust:u.trust},crawler:i}}function Pt(e){let t=new Set(e.routes),r=new Map;for(let o of e.route_exceptions??[])t.has(o.pattern)&&Array.isArray(o.except)&&r.set(o.pattern,o.except);let n=e.routes.map(o=>({pattern:o,exceptions:r.get(o)}));for(let o of e.route_min_trust??[])t.has(o.pattern)&&n.push({pattern:o.pattern,min_trust:o.min_trust,exceptions:r.get(o.pattern)});for(let o of e.monitor_routes??[])n.push({pattern:o.pattern,min_trust:o.min_trust,mode:"monitor",exceptions:o.except});for(let o of e.route_refusals??[])!Array.isArray(o.refuse_behaviors)||o.refuse_behaviors.length===0||(o.mode==="monitor"?n.push({pattern:o.pattern,mode:"monitor",refuse_behaviors:o.refuse_behaviors}):t.has(o.pattern)&&n.push({pattern:o.pattern,exceptions:r.get(o.pattern),refuse_behaviors:o.refuse_behaviors}));return n}var Tt={"attested-human":2,"human-likely":1,clean:0};function _e(e){return Tt[e??""]??0}function Bt(e,t){return _e(e)>=_e(t)}function Ot(e,t){let r=t.allow_behaviors;return r?W(e,r):Dt(e,t.allow)}function It(e,t){let r=t.allow_behaviors;return r?be(e,r):de(e,t.allow)}function xe(e){return e==="search_engines"?"search":e==="monitoring"?"monitoring":""}function Dt(e,t){let r=e.trim().toLowerCase();return!!(t.search_engines&&r.includes("search engine crawler")||t.ai_crawlers&&r.startsWith("ai ")||t.monitoring&&r.includes("monitoring"))}function F(e){return!e.pass&&e.mode==="enforce"&&(e.label==="missing"||e.label==="invalid")}function Ce(e){return F(e)?{...e,pass:!0,label:"outage-pass"}:e}function j(e,t){for(let r of e.split(";")){let n=r.indexOf("=");if(!(n<0)&&r.slice(0,n).trim()===t)return r.slice(n+1).trim()||null}return null}async function ke(e,t,r){try{let n=e.indexOf(".");if(n<0)return null;let o=Ae(e.slice(0,n)),a=Ae(e.slice(n+1)),i=JSON.parse(new TextDecoder().decode(o)),s=t.find(p=>p.kid===i.kid);if(!s)return null;let c=await crypto.subtle.importKey("raw",Mt(s.public_key),{name:"Ed25519"},!1,["verify"]);return!await crypto.subtle.verify({name:"Ed25519"},c,a,o)||i.tenant!==r||Date.now()/1e3>=i.exp?null:i}catch{return null}}function Ae(e){let t=e.replace(/-/g,"+").replace(/_/g,"/")+"=".repeat((4-e.length%4)%4);return Se(atob(t))}function Mt(e){return Se(atob(e))}function Se(e){let t=new Uint8Array(new ArrayBuffer(e.length));for(let r=0;r{let o=!1;try{let a=new AbortController,i=setTimeout(()=>a.abort(),3e3);try{let s=await n(e.replace(/\/+$/,"")+"/api/v1/clearance",{method:"POST",headers:{"content-type":"application/json","user-agent":"WebDecoy-OutageProbe/1"},body:JSON.stringify({aid:t,fp:"wd-outage-probe",scope:"",ua:"WebDecoy-OutageProbe/1"}),signal:a.signal});s.status<300&&(o=typeof(await s.json())?.granted=="boolean")}finally{clearTimeout(i)}}catch{o=!1}return D={at:r,available:o},o})();try{return await S}finally{S=null}}function Ee(e){let t="";for(let r of new Uint8Array(e))t+=String.fromCharCode(r);return btoa(t).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}async function Pe(e,t){let r=await crypto.subtle.importKey("raw",new TextEncoder().encode(e),{name:"HMAC",hash:"SHA-256"},!1,["sign"]);return Ee(await crypto.subtle.sign("HMAC",r,new TextEncoder().encode(t)))}async function Te(e){return Ee(await crypto.subtle.digest("SHA-256",new TextEncoder().encode(e))).slice(0,16)}function Be(e,t,r){return`${Lt}|${e}|${t}|${r}`}async function Nt(e,t,r,n){let o=Math.floor(n/1e3)+300;return`${o}.${await Pe(e,Be(t,o,await Te(r)))}`}async function Oe(e,t,r,n,o){if(!t||!e)return!1;let a=e.indexOf(".");if(a<=0)return!1;let i=Number(e.slice(0,a));if(!Number.isInteger(i)||i<=Math.floor(o/1e3)||i>Math.floor(o/1e3)+300)return!1;let s=await Pe(t,Be(r,i,await Te(n))),c=e.slice(a+1);if(c.length!==s.length)return!1;let u=0;for(let p=0;pnew Response(JSON.stringify(s),{status:i,headers:{"content-type":"application/json","cache-control":"no-store",...c}});if(e.method!=="POST")return o(405,{pass:!1});if(!t.sensorKey)return o(503,{pass:!1,reason:"unsupported"});if(await Ut(t.apiBase,t.siteKey,r,n))return o(409,{pass:!1,reason:"mint_available"});let a=await Nt(t.sensorKey,t.host,e.headers.get("user-agent")??"",r);return o(200,{pass:!0,expires_in:300},{"set-cookie":`${q}=${a}; Path=/; Secure; SameSite=Lax; HttpOnly; Max-Age=300`})}var Gt="wdfp1",De=` +async function wdDeviceFP() { + // Canvas: 32-bit rolling hash of the dataURL, matching + // EnvironmentalCollector._getCanvasHash in @webdecoy/client. The drawing + // parameters below are part of the fingerprint \u2014 changing any of them + // changes every fingerprint. + var canvas32 = 'na'; + try { + var c = document.createElement('canvas'); + c.width = 200; c.height = 50; + var x = c.getContext('2d'); + x.textBaseline = 'top'; x.font = '14px Arial'; + x.fillStyle = '#f60'; x.fillRect(125, 1, 62, 20); + x.fillStyle = '#069'; x.fillText('FCaptcha', 2, 15); + x.fillStyle = 'rgba(102, 204, 0, 0.7)'; x.fillText('FCaptcha', 4, 17); + var dataUrl = c.toDataURL(); + var h = 0; + for (var i = 0; i < dataUrl.length; i++) { h = ((h << 5) - h) + dataUrl.charCodeAt(i); h = h & h; } + canvas32 = h.toString(16); + } catch (e) { canvas32 = 'na'; } + + var webgl = 'na'; + try { + var gc = document.createElement('canvas'); + var gl = gc.getContext('webgl') || gc.getContext('experimental-webgl'); + if (gl) { + var dbg = gl.getExtension('WEBGL_debug_renderer_info'); + var vendor = dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : 'unknown'; + var renderer = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : 'unknown'; + webgl = String(renderer) + '~' + String(vendor); + } + } catch (e) { webgl = 'na'; } + + var tz = 'na'; + try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'na'; } catch (e) {} + + var raw = ['${Gt}', canvas32, webgl, + screen.width + 'x' + screen.height + 'x' + screen.colorDepth, + tz, navigator.platform || 'na', navigator.language || 'na' + ].join('|'); + var buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw)); + var fp = Array.from(new Uint8Array(buf)).map(function (b) { + return b.toString(16).padStart(2, '0'); + }).join(''); + + return { fp: fp, canvas32: canvas32, webgl: webgl }; +} +`;function J(e){let r=["missing","invalid","insufficient-trust","revoked","crawler-refused","agent-impersonation"].includes(e)?e:"denied";return{version:1,reason:r,recoverable:r==="missing"||r==="invalid"||r==="insufficient-trust"}}function Me(e){let t=J(e);return{"cache-control":"no-store","x-wd-clearance":"challenged","x-wd-challenge":String(t.version),"x-wd-challenge-reason":t.reason,"x-wd-challenge-recoverable":String(t.recoverable)}}var $t=6e4,L="x-wd-clearance",Le="x-wd-class",Ue="__wd_clearance_check",Z="/.well-known/wd-clearance/pat",E=new Map,Ht=16,Kt=3e4,Vt=3e5,X=new Map,Y=new Map,zt=86400;function Ne(e,t){return`https://wd-config.invalid/${encodeURIComponent(e)}/${encodeURIComponent(t)}`}function Ge(){try{return globalThis.caches?.default??null}catch{return null}}async function $e(e,t){let r=Ge();if(!r)return null;try{let n=await r.match(Ne(e,t));return n?await n.json():null}catch{return null}}async function Wt(e,t,r){let n=Ge();if(n)try{await n.put(Ne(e,t),new Response(JSON.stringify(r),{headers:{"content-type":"application/json","cache-control":`max-age=${zt}`}}))}catch{}}function Ft(e){let t=e?.headers.get("retry-after"),r=t?Number(t):NaN;return Number.isFinite(r)&&r>0?Math.min(Math.max(r*1e3,5e3),Vt):Kt}async function He(e,t){let r=Date.now(),n=t,o=E.get(n);if(o&&r-o.at<$t)return o.config;let a=X.get(n);if(a!==void 0&&rHt){let c=E.keys().next();c.done||E.delete(c.value)}return await Wt(e.siteKey,t,s),s}}catch{}return X.set(r,n+Ft(a)),o??await $e(e.siteKey,t)}function Ke(e,t,r,n){return new Response(JSON.stringify({wd_clearance:!0,nonce:e,mode:t,site_key:r,capabilities:n}),{status:200,headers:{"content-type":"application/json","cache-control":"no-store","access-control-allow-origin":"*"}})}var qt=3e3;async function Ve(e,t){let r=new URL(e.url).searchParams.get("fp")??"",n=`${t}/api/v1/clearance/pat?fp=${encodeURIComponent(r)}`,o=new Headers,a=e.headers.get("authorization");a&&o.set("authorization",a);let i;try{i=await fetch(n,{method:"GET",headers:o,redirect:"manual",signal:AbortSignal.timeout(qt)})}catch{return new Response(JSON.stringify({attested:!1,reason:"unreachable"}),{status:200,headers:{"content-type":"application/json","cache-control":"no-store"}})}let s=new Headers({"content-type":"application/json","cache-control":"no-store"}),c=i.headers.get("www-authenticate");return c&&s.set("www-authenticate",c),new Response(i.body,{status:i.status,headers:s})}function ze(e,t,r,n="denied"){let o=e.headers.get("accept")??"";if(e.method!=="GET"||!o.includes("text/html"))return new Response(JSON.stringify({error:"clearance required",challenge:J(n)}),{status:403,headers:{"content-type":"application/json",...Me(n)}});let a=Yt(t,r);return new Response(a,{status:403,headers:{"content-type":"text/html; charset=utf-8","cache-control":"no-store",[L]:"challenged"}})}var Jt="https://cdn.webdecoy.com/wordpress/assets/icon-128x128.png";function Yt(e,t){return` + + + + +Checking your browser\u2026 + + + +
+
WebDecoy
+
+

Checking your browser

+

This takes a moment and happens only once.

+

This site is protected by WebDecoy.

+
+ + +`}function U(e){if(typeof e!="string")return"";let t=e.trim().toLowerCase().replace(/:\d+$/,"").replace(/\.$/,"");return t.length>0&&t.length<=253?t:""}var We=["route_min_trust","route_attribution","monitor_routes","route_exceptions","bot_behaviors","credential_reach","route_refusals","crawler_behavior","ai_referrals"];var Q=60,Xt=500,Zt=6e3,Fe=40,Qt=60,er=16,C=new TextEncoder,tr=3e3,rr=2e4,nr=1e4,or=16,x=new Map;function ar(e,t,r){return`${e}|${t}|${r}`}function je(e,t,r,n,o=Date.now()){try{let a=e?.label??"",i=e?.mode??"",s=e?.pattern??"",c=e?.previews??[],u=e?.behavior??"",p=e?.crawlerBehavior??"",w=e?.outsideReach??"";if(!a||!r||!r.siteKey||!r.apiBase)return;let d=U(t);if(!d)return;let y=Math.floor(o/1e3/Q)*Q,b=ar(y,i,d);for(let[h,m]of x)m.bucketStart!==y&&(N(m,r,n),x.delete(h));let g=x.get(b);if(!g){if(x.size>=or)for(let[h,m]of x)N(m,r,n),x.delete(h);g={bucketStart:y,mode:i,host:d,counts:new Map,routes:new Map,previews:new Map,credentials:new Map,referrals:new Map,routeBytes:0,total:0},x.set(b,g),ir(b,g,r,n,o)}g.counts.set(a,(g.counts.get(a)??0)+1);let A=typeof s=="string"?s:"",l=typeof u=="string"?u:"",f=typeof p=="string"?p:"",v=`${A} +${a} +${l} +${f}`,R=g.routes.get(v);R?R.count++:(g.routes.set(v,{pattern:A,verdict:a,behavior:l,crawlerBehavior:f,count:1}),g.routeBytes+=C.encode(A).length+C.encode(a).length+C.encode(l).length+C.encode(f).length+Fe);for(let h of Array.isArray(c)?c:[]){if(!h||typeof h.pattern!="string"||!h.pattern)continue;let m=g.previews.get(h.pattern);m||(m={evaluated:0,refused:new Map},g.previews.set(h.pattern,m),g.routeBytes+=C.encode(h.pattern).length+Qt),m.evaluated++,typeof h.refused=="string"&&h.refused&&(m.refused.has(h.refused)||(g.routeBytes+=C.encode(h.refused).length+er),m.refused.set(h.refused,(m.refused.get(h.refused)??0)+1))}typeof w=="string"&&w&&g.credentials.set(w,(g.credentials.get(w)??0)+1);let _=e?.referral;if(_&&typeof _.platform=="string"&&_.platform&&typeof _.path=="string"){let h=`${_.platform} +${_.path}`,m=g.referrals.get(h);m?m.count++:(g.referrals.set(h,{platform:_.platform,path:_.path,count:1}),g.routeBytes+=C.encode(_.platform).length+C.encode(_.path).length+Fe)}g.total++,(g.total>=Xt||g.routeBytes>=Zt)&&(N(g,r,n),x.delete(b))}catch{}}function ir(e,t,r,n,o){try{let a=(t.bucketStart+Q)*1e3,i=Math.floor(Math.random()*nr),s=Math.max(0,Math.min(a-o+i,rr));n.waitUntil(new Promise(c=>{setTimeout(()=>{try{x.get(e)===t&&(N(t,r,n),x.delete(e))}catch{}c()},s)}))}catch{}}function N(e,t,r){try{if(e.total<=0||e.counts.size===0)return;let n={};for(let[s,c]of e.counts)n[s]=c;let o=[];for(let s of e.routes.values()){let c={pattern:s.pattern,verdict:s.verdict,count:s.count};s.behavior&&(c.behavior=s.behavior),s.crawlerBehavior&&(c.crawler_behavior=s.crawlerBehavior),o.push(c)}let a={organization_id:t.siteKey,bucket_start:e.bucketStart,mode:e.mode,counts:n,route_counts:o,report_id:crypto.randomUUID()};if(e.previews.size>0){let s=[];for(let[c,u]of e.previews){let p={};for(let[w,d]of u.refused)p[w]=d;s.push({pattern:c,evaluated:u.evaluated,additional_refusals:p})}a.previews=s}if(e.credentials.size>0){let s=[];for(let[c,u]of e.credentials)s.push({credential_id:c,outside_reach:u});a.credential_presentations=s}e.referrals.size>0&&(a.ai_referrals=[...e.referrals.values()].map(s=>({platform:s.platform,path:s.path,count:s.count}))),t.propertyId&&(a.property_id=t.propertyId),a.host=e.host,t.bundleVersion&&(a.reporter_version=t.bundleVersion);let i=!!(t.scannerId&&t.sensorKey);i&&(a.scanner_id=t.scannerId),a.capabilities=t.capabilities??We,r.waitUntil(sr(t.apiBase,JSON.stringify(a),i?t.sensorKey:void 0).then(()=>{},()=>{}))}catch{}}async function sr(e,t,r){let n=new AbortController,o=setTimeout(()=>n.abort(),tr),a={"content-type":"application/json"};r&&(a["x-wd-sensor-key"]=r);try{await fetch(e.replace(/\/+$/,"")+"/api/v1/clearance/telemetry",{method:"POST",headers:a,body:t,signal:n.signal})}finally{clearTimeout(o)}}var G=["route_min_trust","route_attribution","monitor_routes","route_exceptions","bot_behaviors","credential_reach","bot_verification_rdns","crawler_behavior","ai_referrals"];var cr=[{category:"search_engines",suffixes:[".googlebot.com",".google.com",".search.msn.com",".crawl.yahoo.net",".applebot.apple.com",".yandex.com",".yandex.net",".yandex.ru",".crawl.baidu.com",".crawl.baidu.jp"]}];function lr(e,t){let r=e.toLowerCase().replace(/\.$/,"");for(let n of cr)if(t[n.category]){for(let o of n.suffixes)if(r.endsWith(o))return n.category}return null}var $=new Map,ur=36e5,gr=5e3;async function qe(e,t,r,n){let o=$.get(e);if(o&&n-o.at=gr&&$.clear(),$.set(e,{category:a,at:n}),a}async function fr(e,t,r){let n=e.replace(/\.$/,""),o=t.includes(":")?[r.resolve6(n)]:[r.resolve4(n)];try{return(await Promise.all(o)).flat().some(i=>i===t)}catch{return!1}}var Je=[["reflectionbot","Reflectionbot","reflectionbot","crawler"],["gptbot","GPTBot","gptbot","crawler"],["claudebot","ClaudeBot","claudebot","crawler"],["anthropic-ai","Anthropic","anthropic","crawler"],["claude-web","Claude-Web","claude-web","crawler"],["ccbot","CCBot","ccbot","crawler"],["google-extended","Google-Extended","google-extended","crawler"],["bytespider","ByteSpider","bytespider","crawler"],["amazonbot","Amazonbot","amazonbot","crawler"],["facebookbot","FacebookBot","facebookbot","crawler"],["meta-externalagent","Meta-ExternalAgent","meta-externalagent","crawler"],["cohere-ai","Cohere","cohere-ai","crawler"],["cohere","Cohere","cohere-ai","crawler"],["applebot-extended","Applebot-Extended","applebot-extended","crawler"],["youbot","YouBot","youbot","crawler"],["mistralbot","MistralBot","mistral","crawler"],["gemini","Gemini","gemini","crawler"],["ai2bot","AI2Bot","ai2bot","crawler"],["deepseek","DeepSeek","deepseek","crawler"],["qwen","Qwen","qwen","crawler"],["webzio","Webz.io","webzio","crawler"],["sentibot","Sentibot","sentibot","crawler"],["omgili","Omgili","omgili","crawler"],["img2dataset","img2dataset","img2dataset","crawler"],["timpibot","Timpibot","timpibot","crawler"],["velenpublicwebcrawler","VelenPublicWebCrawler","velenpublicwebcrawler","crawler"],["isscyberriskcrawler","ISSCyberRiskCrawler","isscyberriskcrawler","crawler"],["friendlycrawler","FriendlyCrawler","friendlycrawler","crawler"],["duckassistbot","DuckAssistBot","duckassistbot","crawler"],["sofyabot","SofyaBot","sofyabot","crawler"],["xai-searchbot","xAI-SearchBot","xai-searchbot","crawler"],["linkupbot","LinkupBot","linkupbot","crawler"],["perplexitybot","PerplexityBot","perplexitybot","crawler"],["claude-searchbot","Claude-SearchBot","claude-searchbot","crawler"],["oai-searchbot","OAI-SearchBot","oai-searchbot","crawler"],["searchgpt","SearchGPT","searchgpt","crawler"],["phind","Phind","phind","crawler"],["kagi","Kagi","kagi","crawler"],["exa.ai","Exa","exa","crawler"],["exabot","Exa","exa","crawler"],["metaphor","Metaphor","metaphor","crawler"],["tavily","Tavily","tavily","crawler"],["bravesearch","BraveSearch","brave-search","crawler"],["neevabot","NeevaBot","neeva","crawler"],["operator","Operator","operator","crawler"],["claude-user","Claude-User","claude-user","crawler"],["gemini-user","Gemini-User","gemini-user","crawler"],["googleagent-mariner","GoogleAgent-Mariner","googleagent-mariner","crawler"],["mariner","GoogleAgent-Mariner","googleagent-mariner","crawler"],["amazonbuyforme","AmazonBuyForMe","amazon-buyforme","crawler"],["buyforme","AmazonBuyForMe","amazon-buyforme","crawler"],["browser-use","Browser-Use","browser-use","crawler"],["stagehand","Stagehand","stagehand","crawler"],["multion","MultiOn","multion","crawler"],["iboubot","IbouBot","iboubot","crawler"],["googlebot","Googlebot","googlebot","crawler"],["google-inspectiontool","Google-InspectionTool","google-inspectiontool","crawler"],["bingbot","Bingbot","bingbot","crawler"],["msnbot","MSNBot","msnbot","crawler"],["yandexbot","YandexBot","yandexbot","crawler"],["baiduspider","Baiduspider","baiduspider","crawler"],["duckduckbot","DuckDuckBot","duckduckbot","crawler"],["applebot","Applebot","applebot","crawler"],["sogou","Sogou Spider","sogou","crawler"],["qwantify","Qwantify","qwantify","crawler"],["naver","Naver","naver","crawler"],["yeti","Naver","naver","crawler"],["seznambot","SeznamBot","seznam","crawler"],["ecosia","Ecosia","ecosia","crawler"],["mojeekbot","MojeekBot","mojeek","crawler"],["slurp","Yahoo! Slurp","yahoo-slurp","crawler"],["ahrefsbot","AhrefsBot","ahrefsbot","crawler"],["ahrefs","AhrefsBot","ahrefsbot","crawler"],["semrushbot","SemrushBot","semrushbot","crawler"],["semrush","SemrushBot","semrushbot","crawler"],["mj12bot","MJ12bot","mj12bot","crawler"],["dotbot","DotBot","dotbot","crawler"],["rogerbot","Rogerbot","rogerbot","crawler"],["screaming frog","Screaming Frog","screaming-frog","crawler"],["sitebulb","Sitebulb","sitebulb","crawler"],["deepcrawl","DeepCrawl","deepcrawl","crawler"],["oncrawl","OnCrawl","oncrawl","crawler"],["botify","Botify","botify","crawler"],["contentking","ContentKing","contentking","crawler"],["sistrix","SISTRIX","sistrix","crawler"],["serpstat","Serpstat","serpstat","crawler"],["spyfu","SpyFu","spyfu","crawler"],["nessus","Nessus","nessus","crawler"],["qualys","Qualys","qualys","crawler"],["shodan","Shodan","shodan","crawler"],["censys","Censys","censys","crawler"],["nmap","Nmap","nmap","crawler"],["zap/","OWASP ZAP","zap","crawler"],["burp","Burp Suite","burp","crawler"],["nikto","Nikto","nikto","crawler"],["nuclei","Nuclei","nuclei","crawler"],["acunetix","Acunetix","acunetix","crawler"],["netsparker","Netsparker","netsparker","crawler"],["detectify","Detectify","detectify","crawler"],["intruder","Intruder","intruder","crawler"],["python-requests","Python-Requests","python-requests","tool"],["python-urllib","Python-urllib","python-urllib","tool"],["python/","Python","python","tool"],["scrapy","Scrapy","scrapy","tool"],["httpx","HTTPX","httpx","tool"],["aiohttp","aiohttp","aiohttp","tool"],["beautifulsoup","BeautifulSoup","beautifulsoup","tool"],["node-fetch","node-fetch","node-fetch","tool"],["axios/","Axios","axios","tool"],["got/","Got","got","tool"],["undici","Undici","undici","tool"],["go-http-client","Go-HTTP-Client","go-http-client","tool"],["colly","Colly","colly","tool"],["java/","Java","java","tool"],["okhttp","OkHttp","okhttp","tool"],["apache-httpclient","Apache-HttpClient","apache-httpclient","tool"],["jsoup","Jsoup","jsoup","tool"],["guzzlehttp","GuzzleHttp","guzzlehttp","tool"],["php/","PHP","php","tool"],["ruby","Ruby","ruby","tool"],["faraday","Faraday","faraday","tool"],["mechanize","Mechanize","mechanize","tool"],["headlesschrome","HeadlessChrome","headlesschrome","tool"],["puppeteer","Puppeteer","puppeteer","tool"],["playwright","Playwright","playwright","tool"],["selenium","Selenium","selenium","tool"],["phantomjs","PhantomJS","phantomjs","tool"],["splash","Splash","splash","tool"],["browserless","Browserless","browserless","tool"],["browserbase","Browserbase","browserbase","tool"],["ia_archiver","Internet Archive","ia-archiver","crawler"],["archive.org_bot","Archive.org","archive-org-bot","crawler"],["wayback","Wayback Machine","wayback","crawler"],["heritrix","Heritrix","heritrix","crawler"],["brozzler","Brozzler","brozzler","crawler"],["facebookexternalhit","Facebook","facebookexternalhit","crawler"],["facebot","Facebot","facebot","crawler"],["twitterbot","Twitterbot","twitterbot","crawler"],["linkedinbot","LinkedInBot","linkedinbot","crawler"],["slackbot","Slackbot","slackbot","crawler"],["telegrambot","TelegramBot","telegrambot","crawler"],["whatsapp","WhatsApp","whatsapp","crawler"],["discordbot","Discordbot","discordbot","crawler"],["pinterest","Pinterest","pinterestbot","crawler"],["tumblr","Tumblr","tumblr","crawler"],["reddit","Reddit","reddit","crawler"],["embedly","Embedly","embedly","crawler"],["iframely","Iframely","iframely","crawler"],["curl/","cURL","curl","tool"],["wget/","Wget","wget","tool"],["libwww-perl","libwww-perl","libwww-perl","tool"],["lwp-","LWP","lwp","tool"],["httrack","HTTrack","httrack","tool"],["webdecoybot","WebDecoyBot","webdecoybot","crawler"],["uptimerobot","UptimeRobot","uptimerobot","crawler"],["pingdom","Pingdom","pingdom","crawler"],["statuscake","StatusCake","statuscake","crawler"],["betteruptime","Better Uptime","better-uptime","crawler"],["datadog","Datadog Synthetics","datadog-synthetics","crawler"],["newrelic","New Relic","newrelic","crawler"],["site24x7","Site24x7","site24x7","crawler"],["freshping","Freshping","freshping","crawler"],["hetrixtools","HetrixTools","hetrixtools","crawler"],["nodeping","NodePing","nodeping","crawler"],["gtmetrix","GTmetrix","gtmetrix","crawler"],["chrome-lighthouse","Lighthouse","chrome-lighthouse","crawler"],["google page speed","Lighthouse","chrome-lighthouse","crawler"],["feedly","Feedly","feedly","crawler"],["feedfetcher","Feedfetcher-Google","feedfetcher-google","crawler"],["newsblur","NewsBlur","newsblur","crawler"],["inoreader","Inoreader","inoreader","crawler"],["theoldreader","The Old Reader","theoldreader","crawler"],["feedbin","Feedbin","feedbin","crawler"],["miniflux","Miniflux","miniflux","crawler"],["freshrss","FreshRSS","freshrss","crawler"],["commafeed","CommaFeed","commafeed","crawler"],["diffbot","Diffbot","diffbot","crawler"],["import.io","Import.io","import-io","crawler"],["brightdata","Bright Data","bright-data","crawler"],["luminati","Bright Data","bright-data","crawler"],["oxylabs","Oxylabs","oxylabs","crawler"],["scrapingbee","ScrapingBee","scrapingbee","crawler"],["scrapingant","ScrapingAnt","scrapingant","crawler"],["zyte","Zyte","zyte","crawler"],["scrapinghub","Zyte","zyte","crawler"],["apify","Apify","apify","crawler"],["crawlbase","Crawlbase","crawlbase","crawler"],["webscrapingapi","WebScrapingAPI","webscrapingapi","crawler"],["chatgpt-user","ChatGPT-User","chatgpt-user","crawler"],["perplexity-user","Perplexity-User","perplexity-user","crawler"],["mistralai-user","MistralAI-User","mistralai-user","crawler"],["meta-externalfetcher","Meta-ExternalFetcher","meta-externalfetcher","crawler"],["gemini-deep-research","Gemini-Deep-Research","gemini-deep-research","crawler"],["copilot","Microsoft Copilot","copilot","crawler"],["cortana","Cortana","cortana","crawler"],["siri","Siri","siri","crawler"]];function pr(e){for(let[t,r,n,o]of Je)if(e.includes(t))return{name:r,id:n,kind:o};return null}var dr=/(?:bot|crawler|spider)(?:[/\s);,]|$)/i;function Ye(e){let t=e.toLowerCase(),r=pr(t);return r?.kind==="crawler"||!r&&dr.test(e)}var hr="Search Engine Crawler",ee=1e3,mr={search_engines:!0,ai_crawlers:!0,monitoring:!0};function wr(e){if(!e.includes(":"))return e.split(".").reverse().join(".")+".in-addr.arpa";let[t,r=""]=e.split("::"),n=t?t.split(":"):[],o=r?r.split(":"):[],a=8-n.length-o.length;return a<0?"":[...n,...Array(Math.max(0,a)).fill("0"),...o].map(s=>s.padStart(4,"0")).join("").split("").reverse().join(".")+".ip6.arpa"}function te(e,t){return Promise.race([e,new Promise((r,n)=>setTimeout(()=>n(new Error("dns timeout")),t))])}function br(e){return{reverse:t=>te(e(wr(t),"PTR"),ee),resolve4:t=>te(e(t,"A"),ee),resolve6:t=>te(e(t,"AAAA"),ee)}}function Xe(e,t,r,n=typeof Deno<"u"&&Deno&&typeof Deno.resolveDns=="function"?Deno.resolveDns.bind(Deno):void 0,o=Date.now()){return!r||!e||!n||!Ye(t)?Promise.resolve(""):qe(e,mr,br(n),o).then(a=>a==="search_engines"?hr:"").catch(()=>"")}var yr="https://cloudflare-dns.com/dns-query",vr=1e3,_r={A:1,PTR:12,AAAA:28};function xr(e=fetch){return async(t,r)=>{let n=_r[r];if(!n)throw new Error(`unsupported record type ${r}`);let o=`${yr}?name=${encodeURIComponent(t)}&type=${r}`,a=await e(o,{headers:{accept:"application/dns-json"},signal:AbortSignal.timeout(vr)});if(!a.ok)throw new Error(`doh ${a.status}`);let i=await a.json();if(i.Status!==0)throw new Error(`dns status ${i.Status}`);let s=(i.Answer??[]).filter(c=>c.type===n&&typeof c.data=="string").map(c=>c.data);if(s.length===0)throw new Error("no records");return s}}function Ze(e,t,r,n=fetch){return Xe(e,t,r,xr(n))}var re="vercel-9971cf5b9746";var Qe="https://in.webdecoy.com";function et(e){let t=new Headers(e.headers);return t.delete(L),t.delete(Le),t}function ne(e,t){let r=et(e);return t&&r.set(L,t),{action:"continue",headers:r,verdict:t}}async function Ar(e,t,r){try{let n=(r.ingest??"").trim().replace(/\/+$/,"")||Qe,o=new URL(e.url),a=o.searchParams.get(Ue),i=U(o.hostname),s=await He({apiBase:n,siteKey:r.siteKey},i);if(a!==null)return{action:"respond",response:Ke(a,s?s.mode:"unknown",r.siteKey,G)};if(o.pathname===Z)return{action:"respond",response:await Ve(e,n)};if(o.pathname===M)return{action:"respond",response:await Ie(e,{apiBase:n,siteKey:r.siteKey,sensorKey:r.sensorKey,host:i})};if(!s)return ne(e,null);let c=await Re(e,s,{siteKey:r.siteKey,verifiedBotCategory:(u,p)=>Ze(t.ip,u.headers.get("user-agent")??"",p,t.fetch??fetch)});if(F(c)){let u=j(e.headers.get("cookie")??"",q);u&&await Oe(u,r.sensorKey,i,e.headers.get("user-agent")??"",Date.now())&&(c=Ce(c))}return je(c,o.hostname,{siteKey:r.siteKey,apiBase:n,scannerId:r.scannerId,sensorKey:r.sensorKey,bundleVersion:re,capabilities:G},{waitUntil:u=>t.waitUntil?.(u)},Date.now()),c.pass||c.mode!=="enforce"?ne(e,c.label):{action:"respond",response:ze(e,n,r.siteKey,c.label)}}catch{try{return ne(e,null)}catch{return{action:"continue",headers:null,verdict:null}}}}export{re as BUILD,Qe as DEFAULT_INGEST,G as VERCEL_VALIDATOR_CAPABILITIES,et as cleanHeaders,Ar as runGate}; diff --git a/packages/webdecoy/src/clearance/index.ts b/packages/webdecoy/src/clearance/index.ts new file mode 100644 index 0000000..6780f7f --- /dev/null +++ b/packages/webdecoy/src/clearance/index.ts @@ -0,0 +1,99 @@ +/** + * The clearance gate, for middleware that runs in front of the page. + * + * The same decision the Cloudflare, AWS and Netlify validators make, from the + * same source: `gate.generated.js` is built from WebDecoy's validator code and + * is never edited here. This file is the part an adapter needs around it, the + * site's identity from the environment and one client-IP answer. + * + * What it does with a request, in order: + * + * 1. Credentials, verified crawlers (forward-confirmed reverse DNS) and + * validly signed agents pass before any token is asked for. + * 2. A page no protected path covers passes. + * 3. On a protected path, a valid `wd_clearance` cookie passes. A missing, + * invalid or revoked one is labelled; in monitor mode the request still + * passes, in enforce mode it gets the challenge: a real browser earns the + * cookie and is sent back, a client that runs no JavaScript stays there. + * + * Which paths are protected, and whether the site monitors or enforces, is + * set in WebDecoy, not here. Every failure, WebDecoy unreachable included, + * lets the request through. + */ + +import { runGate, cleanHeaders, DEFAULT_INGEST, VERCEL_VALIDATOR_CAPABILITIES, BUILD } from './gate.generated'; +import type { GateEnv, GateOutcome } from './gate.generated'; +import { resolveClientIp } from '../client-ip'; +import type { TrustedProxies } from '../client-ip'; + +export type { GateEnv, GateOutcome } from './gate.generated'; +export { cleanHeaders, DEFAULT_INGEST, VERCEL_VALIDATOR_CAPABILITIES, BUILD as CLEARANCE_GATE_BUILD }; + +export interface ClearanceGateOptions { + /** WEBDECOY_SITE_KEY by default. */ + siteKey?: string; + /** WEBDECOY_SCANNER_ID by default. */ + scannerId?: string; + /** WEBDECOY_SENSOR_KEY by default. A secret: set it in the platform's environment, never in code. */ + sensorKey?: string; + /** WEBDECOY_INGEST, else WebDecoy's ingest. */ + ingest?: string; + /** + * How many proxies sit in front of the middleware. Defaults to `1`, the + * hosting platform, which is right on Vercel. See resolveClientIp. + */ + trustProxy?: TrustedProxies; +} + +/** What a platform passes with each request. */ +export interface ClearanceContext { + waitUntil?: (promise: Promise) => void; +} + +function fromEnv(name: string): string { + try { + const env = (globalThis as { process?: { env?: Record } }).process?.env; + return (env?.[name] ?? '').trim(); + } catch { + return ''; + } +} + +/** The site's identity, from options first and the environment second; null when incomplete. */ +export function resolveGateEnv(options: ClearanceGateOptions = {}): GateEnv | null { + const siteKey = (options.siteKey ?? fromEnv('WEBDECOY_SITE_KEY')).trim(); + const scannerId = (options.scannerId ?? fromEnv('WEBDECOY_SCANNER_ID')).trim(); + const sensorKey = (options.sensorKey ?? fromEnv('WEBDECOY_SENSOR_KEY')).trim(); + if (!siteKey || !scannerId || !sensorKey) return null; + const ingest = (options.ingest ?? fromEnv('WEBDECOY_INGEST')).trim() || DEFAULT_INGEST; + return { siteKey, scannerId, sensorKey, ingest }; +} + +let warnedMissing = false; + +/** + * A gate bound to one site: call it with each request. + * + * Missing configuration turns it into a pass-through that says so once, + * rather than an unproven gate: a site that forgot its sensor key must keep + * serving pages. + */ +export function createClearanceGate( + options: ClearanceGateOptions = {} +): (request: Request, context?: ClearanceContext) => Promise { + const trustProxy = options.trustProxy ?? 1; + return async (request, context = {}) => { + const env = resolveGateEnv(options); + if (!env) { + if (!warnedMissing) { + warnedMissing = true; + console.warn( + '[WebDecoy] clearance gate is off: set WEBDECOY_SITE_KEY, WEBDECOY_SCANNER_ID and WEBDECOY_SENSOR_KEY.' + ); + } + return { action: 'continue', headers: cleanHeaders(request), verdict: null }; + } + const ip = resolveClientIp({ headers: request.headers, trustProxy }) ?? ''; + return runGate(request, { ip, waitUntil: context.waitUntil }, env); + }; +} diff --git a/packages/webdecoy/src/edge-runtime.test.ts b/packages/webdecoy/src/edge-runtime.test.ts index 83cde78..caada41 100644 --- a/packages/webdecoy/src/edge-runtime.test.ts +++ b/packages/webdecoy/src/edge-runtime.test.ts @@ -153,4 +153,66 @@ describe('Edge Runtime compatibility', () => { expect(result.score).toBe(0.25); expect(result.replayReason).toBe('token_already_used'); }); + it('runs the Vercel clearance middleware in an Edge VM: challenge without a token, pass with one', async () => { + const built = buildSync({ + entryPoints: [path.join(__dirname, 'vercel.ts')], + bundle: true, + write: false, + platform: 'browser', + format: 'cjs', + logLevel: 'silent', + }); + + // A real Ed25519 token, minted here and verified inside the VM. + const pair = (await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as CryptoKeyPair; + const pub = Buffer.from(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))).toString('base64'); + const b64url = (b: Uint8Array) => + Buffer.from(b).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const payload = new TextEncoder().encode( + JSON.stringify({ kid: 'k1', tenant: 'org-1', fp: 'wdfp1-x', exp: Math.floor(Date.now() / 1000) + 600 }) + ); + const sig = new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, pair.privateKey, payload)); + const token = `${b64url(payload)}.${b64url(sig)}`; + const config = { + mode: 'enforce', + allow: { search_engines: true, ai_crawlers: true, monitoring: true }, + keys: [{ kid: 'k1', public_key: pub }], + active_credentials: [], + denied_fps: [], + generated_at: 0, + routes: ['/docs/*'], + }; + + const vm = new EdgeVM(); + vm.evaluate('var module = { exports: {} }; var exports = module.exports;'); + vm.evaluate(built.outputFiles[0].text); + // The verdict telemetry's delayed flush would otherwise hold jest open + // (on Vercel, waitUntil keeps it alive past the response), so the VM's + // timers are tracked and cleared once the test has its answer. + vm.evaluate(`(() => { const realSetTimeout = globalThis.setTimeout; + globalThis.__wdTimers = []; + globalThis.setTimeout = (f, ms, ...a) => { const t = realSetTimeout(f, ms, ...a); globalThis.__wdTimers.push(t); return t; }; + // The stubbed fetch answers at once; a real 3 s abort timer would outlive the test. + AbortSignal.timeout = () => new AbortController().signal; })();`); + vm.evaluate(`globalThis.fetch = async (u) => String(u).includes('/api/v1/clearance/config') + ? new Response(${JSON.stringify(JSON.stringify(config))}, { status: 200 }) + : new Response('{}', { status: 200 });`); + + const result = await vm.evaluate>(` + (async () => { + const mw = module.exports.clearanceMiddleware({ siteKey: 'org-1', scannerId: 's-1', sensorKey: 'wds1.x' }); + const ua = 'Mozilla/5.0 (Macintosh) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0 Safari/537.36'; + const a = await mw(new Request('https://edge.example/docs/a', { headers: { accept: 'text/html', 'user-agent': ua } })); + const b = await mw(new Request('https://edge.example/docs/a', { + headers: { accept: 'text/html', 'user-agent': ua, cookie: 'wd_clearance=${token}' }, + })); + return { challenged: a ? a.status : null, passed: b ? b.headers.get('x-middleware-request-x-wd-clearance') : null }; + })() + `); + + vm.evaluate('for (const t of globalThis.__wdTimers) clearTimeout(t);'); + + expect(result.challenged).toBe(403); + expect(result.passed).toBe('valid'); + }); }); diff --git a/packages/webdecoy/src/index.ts b/packages/webdecoy/src/index.ts index 86ceea0..2181f51 100644 --- a/packages/webdecoy/src/index.ts +++ b/packages/webdecoy/src/index.ts @@ -73,6 +73,13 @@ export type { TrustedProxies, ResolveClientIpOptions, HeaderSource } from './cli export { readEdgeVerdict, EDGE_CLASS_HEADER, EDGE_CLEARANCE_HEADER } from './edge'; export type { EdgeClass, EdgeVerdict } from './edge'; +// The clearance gate itself, for middleware in front of the page: the same +// decision WebDecoy's Cloudflare, AWS and Netlify validators make, built from +// their source. `@webdecoy/node/vercel` wraps it for Vercel Routing Middleware +// and `withClearance()` in @webdecoy/nextjs for Next.js. +export { createClearanceGate, resolveGateEnv, CLEARANCE_GATE_BUILD } from './clearance'; +export type { ClearanceGateOptions, ClearanceContext, GateEnv, GateOutcome } from './clearance'; + // Declared-agent classification. Exported as values for the same reason // as readEdgeVerdict: code outside protect() — a route handler deciding whether // to serve a paywall, a robots.txt generator — needs the same answer without diff --git a/packages/webdecoy/src/vercel.test.ts b/packages/webdecoy/src/vercel.test.ts new file mode 100644 index 0000000..d4dd193 --- /dev/null +++ b/packages/webdecoy/src/vercel.test.ts @@ -0,0 +1,197 @@ +/** + * The Vercel clearance middleware, through the bundled gate. + * + * The decision itself is tested where it is written, against the vectors all + * four validators replay. What is tested here is this package's part: the + * environment, the client IP, Vercel's continue-with-headers protocol, and + * that a real signed token is honoured and a missing one is challenged. + */ + +import { createHash } from 'crypto'; +import { readFileSync } from 'fs'; +import path from 'path'; +import { clearanceMiddleware, toMiddlewareResponse } from './vercel'; + +const ENV = { siteKey: 'org-1', scannerId: 'scanner-1', sensorKey: 'wds1.test' }; +const BROWSER_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0 Safari/537.36'; + +function b64url(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +async function signingKey() { + const pair = (await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as CryptoKeyPair; + const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)); + const mint = async (claims: Record) => { + const payload = new TextEncoder().encode(JSON.stringify(claims)); + const sig = new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, pair.privateKey, payload)); + return `${b64url(payload)}.${b64url(sig)}`; + }; + return { publicKey: Buffer.from(raw).toString('base64'), mint }; +} + +function configWith(keys: { kid: string; public_key: string }[], mode = 'enforce') { + return { + mode, + allow: { search_engines: true, ai_crawlers: true, monitoring: true }, + keys, + active_credentials: [], + denied_fps: [], + generated_at: 0, + routes: ['/docs/*'], + }; +} + +let hosts = 0; +const site = () => `https://site${++hosts}.example`; +const realFetch = global.fetch; + +function stubConfig(config: unknown): jest.Mock { + const fn = jest.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes('/api/v1/clearance/config')) return new Response(JSON.stringify(config), { status: 200 }); + return new Response('{}', { status: 200 }); + }); + global.fetch = fn as unknown as typeof fetch; + return fn; +} + +// Verdict telemetry schedules its flush for the end of a time bucket and keeps +// it alive with waitUntil, which on Vercel outlives the response. Timers that +// advance on their own let every test run in real time; clearing them at the +// end stops a pending flush from holding jest open. +beforeAll(() => { + jest.useFakeTimers({ advanceTimers: true }); +}); + +afterAll(() => { + jest.clearAllTimers(); + jest.useRealTimers(); +}); + +afterEach(() => { + global.fetch = realFetch; +}); + +/** The request headers Vercel would hand the page, decoded from the middleware's instructions. */ +function forwarded(res: Response): Headers { + expect(res.headers.get('x-middleware-next')).toBe('1'); + const out = new Headers(); + for (const name of (res.headers.get('x-middleware-override-headers') ?? '').split(',').filter(Boolean)) { + out.set(name, res.headers.get(`x-middleware-request-${name}`) ?? ''); + } + return out; +} + +describe('@webdecoy/node/vercel', () => { + it('challenges a browser with no token on a protected path in enforce mode', async () => { + stubConfig(configWith([])); + const res = await clearanceMiddleware(ENV)( + new Request(`${site()}/docs/start`, { headers: { accept: 'text/html', 'user-agent': BROWSER_UA } }) + ); + expect(res?.status).toBe(403); + expect(res?.headers.get('x-middleware-next')).toBeNull(); + expect(await res?.text()).toContain('wd_clearance'); + }); + + it('serves the page to a browser holding a valid signed token, and says so', async () => { + const key = await signingKey(); + stubConfig(configWith([{ kid: 'k1', public_key: key.publicKey }])); + const token = await key.mint({ kid: 'k1', tenant: 'org-1', fp: 'wdfp1-abc', exp: Math.floor(Date.now() / 1000) + 600 }); + const res = await clearanceMiddleware(ENV)( + new Request(`${site()}/docs/start`, { + headers: { accept: 'text/html', 'user-agent': BROWSER_UA, cookie: `wd_clearance=${token}; other=1` }, + }) + ); + expect(res).toBeDefined(); + const headers = forwarded(res!); + expect(headers.get('x-wd-clearance')).toBe('valid'); + expect(headers.get('cookie')).toContain('other=1'); + expect(headers.get('user-agent')).toBe(BROWSER_UA); + }); + + it('challenges a token signed for another site', async () => { + const key = await signingKey(); + stubConfig(configWith([{ kid: 'k1', public_key: key.publicKey }])); + const token = await key.mint({ kid: 'k1', tenant: 'org-2', fp: 'wdfp1-abc', exp: Math.floor(Date.now() / 1000) + 600 }); + const res = await clearanceMiddleware(ENV)( + new Request(`${site()}/docs/start`, { headers: { accept: 'application/json', cookie: `wd_clearance=${token}` } }) + ); + expect(res?.status).toBe(403); + expect(await res?.json()).toMatchObject({ challenge: { reason: 'invalid' } }); + }); + + it('lets everything through in monitor mode, labelled', async () => { + stubConfig(configWith([], 'monitor')); + const res = await clearanceMiddleware(ENV)( + new Request(`${site()}/docs/start`, { headers: { accept: 'text/html', 'user-agent': BROWSER_UA } }) + ); + expect(forwarded(res!).get('x-wd-clearance')).toBe('missing'); + }); + + it('strips a forged verdict before the page can read it', async () => { + stubConfig(configWith([])); + const res = await clearanceMiddleware(ENV)( + new Request(`${site()}/pricing`, { headers: { 'x-wd-clearance': 'valid', 'x-wd-class': 'verified' } }) + ); + const headers = forwarded(res!); + expect(headers.get('x-wd-clearance')).toBe('unscoped'); + expect(headers.has('x-wd-class')).toBe(false); + }); + + it('is a pass-through, not an unproven gate, without its configuration', async () => { + const fetchMock = stubConfig(configWith([])); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const res = await clearanceMiddleware({ siteKey: '', scannerId: '', sensorKey: '' })( + new Request(`${site()}/docs/start`, { headers: { accept: 'text/html', 'x-wd-clearance': 'valid' } }) + ); + expect(res?.status).not.toBe(403); + expect(forwarded(res!).has('x-wd-clearance')).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('reads its identity from WEBDECOY_* when no options are given', async () => { + stubConfig(configWith([])); + process.env.WEBDECOY_SITE_KEY = 'org-1'; + process.env.WEBDECOY_SCANNER_ID = 'scanner-1'; + process.env.WEBDECOY_SENSOR_KEY = 'wds1.test'; + try { + const res = await clearanceMiddleware()( + new Request(`${site()}/docs/start`, { headers: { accept: 'text/html', 'user-agent': BROWSER_UA } }) + ); + expect(res?.status).toBe(403); + } finally { + delete process.env.WEBDECOY_SITE_KEY; + delete process.env.WEBDECOY_SCANNER_ID; + delete process.env.WEBDECOY_SENSOR_KEY; + } + }); + + it('fails open when WebDecoy is unreachable', async () => { + global.fetch = jest.fn(async () => { + throw new Error('down'); + }) as unknown as typeof fetch; + const res = await clearanceMiddleware(ENV)( + new Request(`${site()}/docs/start`, { headers: { accept: 'text/html', 'user-agent': BROWSER_UA } }) + ); + expect(res?.status ?? 200).not.toBe(403); + }); + + it('continues untouched when the gate has no headers to set', () => { + expect(toMiddlewareResponse({ action: 'continue', headers: null, verdict: null })).toBeUndefined(); + }); +}); + +describe('the generated gate', () => { + it('is exactly what the validator source built, not a hand edit', () => { + const file = readFileSync(path.join(__dirname, 'clearance', 'gate.generated.js'), 'utf8'); + const stamp = /\/\/ Build (vercel-[0-9a-f]{12})\./.exec(file)?.[1]; + expect(stamp).toBeDefined(); + const body = file.split('\n').slice(2).join('\n'); + const original = body.split(stamp!).join('__WD_VERCEL_BUILD__'); + const hash = 'vercel-' + createHash('sha256').update(original).digest('hex').slice(0, 12); + expect(hash).toBe(stamp); + }); +}); diff --git a/packages/webdecoy/src/vercel.ts b/packages/webdecoy/src/vercel.ts new file mode 100644 index 0000000..9343f31 --- /dev/null +++ b/packages/webdecoy/src/vercel.ts @@ -0,0 +1,69 @@ +/** + * WebDecoy clearance for Vercel Routing Middleware, any framework. + * + * Astro, SvelteKit, Nuxt, Remix or a static site on Vercel: put this in + * `middleware.ts` at the project root and set WEBDECOY_SITE_KEY, + * WEBDECOY_SCANNER_ID and WEBDECOY_SENSOR_KEY in the project's environment. + * + * ```ts + * // middleware.ts + * export { default } from '@webdecoy/node/vercel'; + * + * export const config = { + * // Everything but static assets. Keep /.well-known: the challenge uses it. + * matcher: ['/((?!_astro/|_next/static/|favicon.ico|.*\\.(?:css|js|png|jpg|svg|webp|woff2?)$).*)'], + * }; + * ``` + * + * Next.js sites use `withClearance()` from @webdecoy/nextjs instead. + * + * Starts in whatever mode the site has in WebDecoy, monitor by default, and + * enforces only once it is switched there and paths are protected. + */ + +import { createClearanceGate } from './clearance'; +import type { ClearanceGateOptions, ClearanceContext } from './clearance'; +import type { GateOutcome } from './clearance'; + +export type { ClearanceGateOptions, ClearanceContext } from './clearance'; + +/** + * Turn the gate's answer into what Vercel middleware returns. + * + * Continuing with changed request headers is a response carrying + * instructions: `x-middleware-next` and the full replacement header set + * under `x-middleware-request-*`, listed in `x-middleware-override-headers`. + * That is what `next({ request: { headers } })` from @vercel/functions + * sends; spelled out here so the SDK needs no Vercel package. Returning + * nothing continues the request untouched. + */ +export function toMiddlewareResponse(outcome: GateOutcome): Response | undefined { + if (outcome.action === 'respond') return outcome.response; + if (!outcome.headers) return undefined; + const instructions = new Headers({ 'x-middleware-next': '1' }); + const names: string[] = []; + outcome.headers.forEach((value, name) => { + instructions.set(`x-middleware-request-${name}`, value); + names.push(name); + }); + instructions.set('x-middleware-override-headers', names.join(',')); + return new Response(null, { headers: instructions }); +} + +/** Routing Middleware bound to one site. Options default to the environment. */ +export function clearanceMiddleware( + options: ClearanceGateOptions = {} +): (request: Request, context?: ClearanceContext) => Promise { + const gate = createClearanceGate(options); + return async (request, context) => { + try { + return toMiddlewareResponse(await gate(request, context)); + } catch { + return undefined; // fail open + } + }; +} + +/** The middleware configured from WEBDECOY_* environment variables. */ +const middleware = clearanceMiddleware(); +export default middleware;