diff options
Diffstat (limited to 'README.md')
| -rw-r--r-- | README.md | 345 |
1 files changed, 345 insertions, 0 deletions
diff --git a/README.md b/README.md new file mode 100644 index 0000000..064fa71 --- /dev/null +++ b/README.md @@ -0,0 +1,345 @@ +# Posthook - Dynamic Webhook Receiver + +A simple API service for receiving and storing webhook requests with dynamic route registration. + +## Features + +- Dynamic route/topic registration +- Multiple content type support (JSON, form, text, raw) +- CSRF token protection with HMAC-signed stateless tokens +- hCaptcha protection per route +- ntfy notification alerts when webhooks are received +- Form redirect support (`_redirect` field) +- Request storage with timestamp-prefixed UUIDs +- Per-topic storage organization +- TypeScript with Pengueno framework +- Comprehensive metrics and tracing +- Single Dockerfile deployment + +## API Endpoints + +### Route Prefixes + +- **`/`** - Public routes (webhooks, token generation, health) +- **`/admin`** - Admin routes (route management) - Put behind OAuth proxy + +### Admin Endpoints + +**⚠️ Recommendation**: Put `/admin/*` behind an OAuth proxy (e.g., OAuth2 Proxy, Pomerium) for authentication. + +#### Register a Route +```bash +POST /admin/routes +Content-Type: application/json + +{ + "name": "my-webhook", + "contentType": "json", + "hcaptchaProtected": false +} +``` + +With hCaptcha protection: +```bash +POST /admin/routes +Content-Type: application/json + +{ + "name": "protected-webhook", + "contentType": "json", + "hcaptchaProtected": true, + "hcaptchaSecret": "your-hcaptcha-secret" +} +``` + +With ntfy notifications: +```bash +POST /admin/routes +Content-Type: application/json + +{ + "name": "notified-webhook", + "contentType": "json", + "hcaptchaProtected": false, + "ntfy": { + "enabled": true, + "server": "https://ntfy.sh", + "topic": "my-webhook-alerts" + } +} +``` + +With CSRF token protection: +```bash +POST /admin/routes +Content-Type: application/json + +{ + "name": "secure-form", + "contentType": "form", + "hcaptchaProtected": false, + "requireToken": true +} +``` + +#### List Routes +```bash +GET /admin/routes +``` + +### Public Webhook Endpoints + +#### Get CSRF Token (for routes with requireToken: true) +```bash +GET /hook/{routeName}/token +``` + +Response: +```json +{ + "ok": { + "token": "eyJ0aW1lc3RhbXAiOjE3MDI4MzQ1Njc4OTB5...", + "expiresAt": 1702835467890 + } +} +``` + +**Note**: Tokens expire in **30 seconds**. Generate the token server-side when rendering your form, not via client-side fetch. + +#### Send Webhook + +Send webhooks to registered routes: +```bash +POST /hook/{routeName} +Content-Type: application/json + +{ + "your": "data" +} +``` + +For hCaptcha-protected routes, include the token: +```bash +POST /hook/{routeName} +H-Captcha-Response: <token> +Content-Type: application/json + +{ + "your": "data" +} +``` + +For form submissions with redirect (returns 303 redirect instead of JSON): +```bash +POST /hook/{routeName} +Content-Type: application/x-www-form-urlencoded + +name=John&email=john@example.com&_redirect=https://example.com/thank-you +``` + +Or in HTML: +```html +<form action="/hook/my-form" method="POST"> + <input type="text" name="name" required> + <input type="email" name="email" required> + <input type="hidden" name="_redirect" value="https://example.com/thank-you"> + <button type="submit">Submit</button> +</form> +``` + +For token-protected routes, include the token: +```bash +POST /hook/{routeName} +X-CSRF-Token: <token> +Content-Type: application/json + +{ + "your": "data" +} +``` + +Or in form data: +```bash +POST /hook/{routeName} +Content-Type: application/x-www-form-urlencoded + +name=John&email=john@example.com&_token=<token> +``` + +#### Health Check +```bash +GET /health +``` + +## Content Types + +- `json` - Parse request body as JSON +- `form` - Parse as URL-encoded form data (supports `_redirect` field) +- `text` - Store as plain text +- `raw` - Store raw body +- `multipart` - Multipart form data (not yet implemented, will support `_redirect` field) + +## CSRF Token Protection + +Posthook supports stateless CSRF token protection using HMAC-signed tokens. When `requireToken: true` is set on a route, clients must include a valid token with their request. + +### How it works: + +1. **Token Generation**: Tokens are HMAC-signed with a secret and contain: + - Route name (prevents token reuse across routes) + - Timestamp (30-second TTL) + +2. **Token Delivery**: Tokens can be provided via: + - `_token` field in form data (extracted and not stored) + - `X-CSRF-Token` request header + +3. **Validation**: Server validates: + - HMAC signature + - Route name matches + - Token not expired (30 seconds) + - Timestamp not from future + +### Token Secret Configuration: + +```bash +# Via environment variable (recommended for production) +export POSTHOOK_TOKEN_SECRET=your-long-random-secret-here +npm start + +# Via command line +npm start -- --token-secret your-long-random-secret-here + +# Auto-generated (not recommended - changes on restart) +npm start # Warns and generates random secret +``` + +### Server-Side Token Generation (Recommended): + +With a 30-second TTL, you should generate the token server-side when rendering the form, not via client-side JavaScript. + +**Example with server-side rendering**: +```html +<!-- Your server fetches the token before rendering --> +<form action="/hook/contact-form" method="POST"> + <input type="hidden" name="_token" value="<%= token %>"> + <input type="text" name="name" required> + <input type="email" name="email" required> + <input type="hidden" name="_redirect" value="/thanks"> + <button type="submit">Submit</button> +</form> +``` + +**Your server** (Node.js example): +```javascript +app.get('/contact', async (req, res) => { + const response = await fetch('http://localhost:9000/hook/contact-form/token'); + const { ok: { token } } = await response.json(); + res.render('contact', { token }); +}); +``` + +## Form Redirects + +When using `contentType: "form"` or `contentType: "multipart"`, you can include a `_redirect` field in your form data. If the request is successfully stored, the server will respond with a `303 See Other` redirect to the specified URL instead of a JSON response. + +The `_redirect` field is: +- Extracted from the form data and not stored in the request body +- Only honored on successful requests (validation failures, errors, etc. will still return JSON error responses) +- Useful for traditional HTML form submissions where you want to redirect users after submission + +Example form flow: +1. User submits form with `_redirect` field (and `_token` if required) +2. Posthook validates token (if required) and stores the request +3. Posthook sends ntfy notification (if configured) +4. Posthook responds with `303 See Other` and `Location: {redirect_url}` header +5. Browser automatically redirects user to the specified URL + +## ntfy Notifications + +When you register a route with ntfy enabled, posthook will send a notification to your specified ntfy server and topic whenever a webhook is received. The notification includes: + +- Request timestamp +- Request UUID +- HTTP method +- Route name + +Example notification: +``` +Title: Webhook received: my-webhook +Message: Method: POST +Timestamp: 2024-12-14T19:30:45.123Z +UUID: 123e4567-e89b-12d3-a456-426614174000 +``` + +## Storage Format + +Requests are stored per-topic in `data/{routeName}/{timestamp}_{uuid}.json`: + +```json +{ + "timestamp": 1702834567890, + "uuid": "123e4567-e89b-12d3-a456-426614174000", + "routeName": "my-webhook", + "method": "POST", + "headers": { + "content-type": "application/json" + }, + "body": { + "your": "data" + } +} +``` + +## Usage + +### Development +```bash +npm install +npm run build +export POSTHOOK_TOKEN_SECRET=your-secret-here +npm start -- --port 9000 --host 0.0.0.0 --data-dir ./data +``` + +### Docker +```bash +docker build -t posthook . +docker run -p 9000:9000 \ + -v $(pwd)/data:/app/data \ + -e POSTHOOK_TOKEN_SECRET=your-secret-here \ + posthook +``` + +### With OAuth Proxy (Recommended for Production) + +Protect admin routes with an OAuth proxy: + +```bash +# Run posthook +docker run -p 9000:9000 posthook + +# Run OAuth2 Proxy in front of /admin routes +docker run -p 4180:4180 \ + quay.io/oauth2-proxy/oauth2-proxy:latest \ + --upstream=http://localhost:9000/admin \ + --http-address=0.0.0.0:4180 \ + --provider=google \ + --client-id=your-client-id \ + --client-secret=your-client-secret \ + --cookie-secret=random-secret-here \ + --email-domain=yourdomain.com +``` + +Then configure your reverse proxy: +- `/admin/*` → OAuth2 Proxy on port 4180 +- `/` → Posthook on port 9000 + +## Configuration + +### Command Line Arguments: +- `--port` - Server port (default: 9000) +- `--host` - Server host (default: 0.0.0.0) +- `--data-dir` - Data storage directory (default: ./data) +- `--token-secret` - HMAC secret for token signing (optional, generates random if not provided) + +### Environment Variables: +- `POSTHOOK_TOKEN_SECRET` - HMAC secret for token signing (recommended for production) |
