# 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 ```