diff --git a/examples/express-basic/README.md b/examples/express-basic/README.md index 6475abd..6831fc3 100644 --- a/examples/express-basic/README.md +++ b/examples/express-basic/README.md @@ -1,142 +1,68 @@ -# Express.js Basic Example +# See a WebDecoy tripwire work in Express -Basic example of Web Decoy integration with Express.js. +![Controlled local test: ordinary traffic returns200; the decoy records DENY in monitor mode and403 only when enforcement is enabled.](docs/tripwire-demo.png) -## Setup +Run a local server, compare an ordinary request with a controlled decoy request, +and inspect the verdict before choosing to block. Local rules need no account +or API key. This demo starts in monitor mode and binds to `127.0.0.1`. -1. Install dependencies: +## Run from this repository -```bash -npm install -``` - -2. Copy `.env.example` to `.env` and add your API key: - -```bash -cp .env.example .env -``` - -3. Edit `.env` and set your Web Decoy API key: - -```env -WEBDECOY_API_KEY=sk_live_your_key_here -``` - -## Run - -Development mode: - -```bash -npm run dev -``` - -Production build: - -```bash -npm run build -npm start -``` - -## Test - -The server will start on `http://localhost:3000`. - -### Endpoints - -- `GET /health` - Health check (unprotected) -- `GET /` - Protected home endpoint -- `GET /api/data` - Protected API endpoint -- `POST /api/submit` - Protected POST endpoint - -### Testing Bot Detection - -Use these curl commands to test different bot detection scenarios: - -#### 1. Chrome Browser (should ALLOW) - -Real browser with full security headers - passes local analysis with minimal threat score: - -```bash -curl -s http://localhost:3000/ \ - -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" \ - -H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8" \ - -H "Accept-Language: en-US,en;q=0.5" \ - -H "Accept-Encoding: gzip, deflate, br" \ - -H "Sec-Ch-Ua: \"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"" \ - -H "Sec-Ch-Ua-Mobile: ?0" \ - -H "Sec-Ch-Ua-Platform: \"macOS\"" \ - -H "Sec-Fetch-Dest: document" \ - -H "Sec-Fetch-Mode: navigate" \ - -H "Sec-Fetch-Site: none" \ - -H "Sec-Fetch-User: ?1" -``` - -#### 2. Python Requests (should BLOCK) - -Common scraping library - easily detected and blocked: - -```bash -curl -s http://localhost:3000/ \ - -H "User-Agent: python-requests/2.28.0" -``` - -#### 3. Curl User-Agent (should BLOCK) - -Default curl user-agent - flagged as automated tool: +Requires Node.js 20+ for the verification tests. From the repository root: ```bash -curl -s http://localhost:3000/ \ - -H "User-Agent: curl/8.1.2" +npm ci +npm run build -w @webdecoy/node -w @webdecoy/express +npm run dev -w webdecoy-express-example ``` -#### 4. Empty User-Agent (should BLOCK) +## Compare two requests -Missing user-agent is highly suspicious: +In another terminal: ```bash -curl -s http://localhost:3000/ \ - -H "User-Agent: " +curl -i http://127.0.0.1:3000/ +curl -i http://127.0.0.1:3000/demo-decoy ``` -#### 5. Scrapy Bot (should BLOCK) +| Request | Monitor-mode HTTP response | Local decision | +| --- | --- | --- | +| `/` | `200` | `ALLOW`, `tripwire: false` | +| `/demo-decoy` | `404`, from the demo route | `DENY`, `tripwire: true` | -Known web scraping framework: +The second request is deliberately made by you. It demonstrates that a selected +path matched a rule, not that a malicious AI bot was identified. The `404` alone +is not a detection receipt: inspect the `decision` JSON, which comes from +`req.webdecoyDecision`. -```bash -curl -s http://localhost:3000/ \ - -H "User-Agent: Scrapy/2.8.0 (+https://scrapy.org)" -``` +## Opt into enforcement on this local demo -#### 6. Go HTTP Client (should BLOCK) - -Default Go HTTP client user-agent: +Stop the server with Ctrl+C, then restart it: ```bash -curl -s http://localhost:3000/ \ - -H "User-Agent: Go-http-client/1.1" +WEBDECOY_MODE=enforce npm run dev -w webdecoy-express-example ``` -### Simulating External IP Addresses +Repeat the two requests. `/` remains `200`; `/demo-decoy` returns `403` before +the demo route runs. Restart without that variable to return to monitor mode. -When testing locally, connections come from `::1` (IPv6 localhost). To simulate external IPs for realistic testing, use the `X-Forwarded-For` header: +Run the HTTP checks without a browser or API key: ```bash -curl -s http://localhost:3000/ \ - -H "User-Agent: python-requests/2.28.0" \ - -H "X-Forwarded-For: 203.0.113.50" +npm test -w webdecoy-express-example ``` -This detection will show the IP `203.0.113.50` in the Web Decoy dashboard instead of `::1`. - -## What's Happening +## Use it on your own app -1. All requests (except `/health`) go through Web Decoy middleware -2. The middleware extracts request metadata (IP, headers, etc.) -3. Local analysis checks for suspicious patterns -4. If needed, server-side verification with TLS fingerprinting -5. Request is allowed or blocked based on threat score -6. Detection info is available via `req.webdecoy` +The example keeps one controlled decoy path and exposes summaries in local +responses for verification. In production, keep verdicts in server-side logs, +choose decoy paths outside legitimate user flows, and review scanners, +prefetchers and accessibility flows before enforcing. Changing a User-Agent +does not prove that a client is malicious or human. -## Learn More +Behind a proxy, configure the documented `trustProxy` setting for your actual +topology; never trust arbitrary forwarded headers. This example does not +collect JA4, identify every AI agent, or report to the cloud dashboard. -See the [main README](../../README.md) for more documentation. +[Express setup documentation](https://docs.webdecoy.com/sdk-plugins/express/?utm_source=github&utm_medium=example&utm_campaign=express_tripwire_proof) +ยท [Optional WebDecoy dashboard](https://app.webdecoy.com/?utm_source=github&utm_medium=example&utm_campaign=express_tripwire_proof) diff --git a/examples/express-basic/docs/tripwire-demo.png b/examples/express-basic/docs/tripwire-demo.png new file mode 100644 index 0000000..6805acb Binary files /dev/null and b/examples/express-basic/docs/tripwire-demo.png differ diff --git a/examples/express-basic/docs/tripwire-demo.svg b/examples/express-basic/docs/tripwire-demo.svg new file mode 100644 index 0000000..6730fa1 --- /dev/null +++ b/examples/express-basic/docs/tripwire-demo.svg @@ -0,0 +1,18 @@ + + + + WEBDECOY / EXPRESS + Test your Express bot trap. + Two requests. A visible verdict. Your choice to enforce. + + GET / + 200 + ALLOW + + GET /demo-decoy + 404 in monitor + DENY + Enable enforcement on the local demo: + /demo-decoy โ†’ 403 + CONTROLLED LOCAL TEST ยท NODE SDK 0.21.0 ยท NO API KEY REQUIRED + diff --git a/examples/express-basic/package.json b/examples/express-basic/package.json index 6046a13..939275d 100644 --- a/examples/express-basic/package.json +++ b/examples/express-basic/package.json @@ -2,11 +2,12 @@ "name": "webdecoy-express-example", "version": "0.1.0", "private": true, - "description": "Basic Express.js example with Web Decoy", + "description": "Verify local WebDecoy tripwires in Express, then opt into enforcement", "scripts": { "dev": "tsx src/index.ts", "build": "tsc", - "start": "node dist/index.js" + "start": "node dist/index.js", + "test": "tsx --test test/*.test.ts" }, "dependencies": { "@webdecoy/express": "*", diff --git a/examples/express-basic/src/app.ts b/examples/express-basic/src/app.ts new file mode 100644 index 0000000..f7c757f --- /dev/null +++ b/examples/express-basic/src/app.ts @@ -0,0 +1,36 @@ +import express from 'express'; +import { webdecoy } from '@webdecoy/express'; +import { tripwire } from '@webdecoy/node'; + +// Local demonstration: expose summaries here so the verdict is visible. +// Keep this information server-side in a production application. +export function createApp(mode: 'monitor' | 'enforce' = 'monitor') { + const app = express(); + app.disable('x-powered-by'); + app.use(express.json()); + app.get('/health', (_req, res) => res.json({ status: 'ok' })); + app.use(webdecoy({ + mode, + rules: [tripwire({ paths: ['/demo-decoy'], includeDefaults: false })], + skipPaths: ['/health'], + })); + function decision(req: express.Request) { + return { + conclusion: req.webdecoyDecision?.conclusion, + tripwire: req.webdecoyDecision?.deniedBy('tripwire') ?? false, + }; + } + app.get('/', (req, res) => { + res.json({ message: 'Ordinary route', decision: decision(req) }); + }); + app.get('/demo-decoy', (req, res) => { + res.status(404).json({ message: 'Controlled decoy route', decision: decision(req) }); + }); + app.get('/api/data', (req, res) => { + res.json({ data: { message: 'Example data' }, decision: decision(req) }); + }); + app.post('/api/submit', (req, res) => { + res.json({ success: true, received: req.body.data, decision: decision(req) }); + }); + return app; +} diff --git a/examples/express-basic/src/index.ts b/examples/express-basic/src/index.ts index 6cd750b..1cdfe4b 100644 --- a/examples/express-basic/src/index.ts +++ b/examples/express-basic/src/index.ts @@ -1,89 +1,8 @@ -/** - * Basic Express.js example with Web Decoy protection - * - * This example demonstrates: - * - Basic middleware setup - * - Protecting all routes - * - Skipping health check endpoints - * - Accessing detection info in routes - */ +import { createApp } from './app'; -import express from 'express'; -import { webdecoy } from '@webdecoy/express'; -import dotenv from 'dotenv'; - -// Load environment variables -dotenv.config(); - -const app = express(); -const port = process.env.PORT || 3000; - -// Parse JSON bodies -app.use(express.json()); - -// Health check endpoint (unprotected) -app.get('/health', (req, res) => { - res.json({ status: 'ok' }); -}); - -// Add Web Decoy protection -app.use( - webdecoy({ - apiKey: process.env.WEBDECOY_API_KEY!, - apiUrl: process.env.WEBDECOY_API_URL, - threatScoreThreshold: 70, // Block requests with score >= 70 - skipPaths: ['/health'], // Don't protect health checks - debug: process.env.NODE_ENV === 'development', - tlsRejectUnauthorized: process.env.NODE_ENV !== 'development', // Allow self-signed certs in dev - }) -); - -// Protected routes -app.get('/', (req, res) => { - res.json({ - message: 'Hello from Web Decoy protected API!', - detection: req.webdecoy, - }); -}); - -app.get('/api/data', (req, res) => { - // Access detection info - const isBot = req.webdecoy?.bot_detected; - const threatLevel = req.webdecoy?.threat_level; - - res.json({ - data: { - message: 'This is protected data', - timestamp: new Date().toISOString(), - }, - security: { - bot_detected: isBot, - threat_level: threatLevel, - detection_id: req.webdecoy?.detection_id, - }, - }); -}); - -app.post('/api/submit', (req, res) => { - // Even POST requests are protected - const { data } = req.body; - - res.json({ - success: true, - received: data, - detection_id: req.webdecoy?.detection_id, - }); -}); - -// Error handler -app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => { - console.error('Error:', err); - res.status(500).json({ error: 'Internal server error' }); -}); - -// Start server -app.listen(port, () => { - console.log(`๐Ÿš€ Server running on http://localhost:${port}`); - console.log(`๐Ÿ›ก๏ธ Web Decoy protection enabled`); - console.log(`๐Ÿ“Š Health check available at http://localhost:${port}/health`); +const mode = process.env.WEBDECOY_MODE === 'enforce' ? 'enforce' : 'monitor'; +const port = Number(process.env.PORT || 3000); +createApp(mode).listen(port, '127.0.0.1', () => { + console.log(`WebDecoy local demo: http://127.0.0.1:${port} (${mode} mode)`); + console.log('Compare / with /demo-decoy. No API key or cloud reporting.'); }); diff --git a/examples/express-basic/test/app.test.ts b/examples/express-basic/test/app.test.ts new file mode 100644 index 0000000..ec1a6ce --- /dev/null +++ b/examples/express-basic/test/app.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import type { AddressInfo } from 'node:net'; +import { test } from 'node:test'; +import { createApp } from '../src/app'; + +async function serve(mode: 'monitor' | 'enforce' = 'monitor') { + const server = createApp(mode).listen(0, '127.0.0.1'); + await once(server, 'listening'); + return { server, base: `http://127.0.0.1:${(server.address() as AddressInfo).port}` }; +} + +test('ordinary traffic is allowed in both modes', async () => { + for (const mode of ['monitor', 'enforce'] as const) { + const { server, base } = await serve(mode); + try { + const response = await fetch(base); + assert.equal(response.status, 200); + assert.deepEqual((await response.json()).decision, { conclusion: 'ALLOW', tripwire: false }); + } finally { server.close(); } + } +}); + +test('monitor mode records the tripwire and preserves the route response', async () => { + const { server, base } = await serve(); + try { + const response = await fetch(`${base}/demo-decoy`); + assert.equal(response.status, 404); + const body = await response.json(); + assert.equal(body.message, 'Controlled decoy route'); + assert.deepEqual(body.decision, { conclusion: 'DENY', tripwire: true }); + } finally { server.close(); } +}); + +test('explicit enforcement stops the decoy request before its route runs', async () => { + const { server, base } = await serve('enforce'); + try { + const response = await fetch(`${base}/demo-decoy`); + assert.equal(response.status, 403); + assert.notEqual((await response.json()).message, 'Controlled decoy route'); + } finally { server.close(); } +}); + +test('health bypasses detection and JSON submissions still work', async () => { + const { server, base } = await serve(); + try { + assert.deepEqual(await (await fetch(`${base}/health`)).json(), { status: 'ok' }); + const response = await fetch(`${base}/api/submit`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ data: 'ordinary submission' }), + }); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.received, 'ordinary submission'); + assert.equal(body.decision.tripwire, false); + } finally { server.close(); } +});