From b1445b0028b8433edc13515b64f00d593bf38978 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Mon, 20 Jul 2026 22:41:16 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20Edge=20Runtime=20compatibility=20?= =?UTF-8?q?=E2=80=94=20drop=20axios=20and=20all=20node:=20imports=20(WebDe?= =?UTF-8?q?coy/app#281)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make @webdecoy/node and @webdecoy/nextjs run in Vercel Edge Middleware (and other WinterCG runtimes) by removing every Node-only API from the bundled module graph: - client: axios + https.Agent -> Web-standard fetch with AbortController timeout; identical error messages preserved. tlsRejectUnauthorized is kept for API compatibility but ignored (use NODE_EXTRA_CA_CERTS). - captcha (token/pow/service/http): node:crypto -> Web Crypto (crypto.subtle); issue/verify/generate/score are now async. BREAKING — version bumped to 0.5.0. Framework adapters already awaited the async handle() so they are unaffected. - fingerprint detector: sha256 -> FNV-1a 64 (correlation key only; keeps DetectionEngine.score sync) - ip: net.isIPv4 -> strict local check; honeytoken: randomBytes -> crypto.getRandomValues - new src/webcrypto.ts: sha256/hmac/randomHex/timing-safe-compare/ base64url helpers, Web-standard only Verification, wired into CI (build + test + check:edge): - scripts/check-edge.mjs: esbuild browser-platform bundle gate — any node: import anywhere in the graph fails the build - edge-runtime.test.ts: executes the bundled SDK inside @edge-runtime/vm (Vercel's Edge Runtime): keyless rate limiting, tripwire/honeytoken, and async token issue/verify all pass in the VM Also: jest --passWithNoTests for adapter packages (pre-existing 'No tests found' failure made turbo test unusable as a gate). --- .github/workflows/ci.yml | 2 + examples/captcha-express/src/server.ts | 4 +- package-lock.json | 301 +++++++++--------- package.json | 7 +- packages/client/package.json | 2 +- packages/express/package.json | 6 +- packages/fastify/package.json | 6 +- packages/nextjs/package.json | 9 +- packages/webdecoy/package.json | 8 +- packages/webdecoy/src/captcha/captcha.test.ts | 121 +++---- packages/webdecoy/src/captcha/http.ts | 8 +- packages/webdecoy/src/captcha/pow.ts | 18 +- packages/webdecoy/src/captcha/service.ts | 18 +- packages/webdecoy/src/captcha/token.ts | 28 +- packages/webdecoy/src/client.ts | 189 ++++++----- .../src/detection/detectors/fingerprint.ts | 7 +- packages/webdecoy/src/detection/ip.ts | 7 +- packages/webdecoy/src/edge-runtime.test.ts | 100 ++++++ packages/webdecoy/src/rules/honeytoken.ts | 4 +- packages/webdecoy/src/webcrypto.ts | 85 +++++ scripts/check-edge.mjs | 44 +++ 21 files changed, 624 insertions(+), 350 deletions(-) create mode 100644 packages/webdecoy/src/edge-runtime.test.ts create mode 100644 packages/webdecoy/src/webcrypto.ts create mode 100644 scripts/check-edge.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 612738f..bf7fc7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,3 +21,5 @@ jobs: cache: 'npm' - run: npm ci - run: npm run build + - run: npm test + - run: npm run check:edge diff --git a/examples/captcha-express/src/server.ts b/examples/captcha-express/src/server.ts index 0a81fb6..8286730 100644 --- a/examples/captcha-express/src/server.ts +++ b/examples/captcha-express/src/server.ts @@ -33,13 +33,13 @@ app.use(webdecoyCaptcha({ secret: SECRET })); // this check so single-use replay protection spans both.) const captcha = new Captcha({ secret: SECRET }); -app.post('/login', (req: Request, res: Response) => { +app.post('/login', async (req: Request, res: Response) => { const token = req.body?.webdecoy_token as string | undefined; if (!token) { res.status(400).json({ ok: false, error: 'missing token' }); return; } - const result = captcha.verifyToken(token, req.ip); + const result = await captcha.verifyToken(token, req.ip); if (!result.valid) { res.status(403).json({ ok: false, error: `captcha failed: ${result.reason}` }); return; diff --git a/package-lock.json b/package-lock.json index 0e75c4e..b4ea643 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ ], "devDependencies": { "@changesets/cli": "^2.27.1", + "@edge-runtime/vm": "^5.0.0", "@types/node": "^20.11.0", + "esbuild": "^0.27.7", "prettier": "^3.2.4", "tsup": "^8.0.1", "turbo": "^1.12.0", @@ -863,10 +865,33 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/@edge-runtime/primitives": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/primitives/-/primitives-6.0.0.tgz", + "integrity": "sha512-FqoxaBT+prPBHBwE1WXS1ocnu/VLTQyZ6NMUBAdbP7N2hsFTTxMC/jMu2D/8GAlMQfxeuppcPuCUk/HO3fpIvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@edge-runtime/vm": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/vm/-/vm-5.0.0.tgz", + "integrity": "sha512-NKBGBSIKUG584qrS1tyxVpX/AKJKQw5HgjYEnPLC0QsTw79JrGn+qUr8CXFb955Iy7GUdiiUv1rJ6JBGvaKb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@edge-runtime/primitives": "6.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], @@ -881,9 +906,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -898,9 +923,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -915,9 +940,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -932,9 +957,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -949,9 +974,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -966,9 +991,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -983,9 +1008,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -1000,9 +1025,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -1017,9 +1042,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -1034,9 +1059,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -1051,9 +1076,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -1068,9 +1093,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -1085,9 +1110,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -1102,9 +1127,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -1119,9 +1144,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -1136,9 +1161,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -1153,9 +1178,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], @@ -1170,9 +1195,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -1187,9 +1212,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], @@ -1204,9 +1229,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -1221,9 +1246,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], @@ -1238,9 +1263,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -1255,9 +1280,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -1272,9 +1297,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -1289,9 +1314,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -3408,6 +3433,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, "license": "MIT" }, "node_modules/atomic-sleep": { @@ -3431,17 +3457,6 @@ "fastq": "^1.17.1" } }, - "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -3990,6 +4005,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -4215,6 +4231,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -4448,6 +4465,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4460,9 +4478,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4473,32 +4491,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/escalade": { @@ -5291,30 +5309,11 @@ "dev": true, "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -5664,6 +5663,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -7987,12 +7987,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -9967,7 +9961,7 @@ }, "packages/client": { "name": "@webdecoy/client", - "version": "0.3.0", + "version": "0.5.0", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.11", @@ -9987,10 +9981,10 @@ }, "packages/express": { "name": "@webdecoy/express", - "version": "0.3.0", + "version": "0.5.0", "license": "MIT", "dependencies": { - "@webdecoy/node": "^0.3.0" + "@webdecoy/node": "^0.5.0" }, "devDependencies": { "@types/express": "^4.17.21", @@ -10014,10 +10008,10 @@ }, "packages/fastify": { "name": "@webdecoy/fastify", - "version": "0.3.0", + "version": "0.5.0", "license": "MIT", "dependencies": { - "@webdecoy/node": "^0.3.0", + "@webdecoy/node": "^0.5.0", "fastify-plugin": "^4.5.1" }, "devDependencies": { @@ -10041,10 +10035,10 @@ }, "packages/nextjs": { "name": "@webdecoy/nextjs", - "version": "0.3.0", + "version": "0.5.0", "license": "MIT", "dependencies": { - "@webdecoy/node": "^0.3.0" + "@webdecoy/node": "^0.5.0" }, "devDependencies": { "@types/jest": "^29.5.11", @@ -10067,11 +10061,8 @@ }, "packages/webdecoy": { "name": "@webdecoy/node", - "version": "0.3.0", + "version": "0.5.0", "license": "MIT", - "dependencies": { - "axios": "^1.6.5" - }, "devDependencies": { "@types/jest": "^29.5.11", "@types/node": "^20.11.0", diff --git a/package.json b/package.json index 6c0d177..e053d6e 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "lint": "turbo run lint", "test": "turbo run test", "clean": "turbo run clean && rm -rf node_modules", - "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"" + "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", + "check:edge": "npm run check:edge -w @webdecoy/node -w @webdecoy/nextjs" }, "devDependencies": { "@changesets/cli": "^2.27.1", @@ -27,7 +28,9 @@ "prettier": "^3.2.4", "tsup": "^8.0.1", "turbo": "^1.12.0", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "esbuild": "^0.27.7", + "@edge-runtime/vm": "^5.0.0" }, "packageManager": "npm@10.2.4", "engines": { diff --git a/packages/client/package.json b/packages/client/package.json index 7d94990..faa04e8 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/client", - "version": "0.4.1", + "version": "0.5.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 267836f..d7c7aaf 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/express", - "version": "0.4.1", + "version": "0.5.0", "description": "Web Decoy middleware for Express.js", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -17,7 +17,7 @@ "scripts": { "build": "tsup src/index.ts --format cjs,esm --dts --clean", "dev": "tsup src/index.ts --format cjs,esm --dts --watch", - "test": "jest", + "test": "jest --passWithNoTests", "lint": "eslint src/**/*.ts", "clean": "rm -rf dist" }, @@ -40,7 +40,7 @@ "url": "https://github.com/WebDecoy/node/issues" }, "dependencies": { - "@webdecoy/node": "^0.4.1" + "@webdecoy/node": "^0.5.0" }, "peerDependencies": { "express": "^4.18.0 || ^5.0.0" diff --git a/packages/fastify/package.json b/packages/fastify/package.json index 3c88b24..a3e43af 100644 --- a/packages/fastify/package.json +++ b/packages/fastify/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/fastify", - "version": "0.4.1", + "version": "0.5.0", "description": "Web Decoy plugin for Fastify", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -17,7 +17,7 @@ "scripts": { "build": "tsup src/index.ts --format cjs,esm --dts --clean", "dev": "tsup src/index.ts --format cjs,esm --dts --watch", - "test": "jest", + "test": "jest --passWithNoTests", "lint": "eslint src/**/*.ts", "clean": "rm -rf dist" }, @@ -40,7 +40,7 @@ "url": "https://github.com/WebDecoy/node/issues" }, "dependencies": { - "@webdecoy/node": "^0.4.1", + "@webdecoy/node": "^0.5.0", "fastify-plugin": "^4.5.1" }, "peerDependencies": { diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 3c2db9a..6f78745 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/nextjs", - "version": "0.4.1", + "version": "0.5.0", "description": "Web Decoy middleware for Next.js", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -17,9 +17,10 @@ "scripts": { "build": "tsup src/index.ts --format cjs,esm --dts --clean", "dev": "tsup src/index.ts --format cjs,esm --dts --watch", - "test": "jest", + "test": "jest --passWithNoTests", "lint": "eslint src/**/*.ts", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "check:edge": "node ../../scripts/check-edge.mjs src/index.ts" }, "keywords": [ "web-decoy", @@ -41,7 +42,7 @@ "url": "https://github.com/WebDecoy/node/issues" }, "dependencies": { - "@webdecoy/node": "^0.4.1" + "@webdecoy/node": "^0.5.0" }, "peerDependencies": { "next": ">=13.0.0" diff --git a/packages/webdecoy/package.json b/packages/webdecoy/package.json index 9c8ccbe..73da05f 100644 --- a/packages/webdecoy/package.json +++ b/packages/webdecoy/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/node", - "version": "0.4.1", + "version": "0.5.0", "description": "Web Decoy SDK for Node.js - Bot detection with TLS fingerprinting", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -19,7 +19,8 @@ "dev": "tsup src/index.ts --format cjs,esm --dts --watch", "test": "jest", "lint": "eslint src/**/*.ts", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "check:edge": "node ../../scripts/check-edge.mjs src/index.ts" }, "keywords": [ "web-decoy", @@ -41,9 +42,6 @@ "bugs": { "url": "https://github.com/WebDecoy/node/issues" }, - "dependencies": { - "axios": "^1.6.5" - }, "devDependencies": { "@types/jest": "^29.5.11", "@types/node": "^20.11.0", diff --git a/packages/webdecoy/src/captcha/captcha.test.ts b/packages/webdecoy/src/captcha/captcha.test.ts index 2151ff7..608a1ad 100644 --- a/packages/webdecoy/src/captcha/captcha.test.ts +++ b/packages/webdecoy/src/captcha/captcha.test.ts @@ -1,6 +1,9 @@ /** * Tests for the Phase 2 captcha layer: PoW manager, token manager, and the * end-to-end Captcha service (challenge → solve → verify → token). + * + * The solver + expected-hash helpers use node:crypto (sync, fast in a loop); + * tests always run in Node. The code under test uses Web Crypto only. */ import { createHash } from 'crypto'; @@ -78,73 +81,75 @@ describe('secret resolution', () => { }); describe('PoWManager', () => { - it('generates a signed challenge with a bound nonce', () => { + it('generates a signed challenge with a bound nonce', async () => { const pow = new PoWManager({ secret: SECRET }); - const c = pow.generate('site', '1.2.3.4', 2); + const c = await pow.generate('site', '1.2.3.4', 2); expect(c.difficulty).toBe(2); expect(c.sig).toHaveLength(64); expect(c.nonce).toHaveLength(32); expect(c.prefix).toBe(`${c.id}:${c.timestamp}:2`); }); - it('verifies a correct solution and reports server timing + nonce', () => { + it('verifies a correct solution and reports server timing + nonce', async () => { const pow = new PoWManager({ secret: SECRET }); - const c = pow.generate('site', '1.2.3.4', 2); - const result = pow.verify(solve(c), 'site'); + const c = await pow.generate('site', '1.2.3.4', 2); + const result = await pow.verify(solve(c), 'site'); expect(result.valid).toBe(true); expect(result.nonce).toBe(c.nonce); expect(typeof result.serverElapsed).toBe('number'); }); - it('rejects replay of a used solution', () => { + it('rejects replay of a used solution', async () => { const pow = new PoWManager({ secret: SECRET }); - const c = pow.generate('site', '1.2.3.4', 2); + const c = await pow.generate('site', '1.2.3.4', 2); const sol = solve(c); - expect(pow.verify(sol, 'site').valid).toBe(true); + expect((await pow.verify(sol, 'site')).valid).toBe(true); // Challenge is consumed; second attempt no longer finds it. - expect(pow.verify(sol, 'site').reason).toBe('challenge_not_found'); + expect((await pow.verify(sol, 'site')).reason).toBe('challenge_not_found'); }); - it('rejects an unknown challenge', () => { + it('rejects an unknown challenge', async () => { const pow = new PoWManager({ secret: SECRET }); - expect(pow.verify({ challengeId: 'nope', nonce: 0, hash: 'x' }, 'site').reason).toBe( + expect((await pow.verify({ challengeId: 'nope', nonce: 0, hash: 'x' }, 'site')).reason).toBe( 'challenge_not_found', ); }); - it('rejects a site-key mismatch', () => { + it('rejects a site-key mismatch', async () => { const pow = new PoWManager({ secret: SECRET }); - const c = pow.generate('site', '1.2.3.4', 2); - expect(pow.verify(solve(c), 'other').reason).toBe('site_key_mismatch'); + const c = await pow.generate('site', '1.2.3.4', 2); + expect((await pow.verify(solve(c), 'other')).reason).toBe('site_key_mismatch'); }); - it('rejects an incorrect hash', () => { + it('rejects an incorrect hash', async () => { const pow = new PoWManager({ secret: SECRET }); - const c = pow.generate('site', '1.2.3.4', 2); - expect(pow.verify({ challengeId: c.id, nonce: 0, hash: 'deadbeef' }, 'site').reason).toBe( - 'invalid_hash', - ); + const c = await pow.generate('site', '1.2.3.4', 2); + expect( + (await pow.verify({ challengeId: c.id, nonce: 0, hash: 'deadbeef' }, 'site')).reason, + ).toBe('invalid_hash'); }); - it('rejects insufficient difficulty', () => { + it('rejects insufficient difficulty', async () => { const pow = new PoWManager({ secret: SECRET }); - const c = pow.generate('site', '1.2.3.4', 5); + const c = await pow.generate('site', '1.2.3.4', 5); // The correct hash for nonce 0 almost never has 5 leading zeros. const input = `${c.prefix}:0`; const hash = createHash('sha256').update(input).digest('hex'); - expect(pow.verify({ challengeId: c.id, nonce: 0, hash }, 'site').reason).toBe( + expect((await pow.verify({ challengeId: c.id, nonce: 0, hash }, 'site')).reason).toBe( 'insufficient_difficulty', ); }); - it('binds the solution to a signals hash', () => { + it('binds the solution to a signals hash', async () => { const pow = new PoWManager({ secret: SECRET }); - const c = pow.generate('site', '1.2.3.4', 2); + const c = await pow.generate('site', '1.2.3.4', 2); const signalsHash = createHash('sha256').update('{"a":1}').digest('hex'); const sol = solve(c, signalsHash); // Verifying without the same signalsHash fails the hash check. - expect(pow.verify({ ...sol, signalsHash: undefined }, 'site').reason).toBe('invalid_hash'); - expect(pow.verify(sol, 'site', signalsHash).valid).toBe(true); + expect((await pow.verify({ ...sol, signalsHash: undefined }, 'site')).reason).toBe( + 'invalid_hash', + ); + expect((await pow.verify(sol, 'site', signalsHash)).valid).toBe(true); }); it('scales difficulty up for datacenter IPs', () => { @@ -155,55 +160,55 @@ describe('PoWManager', () => { }); describe('TokenManager', () => { - it('issues and verifies a token', () => { + it('issues and verifies a token', async () => { const tm = new TokenManager({ secret: SECRET }); - const token = tm.issue('1.2.3.4', 'site', 0.12); - const v = tm.verify(token); + const token = await tm.issue('1.2.3.4', 'site', 0.12); + const v = await tm.verify(token); expect(v.valid).toBe(true); expect(v.site_key).toBe('site'); expect(v.score).toBe(0.12); }); - it('enforces single use (replay protection)', () => { + it('enforces single use (replay protection)', async () => { const tm = new TokenManager({ secret: SECRET }); - const token = tm.issue('1.2.3.4', 'site', 0.1); - expect(tm.verify(token).valid).toBe(true); - expect(tm.verify(token).reason).toBe('token_already_used'); + const token = await tm.issue('1.2.3.4', 'site', 0.1); + expect((await tm.verify(token)).valid).toBe(true); + expect((await tm.verify(token)).reason).toBe('token_already_used'); }); - it('binds the token to the client IP', () => { + it('binds the token to the client IP', async () => { const tm = new TokenManager({ secret: SECRET }); - const token = tm.issue('1.2.3.4', 'site', 0.1); - expect(tm.verify(token, '9.9.9.9').reason).toBe('ip_mismatch'); + const token = await tm.issue('1.2.3.4', 'site', 0.1); + expect((await tm.verify(token, '9.9.9.9')).reason).toBe('ip_mismatch'); }); - it('rejects a tampered signature', () => { + it('rejects a tampered signature', async () => { const tm = new TokenManager({ secret: SECRET }); const other = new TokenManager({ secret: 'different-secret' }); - const token = other.issue('1.2.3.4', 'site', 0.1); - expect(tm.verify(token).reason).toBe('invalid_signature'); + const token = await other.issue('1.2.3.4', 'site', 0.1); + expect((await tm.verify(token)).reason).toBe('invalid_signature'); }); - it('rejects an expired token', () => { + it('rejects an expired token', async () => { const tm = new TokenManager({ secret: SECRET }); - const token = tm.issue('1.2.3.4', 'site', 0.1); + const token = await tm.issue('1.2.3.4', 'site', 0.1); const realNow = Date.now(); const spy = jest.spyOn(Date, 'now').mockReturnValue(realNow + 301_000); try { - expect(tm.verify(token).reason).toBe('expired'); + expect((await tm.verify(token)).reason).toBe('expired'); } finally { spy.mockRestore(); } }); - it('rejects garbage', () => { + it('rejects garbage', async () => { const tm = new TokenManager({ secret: SECRET }); - expect(tm.verify('not-a-token').valid).toBe(false); + expect((await tm.verify('not-a-token')).valid).toBe(false); }); }); describe('Captcha end-to-end', () => { - it('passes a clean human with a valid PoW and issues a usable token', () => { + it('passes a clean human with a valid PoW and issues a usable token', async () => { const captcha = new Captcha({ secret: SECRET }); const ip = '73.15.22.100'; @@ -212,13 +217,13 @@ describe('Captcha end-to-end', () => { const spy = jest.spyOn(Date, 'now').mockReturnValue(realNow); let token: string | null; try { - const challenge = captcha.issueChallenge('site', ip, 2); + const challenge = await captcha.issueChallenge('site', ip, 2); const solution = solve(challenge); // Advance time so server-side elapsed exceeds the too-fast threshold. spy.mockReturnValue(realNow + 3000); - const result = captcha.verify({ + const result = await captcha.verify({ siteKey: 'site', ip, userAgent: UA_CHROME_MAC, @@ -240,13 +245,13 @@ describe('Captcha end-to-end', () => { } // The issued token verifies once, then is consumed. - expect(captcha.verifyToken(token!, ip).valid).toBe(true); - expect(captcha.verifyToken(token!, ip).reason).toBe('token_already_used'); + expect((await captcha.verifyToken(token!, ip)).valid).toBe(true); + expect((await captcha.verifyToken(token!, ip)).reason).toBe('token_already_used'); }); - it('fails a clear bot (headless + CDP + datacenter, no PoW) and issues no token', () => { + it('fails a clear bot (headless + CDP + datacenter, no PoW) and issues no token', async () => { const captcha = new Captcha({ secret: SECRET }); - const result = captcha.verify({ + const result = await captcha.verify({ siteKey: 'site', ip: '52.1.2.3', // datacenter userAgent: 'Mozilla/5.0 (X11; Linux x86_64) HeadlessChrome/120.0.0.0', @@ -263,11 +268,11 @@ describe('Captcha end-to-end', () => { expect(result.detections.some((d) => d.reason.includes('No PoW solution'))).toBe(true); }); - it('does not block on "no PoW" alone — pushes to challenge, not block', () => { + it('does not block on "no PoW" alone — pushes to challenge, not block', async () => { // A single maxed category cannot cross the 0.5 success threshold; missing // PoW on an otherwise-clean request yields a "challenge" recommendation. const captcha = new Captcha({ secret: SECRET }); - const result = captcha.verify({ + const result = await captcha.verify({ siteKey: 'site', ip: '52.1.2.3', userAgent: 'curl/8.0', @@ -278,15 +283,15 @@ describe('Captcha end-to-end', () => { expect(result.detections.some((d) => d.reason.includes('No PoW solution'))).toBe(true); }); - it('detects signals tampering via the commitment hash', () => { + it('detects signals tampering via the commitment hash', async () => { const captcha = new Captcha({ secret: SECRET }); const ip = '73.15.22.100'; - const challenge = captcha.issueChallenge('site', ip, 2); + const challenge = await captcha.issueChallenge('site', ip, 2); const realJson = JSON.stringify({ behavioral: HUMAN_BEHAVIOR }); const wrongHash = createHash('sha256').update('something-else').digest('hex'); const solution = solve(challenge, wrongHash); - const result = captcha.verify({ + const result = await captcha.verify({ siteKey: 'site', ip, userAgent: UA_CHROME_MAC, @@ -298,9 +303,9 @@ describe('Captcha end-to-end', () => { expect(result.detections.some((d) => d.reason.includes('Signals tampered'))).toBe(true); }); - it('returns an action in invisible (score) mode', () => { + it('returns an action in invisible (score) mode', async () => { const captcha = new Captcha({ secret: SECRET }); - const out = captcha.score({ + const out = await captcha.score({ siteKey: 'site', ip: '73.15.22.100', userAgent: UA_CHROME_MAC, diff --git a/packages/webdecoy/src/captcha/http.ts b/packages/webdecoy/src/captcha/http.ts index 43788be..3edcef4 100644 --- a/packages/webdecoy/src/captcha/http.ts +++ b/packages/webdecoy/src/captcha/http.ts @@ -83,13 +83,13 @@ export function createCaptchaEndpoints(options: CaptchaEndpointsOptions = {}) { // GET {base}/challenge if (sub === '/challenge' && method === 'GET') { const siteKey = req.query.siteKey ?? 'default'; - return json(200, captcha.issueChallenge(siteKey, req.ip)); + return json(200, await captcha.issueChallenge(siteKey, req.ip)); } // POST {base}/verify if (sub === '/verify' && method === 'POST') { const b = (req.body ?? {}) as VerifyBody; - const result = captcha.verify({ + const result = await captcha.verify({ siteKey: b.siteKey ?? 'default', ip: req.ip, userAgent, @@ -106,7 +106,7 @@ export function createCaptchaEndpoints(options: CaptchaEndpointsOptions = {}) { // POST {base}/score (invisible mode) if (sub === '/score' && method === 'POST') { const b = (req.body ?? {}) as VerifyBody; - const result = captcha.score({ + const result = await captcha.score({ siteKey: b.siteKey ?? 'default', ip: req.ip, userAgent, @@ -125,7 +125,7 @@ export function createCaptchaEndpoints(options: CaptchaEndpointsOptions = {}) { if (sub === '/token/verify' && method === 'POST') { const b = (req.body ?? {}) as VerifyBody; if (!b.token) return json(400, { valid: false, reason: 'missing_token' }); - return json(200, captcha.verifyToken(b.token, req.ip)); + return json(200, await captcha.verifyToken(b.token, req.ip)); } return json(404, { error: 'not_found' }); diff --git a/packages/webdecoy/src/captcha/pow.ts b/packages/webdecoy/src/captcha/pow.ts index a434f86..6d12a09 100644 --- a/packages/webdecoy/src/captcha/pow.ts +++ b/packages/webdecoy/src/captcha/pow.ts @@ -8,7 +8,7 @@ * Redis for serverless / multi-instance deployments. */ -import { createHash, createHmac, randomBytes } from 'crypto'; +import { sha256Hex, hmacSha256Hex, randomHex } from '../webcrypto'; import { isDatacenterIP } from '../detection/ip'; import type { RateLimiter } from '../detection/stores'; import { InMemoryRateLimiter } from '../detection/stores'; @@ -88,10 +88,10 @@ export class PoWManager { } /** Generate and store a signed challenge. */ - generate(siteKey: string, ip: string, difficulty?: number): ChallengeData { + async generate(siteKey: string, ip: string, difficulty?: number): Promise { const resolvedDifficulty = difficulty ?? this.scaleDifficulty(siteKey, ip); - const challengeId = randomBytes(16).toString('hex'); - const nonce = randomBytes(16).toString('hex'); + const challengeId = randomHex(16); + const nonce = randomHex(16); const timestamp = Date.now(); const expiresAt = timestamp + EXPIRATION_MS; @@ -109,14 +109,18 @@ export class PoWManager { // Sign over the challenge (excluding the not-yet-set sig field — it's ''). const { sig: _omit, ...toSign } = challengeData; void _omit; - challengeData.sig = createHmac('sha256', this.secret).update(JSON.stringify(toSign)).digest('hex'); + challengeData.sig = await hmacSha256Hex(this.secret, JSON.stringify(toSign)); this.store.put({ ...challengeData, ip, createdAt: timestamp }); return challengeData; } /** Verify a submitted solution. `signalsHash` binds it to the signals. */ - verify(solution: PoWSolution, siteKey: string, signalsHash: string | null = null): PoWVerification { + async verify( + solution: PoWSolution, + siteKey: string, + signalsHash: string | null = null, + ): Promise { const challenge = this.store.get(solution.challengeId); if (!challenge) return { valid: false, reason: 'challenge_not_found' }; @@ -134,7 +138,7 @@ export class PoWManager { const input = signalsHash ? `${challenge.prefix}:${signalsHash}:${solution.nonce}` : `${challenge.prefix}:${solution.nonce}`; - const expectedHash = createHash('sha256').update(input).digest('hex'); + const expectedHash = await sha256Hex(input); if (solution.hash !== expectedHash) { return { valid: false, reason: 'invalid_hash' }; diff --git a/packages/webdecoy/src/captcha/service.ts b/packages/webdecoy/src/captcha/service.ts index 79ecf94..b67bfb8 100644 --- a/packages/webdecoy/src/captcha/service.ts +++ b/packages/webdecoy/src/captcha/service.ts @@ -8,7 +8,7 @@ * the SDK. */ -import { createHash } from 'crypto'; +import { sha256Hex } from '../webcrypto'; import { DetectionEngine } from '../detection/engine'; import type { Detection, PoWOutcome, Signals } from '../detection/types'; import { @@ -67,7 +67,7 @@ export class Captcha { } /** Issue a PoW challenge for the client to solve (difficulty auto-scaled). */ - issueChallenge(siteKey: string, ip: string, difficulty?: number): ChallengeData { + async issueChallenge(siteKey: string, ip: string, difficulty?: number): Promise { return this.pow.generate(siteKey, ip, difficulty); } @@ -75,7 +75,7 @@ export class Captcha { * Verify a submission: checks the signals commitment, verifies PoW, scores the * signals, and issues a session token on success. Mirrors `runVerification`. */ - verify(input: VerifyInput): VerifyResult { + async verify(input: VerifyInput): Promise { const { siteKey, ip, userAgent } = input; const headers = input.headers ?? {}; let signals: Signals = input.signals ?? {}; @@ -84,7 +84,7 @@ export class Captcha { // 1. Signals commitment: the solution may bind a hash of the raw signals. const clientSignalsHash = input.powSolution?.signalsHash ?? null; if (input.signalsJson && clientSignalsHash) { - const computed = createHash('sha256').update(input.signalsJson).digest('hex'); + const computed = await sha256Hex(input.signalsJson); if (computed !== clientSignalsHash) { extraDetections.push({ category: 'bot', @@ -108,7 +108,7 @@ export class Captcha { // 3. Verify proof of work → outcome consumed by the engine. let pow: PoWOutcome; if (input.powSolution && input.powSolution.challengeId) { - const result = this.pow.verify(input.powSolution, siteKey, clientSignalsHash); + const result = await this.pow.verify(input.powSolution, siteKey, clientSignalsHash); pow = { provided: true, valid: result.valid, @@ -133,14 +133,14 @@ export class Captcha { }); // 5. Issue a token on success. - const token = verdict.success ? this.tokens.issue(ip, siteKey, verdict.score) : null; + const token = verdict.success ? await this.tokens.issue(ip, siteKey, verdict.score) : null; return { ...verdict, token }; } /** Invisible-mode scoring — same pipeline, returns a compact result + action. */ - score(input: VerifyInput & { action?: string }): ScoreResult { - const result = this.verify(input); + async score(input: VerifyInput & { action?: string }): Promise { + const result = await this.verify(input); return { success: result.success, score: result.score, @@ -151,7 +151,7 @@ export class Captcha { } /** Verify a previously issued token (single-use, optionally IP-bound). */ - verifyToken(token: string, ip: string | null = null): TokenVerification { + async verifyToken(token: string, ip: string | null = null): Promise { return this.tokens.verify(token, ip); } } diff --git a/packages/webdecoy/src/captcha/token.ts b/packages/webdecoy/src/captcha/token.ts index 6ed52b9..bf1856a 100644 --- a/packages/webdecoy/src/captcha/token.ts +++ b/packages/webdecoy/src/captcha/token.ts @@ -6,7 +6,13 @@ * an IP-hash binding, and single-use replay protection. */ -import { createHash, createHmac, timingSafeEqual } from 'crypto'; +import { + sha256Hex, + hmacSha256Hex, + timingSafeEqualStr, + base64urlEncode, + base64urlDecode, +} from '../webcrypto'; import type { TokenVerification } from './types'; const TOKEN_TTL_SECONDS = 300; @@ -56,25 +62,25 @@ export class TokenManager { } /** Issue a signed, IP-bound token for a passing request. */ - issue(ip: string, siteKey: string, score: number): string { + async issue(ip: string, siteKey: string, score: number): Promise { const data: TokenPayload = { site_key: siteKey, timestamp: Math.floor(Date.now() / 1000), score: Math.round(score * 1000) / 1000, - ip_hash: createHash('sha256').update(ip).digest('hex').slice(0, 8), + ip_hash: (await sha256Hex(ip)).slice(0, 8), }; const payload = JSON.stringify(data, Object.keys(data).sort()); - data.sig = createHmac('sha256', this.secret).update(payload).digest('hex'); + data.sig = await hmacSha256Hex(this.secret, payload); - return Buffer.from(JSON.stringify(data)).toString('base64url'); + return base64urlEncode(JSON.stringify(data)); } /** Verify a token: signature, expiry, replay, and optional IP binding. */ - verify(token: string, ip: string | null = null): TokenVerification { + async verify(token: string, ip: string | null = null): Promise { let decoded: TokenPayload; try { - decoded = JSON.parse(Buffer.from(token, 'base64url').toString()); + decoded = JSON.parse(base64urlDecode(token)); } catch (e) { return { valid: false, reason: e instanceof Error ? e.message : 'invalid_token' }; } @@ -88,11 +94,9 @@ export class TokenManager { delete decoded.sig; const payload = JSON.stringify(decoded, Object.keys(decoded).sort()); - const expectedSig = createHmac('sha256', this.secret).update(payload).digest('hex'); + const expectedSig = await hmacSha256Hex(this.secret, payload); - const sigBuf = Buffer.from(sig); - const expectedBuf = Buffer.from(expectedSig); - if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) { + if (!timingSafeEqualStr(sig, expectedSig)) { return { valid: false, reason: 'invalid_signature' }; } @@ -101,7 +105,7 @@ export class TokenManager { } if (ip) { - const expectedIpHash = createHash('sha256').update(ip).digest('hex').slice(0, 8); + const expectedIpHash = (await sha256Hex(ip)).slice(0, 8); if (decoded.ip_hash !== expectedIpHash) { return { valid: false, reason: 'ip_mismatch' }; } diff --git a/packages/webdecoy/src/client.ts b/packages/webdecoy/src/client.ts index a7e9e11..73ed0d2 100644 --- a/packages/webdecoy/src/client.ts +++ b/packages/webdecoy/src/client.ts @@ -1,10 +1,12 @@ /** * Web Decoy API Client - * Handles communication with the ingest service + * Handles communication with the ingest service. + * + * Uses the Web-standard `fetch` API (no axios, no `node:` imports) so the SDK + * runs in Node >= 18 and edge runtimes (Vercel Edge Middleware, Cloudflare + * Workers) alike. */ -import axios, { AxiosInstance, AxiosError } from 'axios'; -import https from 'https'; import { SDKDetectionRequest, SDKDetectionResponse } from './types'; import type { ViolationEvent, IPEnrichmentData } from './rules/types'; @@ -13,59 +15,91 @@ export interface ClientConfig { apiUrl: string; timeout: number; debug: boolean; + /** + * Kept for API compatibility; the fetch transport does not support disabling + * TLS verification. For local self-signed certs in Node, trust the CA + * instead: `NODE_EXTRA_CA_CERTS=/path/to/ca.pem`. + */ tlsRejectUnauthorized: boolean; } +interface ApiErrorBody { + error?: string; + message?: string; +} + +interface ApiResponse { + status: number; + data: T | undefined; +} + export class WebDecoyClient { - private axios: AxiosInstance; private config: ClientConfig; + private baseUrl: string; + private headers: Record; constructor(config: ClientConfig) { this.config = config; + this.baseUrl = config.apiUrl.replace(/\/+$/, ''); + this.headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${config.apiKey}`, + 'User-Agent': 'webdecoy-node-sdk/0.1.0', + }; + + if (config.tlsRejectUnauthorized === false && config.debug) { + console.warn( + '[WebDecoy] tlsRejectUnauthorized=false is not supported by the fetch transport and is ignored; ' + + 'for local self-signed certs, trust your CA via NODE_EXTRA_CA_CERTS=/path/to/ca.pem.', + ); + } + } - // Create HTTPS agent with TLS configuration - const httpsAgent = new https.Agent({ - rejectUnauthorized: config.tlsRejectUnauthorized, - }); - - this.axios = axios.create({ - baseURL: config.apiUrl, - timeout: config.timeout, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${config.apiKey}`, - 'User-Agent': 'webdecoy-node-sdk/0.1.0', - }, - httpsAgent, - }); - - // Add request interceptor for debugging - if (config.debug) { - this.axios.interceptors.request.use((request) => { - console.log('[WebDecoy] Request:', { - method: request.method, - url: request.url, - data: request.data, - }); - return request; + private async request(method: 'GET' | 'POST', path: string, body?: unknown): Promise> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.config.timeout); + // Node returns a Timeout object that would otherwise hold the process open; + // edge runtimes return a number with no unref. + if (typeof (timer as { unref?: () => void }).unref === 'function') { + (timer as unknown as { unref: () => void }).unref(); + } + + if (this.config.debug) { + console.log('[WebDecoy] Request:', { method, url: this.baseUrl + path, data: body }); + } + + try { + const response = await fetch(this.baseUrl + path, { + method, + headers: this.headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, }); - this.axios.interceptors.response.use( - (response) => { - console.log('[WebDecoy] Response:', { - status: response.status, - data: response.data, - }); - return response; - }, - (error) => { - console.error('[WebDecoy] Error:', { - message: error.message, - response: error.response?.data, - }); - return Promise.reject(error); + let data: T | undefined; + const text = await response.text(); + if (text) { + try { + data = JSON.parse(text) as T; + } catch { + data = undefined; } - ); + } + + if (this.config.debug) { + console.log('[WebDecoy] Response:', { status: response.status, data }); + } + + return { status: response.status, data }; + } catch (error) { + if (this.config.debug) { + console.error('[WebDecoy] Error:', { + message: error instanceof Error ? error.message : String(error), + }); + } + throw error; + } finally { + clearTimeout(timer); } } @@ -73,47 +107,40 @@ export class WebDecoyClient { * Send a detection request to the ingest service */ async detect(request: SDKDetectionRequest): Promise { + let response: ApiResponse; try { - const response = await this.axios.post( + response = await this.request( + 'POST', '/api/v1/sdk/detect', - request + request, ); - - return response.data; } catch (error) { - if (axios.isAxiosError(error)) { - const axiosError = error as AxiosError<{ error?: string; message?: string }>; - - // Handle specific error cases - if (axiosError.response?.status === 401) { - throw new Error('Invalid API key. Please check your Web Decoy configuration.'); - } - - if (axiosError.response?.status === 429) { - throw new Error('Rate limit exceeded. Please try again later.'); - } - - if (axiosError.response?.data?.error || axiosError.response?.data?.message) { - throw new Error( - axiosError.response.data.error || axiosError.response.data.message || 'API error' - ); - } + if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) { + throw new Error('Request timeout. The Web Decoy service did not respond in time.'); + } + if (error instanceof TypeError) { + // fetch signals DNS/connection failures as TypeError + throw new Error('Unable to connect to Web Decoy service. Please check your network.'); + } + throw error; + } - // Network or timeout errors - if (axiosError.code === 'ECONNABORTED') { - throw new Error('Request timeout. The Web Decoy service did not respond in time.'); - } + if (response.status === 401) { + throw new Error('Invalid API key. Please check your Web Decoy configuration.'); + } - if (axiosError.code === 'ENOTFOUND' || axiosError.code === 'ECONNREFUSED') { - throw new Error('Unable to connect to Web Decoy service. Please check your network.'); - } + if (response.status === 429) { + throw new Error('Rate limit exceeded. Please try again later.'); + } - throw new Error(`API request failed: ${axiosError.message}`); + if (response.status >= 400) { + if (response.data?.error || response.data?.message) { + throw new Error(response.data.error || response.data.message || 'API error'); } - - // Unknown error - throw error; + throw new Error(`API request failed: HTTP ${response.status}`); } + + return response.data as SDKDetectionResponse; } /** @@ -121,7 +148,7 @@ export class WebDecoyClient { */ async sendViolations(events: ViolationEvent[]): Promise { try { - await this.axios.post('/api/v1/sdk/violations/batch', { events }); + await this.request('POST', '/api/v1/sdk/violations/batch', { events }); } catch (error) { if (this.config.debug) { console.error('[WebDecoy] Failed to send violations:', error); @@ -135,10 +162,12 @@ export class WebDecoyClient { */ async getIPEnrichment(ip: string): Promise { try { - const response = await this.axios.get( - `/api/v1/sdk/ip/${encodeURIComponent(ip)}/enrichment` + const response = await this.request( + 'GET', + `/api/v1/sdk/ip/${encodeURIComponent(ip)}/enrichment`, ); - return response.data; + if (response.status >= 400) return null; + return response.data ?? null; } catch (error) { if (this.config.debug) { console.error('[WebDecoy] Failed to get IP enrichment:', error); diff --git a/packages/webdecoy/src/detection/detectors/fingerprint.ts b/packages/webdecoy/src/detection/detectors/fingerprint.ts index 6d7d141..a1a02f1 100644 --- a/packages/webdecoy/src/detection/detectors/fingerprint.ts +++ b/packages/webdecoy/src/detection/detectors/fingerprint.ts @@ -1,6 +1,6 @@ /** Fingerprint correlation: cross-IP reuse + canvas anomalies. */ -import { createHash } from 'crypto'; +import { fnv1a64Hex } from '../../webcrypto'; import type { Detection, Signals } from '../types'; import type { FingerprintStore } from '../stores'; @@ -20,7 +20,10 @@ export function detectFingerprint( String(automation.platform ?? ''), String(automation.hardwareConcurrency ?? ''), ]; - const fp = createHash('sha256').update(components.join('|')).digest('hex').slice(0, 16); + // Correlation key only — an adversary fully controls their own signals, so + // collision resistance buys nothing; a non-crypto hash keeps this sync and + // edge-runtime compatible. + const fp = fnv1a64Hex(components.join('|')); store.record(fp, ip, siteKey); diff --git a/packages/webdecoy/src/detection/ip.ts b/packages/webdecoy/src/detection/ip.ts index 98dc249..f10c0c1 100644 --- a/packages/webdecoy/src/detection/ip.ts +++ b/packages/webdecoy/src/detection/ip.ts @@ -6,7 +6,12 @@ * api.webdecoy.com enrichment endpoint. */ -import { isIPv4 } from 'net'; +/** Strict dotted-quad check (no leading zeros), matching `net.isIPv4` semantics. */ +const IPV4_RE = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; + +function isIPv4(ip: string): boolean { + return IPV4_RE.test(ip); +} /** Coarse datacenter CIDR blocks (AWS, GCP, Azure, DO, Linode, Vultr, Hetzner, OVH). */ export const DATACENTER_CIDRS: string[] = [ diff --git a/packages/webdecoy/src/edge-runtime.test.ts b/packages/webdecoy/src/edge-runtime.test.ts new file mode 100644 index 0000000..16e6453 --- /dev/null +++ b/packages/webdecoy/src/edge-runtime.test.ts @@ -0,0 +1,100 @@ +/** + * Edge Runtime execution test. + * + * Bundles the SDK for the browser platform (where Node built-ins do not + * resolve) and executes the keyless middleware path inside Vercel's Edge + * Runtime VM — the same environment as Vercel Edge Middleware. A Node-only + * API anywhere in the bundle fails the build step; a runtime dependency on a + * Node global fails the evaluate step. + */ + +import path from 'path'; +import { buildSync } from 'esbuild'; +import { EdgeVM } from '@edge-runtime/vm'; + +describe('Edge Runtime compatibility', () => { + function bundleForEdge(): string { + const built = buildSync({ + entryPoints: [path.join(__dirname, 'index.ts')], + bundle: true, + write: false, + platform: 'browser', + format: 'cjs', + logLevel: 'silent', + }); + return built.outputFiles[0].text; + } + + it('bundles without Node built-ins and runs keyless protect() in an Edge VM', async () => { + const bundle = bundleForEdge(); + + const vm = new EdgeVM(); + vm.evaluate('var module = { exports: {} }; var exports = module.exports;'); + vm.evaluate(bundle); + + const result = await vm.evaluate< + Promise<{ + firstAllowed: boolean; + thirdAllowed: boolean; + tripwireAllowed: boolean; + honeytokenPathOk: boolean; + }> + >(` + (async () => { + const { WebDecoy, rateLimit, tripwire, honeytoken } = module.exports; + const hp = honeytoken(); + const sdk = new WebDecoy({ + rules: [rateLimit({ max: 2, window: 60 }), tripwire({ paths: [hp.path] })], + }); + const meta = { + method: 'GET', + path: '/', + ip: '1.2.3.4', + headers: { 'user-agent': 'test' }, + timestamp: Date.now(), + }; + const r1 = await sdk.protect(meta); + await sdk.protect(meta); + const r3 = await sdk.protect(meta); + const trip = await sdk.protect({ ...meta, path: hp.path }); + await sdk.destroy(); // clear the rate-limiter interval so jest can exit + return { + firstAllowed: r1.allowed, + thirdAllowed: r3.allowed, + tripwireAllowed: trip.allowed, + honeytokenPathOk: hp.path.startsWith('/__wd/'), + }; + })() + `); + + expect(result.firstAllowed).toBe(true); + expect(result.thirdAllowed).toBe(false); // rate limit max=2 exceeded + expect(result.tripwireAllowed).toBe(false); // honeytoken path tripped + expect(result.honeytokenPathOk).toBe(true); + }); + + it('captcha token issue/verify works in an Edge VM (Web Crypto path)', async () => { + const bundle = bundleForEdge(); + + const vm = new EdgeVM(); + vm.evaluate('var module = { exports: {} }; var exports = module.exports;'); + vm.evaluate(bundle); + + const result = await vm.evaluate< + Promise<{ valid: boolean; replayReason: string | undefined; score: number | undefined }> + >(` + (async () => { + const { TokenManager } = module.exports; + const tm = new TokenManager({ secret: 'edge-test-secret' }); + const token = await tm.issue('1.2.3.4', 'site', 0.25); + const v = await tm.verify(token, '1.2.3.4'); + const replay = await tm.verify(token, '1.2.3.4'); + return { valid: v.valid, replayReason: replay.reason, score: v.score }; + })() + `); + + expect(result.valid).toBe(true); + expect(result.score).toBe(0.25); + expect(result.replayReason).toBe('token_already_used'); + }); +}); diff --git a/packages/webdecoy/src/rules/honeytoken.ts b/packages/webdecoy/src/rules/honeytoken.ts index bdb45db..b9f333c 100644 --- a/packages/webdecoy/src/rules/honeytoken.ts +++ b/packages/webdecoy/src/rules/honeytoken.ts @@ -9,7 +9,7 @@ * genuine users are never affected. */ -import { randomBytes } from 'crypto'; +import { randomHex } from '../webcrypto'; export interface HoneytokenOptions { /** Base path under which honeytoken tripwires are generated. Default `/__wd`. */ @@ -40,7 +40,7 @@ export interface Honeytoken { */ export function honeytoken(options: HoneytokenOptions = {}): Honeytoken { const base = (options.basePath ?? '/__wd').replace(/\/+$/, ''); - const token = options.token ?? randomBytes(6).toString('hex'); + const token = options.token ?? randomHex(6); const path = `${base}/${token}`; const text = options.text ?? '.'; const linkHtml = diff --git a/packages/webdecoy/src/webcrypto.ts b/packages/webdecoy/src/webcrypto.ts new file mode 100644 index 0000000..aef9fe3 --- /dev/null +++ b/packages/webdecoy/src/webcrypto.ts @@ -0,0 +1,85 @@ +/** + * Web-standard crypto + encoding helpers. + * + * Everything here runs on WinterCG-compatible edge runtimes (Vercel Edge + * Middleware, Cloudflare Workers, Deno, Bun) as well as Node >= 18 — only + * `globalThis.crypto`, `TextEncoder`/`TextDecoder`, and `atob`/`btoa` are used. + * The package is bundled into a single file, so a single `node:` import + * anywhere in the graph would break consumers' Edge builds; keep it out. + */ + +const encoder = new TextEncoder(); + +function toHex(bytes: Uint8Array): string { + let hex = ''; + for (let i = 0; i < bytes.length; i++) hex += bytes[i].toString(16).padStart(2, '0'); + return hex; +} + +/** SHA-256 of a UTF-8 string, hex-encoded. */ +export async function sha256Hex(data: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', encoder.encode(data)); + return toHex(new Uint8Array(digest)); +} + +/** HMAC-SHA-256 of a UTF-8 string with a UTF-8 secret, hex-encoded. */ +export async function hmacSha256Hex(secret: string, data: string): Promise { + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); + return toHex(new Uint8Array(sig)); +} + +/** `bytes` cryptographically-random bytes, hex-encoded. */ +export function randomHex(bytes: number): string { + const buf = new Uint8Array(bytes); + crypto.getRandomValues(buf); + return toHex(buf); +} + +/** Constant-time string comparison. */ +export function timingSafeEqualStr(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} + +/** UTF-8 string → base64url (unpadded). */ +export function base64urlEncode(data: string): string { + const bytes = encoder.encode(data); + let binary = ''; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** base64url → UTF-8 string. Throws on invalid input. */ +export function base64urlDecode(data: string): string { + const b64 = data.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new TextDecoder().decode(bytes); +} + +/** + * FNV-1a 64-bit hash, hex-encoded (16 chars). Non-cryptographic — use only for + * correlation keys, never for anything an attacker gains by forging. + */ +export function fnv1a64Hex(data: string): string { + const PRIME = 0x100000001b3n; + const MASK = 0xffffffffffffffffn; + let hash = 0xcbf29ce484222325n; + const bytes = encoder.encode(data); + for (let i = 0; i < bytes.length; i++) { + hash ^= BigInt(bytes[i]); + hash = (hash * PRIME) & MASK; + } + return hash.toString(16).padStart(16, '0'); +} diff --git a/scripts/check-edge.mjs b/scripts/check-edge.mjs new file mode 100644 index 0000000..a0f358a --- /dev/null +++ b/scripts/check-edge.mjs @@ -0,0 +1,44 @@ +/** + * Edge-runtime compatibility gate. + * + * Bundles each entry point with esbuild targeting the browser platform, where + * Node built-ins (`crypto`, `net`, `https`, ...) do not resolve. Any Node-only + * import anywhere in the module graph fails the build — the same failure a + * consumer would hit deploying the package in Vercel Edge Middleware. + * + * Usage: node scripts/check-edge.mjs [...more entries] + * Framework packages keep their host externals (next, express, fastify). + */ + +import { build } from 'esbuild'; + +const entries = process.argv.slice(2); +if (entries.length === 0) { + console.error('usage: node check-edge.mjs [...entries]'); + process.exit(2); +} + +let failed = false; + +for (const entry of entries) { + try { + await build({ + entryPoints: [entry], + bundle: true, + write: false, + platform: 'browser', + format: 'esm', + logLevel: 'silent', + external: ['next', 'next/*', 'express', 'fastify', 'fastify-plugin'], + }); + console.log(`✓ edge-compatible: ${entry}`); + } catch (error) { + failed = true; + console.error(`✗ NOT edge-compatible: ${entry}`); + for (const e of error.errors ?? []) { + console.error(` ${e.text}${e.location ? ` (${e.location.file}:${e.location.line})` : ''}`); + } + } +} + +process.exit(failed ? 1 : 0);