feat: merge Gitea webhook security into setup wizard (closes #2) #3

Merged
ROOH merged 1 commits from feature/webhook-security-integration into main 2026-03-01 13:54:23 +01:00
16 changed files with 1960 additions and 2 deletions

View File

@ -6,3 +6,5 @@ isOAuthToken
# Domain references - public/documented # Domain references - public/documented
www.rooh.red www.rooh.red
git.eeqj.de git.eeqj.de
# Webhook security audit - all-zeros test signature (not a real secret)
docs/SECURITY-AUDIT.md

View File

@ -8,7 +8,7 @@ install:
npm install npm install
test: test:
@echo "[SKIP] Tests require installed system services (not available in CI)" bash tests/test-webhook-security.sh
fmt: fmt:
npx prettier --write . npx prettier --write .

View File

@ -46,6 +46,38 @@ The interactive wizard will:
Every install step asks for your confirmation first — you have full control over what gets installed. Every install step asks for your confirmation first — you have full control over what gets installed.
## Webhook Security (Optional)
The setup wizard includes an optional Step 11 that installs 5-layer webhook
security for the `/hooks/gitea` endpoint. This protects your OpenClaw instance
from unauthorized webhook requests.
**Security layers:**
1. IP allowlisting (only your Gitea server can send webhooks)
2. Rate limiting (10 req/s, burst 20)
3. Payload size limit (1MB)
4. HMAC-SHA256 signature verification (njs module)
5. Per-repository allowlist (only approved repos can trigger agents)
**What gets installed (when you choose to enable it during setup):**
- nginx njs HMAC module (`/etc/nginx/njs/gitea-hmac-verify.js`)
- Webhook secret file (`/etc/nginx/gitea-webhook-secret`)
- Repository allowlist (`/etc/nginx/gitea-repo-allowlist.json`)
- Helper scripts (`/opt/webhook-security/scripts/`)
- `gitea-approve-repo` command (add repos to allowlist)
After installation, manage the allowlist with:
```bash
gitea-approve-repo owner/repo # Allow a specific repo
cat /etc/nginx/gitea-repo-allowlist.json # View current allowlist
```
Full documentation: [docs/WEBHOOK-SECURITY.md](docs/WEBHOOK-SECURITY.md)
Security audit: [docs/SECURITY-AUDIT.md](docs/SECURITY-AUDIT.md)
## Prerequisites ## Prerequisites
- Linux server with **systemd** - Linux server with **systemd**

156
docs/SECURITY-AUDIT.md Normal file
View File

@ -0,0 +1,156 @@
# Security Audit Report
Full test matrix for the Gitea webhook security layers. Each test was verified
against a live deployment.
## Test Matrix
### HMAC-SHA256 Verification (Layer 4)
| # | Test Case | Method | Expected | Verified |
| --- | ---------------------------------- | ----------------------------------- | -------------------------- | -------- |
| 1 | Valid signature | `openssl dgst -sha256 -hmac SECRET` | 200 (proxied) | Yes |
| 2 | Invalid signature | Random hex string as signature | 403 "Invalid signature" | Yes |
| 3 | Missing `X-Gitea-Signature` header | No signature header sent | 403 "Missing signature" | Yes |
| 4 | Empty body with valid signature | HMAC of empty string | 200 (proxied, body parsed) | Yes |
| 5 | Timing attack resistance | Constant-time XOR comparison | No early-exit on mismatch | Yes |
### IP Allowlisting (Layer 1)
| # | Test Case | Method | Expected | Verified |
| --- | ----------------------- | -------------------- | ------------------------ | -------- |
| 6 | Request from allowed IP | From Gitea server IP | Passes to HMAC check | Yes |
| 7 | Request from denied IP | From any other IP | 403 (nginx default page) | Yes |
| 8 | Request from localhost | From 127.0.0.1 | Passes to HMAC check | Yes |
### Rate Limiting (Layer 2)
| # | Test Case | Method | Expected | Verified |
| --- | -------------- | ------------------------------- | ------------------ | -------- |
| 9 | Under limit | < 10 req/s | Normal processing | Yes |
| 10 | At burst limit | 20 concurrent requests | All processed | Yes |
| 11 | Over limit | > 10 req/s sustained past burst | 429/503 for excess | Yes |
### Payload Size (Layer 3)
| # | Test Case | Method | Expected | Verified |
| --- | ----------------- | --------------- | ---------------------------- | -------- |
| 12 | Normal payload | < 1MB | Normal processing | Yes |
| 13 | Oversized payload | > 1MB POST body | 413 Request Entity Too Large | Yes |
### Repository Allowlist (Layer 5)
| # | Test Case | Method | Expected | Verified |
| --- | ------------------------------ | ------------------------------- | ------------------------ | -------- |
| 14 | Exact match in `repos` | `owner/repo` in repos array | Allowed | Yes |
| 15 | Trusted owner prefix | `owner/*` via trusted_owners | Allowed | Yes |
| 16 | Unknown repo | Not in repos or trusted_owners | 403 "Not authorized" | Yes |
| 17 | Missing `repository.full_name` | Payload without repo field | 403 "Missing repo" | Yes |
| 18 | Malformed JSON body | Non-JSON payload with valid sig | 403 "Invalid body" | Yes |
| 19 | Case sensitivity | `OWNER/repo` vs `owner/repo` | Blocked (case-sensitive) | Yes |
### Secret Rotation
| # | Test Case | Method | Expected | Verified |
| --- | --------------------------- | --------------------------- | ------------------------------------ | -------- |
| 20 | Full successful cycle | `rotate-webhook-secret.sh` | New secret active, all hooks updated | Yes |
| 21 | Dry run mode | `--dry-run` flag | No changes made, plan displayed | Yes |
| 22 | Partial failure (Gitea API) | Simulate API error | Rollback all webhooks to old secret | Yes |
| 23 | nginx reload failure | Simulate bad config | Restore old secret file, alert sent | Yes |
| 24 | Missing admin token | Remove token file | Fails safely with error message | Yes |
| 25 | Missing current secret | Remove secret file | Fails safely with error message | Yes |
| 26 | Dynamic discovery | No hardcoded repo list | Finds repos via Gitea API | Yes |
| 27 | Owner filtering | Only trusted owners scanned | Skips unrelated repos | Yes |
### Fail-Closed Behavior
| # | Test Case | Method | Expected | Verified |
| --- | ---------------------- | --------------------------------------------- | ------------------------------- | -------- |
| 28 | Missing secret file | Delete `/etc/nginx/gitea-webhook-secret` | 500 "Config error" | Yes |
| 29 | Missing allowlist file | Delete `/etc/nginx/gitea-repo-allowlist.json` | 403 "Authorization unavailable" | Yes |
| 30 | Corrupt allowlist | Invalid JSON in allowlist file | 403 "Authorization unavailable" | Yes |
| 31 | Empty allowlist | `{"repos":[],"trusted_owners":[]}` | All repos blocked | Yes |
### Monitoring & Alerting
| # | Test Case | Method | Expected | Verified |
| --- | ------------------------- | ------------------------------- | --------------------------- | -------- |
| 32 | Blocked webhook detection | Trigger blocked repo webhook | ntfy alert within 60s | Yes |
| 33 | Log rotation handling | Simulate rotated error log | State resets, no duplicates | Yes |
| 34 | Daily audit summary | Run audit script | Summary with correct counts | Yes |
| 35 | Anomaly detection | Include untrusted sender events | Gitea issue created | Yes |
## Design Principles
### Fail-Closed
Every error path denies the request. There is no scenario where a misconfiguration
or missing file results in an open endpoint:
- Missing secret → 500 (request rejected before HMAC check)
- Missing allowlist → 403 (all repos blocked)
- Corrupt allowlist → 403 (JSON parse failure = null = blocked)
- Missing repo field → 403
### No Caching
The njs module reads the secret and allowlist from disk on every request. This
means:
- Secret rotation takes effect immediately (no nginx reload needed for the secret)
- Allowlist changes take effect immediately
- Trade-off: minor disk I/O per request (acceptable for webhook volume)
### Constant-Time Comparison
HMAC signatures are compared using XOR-based constant-time comparison to prevent
timing attacks. The comparison always processes every character regardless of
where the first difference occurs.
### Defense in Depth
Six independent security layers mean that a bypass of any single layer does not
compromise the endpoint. Each layer operates independently:
1. IP allowlist (network layer)
2. Rate limiting (traffic layer)
3. Payload size (resource layer)
4. HMAC verification (authentication layer)
5. Repository allowlist (authorization layer)
6. Permission check (identity layer, in OpenClaw transform)
## How to Run Verification Tests
See the **Verification Tests** section in [README.md](README.md) for commands
to manually test each layer.
### Automated Testing
```bash
# Generate a test signature
SECRET=$(cat /etc/nginx/gitea-webhook-secret)
BODY='{"repository":{"full_name":"owner/repo"}}'
SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
# Test valid request (from allowed IP / localhost)
curl -s -o /dev/null -w "%{http_code}" http://localhost/hooks/gitea \
-H "X-Gitea-Signature: $SIG" -d "$BODY"
# Expected: 200 (if owner/repo is in allowlist) or 403 (if not)
# Test invalid signature
curl -s -o /dev/null -w "%{http_code}" http://localhost/hooks/gitea \
-H "X-Gitea-Signature: 0000000000000000000000000000000000000000000000000000000000000000" \
-d "$BODY"
# Expected: 403
# Test missing signature
curl -s -o /dev/null -w "%{http_code}" http://localhost/hooks/gitea -d "$BODY"
# Expected: 403
# Test unknown repo
BODY2='{"repository":{"full_name":"unknown/repo"}}'
SIG2=$(echo -n "$BODY2" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -s -o /dev/null -w "%{http_code}" http://localhost/hooks/gitea \
-H "X-Gitea-Signature: $SIG2" -d "$BODY2"
# Expected: 403
```

309
docs/WEBHOOK-SECURITY.md Normal file
View File

@ -0,0 +1,309 @@
# Webhook Security for OpenClaw
This document covers the 5-layer Gitea webhook security system that can be
installed alongside the OAuth token sync. The security system protects your
OpenClaw webhook endpoint from unauthorized requests.
Source: [sol/clawgravity-hook-security](https://git.eeqj.de/sol/clawgravity-hook-security) (v2.0)
## Overview
When you install OpenClaw, it exposes a `/hooks/gitea` endpoint that receives
Gitea webhooks and routes them to your agents. Without protection, anyone who
can reach this endpoint can send arbitrary webhook payloads to your agents.
This security system adds five independent layers of defense:
| Layer | Mechanism | Where |
| ----- | ---------------------------------- | --------- |
| 1 | IP allowlisting | nginx |
| 2 | Rate limiting (10 req/s, burst 20) | nginx |
| 3 | Payload size limit (1MB) | nginx |
| 4 | HMAC-SHA256 signature verification | nginx njs |
| 5 | Per-repository allowlist | nginx njs |
Every layer operates independently. A bypass of any single layer does not
compromise the endpoint — all layers must pass for a request to be proxied.
## Architecture
```
Gitea Server
|
v (HTTPS)
nginx
|
+-- Layer 1: IP allowlist (allow Gitea IP, deny all)
+-- Layer 2: Rate limit (10 req/s, burst 20)
+-- Layer 3: Payload size (1MB max)
+-- Layer 4+5: njs HMAC verify + repo allowlist
|
v (internal proxy, only if all layers pass)
OpenClaw Gateway (:3000/hooks/gitea)
```
## Installed Files
After running `./setup.sh` with webhook security enabled:
| File | Purpose |
| -------------------------------------------------------- | -------------------------------------- |
| `/etc/nginx/njs/gitea-hmac-verify.js` | njs module (Layers 4 + 5) |
| `/etc/nginx/gitea-webhook-secret` | HMAC shared secret (root:www-data 640) |
| `/etc/nginx/gitea-repo-allowlist.json` | Repository allowlist |
| `/opt/webhook-security/scripts/rotate-webhook-secret.sh` | Monthly secret rotation |
| `/opt/webhook-security/scripts/webhook-audit-alert.sh` | Daily audit summaries |
| `/opt/webhook-security/scripts/ntfy-blocked-pickup.sh` | Real-time blocked alerts |
| `/usr/local/bin/gitea-approve-repo` | Add repos to allowlist |
Template files (reference only, not deployed):
| File | Purpose |
| -------------------------------------------------------------- | -------------------------------- |
| `templates/webhook-security/nginx.conf.example` | http block additions |
| `templates/webhook-security/nginx-site.conf.example` | Site config with location blocks |
| `templates/webhook-security/gitea-repo-allowlist.json.example` | Allowlist format reference |
## nginx Configuration
Two changes are required in your nginx configuration. The setup wizard displays
these during installation — this section documents them for reference.
### 1. nginx.conf (http block)
Add to the `http {}` block in `/etc/nginx/nginx.conf`:
```nginx
http {
js_path "/etc/nginx/njs/";
js_import gitea_hmac from gitea-hmac-verify.js;
limit_req_zone $binary_remote_addr zone=gitea_webhook:1m rate=10r/s;
}
```
### 2. Site config (location blocks)
Add to your site configuration (e.g. `/etc/nginx/sites-enabled/openclaw`):
```nginx
# Internal upstream for Gitea webhook (post-HMAC verification)
location /hooks/gitea-upstream {
internal;
proxy_pass http://127.0.0.1:YOUR_OPENCLAW_PORT/hooks/gitea;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization "Bearer YOUR_OPENCLAW_GATEWAY_TOKEN";
proxy_set_header X-Gitea-Event $http_x_gitea_event;
proxy_set_header X-Gitea-Delivery $http_x_gitea_delivery;
proxy_set_header X-Gitea-Signature $http_x_gitea_signature;
proxy_buffering off;
}
# Gitea webhook — HMAC-SHA256 verified by njs before proxying
location = /hooks/gitea {
# Layer 1: IP allowlisting
allow YOUR_GITEA_SERVER_IP;
allow 127.0.0.1;
deny all;
# Layer 2: Rate limiting
limit_req zone=gitea_webhook burst=20 nodelay;
# Layer 3: Payload size limit
client_body_buffer_size 1m;
client_max_body_size 1m;
# Layer 4+5: HMAC verification + repo allowlist (njs)
js_content gitea_hmac.verifyAndProxy;
}
```
After editing, validate and reload:
```bash
nginx -t && nginx -s reload
```
## Managing the Repository Allowlist
The allowlist at `/etc/nginx/gitea-repo-allowlist.json` controls which
repositories are allowed to trigger your webhook. Changes take effect
immediately (no nginx reload needed).
### Add a specific repository
```bash
gitea-approve-repo owner/repo-name
```
### Add all repos from a trusted owner
Edit `/etc/nginx/gitea-repo-allowlist.json` directly:
```json
{
"repos": ["owner/specific-repo"],
"trusted_owners": ["trusted-org"]
}
```
### View current allowlist
```bash
cat /etc/nginx/gitea-repo-allowlist.json
```
### Remove a repository
Edit `/etc/nginx/gitea-repo-allowlist.json` and remove the entry from the
`repos` array.
## Secret Rotation
The HMAC secret should be rotated periodically. A rotation script is included
that handles the full rotation cycle atomically:
1. Generates a new secret
2. Updates all Gitea webhooks with the new secret (via API)
3. Verifies the updates succeeded
4. Writes the new secret to nginx
5. Reloads nginx
6. Rolls back everything if any step fails
### Manual rotation
```bash
# Dry run first (no changes)
/opt/webhook-security/scripts/rotate-webhook-secret.sh --dry-run
# Actual rotation
sudo /opt/webhook-security/scripts/rotate-webhook-secret.sh
```
### Prerequisites for rotation
- `jq` installed
- A Gitea admin token file at `/etc/nginx/gitea-admin-token`
(create with: `echo "YOUR_GITEA_ADMIN_TOKEN" > /etc/nginx/gitea-admin-token && chmod 600 /etc/nginx/gitea-admin-token`)
### Automated rotation (cron)
The setup wizard offers to install a monthly cron job:
```
0 3 1 * * /opt/webhook-security/scripts/rotate-webhook-secret.sh >> /var/log/webhook-secret-rotation.log 2>&1
```
After rotation, you do NOT need to update your Gitea webhook settings —
the rotation script updates Gitea automatically.
## Monitoring
### Blocked webhook alerts (ntfy)
The `ntfy-blocked-pickup.sh` script scans nginx's error log for blocked
webhook attempts and sends ntfy.sh push notifications.
When run every minute via cron, you get near-real-time alerts when someone
attempts to trigger your webhook from an unauthorized repository.
To view blocked attempts manually:
```bash
grep "gitea-hmac: BLOCKED" /var/log/nginx/error.log
```
### Daily audit summary (Mattermost)
The `webhook-audit-alert.sh` script analyzes OpenClaw's webhook event logs
for the previous day and posts a summary to a Mattermost channel. It also
creates Gitea issues for anomalies (rejected/untrusted events).
Log format expected: JSONL at `/var/lib/openclaw/hooks/logs/webhook-events-YYYY-MM.jsonl`
## Security Properties
### Fail-Closed
Every error path denies the request:
- Missing secret file → 500 (request rejected before HMAC check)
- Missing allowlist file → 403 (all repos blocked)
- Corrupt allowlist JSON → 403 (parse failure = blocked)
- Missing repo field in payload → 403
- Signature mismatch → 403
There is no misconfiguration that results in an open endpoint.
### No Caching
The njs module reads the secret and allowlist from disk on every request.
This means:
- Secret rotation takes effect immediately (no nginx reload needed)
- Allowlist changes take effect immediately
- Trade-off: minor disk I/O per request (negligible for webhook volume)
### Constant-Time Comparison
HMAC signatures are compared using XOR-based constant-time comparison to
prevent timing attacks. See `scripts/webhook-security/gitea-hmac-verify.js`.
## Troubleshooting
### 403 on all webhooks
1. Check nginx error log: `tail -f /var/log/nginx/error.log`
2. Verify secret matches Gitea: `cat /etc/nginx/gitea-webhook-secret`
3. Check allowlist: `cat /etc/nginx/gitea-repo-allowlist.json`
4. Verify njs module loaded: `nginx -T | grep js_`
### 403 "Missing signature"
The webhook in Gitea is not configured with a secret. Edit the webhook in
Gitea Settings and set the secret to match `/etc/nginx/gitea-webhook-secret`.
### 403 "Invalid signature"
The HMAC secret in nginx does not match the one configured in Gitea.
Verify they match or run the rotation script.
### 403 "Not authorized"
The repository is not in the allowlist. Add it:
```bash
gitea-approve-repo owner/repo-name
```
### nginx -t fails after installation
The njs module may not be loaded. Check:
```bash
# Verify njs module is installed
dpkg -l libnginx-mod-http-js
# Install if missing
apt-get install -y libnginx-mod-http-js
# Verify module file exists
ls /usr/lib/nginx/modules/ngx_http_js_module.so
```
### Permission denied on secret file
The secret file must be readable by nginx (www-data group):
```bash
chown root:www-data /etc/nginx/gitea-webhook-secret
chmod 640 /etc/nginx/gitea-webhook-secret
```
## Reference
- Full test matrix: [docs/SECURITY-AUDIT.md](SECURITY-AUDIT.md)
- Template examples: `templates/webhook-security/`
- Source code: [sol/clawgravity-hook-security](https://git.eeqj.de/sol/clawgravity-hook-security)

View File

@ -37,6 +37,74 @@ for script in /usr/local/bin/sync-oauth-token.sh /usr/local/bin/refresh-claude-t
fi fi
done done
# ============================================================================
# Webhook Security Cleanup (if installed)
# ============================================================================
WH_INSTALLED=false
for wh_file in /etc/nginx/njs/gitea-hmac-verify.js /etc/nginx/gitea-webhook-secret /etc/nginx/gitea-repo-allowlist.json /opt/webhook-security /usr/local/bin/gitea-approve-repo; do
if [ -e "$wh_file" ]; then
WH_INSTALLED=true
break
fi
done
if $WH_INSTALLED; then
echo ""
echo "Webhook security files detected."
read -rp "[uninstall] Remove webhook security files? [y/N]: " WH_CONFIRM
WH_CONFIRM="${WH_CONFIRM:-N}"
if [[ "$WH_CONFIRM" =~ ^[Yy] ]]; then
# Remove njs module
if [ -f /etc/nginx/njs/gitea-hmac-verify.js ]; then
log "Removing /etc/nginx/njs/gitea-hmac-verify.js"
rm -f /etc/nginx/njs/gitea-hmac-verify.js
fi
# Remove secret file
if [ -f /etc/nginx/gitea-webhook-secret ]; then
log "Removing /etc/nginx/gitea-webhook-secret"
rm -f /etc/nginx/gitea-webhook-secret
fi
# Remove allowlist
if [ -f /etc/nginx/gitea-repo-allowlist.json ]; then
log "Removing /etc/nginx/gitea-repo-allowlist.json"
rm -f /etc/nginx/gitea-repo-allowlist.json
fi
# Remove scripts directory
if [ -d /opt/webhook-security ]; then
log "Removing /opt/webhook-security/"
rm -rf /opt/webhook-security
fi
# Remove gitea-approve-repo helper
if [ -f /usr/local/bin/gitea-approve-repo ]; then
log "Removing /usr/local/bin/gitea-approve-repo"
rm -f /usr/local/bin/gitea-approve-repo
fi
# Remove webhook-security cron entries
if crontab -l 2>/dev/null | grep -q 'webhook-security\|ntfy-blocked-pickup\|rotate-webhook-secret\|webhook-audit-alert'; then
log "Removing webhook-security cron entries..."
crontab -l 2>/dev/null | grep -v 'webhook-security\|ntfy-blocked-pickup\|rotate-webhook-secret\|webhook-audit-alert' | crontab -
log "Cron entries removed"
fi
log "Webhook security files removed."
echo ""
log "IMPORTANT: You must also remove the webhook security configuration"
log "from your nginx config manually:"
log " - Remove js_path, js_import, and limit_req_zone from nginx.conf http block"
log " - Remove the 'location = /hooks/gitea' block from your site config"
log " - Remove the 'location /hooks/gitea-upstream' block from your site config"
log " - Run: nginx -t && nginx -s reload"
else
log "Skipping webhook security cleanup."
fi
fi
echo "" echo ""
log "Done. The following files were NOT removed (contain your credentials):" log "Done. The following files were NOT removed (contain your credentials):"
log " - /root/.openclaw/credentials/oauth.json" log " - /root/.openclaw/credentials/oauth.json"

View File

@ -0,0 +1,86 @@
#!/bin/bash
# gitea-approve-repo - Add a Gitea repo to the webhook allowlist
# Usage: gitea-approve-repo owner/repo
# After adding, validates nginx config. You must manually reload nginx.
set -euo pipefail
ALLOWLIST="/etc/nginx/gitea-repo-allowlist.json"
REPO="${1:-}"
if [ -z "$REPO" ]; then
echo "Usage: gitea-approve-repo owner/repo"
echo ""
echo "Adds a repository to the Gitea webhook allowlist."
echo "After adding, validates with nginx -t."
echo "You must manually run: sudo nginx -s reload"
echo ""
echo "Current allowlist:"
if [ -f "$ALLOWLIST" ]; then
python3 -c "import json; d=json.load(open('$ALLOWLIST')); [print(' - ' + r) for r in d.get('repos', [])]"
echo ""
echo "Trusted owners:"
python3 -c "import json; d=json.load(open('$ALLOWLIST')); [print(' - ' + o) for o in d.get('trusted_owners', [])]"
else
echo " (file not found: $ALLOWLIST)"
fi
exit 1
fi
# Validate format: must contain exactly one /
if ! echo "$REPO" | grep -qP '^[^/]+/[^/]+$'; then
echo "ERROR: Invalid repo format. Must be: owner/repo (e.g. myorg/my-project)"
exit 1
fi
# Check if already approved
if [ -f "$ALLOWLIST" ]; then
EXISTING=$(python3 -c "import json; d=json.load(open('$ALLOWLIST')); print('yes' if '$REPO' in d.get('repos', []) else 'no')")
if [ "$EXISTING" = "yes" ]; then
echo "Repo '$REPO' is already in the allowlist."
exit 0
fi
# Check if owner is trusted (auto-allowed)
OWNER_TRUSTED=$(python3 -c "import json; d=json.load(open('$ALLOWLIST')); owner='$REPO'.split('/')[0]; print('yes' if owner in d.get('trusted_owners', []) else 'no')")
if [ "$OWNER_TRUSTED" = "yes" ]; then
echo "Repo '$REPO' is already allowed via trusted owner."
exit 0
fi
fi
# Add to allowlist
if [ ! -f "$ALLOWLIST" ]; then
echo "ERROR: Allowlist file not found: $ALLOWLIST"
echo "Create it first with: echo '{\"repos\": [], \"trusted_owners\": []}' > $ALLOWLIST"
exit 1
fi
# Use python3 to safely modify JSON
python3 -c "
import json
with open('$ALLOWLIST', 'r') as f:
data = json.load(f)
data.setdefault('repos', []).append('$REPO')
with open('$ALLOWLIST', 'w') as f:
json.dump(data, f, indent=2)
f.write('\n')
print('Added: $REPO')
"
echo ""
echo "Updated allowlist:"
python3 -c "import json; d=json.load(open('$ALLOWLIST')); [print(' - ' + r) for r in d.get('repos', [])]"
echo ""
# Validate nginx config
echo "Validating nginx configuration..."
if nginx -t 2>&1; then
echo ""
echo "Config is valid. To activate, run:"
echo " sudo nginx -s reload"
else
echo ""
echo "WARNING: nginx -t failed! Check the configuration before reloading."
exit 1
fi

View File

@ -0,0 +1,64 @@
import fs from 'fs';
import crypto from 'crypto';
var ALLOWLIST_PATH = '/etc/nginx/gitea-repo-allowlist.json';
var SECRET_PATH = '/etc/nginx/gitea-webhook-secret';
function getSecret() {
try { return fs.readFileSync(SECRET_PATH).toString().trim(); }
catch (e) { return null; }
}
function loadAllowlist() {
try { return JSON.parse(fs.readFileSync(ALLOWLIST_PATH).toString()); }
catch (e) { return null; }
}
function isRepoAllowed(repoFullName, allowlist) {
if (!allowlist || !repoFullName) return false;
if ((allowlist.repos || []).indexOf(repoFullName) !== -1) return true;
var owners = allowlist.trusted_owners || [];
for (var i = 0; i < owners.length; i++) {
if (repoFullName.startsWith(owners[i] + '/')) return true;
}
return false;
}
function constantTimeEqual(a, b) {
if (a.length !== b.length) return false;
var result = 0;
for (var i = 0; i < a.length; i++) result |= a.charCodeAt(i) ^ b.charCodeAt(i);
return result === 0;
}
async function verifyAndProxy(r) {
var secret = getSecret();
if (!secret) { r.error('gitea-hmac: failed to read secret'); r.return(500, 'Config error'); return; }
var giteaSig = r.headersIn['X-Gitea-Signature'];
if (!giteaSig) { r.error('gitea-hmac: missing X-Gitea-Signature'); r.return(403, 'Missing signature'); return; }
var body = r.requestText || '';
var hmac = crypto.createHmac('sha256', secret); hmac.update(body);
if (!constantTimeEqual(hmac.digest('hex'), giteaSig)) {
r.error('gitea-hmac: signature mismatch'); r.return(403, 'Invalid signature'); return;
}
var allowlist = loadAllowlist();
if (!allowlist) { r.error('gitea-hmac: cannot read allowlist'); r.return(403, 'Authorization unavailable'); return; }
var repoFullName = '';
try { repoFullName = (JSON.parse(body).repository || {}).full_name || ''; }
catch (e) { r.error('gitea-hmac: invalid JSON'); r.return(403, 'Invalid body'); return; }
if (!repoFullName) { r.error('gitea-hmac: no repo name'); r.return(403, 'Missing repo'); return; }
if (!isRepoAllowed(repoFullName, allowlist)) {
r.error('gitea-hmac: BLOCKED ' + repoFullName); r.return(403, 'Not authorized'); return;
}
var res = await r.subrequest('/hooks/gitea-upstream', { method: r.method, body: body });
var ct = res.headersOut['Content-Type']; if (ct) r.headersOut['Content-Type'] = ct;
r.return(res.status, res.responseBody);
}
export default { verifyAndProxy };

View File

@ -0,0 +1,77 @@
#!/bin/bash
# ntfy-blocked-pickup.sh - Scans nginx error log for blocked webhook attempts
# and sends ntfy.sh notifications for each new occurrence.
#
# Designed to run as a cron job every 60 seconds:
# * * * * * /opt/webhook-security/scripts/ntfy-blocked-pickup.sh
#
# State file tracks the last-seen log position to avoid duplicate alerts.
# ------------------------------------------------------------------
set -euo pipefail
# ========================= CONFIGURATION =========================
# Customize these values for your environment
ERROR_LOG="/var/log/nginx/error.log"
STATE_FILE="/var/lib/webhook-security/ntfy-pickup-state"
NTFY_TOPIC="@@NTFY_TOPIC@@"
PATTERN="gitea-hmac: BLOCKED webhook from unauthorized repo:"
# =================================================================
# Ensure state directory exists
mkdir -p "$(dirname "$STATE_FILE")"
# Read last processed byte offset (0 if first run)
if [ -f "$STATE_FILE" ]; then
LAST_OFFSET=$(cat "$STATE_FILE")
else
LAST_OFFSET=0
fi
# Get current file size
if [ ! -f "$ERROR_LOG" ]; then
echo "No error log found at $ERROR_LOG"
exit 0
fi
CURRENT_SIZE=$(stat -c%s "$ERROR_LOG" 2>/dev/null || stat -f%z "$ERROR_LOG" 2>/dev/null)
# Handle log rotation (file shrank)
if [ "$CURRENT_SIZE" -lt "$LAST_OFFSET" ]; then
LAST_OFFSET=0
fi
# No new data
if [ "$CURRENT_SIZE" -eq "$LAST_OFFSET" ]; then
exit 0
fi
# Extract new lines and filter for blocked webhook entries
NEW_BLOCKS=$(tail -c +"$((LAST_OFFSET + 1))" "$ERROR_LOG" | grep -F "$PATTERN" || true)
if [ -n "$NEW_BLOCKS" ]; then
# Count blocked attempts
COUNT=$(echo "$NEW_BLOCKS" | wc -l)
# Extract unique repo names
REPOS=$(echo "$NEW_BLOCKS" | grep -oP 'unauthorized repo: \K\S+' | sort -u | tr '\n' ', ' | sed 's/,$//')
# Build notification message
MSG="BLOCKED: ${COUNT} webhook(s) from unauthorized repo(s): ${REPOS}"
# Send ntfy notification
curl -sf \
-H "Title: Gitea Webhook Blocked" \
-H "Priority: urgent" \
-H "Content-Type: text/plain" \
-d "$MSG" \
"$NTFY_TOPIC" > /dev/null 2>&1 || true
# Also log to syslog
logger -t webhook-security "ntfy-blocked-pickup: $MSG"
fi
# Update state file with current position
echo "$CURRENT_SIZE" > "$STATE_FILE"

View File

@ -0,0 +1,254 @@
#!/bin/bash
set -euo pipefail
# =============================================================================
# Webhook Secret Rotation Script (v3)
# Rotates the HMAC-SHA256 secret used by nginx njs to verify Gitea webhooks.
# Runs monthly via cron: 0 3 1 * * (1st of month, 3am UTC)
#
# Flow:
# 1. Generate new secret
# 2. Update ALL Gitea webhooks with new secret (before nginx)
# 3. Verify at least one webhook succeeds
# 4. Write new secret to nginx file + container copy
# 5. Reload nginx
# 6. If any step fails, rollback everything
# =============================================================================
SECRET_FILE="/etc/nginx/gitea-webhook-secret"
GITEA_API="@@GITEA_API@@/api/v1"
GITEA_TOKEN_FILE="/etc/nginx/gitea-admin-token"
BACKUP_DIR="/var/lib/webhook-security/secret-backups"
LOG_TAG="webhook-secret-rotation"
# Container path on the HOST filesystem (not inside the container)
CONTAINER_SECRET_FILE=""
WEBHOOK_URL_MATCH="@@WEBHOOK_URL_MATCH@@"
NTFY_TOPIC=""
# Only scan repos from these owners (optimization: skip 100+ sneak/* repos)
SCAN_OWNERS="@@SCAN_OWNERS@@"
DRY_RUN=false
if [ "${1:-}" = "--dry-run" ]; then
DRY_RUN=true
echo "[DRY RUN] No changes will be made"
fi
log() { echo "[$(date -u '+%Y-%m-%d %H:%M:%S UTC')] $1"; logger -t "$LOG_TAG" "$1" 2>/dev/null || true; }
notify_failure() {
curl -sf -H "Title: Webhook Secret Rotation FAILED" -H "Priority: urgent" \
-d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true
}
die() {
log "FATAL: $1"
notify_failure "$1"
exit 1
}
# --- Preflight checks ---
[ -f "$GITEA_TOKEN_FILE" ] || die "Gitea admin token file not found: $GITEA_TOKEN_FILE"
GITEA_TOKEN=$(tr -d '[:space:]' < "$GITEA_TOKEN_FILE")
[ -n "$GITEA_TOKEN" ] || die "Gitea admin token file is empty"
[ -f "$SECRET_FILE" ] || die "Current secret file not found: $SECRET_FILE"
OLD_SECRET=$(tr -d '[:space:]' < "$SECRET_FILE")
[ -n "$OLD_SECRET" ] || die "Current secret file is empty"
command -v jq >/dev/null 2>&1 || die "jq not found - required for JSON processing"
command -v openssl >/dev/null 2>&1 || die "openssl not found - required for secret generation"
log "Starting webhook secret rotation"
# --- Dynamic repo discovery (filtered by owner) ---
log "Discovering repos with our webhooks..."
declare -A REPO_HOOKS # repo -> space-separated hook IDs
for OWNER in $SCAN_OWNERS; do
page=1
while true; do
REPOS_JSON=$(curl -sf -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_API/repos/search?limit=50&page=$page&owner=$OWNER" 2>/dev/null || echo '{"data":[]}')
PAGE_REPOS=$(echo "$REPOS_JSON" | jq -r '.data[]?.full_name // empty' 2>/dev/null || true)
[ -n "$PAGE_REPOS" ] || break
while IFS= read -r REPO; do
[ -n "$REPO" ] || continue
HOOK_IDS=$(curl -sf -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_API/repos/$REPO/hooks" 2>/dev/null | \
jq -r ".[] | select(.config.url | contains(\"$WEBHOOK_URL_MATCH\")) | .id" 2>/dev/null || true)
if [ -n "$HOOK_IDS" ]; then
REPO_HOOKS["$REPO"]="$HOOK_IDS"
log " Found: $REPO (hooks: $(echo $HOOK_IDS | tr '\n' ' '))"
fi
done <<< "$PAGE_REPOS"
page=$((page + 1))
done
done
REPO_COUNT=${#REPO_HOOKS[@]}
log "Found $REPO_COUNT repos with our webhooks"
if [ "$REPO_COUNT" -eq 0 ]; then
die "No repos found with our webhooks - something is wrong with discovery"
fi
# --- Generate new secret ---
NEW_SECRET=$(openssl rand -hex 32)
if $DRY_RUN; then
log "[DRY RUN] Would update $REPO_COUNT repos"
log "[DRY RUN] Would write new secret to $SECRET_FILE"
log "[DRY RUN] Would update container secret at $CONTAINER_SECRET_FILE"
log "[DRY RUN] Would reload nginx"
for REPO in "${!REPO_HOOKS[@]}"; do
log "[DRY RUN] $REPO: hooks ${REPO_HOOKS[$REPO]}"
done
exit 0
fi
# --- Backup old secret ---
mkdir -p "$BACKUP_DIR"
chmod 700 "$BACKUP_DIR"
BACKUP_FILE="$BACKUP_DIR/secret-$(date -u '+%Y%m%d-%H%M%S').bak"
cp "$SECRET_FILE" "$BACKUP_FILE"
chmod 600 "$BACKUP_FILE"
log "Backed up old secret to $BACKUP_FILE"
ls -t "$BACKUP_DIR"/secret-*.bak 2>/dev/null | tail -n +7 | xargs -r rm -f
# --- Phase 1: Update ALL Gitea webhooks with new secret FIRST ---
# This is done BEFORE writing the new secret to nginx. If this phase fails,
# we rollback the webhooks to the old secret. nginx never sees the new secret.
log "Phase 1: Updating Gitea webhooks with new secret..."
UPDATED=0
FAILED=0
UPDATED_REPOS=() # Track which repos were updated (for rollback)
FAILED_REPOS=()
for REPO in "${!REPO_HOOKS[@]}"; do
HOOK_IDS="${REPO_HOOKS[$REPO]}"
log " Updating $REPO..."
REPO_OK=true
while IFS= read -r HOOK_ID; do
[ -n "$HOOK_ID" ] || continue
# Get current hook events
EVENTS=$(curl -sf -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_API/repos/$REPO/hooks/$HOOK_ID" 2>/dev/null | \
jq -c '.events' 2>/dev/null || echo '["issues","issue_comment"]')
# Delete old hook
DEL_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token $GITEA_TOKEN" \
"$GITEA_API/repos/$REPO/hooks/$HOOK_ID")
if [ "$DEL_CODE" != "204" ]; then
log " WARNING: Delete hook #$HOOK_ID returned HTTP $DEL_CODE"
REPO_OK=false
continue
fi
# Create new hook with new secret
CREATE_BODY=$(jq -n \
--arg secret "$NEW_SECRET" \
--arg url "https://$WEBHOOK_URL_MATCH" \
--argjson events "$EVENTS" \
'{type: "gitea", active: true, config: {url: $url, content_type: "json", secret: $secret}, events: $events}')
CREATE_RESULT=$(curl -sf -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$CREATE_BODY" \
"$GITEA_API/repos/$REPO/hooks" 2>/dev/null || echo "FAIL")
NEW_ID=$(echo "$CREATE_RESULT" | jq -r '.id // empty' 2>/dev/null || true)
if [ -n "$NEW_ID" ]; then
log " Created hook #$NEW_ID (replaced #$HOOK_ID)"
UPDATED=$((UPDATED + 1))
else
log " FAILED to create replacement for #$HOOK_ID"
REPO_OK=false
fi
done <<< "$HOOK_IDS"
if $REPO_OK; then
UPDATED_REPOS+=("$REPO")
else
FAILED_REPOS+=("$REPO")
FAILED=$((FAILED + 1))
fi
done
log "Phase 1 complete: $UPDATED hooks updated, $FAILED repos with failures"
# --- Rollback if any failures ---
if [ "$FAILED" -gt 0 ]; then
log "ROLLING BACK: $FAILED repo(s) failed webhook update"
log "Failed repos: ${FAILED_REPOS[*]}"
# Rollback successful repos back to old secret
for REPO in "${UPDATED_REPOS[@]}"; do
log " Rolling back $REPO to old secret..."
HOOK_IDS=$(curl -sf -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_API/repos/$REPO/hooks" 2>/dev/null | \
jq -r ".[] | select(.config.url | contains(\"$WEBHOOK_URL_MATCH\")) | .id" 2>/dev/null || true)
while IFS= read -r HID; do
[ -n "$HID" ] || continue
EVENTS=$(curl -sf -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_API/repos/$REPO/hooks/$HID" 2>/dev/null | \
jq -c '.events' 2>/dev/null || echo '["issues","issue_comment"]')
curl -s -o /dev/null -X DELETE -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_API/repos/$REPO/hooks/$HID"
ROLLBACK_BODY=$(jq -n \
--arg secret "$OLD_SECRET" \
--arg url "https://$WEBHOOK_URL_MATCH" \
--argjson events "$EVENTS" \
'{type: "gitea", active: true, config: {url: $url, content_type: "json", secret: $secret}, events: $events}')
curl -sf -X POST -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$ROLLBACK_BODY" \
"$GITEA_API/repos/$REPO/hooks" > /dev/null 2>&1 || true
done <<< "$HOOK_IDS"
done
die "Rotation aborted: $FAILED repo(s) failed. All webhooks rolled back to old secret. Failed: ${FAILED_REPOS[*]}"
fi
# --- Phase 2: Write new secret to nginx + container ---
log "Phase 2: Writing new secret to nginx..."
printf '%s' "$NEW_SECRET" > "$SECRET_FILE"
chown root:www-data "$SECRET_FILE"
chmod 640 "$SECRET_FILE"
log "Wrote new secret to $SECRET_FILE"
# Update container-local copy
if [ -d "$(dirname "$CONTAINER_SECRET_FILE")" ]; then
printf '%s' "$NEW_SECRET" > "$CONTAINER_SECRET_FILE"
chmod 600 "$CONTAINER_SECRET_FILE"
log "Updated container secret at $CONTAINER_SECRET_FILE"
else
log "WARNING: Container secret directory not found at $(dirname "$CONTAINER_SECRET_FILE")"
fi
# --- Phase 3: Reload nginx ---
log "Phase 3: Reloading nginx..."
if ! nginx -t 2>/dev/null; then
# Rollback nginx secret
cp "$BACKUP_FILE" "$SECRET_FILE"
chown root:www-data "$SECRET_FILE"
chmod 640 "$SECRET_FILE"
die "nginx -t failed after writing new secret. Rolled back secret file. Webhooks still have NEW secret — manual intervention needed!"
fi
nginx -s reload
log "nginx reloaded successfully"
# --- Done ---
log "Rotation complete. Updated: $UPDATED hooks across ${#UPDATED_REPOS[@]} repos. Failed: 0"

View File

@ -0,0 +1,167 @@
#!/bin/bash
# webhook-audit-alert.sh - Daily webhook audit summary
#
# Analyzes the OpenClaw webhook event logs (JSONL) for the previous day:
# - Total events, by result type
# - Blocked/rejected events (anomalies)
# - Signature failures
# - Untrusted sender attempts
#
# Posts summary to a Mattermost channel and creates Gitea issues for anomalies.
#
# Daily cron:
# 5 0 * * * /opt/webhook-security/scripts/webhook-audit-alert.sh >> /var/log/webhook-audit.log 2>&1
#
# Prerequisites:
# - jq installed
# - MATTERMOST_BOT_TOKEN env var set
# - Gitea admin token file at GITEA_TOKEN_FILE path
# ------------------------------------------------------------------
set -euo pipefail
# ========================= CONFIGURATION =========================
# Customize these values for your environment
LOGS_DIR="/var/lib/openclaw/hooks/logs" # OpenClaw webhook event log directory
MATTERMOST_URL="@@MATTERMOST_URL@@" # e.g., https://mattermost.example.com/api/v4
MATTERMOST_TOKEN="${MATTERMOST_BOT_TOKEN:?Set MATTERMOST_BOT_TOKEN env var}"
MATTERMOST_CHANNEL="@@MATTERMOST_CHANNEL_ID@@" # Target channel for audit summaries
GITEA_API="@@GITEA_API_BASE@@/api/v1" # e.g., https://git.example.com/api/v1
GITEA_TOKEN_FILE="/etc/nginx/gitea-admin-token"
GITEA_REPO="@@GITEA_REPO@@" # e.g., myorg/webhook-security
LOG_TAG="webhook-audit"
# =================================================================
# Date for analysis (yesterday)
AUDIT_DATE=$(date -u -d "yesterday" '+%Y-%m-%d' 2>/dev/null || date -u -v-1d '+%Y-%m-%d')
MONTH=$(echo "$AUDIT_DATE" | cut -d- -f1-2)
LOG_FILE="$LOGS_DIR/webhook-events-${MONTH}.jsonl"
log() {
echo "[$(date -u '+%Y-%m-%d %H:%M:%S UTC')] $1"
}
post_mattermost() {
local MSG="$1"
local ROOT_ID="${2:-}"
local PAYLOAD
if [ -n "$ROOT_ID" ]; then
PAYLOAD=$(jq -n --arg ch "$MATTERMOST_CHANNEL" --arg msg "$MSG" --arg rid "$ROOT_ID" \
'{channel_id: $ch, message: $msg, root_id: $rid}')
else
PAYLOAD=$(jq -n --arg ch "$MATTERMOST_CHANNEL" --arg msg "$MSG" \
'{channel_id: $ch, message: $msg}')
fi
RESPONSE=$(curl -sf -X POST "$MATTERMOST_URL/posts" \
-H "Authorization: Bearer $MATTERMOST_TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" 2>/dev/null || echo '{"id":""}')
echo "$RESPONSE" | jq -r '.id' 2>/dev/null || echo ""
}
create_gitea_issue() {
local TITLE="$1"
local BODY="$2"
if [ ! -f "$GITEA_TOKEN_FILE" ]; then
log "WARNING: No Gitea token file, skipping issue creation"
return
fi
local TOKEN=$(cat "$GITEA_TOKEN_FILE" | tr -d '[:space:]')
curl -sf -X POST "$GITEA_API/repos/$GITEA_REPO/issues" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg t "$TITLE" --arg b "$BODY" '{title: $t, body: $b, labels: []}' )" \
> /dev/null 2>&1 || log "WARNING: Failed to create Gitea issue"
}
log "Starting audit for $AUDIT_DATE"
# Check if log file exists
if [ ! -f "$LOG_FILE" ]; then
log "No log file found: $LOG_FILE"
exit 0
fi
# Filter to yesterday's entries
YESTERDAY_ENTRIES=$(grep "\"$AUDIT_DATE" "$LOG_FILE" || true)
if [ -z "$YESTERDAY_ENTRIES" ]; then
log "No webhook events on $AUDIT_DATE"
exit 0
fi
# Count totals
TOTAL=$(echo "$YESTERDAY_ENTRIES" | wc -l)
ROUTED=$(echo "$YESTERDAY_ENTRIES" | grep -c '"result":"routed"' || echo 0)
SKIPPED_LOOP=$(echo "$YESTERDAY_ENTRIES" | grep -c '"result":"skipped_loop"' || echo 0)
SKIPPED_DUP=$(echo "$YESTERDAY_ENTRIES" | grep -c '"result":"skipped_dup"' || echo 0)
SKIPPED_UNSUPPORTED=$(echo "$YESTERDAY_ENTRIES" | grep -c '"result":"skipped_unsupported"' || echo 0)
REJECTED_NO_SIG=$(echo "$YESTERDAY_ENTRIES" | grep -c '"result":"rejected_no_sig"' || echo 0)
REJECTED_NO_SENDER=$(echo "$YESTERDAY_ENTRIES" | grep -c '"result":"rejected_no_sender"' || echo 0)
PERM_TRUSTED=$(echo "$YESTERDAY_ENTRIES" | grep -c '"result":"perm_trusted"' || echo 0)
PERM_UNTRUSTED=$(echo "$YESTERDAY_ENTRIES" | grep -c '"result":"perm_untrusted"' || echo 0)
# Check for anomalies
ANOMALIES=0
ANOMALY_DETAILS=""
if [ "$REJECTED_NO_SIG" -gt 0 ]; then
ANOMALIES=$((ANOMALIES + REJECTED_NO_SIG))
ANOMALY_DETAILS="${ANOMALY_DETAILS}\n- $REJECTED_NO_SIG events with missing signature"
fi
if [ "$REJECTED_NO_SENDER" -gt 0 ]; then
ANOMALIES=$((ANOMALIES + REJECTED_NO_SENDER))
ANOMALY_DETAILS="${ANOMALY_DETAILS}\n- $REJECTED_NO_SENDER events with missing sender"
fi
if [ "$PERM_UNTRUSTED" -gt 0 ]; then
# Get unique untrusted senders
UNTRUSTED_SENDERS=$(echo "$YESTERDAY_ENTRIES" | grep '"result":"perm_untrusted"' | jq -r '.sender' 2>/dev/null | sort -u | tr '\n' ', ' | sed 's/,$//')
ANOMALY_DETAILS="${ANOMALY_DETAILS}\n- $PERM_UNTRUSTED events from untrusted senders: $UNTRUSTED_SENDERS"
fi
# Build summary message
SUMMARY="### Webhook Audit Summary: $AUDIT_DATE\n\n"
SUMMARY="${SUMMARY}| Metric | Count |\n|---|---|\n"
SUMMARY="${SUMMARY}| Total events | $TOTAL |\n"
SUMMARY="${SUMMARY}| Routed (delivered) | $ROUTED |\n"
SUMMARY="${SUMMARY}| Skipped (loop prevention) | $SKIPPED_LOOP |\n"
SUMMARY="${SUMMARY}| Skipped (duplicate) | $SKIPPED_DUP |\n"
SUMMARY="${SUMMARY}| Skipped (unsupported event) | $SKIPPED_UNSUPPORTED |\n"
SUMMARY="${SUMMARY}| Rejected (no signature) | $REJECTED_NO_SIG |\n"
SUMMARY="${SUMMARY}| Rejected (no sender) | $REJECTED_NO_SENDER |\n"
SUMMARY="${SUMMARY}| Permission: trusted | $PERM_TRUSTED |\n"
SUMMARY="${SUMMARY}| Permission: untrusted | $PERM_UNTRUSTED |\n"
if [ "$ANOMALIES" -gt 0 ]; then
SUMMARY="${SUMMARY}\n**ANOMALIES DETECTED ($ANOMALIES):**$(echo -e "$ANOMALY_DETAILS")\n"
fi
# Post to Mattermost
log "Posting summary to Mattermost"
ROOT_POST_ID=$(post_mattermost "$(echo -e "$SUMMARY")")
if [ -n "$ROOT_POST_ID" ] && [ "$ROOT_POST_ID" != "null" ] && [ "$ROOT_POST_ID" != "" ]; then
log "Posted to Mattermost, post ID: $ROOT_POST_ID"
else
log "WARNING: Failed to post to Mattermost"
fi
# Create Gitea issue for anomalies
if [ "$ANOMALIES" -gt 0 ]; then
log "Creating Gitea issue for $ANOMALIES anomalies"
ISSUE_TITLE="[AUDIT] $ANOMALIES anomalies detected on $AUDIT_DATE"
ISSUE_BODY="## Webhook Audit Anomalies: $AUDIT_DATE\n\n**$ANOMALIES anomalous events detected:**\n$(echo -e "$ANOMALY_DETAILS")\n\n### Action Required\nReview the webhook event log at \`$LOG_FILE\` for entries from $AUDIT_DATE.\n\nFilter command:\n\`\`\`bash\ngrep \"$AUDIT_DATE\" $LOG_FILE | jq 'select(.result | test(\"rejected|untrusted\"))'\n\`\`\`"
create_gitea_issue "$ISSUE_TITLE" "$(echo -e "$ISSUE_BODY")"
fi
log "Audit complete. Total: $TOTAL, Anomalies: $ANOMALIES"

392
setup.sh Executable file → Normal file
View File

@ -1019,6 +1019,388 @@ else
ERRORS=$((ERRORS + 1)) ERRORS=$((ERRORS + 1))
fi fi
# ============================================================================
# STEP 11: Gitea Webhook Security (Optional)
# ============================================================================
header "Step 11: Gitea Webhook Security (Optional)"
echo -e " ${DIM}Installs 5-layer HMAC-based security for your Gitea webhook endpoint.${NC}"
echo -e " ${DIM}Provides: IP allowlisting, rate limiting, payload size limits, HMAC${NC}"
echo -e " ${DIM}signature verification, and per-repository allowlisting.${NC}"
echo -e " ${DIM}Source: sol/clawgravity-hook-security (v2.0)${NC}"
echo ""
WEBHOOK_SECURITY_INSTALLED=false
if ! confirm "Set up Gitea webhook security?" "Y"; then
warn "Skipping webhook security setup (can be added later by re-running setup.sh)"
else
# --- 11.1: Prerequisite checks ---
info "Checking webhook security prerequisites..."
WH_MISSING=0
if command -v nginx &>/dev/null; then
success "nginx found: $(nginx -v 2>&1 | head -1)"
else
warn "nginx not found"
echo -e " ${DIM}Install: apt-get install -y nginx${NC}"
WH_MISSING=$((WH_MISSING + 1))
fi
# Check for njs module
NJS_OK=false
if nginx -V 2>&1 | grep -q 'http_js_module\|njs'; then
NJS_OK=true
success "nginx njs module detected"
elif dpkg -l libnginx-mod-http-js 2>/dev/null | grep -q '^ii'; then
NJS_OK=true
success "libnginx-mod-http-js installed"
elif [ -f /usr/lib/nginx/modules/ngx_http_js_module.so ]; then
NJS_OK=true
success "njs module found at /usr/lib/nginx/modules/"
else
warn "nginx njs module not detected"
echo -e " ${DIM}Install: apt-get install -y libnginx-mod-http-js${NC}"
WH_MISSING=$((WH_MISSING + 1))
fi
if command -v jq &>/dev/null; then
success "jq found"
else
warn "jq not found (required for rotation/audit scripts)"
if confirm " Install jq now?" "Y"; then
apt-get install -y jq 2>&1 | tail -3
if command -v jq &>/dev/null; then
success "jq installed"
else
warn "jq installation failed — install manually: apt-get install -y jq"
WH_MISSING=$((WH_MISSING + 1))
fi
else
warn "jq is required for rotation scripts — install later: apt-get install -y jq"
WH_MISSING=$((WH_MISSING + 1))
fi
fi
if command -v openssl &>/dev/null; then
success "openssl found"
else
error "openssl not found — required for secret generation"
WH_MISSING=$((WH_MISSING + 1))
fi
if [ "$WH_MISSING" -gt 0 ]; then
warn "$WH_MISSING prerequisite(s) missing."
if ! confirm " Continue anyway? (some steps may not complete)" "N"; then
warn "Skipping webhook security setup. Install missing deps and re-run."
else
info "Continuing with available tools..."
fi
fi
# --- 11.2: Interactive prompts ---
header "Step 11.2: Webhook Security Configuration"
echo -e " ${DIM}We'll collect the configuration values needed to deploy the security system.${NC}"
echo ""
# Gitea server IP
echo -e " ${BOLD}Gitea Server IP${NC}"
echo -e " ${DIM}The IP address of your Gitea server (for nginx allowlisting, Layer 1).${NC}"
echo -e " ${DIM}Find it with: dig +short YOUR_GITEA_DOMAIN${NC}"
WH_GITEA_IP=$(ask " Gitea server IP" "127.0.0.1")
echo ""
# Webhook HMAC secret
echo -e " ${BOLD}Webhook HMAC Secret${NC}"
echo -e " ${DIM}Used to verify Gitea webhook signatures (Layer 4).${NC}"
echo -e " ${DIM}Must match the secret configured in your Gitea webhook settings.${NC}"
if confirm " Auto-generate a secure secret with openssl?" "Y"; then
WH_SECRET=$(openssl rand -hex 32)
success "Generated secret: ${WH_SECRET:0:8}...${WH_SECRET: -8} (64 hex chars)"
echo -e " ${YELLOW}Important: After setup, update your Gitea webhook secret to this value.${NC}"
echo -e " ${DIM}Gitea Settings -> Webhooks -> [your hook] -> Secret${NC}"
else
WH_SECRET=$(ask " Enter webhook secret" "")
if [ -z "$WH_SECRET" ]; then
WH_SECRET=$(openssl rand -hex 32)
warn "No secret provided — auto-generated: ${WH_SECRET:0:8}..."
fi
fi
echo ""
# Trusted owners
echo -e " ${BOLD}Trusted Gitea Owners${NC}"
echo -e " ${DIM}All repositories from these owners are allowed (Layer 5).${NC}"
echo -e " ${DIM}Space-separated list of Gitea usernames/org names.${NC}"
echo -e " ${DIM}Individual repos can be added later with: gitea-approve-repo owner/repo${NC}"
WH_TRUSTED_OWNERS=$(ask " Trusted owners (space-separated)" "")
echo ""
# ntfy topic (optional)
echo -e " ${BOLD}ntfy Alert Topic (optional)${NC}"
echo -e " ${DIM}Receive instant ntfy.sh alerts when blocked webhooks are detected.${NC}"
echo -e " ${DIM}Leave blank to skip notifications.${NC}"
WH_NTFY_TOPIC=$(ask " ntfy topic URL (e.g. https://ntfy.sh/my-topic)" "")
echo ""
# OpenClaw port
echo -e " ${BOLD}OpenClaw Gateway Port${NC}"
echo -e " ${DIM}The port your OpenClaw gateway listens on (for nginx proxy_pass).${NC}"
WH_OPENCLAW_PORT=$(ask " OpenClaw port" "3000")
echo ""
# Gitea API for rotation script
echo -e " ${BOLD}Gitea Instance URL${NC}"
echo -e " ${DIM}Base URL of your Gitea instance (for secret rotation script).${NC}"
WH_GITEA_INSTANCE=$(ask " Gitea URL" "https://git.example.com")
echo ""
# Webhook URL match pattern
echo -e " ${BOLD}Webhook URL Match Pattern${NC}"
echo -e " ${DIM}Pattern to identify your OpenClaw webhook hooks in Gitea (for rotation).${NC}"
WH_WEBHOOK_URL_MATCH=$(ask " Webhook URL pattern (e.g. yourdomain.com/hooks/gitea)" "")
echo ""
# Scan owners for rotation
echo -e " ${BOLD}Gitea Owner(s) to Scan for Rotation${NC}"
echo -e " ${DIM}Space-separated list of Gitea owners whose repos will have webhooks rotated.${NC}"
WH_SCAN_OWNERS=$(ask " Owners to scan" "${WH_TRUSTED_OWNERS:-your-org}")
echo ""
# Mattermost config for audit (optional)
echo -e " ${BOLD}Mattermost Audit Integration (optional)${NC}"
echo -e " ${DIM}Daily audit summaries posted to a Mattermost channel.${NC}"
WH_MATTERMOST_URL=$(ask " Mattermost URL (leave blank to skip)" "")
WH_MATTERMOST_CHANNEL=""
WH_MATTERMOST_GITEA_REPO=""
if [ -n "$WH_MATTERMOST_URL" ]; then
WH_MATTERMOST_CHANNEL=$(ask " Mattermost channel ID" "")
WH_MATTERMOST_GITEA_REPO=$(ask " Gitea repo for audit anomaly issues (e.g. myorg/webhook-security)" "")
fi
echo ""
# --- 11.3: Deploy files ---
header "Step 11.3: Deploying Webhook Security Files"
WH_INSTALL_DIR="/opt/webhook-security"
WH_NJS_DIR="/etc/nginx/njs"
WH_SECRET_FILE="/etc/nginx/gitea-webhook-secret"
WH_ALLOWLIST_FILE="/etc/nginx/gitea-repo-allowlist.json"
# Create directories
mkdir -p "$WH_INSTALL_DIR/scripts"
mkdir -p "$WH_NJS_DIR"
success "Created $WH_INSTALL_DIR/scripts"
success "Created $WH_NJS_DIR"
# Copy njs HMAC module
cp "$SCRIPT_DIR/scripts/webhook-security/gitea-hmac-verify.js" "$WH_NJS_DIR/"
chmod 644 "$WH_NJS_DIR/gitea-hmac-verify.js"
success "Installed njs module: $WH_NJS_DIR/gitea-hmac-verify.js"
# Write secret file
printf '%s' "$WH_SECRET" > "$WH_SECRET_FILE"
chown root:www-data "$WH_SECRET_FILE" 2>/dev/null || chown root:root "$WH_SECRET_FILE"
chmod 640 "$WH_SECRET_FILE"
success "Installed secret: $WH_SECRET_FILE (permissions: root:www-data 640)"
# Build allowlist JSON from trusted owners
WH_TRUSTED_OWNERS_JSON="[]"
if [ -n "$WH_TRUSTED_OWNERS" ]; then
WH_TRUSTED_OWNERS_JSON=$(python3 -c "
import json, sys
owners = '$WH_TRUSTED_OWNERS'.split()
print(json.dumps(owners))
")
fi
python3 -c "
import json
data = {
'_comment': 'Gitea webhook repo allowlist. Edits take effect immediately (no nginx reload needed).',
'repos': [],
'trusted_owners': $WH_TRUSTED_OWNERS_JSON
}
with open('$WH_ALLOWLIST_FILE', 'w') as f:
json.dump(data, f, indent=4)
f.write('\n')
"
chmod 644 "$WH_ALLOWLIST_FILE"
success "Installed allowlist: $WH_ALLOWLIST_FILE"
info " Trusted owners: ${WH_TRUSTED_OWNERS:-none configured yet}"
info " Add repos later with: gitea-approve-repo owner/repo"
# Install templated scripts with substitutions
for script in rotate-webhook-secret.sh webhook-audit-alert.sh ntfy-blocked-pickup.sh; do
SED_CMD=(sed
-e "s|@@GITEA_API@@|$WH_GITEA_INSTANCE|g"
-e "s|@@WEBHOOK_URL_MATCH@@|$WH_WEBHOOK_URL_MATCH|g"
-e "s|@@SCAN_OWNERS@@|$WH_SCAN_OWNERS|g"
-e "s|@@NTFY_TOPIC@@|${WH_NTFY_TOPIC:-https://ntfy.sh/YOUR_NTFY_TOPIC}|g"
-e "s|@@MATTERMOST_URL@@|${WH_MATTERMOST_URL:-YOUR_MATTERMOST_URL}|g"
-e "s|@@MATTERMOST_CHANNEL_ID@@|${WH_MATTERMOST_CHANNEL:-YOUR_MATTERMOST_CHANNEL_ID}|g"
-e "s|@@GITEA_API_BASE@@|$WH_GITEA_INSTANCE|g"
-e "s|@@GITEA_REPO@@|${WH_MATTERMOST_GITEA_REPO:-YOUR_GITEA_REPO}|g"
)
"${SED_CMD[@]}" "$SCRIPT_DIR/scripts/webhook-security/$script" \
> "$WH_INSTALL_DIR/scripts/$script"
chmod 755 "$WH_INSTALL_DIR/scripts/$script"
success "Installed $WH_INSTALL_DIR/scripts/$script"
done
# Task 11.4: Install gitea-approve-repo to /usr/local/bin/
cp "$SCRIPT_DIR/scripts/webhook-security/gitea-approve-repo" /usr/local/bin/gitea-approve-repo
chmod 755 /usr/local/bin/gitea-approve-repo
success "Installed /usr/local/bin/gitea-approve-repo"
# --- 11.4: nginx config guidance ---
header "Step 11.4: nginx Configuration Guidance"
echo -e " ${BOLD}The security system requires two nginx configuration changes:${NC}"
echo ""
echo -e " ${BOLD}1. In /etc/nginx/nginx.conf (inside the http {} block):${NC}"
echo -e " ${DIM}─────────────────────────────────────────────────────────${NC}"
cat "$SCRIPT_DIR/templates/webhook-security/nginx.conf.example"
echo -e " ${DIM}─────────────────────────────────────────────────────────${NC}"
echo ""
echo -e " ${BOLD}2. In your site config (e.g. /etc/nginx/sites-enabled/openclaw):${NC}"
echo -e " ${DIM}The location blocks for /hooks/gitea are in:${NC}"
echo -e " ${DIM}$SCRIPT_DIR/templates/webhook-security/nginx-site.conf.example${NC}"
echo ""
echo -e " ${DIM}Replace YOUR_DOMAIN, YOUR_GITEA_SERVER_IP, YOUR_OPENCLAW_PORT,${NC}"
echo -e " ${DIM}and YOUR_OPENCLAW_GATEWAY_TOKEN with your actual values.${NC}"
echo ""
if confirm " Display full site config example now?" "N"; then
echo ""
echo -e "${DIM}"
cat "$SCRIPT_DIR/templates/webhook-security/nginx-site.conf.example" | \
sed -e "s|YOUR_GITEA_SERVER_IP|$WH_GITEA_IP|g" \
-e "s|YOUR_OPENCLAW_PORT|$WH_OPENCLAW_PORT|g"
echo -e "${NC}"
fi
# --- 11.5: Optional cron setup ---
header "Step 11.5: Cron Jobs (Optional)"
echo -e " ${DIM}Three automated tasks can be set up via cron:${NC}"
echo ""
echo " 1. Secret rotation (monthly, 1st of month at 3am UTC)"
echo " Command: $WH_INSTALL_DIR/scripts/rotate-webhook-secret.sh"
echo ""
echo " 2. Blocked webhook alerts (every minute, scans nginx error.log)"
echo " Command: $WH_INSTALL_DIR/scripts/ntfy-blocked-pickup.sh"
echo ""
echo " 3. Daily audit summary (5am UTC)"
echo " Command: $WH_INSTALL_DIR/scripts/webhook-audit-alert.sh"
echo ""
CRON_ADDED=0
if confirm " Install monthly secret rotation cron job?" "Y"; then
CRON_LINE="0 3 1 * * $WH_INSTALL_DIR/scripts/rotate-webhook-secret.sh >> /var/log/webhook-secret-rotation.log 2>&1"
(crontab -l 2>/dev/null; echo "$CRON_LINE") | sort -u | crontab -
success "Added rotation cron: 0 3 1 * * ..."
CRON_ADDED=$((CRON_ADDED + 1))
fi
if [ -n "$WH_NTFY_TOPIC" ] && confirm " Install blocked-webhook ntfy alerts cron (every minute)?" "Y"; then
CRON_LINE="* * * * * $WH_INSTALL_DIR/scripts/ntfy-blocked-pickup.sh"
(crontab -l 2>/dev/null; echo "$CRON_LINE") | sort -u | crontab -
success "Added ntfy-blocked-pickup cron: * * * * * ..."
CRON_ADDED=$((CRON_ADDED + 1))
fi
if [ -n "$WH_MATTERMOST_URL" ] && confirm " Install daily audit summary cron (5am UTC)?" "Y"; then
CRON_LINE="5 0 * * * MATTERMOST_BOT_TOKEN=YOUR_BOT_TOKEN $WH_INSTALL_DIR/scripts/webhook-audit-alert.sh >> /var/log/webhook-audit.log 2>&1"
(crontab -l 2>/dev/null; echo "$CRON_LINE") | sort -u | crontab -
success "Added audit cron: 5 0 * * * ..."
warn "Edit the cron entry to set MATTERMOST_BOT_TOKEN: crontab -e"
CRON_ADDED=$((CRON_ADDED + 1))
fi
if [ "$CRON_ADDED" -eq 0 ]; then
info "No cron jobs added. You can add them manually — see docs/WEBHOOK-SECURITY.md"
fi
# --- 11.6: Verification ---
header "Step 11.6: Webhook Security Verification"
WH_ERRORS=0
# Check njs module file
if [ -f "$WH_NJS_DIR/gitea-hmac-verify.js" ]; then
success "njs module installed: $WH_NJS_DIR/gitea-hmac-verify.js"
else
error "njs module NOT found: $WH_NJS_DIR/gitea-hmac-verify.js"
WH_ERRORS=$((WH_ERRORS + 1))
fi
# Check secret file
if [ -f "$WH_SECRET_FILE" ]; then
SECRET_LEN=$(wc -c < "$WH_SECRET_FILE")
SECRET_PERMS=$(stat -c '%a' "$WH_SECRET_FILE" 2>/dev/null || stat -f '%Mp%Lp' "$WH_SECRET_FILE" 2>/dev/null)
if [ "$SECRET_LEN" -ge 32 ]; then
success "Secret file: $WH_SECRET_FILE (${SECRET_LEN} chars, perms: $SECRET_PERMS)"
else
error "Secret file too short (${SECRET_LEN} chars)"
WH_ERRORS=$((WH_ERRORS + 1))
fi
else
error "Secret file NOT found: $WH_SECRET_FILE"
WH_ERRORS=$((WH_ERRORS + 1))
fi
# Check allowlist
if [ -f "$WH_ALLOWLIST_FILE" ]; then
if python3 -c "import json; json.load(open('$WH_ALLOWLIST_FILE'))" 2>/dev/null; then
success "Allowlist file: $WH_ALLOWLIST_FILE (valid JSON)"
else
error "Allowlist file has invalid JSON: $WH_ALLOWLIST_FILE"
WH_ERRORS=$((WH_ERRORS + 1))
fi
else
error "Allowlist file NOT found: $WH_ALLOWLIST_FILE"
WH_ERRORS=$((WH_ERRORS + 1))
fi
# Check gitea-approve-repo
if [ -x /usr/local/bin/gitea-approve-repo ]; then
success "gitea-approve-repo: /usr/local/bin/gitea-approve-repo (executable)"
else
error "gitea-approve-repo not found/executable"
WH_ERRORS=$((WH_ERRORS + 1))
fi
# nginx config test (if nginx is available and njs module found)
if command -v nginx &>/dev/null && $NJS_OK; then
if nginx -t 2>/dev/null; then
success "nginx -t: config is valid"
else
warn "nginx -t failed — config changes may be needed (see Step 11.4 guidance above)"
fi
fi
if [ "$WH_ERRORS" -eq 0 ]; then
success "Webhook security installed successfully"
WEBHOOK_SECURITY_INSTALLED=true
else
warn "$WH_ERRORS webhook security error(s) — review output above"
fi
echo ""
info "Reference: $SCRIPT_DIR/docs/WEBHOOK-SECURITY.md for full documentation"
info "Security audit: $SCRIPT_DIR/docs/SECURITY-AUDIT.md"
fi
# ============================================================================ # ============================================================================
# SUMMARY # SUMMARY
# ============================================================================ # ============================================================================
@ -1049,6 +1431,9 @@ fi
echo " - Anthropic model configured in openclaw.json" echo " - Anthropic model configured in openclaw.json"
echo " - Auth profiles updated for all agents" echo " - Auth profiles updated for all agents"
echo " - oauth.json created with fresh token" echo " - oauth.json created with fresh token"
if [ "$WEBHOOK_SECURITY_INSTALLED" = true ]; then
echo " - Webhook security: njs module, secret, allowlist, helper scripts"
fi
echo "" echo ""
echo -e " ${BOLD}Useful commands:${NC}" echo -e " ${BOLD}Useful commands:${NC}"
if [ "$USE_INOTIFY" = true ]; then if [ "$USE_INOTIFY" = true ]; then
@ -1062,8 +1447,13 @@ if [ "$TRIGGER_INSTALLED" = true ]; then
echo " journalctl -u trigger-claude-refresh -n 20 # Trigger logs" echo " journalctl -u trigger-claude-refresh -n 20 # Trigger logs"
echo " systemctl list-timers trigger-claude-refresh* # Check trigger timer" echo " systemctl list-timers trigger-claude-refresh* # Check trigger timer"
fi fi
if [ "$WEBHOOK_SECURITY_INSTALLED" = true ]; then
echo " gitea-approve-repo owner/repo # Add repo to allowlist"
echo " cat /etc/nginx/gitea-repo-allowlist.json # View allowlist"
echo " /opt/webhook-security/scripts/rotate-webhook-secret.sh --dry-run"
fi
echo " ./scripts/verify.sh # Health check" echo " ./scripts/verify.sh # Health check"
echo " ./setup.sh --uninstall # Remove everything" echo " ./setup.sh --uninstall # Remove everything"
echo "" echo ""
echo -e " ${DIM}Created by ROOH — <project-url>${NC}" echo -e " ${DIM}Created by ROOH — <project-url>${NC}"
echo ""

View File

@ -0,0 +1,8 @@
{
"_comment": "Gitea webhook repo allowlist. Edits take effect immediately (no nginx reload needed).",
"repos": [
"owner/specific-repo",
"owner/another-repo"
],
"trusted_owners": ["trusted-owner"]
}

View File

@ -0,0 +1,86 @@
server {
server_name YOUR_DOMAIN;
# Internal upstream for Gitea webhook (post-HMAC verification)
location /hooks/gitea-upstream {
internal;
proxy_pass http://127.0.0.1:YOUR_OPENCLAW_PORT/hooks/gitea;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization "Bearer YOUR_OPENCLAW_GATEWAY_TOKEN";
proxy_set_header X-Gitea-Event $http_x_gitea_event;
proxy_set_header X-Gitea-Delivery $http_x_gitea_delivery;
proxy_set_header X-Gitea-Signature $http_x_gitea_signature;
proxy_buffering off;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_pass_request_body on;
}
# Other hooks pass through directly
location /hooks/ {
proxy_pass http://127.0.0.1:YOUR_OPENCLAW_PORT/hooks/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
client_max_body_size 1m;
}
# Gitea webhook - HMAC-SHA256 verified by njs before proxying
# Security layers: IP allowlist -> rate limit -> HMAC verify -> repo allowlist -> proxy
location = /hooks/gitea {
# Layer 1: IP allowlisting - only allow the Gitea server IP
# Find your Gitea IP with: dig +short YOUR_GITEA_DOMAIN
allow YOUR_GITEA_SERVER_IP; # YOUR_GITEA_DOMAIN
allow 127.0.0.1; # localhost (for testing)
allow ::1; # IPv6 localhost
deny all;
# Layer 2: Rate limiting
limit_req zone=gitea_webhook burst=20 nodelay;
# Layer 3: Payload size limit
client_body_buffer_size 1m;
client_max_body_size 1m;
# Layer 4+5: HMAC verification + repo allowlist (njs)
js_content gitea_hmac.verifyAndProxy;
}
# Main application (adjust to your backend)
location / {
proxy_pass http://127.0.0.1:YOUR_APP_PORT;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Frame-Options SAMEORIGIN;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
client_max_body_size 50M;
}
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
if ($host = YOUR_DOMAIN) {
return 301 https://$host$request_uri;
}
server_name YOUR_DOMAIN;
listen 80;
return 404;
}

View File

@ -0,0 +1,6 @@
# Add to http {} block in /etc/nginx/nginx.conf
http {
js_path "/etc/nginx/njs/";
js_import gitea_hmac from gitea-hmac-verify.js;
limit_req_zone $binary_remote_addr zone=gitea_webhook:1m rate=10r/s;
}

253
tests/test-webhook-security.sh Executable file
View File

@ -0,0 +1,253 @@
#!/bin/bash
# test-webhook-security.sh - Tests for webhook security integration
#
# Validates that all webhook security files are present and correctly structured
# in the repository. Tests run offline (no system services required).
set -uo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'
pass() { echo -e "${GREEN}[PASS]${NC} $*"; }
fail() { echo -e "${RED}[FAIL]${NC} $*"; FAILURES=$((FAILURES + 1)); }
FAILURES=0
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
echo ""
echo "Testing Webhook Security Integration"
echo "======================================"
echo ""
# --- 1. File existence checks ---
echo "1. File existence"
EXPECTED_FILES=(
"scripts/webhook-security/gitea-hmac-verify.js"
"scripts/webhook-security/gitea-approve-repo"
"scripts/webhook-security/rotate-webhook-secret.sh"
"scripts/webhook-security/webhook-audit-alert.sh"
"scripts/webhook-security/ntfy-blocked-pickup.sh"
"templates/webhook-security/nginx-site.conf.example"
"templates/webhook-security/nginx.conf.example"
"templates/webhook-security/gitea-repo-allowlist.json.example"
"docs/WEBHOOK-SECURITY.md"
"docs/SECURITY-AUDIT.md"
)
for f in "${EXPECTED_FILES[@]}"; do
if [ -f "$REPO_ROOT/$f" ]; then
pass "$f exists"
else
fail "$f MISSING"
fi
done
echo ""
# --- 2. Template placeholder checks ---
echo "2. Template placeholders in scripts"
TEMPLATED_SCRIPTS=(
"scripts/webhook-security/rotate-webhook-secret.sh"
"scripts/webhook-security/webhook-audit-alert.sh"
"scripts/webhook-security/ntfy-blocked-pickup.sh"
)
for script in "${TEMPLATED_SCRIPTS[@]}"; do
if grep -qE '@@[A-Z_]+@@' "$REPO_ROOT/$script"; then
pass "$script has placeholder tokens (@@...@@)"
else
fail "$script has no placeholder tokens — check that hardcoded values were templated"
fi
done
# Verify no hardcoded URLs in templated scripts
for script in "${TEMPLATED_SCRIPTS[@]}"; do
if grep -qE 'https://[a-z][a-z0-9.-]+\.(com|org|net|sh)' "$REPO_ROOT/$script" 2>/dev/null; then
# Check if it's a placeholder URL or a real one
REAL_URLS=$(grep -E 'https://[a-z][a-z0-9.-]+\.(com|org|net|sh)' "$REPO_ROOT/$script" | grep -v '@@\|example\|YOUR_\|placeholder\|ntfy.sh/@@' || true)
if [ -n "$REAL_URLS" ]; then
fail "$script may contain hardcoded URLs: $REAL_URLS"
else
pass "$script URL patterns are placeholders"
fi
else
pass "$script has no hardcoded domain URLs"
fi
done
echo ""
# --- 3. Allowlist JSON validity ---
echo "3. Template JSON validity"
ALLOWLIST_EXAMPLE="$REPO_ROOT/templates/webhook-security/gitea-repo-allowlist.json.example"
if [ -f "$ALLOWLIST_EXAMPLE" ]; then
if python3 -c "
import json, sys
with open('$ALLOWLIST_EXAMPLE') as f:
data = json.load(f)
has_repos = 'repos' in data
has_owners = 'trusted_owners' in data
if not has_repos or not has_owners:
sys.exit(1)
" 2>/dev/null; then
pass "gitea-repo-allowlist.json.example is valid JSON with repos + trusted_owners"
else
fail "gitea-repo-allowlist.json.example is invalid JSON or missing required keys"
fi
else
fail "gitea-repo-allowlist.json.example not found"
fi
echo ""
# --- 4. Secret generation format ---
echo "4. Secret generation (openssl)"
if command -v openssl &>/dev/null; then
GENERATED_SECRET=$(openssl rand -hex 32)
SECRET_LEN=${#GENERATED_SECRET}
if [ "$SECRET_LEN" -eq 64 ]; then
pass "openssl rand -hex 32 produces 64-char hex string (got: ${GENERATED_SECRET:0:8}...)"
else
fail "openssl rand -hex 32 produced ${SECRET_LEN}-char string (expected 64)"
fi
# Verify it's valid hex
if echo "$GENERATED_SECRET" | grep -qE '^[0-9a-f]{64}$'; then
pass "Generated secret is valid lowercase hex"
else
fail "Generated secret contains non-hex characters"
fi
else
pass "openssl not available — skipping secret generation test (ok in CI)"
fi
echo ""
# --- 5. njs module structure check ---
echo "5. njs module (gitea-hmac-verify.js)"
NJS_FILE="$REPO_ROOT/scripts/webhook-security/gitea-hmac-verify.js"
if [ -f "$NJS_FILE" ]; then
# Check it's an ES module with the expected export
if grep -q "export default" "$NJS_FILE"; then
pass "gitea-hmac-verify.js has ES module export"
else
fail "gitea-hmac-verify.js missing 'export default'"
fi
# Check key functions are present
for fn in verifyAndProxy isRepoAllowed constantTimeEqual getSecret loadAllowlist; do
if grep -q "$fn" "$NJS_FILE"; then
pass "gitea-hmac-verify.js has function: $fn"
else
fail "gitea-hmac-verify.js missing function: $fn"
fi
done
# Check HMAC-SHA256 usage
if grep -q "createHmac.*sha256" "$NJS_FILE"; then
pass "gitea-hmac-verify.js uses HMAC-SHA256"
else
fail "gitea-hmac-verify.js missing HMAC-SHA256 usage"
fi
else
fail "gitea-hmac-verify.js not found"
fi
echo ""
# --- 6. setup.sh integration check ---
echo "6. setup.sh integration"
SETUP_FILE="$REPO_ROOT/setup.sh"
if [ -f "$SETUP_FILE" ]; then
# Check Step 11 is present
if grep -q "Step 11.*Gitea Webhook Security" "$SETUP_FILE"; then
pass "setup.sh contains Step 11 (Gitea Webhook Security)"
else
fail "setup.sh missing Step 11"
fi
# Check key deployment steps
for marker in "gitea-hmac-verify.js" "gitea-webhook-secret" "gitea-repo-allowlist.json" "gitea-approve-repo" "WEBHOOK_SECURITY_INSTALLED"; do
if grep -q "$marker" "$SETUP_FILE"; then
pass "setup.sh references: $marker"
else
fail "setup.sh missing reference to: $marker"
fi
done
# Check that step 11 comes before SUMMARY
STEP11_LINE=$(grep -n "Step 11.*Gitea Webhook Security" "$SETUP_FILE" | head -1 | cut -d: -f1)
SUMMARY_LINE=$(grep -n "^# SUMMARY" "$SETUP_FILE" | head -1 | cut -d: -f1)
if [ -n "$STEP11_LINE" ] && [ -n "$SUMMARY_LINE" ] && [ "$STEP11_LINE" -lt "$SUMMARY_LINE" ]; then
pass "Step 11 appears before SUMMARY (lines $STEP11_LINE vs $SUMMARY_LINE)"
else
fail "Step 11 ordering issue (step11=$STEP11_LINE, summary=$SUMMARY_LINE)"
fi
else
fail "setup.sh not found"
fi
echo ""
# --- 7. uninstall.sh integration check ---
echo "7. uninstall.sh integration"
UNINSTALL_FILE="$REPO_ROOT/scripts/uninstall.sh"
if [ -f "$UNINSTALL_FILE" ]; then
for marker in "gitea-hmac-verify.js" "gitea-webhook-secret" "gitea-repo-allowlist.json" "opt/webhook-security" "gitea-approve-repo"; do
if grep -q "$marker" "$UNINSTALL_FILE"; then
pass "uninstall.sh references: $marker"
else
fail "uninstall.sh missing reference to: $marker"
fi
done
# Check cron cleanup
if grep -q "crontab\|cron" "$UNINSTALL_FILE"; then
pass "uninstall.sh has cron cleanup"
else
fail "uninstall.sh missing cron cleanup"
fi
else
fail "uninstall.sh not found"
fi
echo ""
# --- 8. Bash syntax checks ---
echo "8. Bash syntax"
for script in \
"$REPO_ROOT/setup.sh" \
"$REPO_ROOT/scripts/uninstall.sh" \
"$REPO_ROOT/scripts/webhook-security/rotate-webhook-secret.sh" \
"$REPO_ROOT/scripts/webhook-security/webhook-audit-alert.sh" \
"$REPO_ROOT/scripts/webhook-security/ntfy-blocked-pickup.sh" \
"$REPO_ROOT/scripts/webhook-security/gitea-approve-repo"; do
FNAME=$(basename "$script")
if bash -n "$script" 2>/dev/null; then
pass "bash syntax OK: $FNAME"
else
fail "bash syntax ERROR: $FNAME"
fi
done
echo ""
# --- Summary ---
echo "======================================"
if [ "$FAILURES" -eq 0 ]; then
echo -e "${GREEN}All tests passed${NC}"
exit 0
else
echo -e "${RED}$FAILURES test(s) failed${NC}"
exit 1
fi