Integrates the 5-layer Gitea webhook security system from sol/clawgravity-hook-security (v2.0) into the setup wizard. ## What's added ### New files (from clawgravity-hook-security v2.0) - scripts/webhook-security/gitea-hmac-verify.js -- njs HMAC-SHA256 module - scripts/webhook-security/gitea-approve-repo -- allowlist helper - scripts/webhook-security/rotate-webhook-secret.sh -- monthly secret rotation (templated) - scripts/webhook-security/webhook-audit-alert.sh -- daily audit summaries (templated) - scripts/webhook-security/ntfy-blocked-pickup.sh -- blocked webhook alerts (templated) - 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 -- full documentation - docs/SECURITY-AUDIT.md -- 35-case test matrix - tests/test-webhook-security.sh -- 48 offline tests ### Modified files - setup.sh: Step 11 (webhook security wizard with 6 sub-sections) - scripts/uninstall.sh: webhook security cleanup section - README.md: Webhook Security section after Quick Start - Makefile: test target now runs test-webhook-security.sh - .secret-scan-allowlist: allowlist docs/SECURITY-AUDIT.md (test fixture) ## Security layers 1. IP allowlisting (nginx) 2. Rate limiting 10 req/s burst 20 (nginx) 3. Payload size 1MB (nginx) 4. HMAC-SHA256 signature verification (njs) 5. Per-repository allowlist (njs) ## make check - prettier: PASS - secret-scan: PASS - tests: 48/48 PASS Closes #2
157 lines
8.4 KiB
Markdown
157 lines
8.4 KiB
Markdown
# 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
|
|
```
|