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:
- IP allowlist (network layer)
- Rate limiting (traffic layer)
- Payload size (resource layer)
- HMAC verification (authentication layer)
- Repository allowlist (authorization layer)
- Permission check (identity layer, in OpenClaw transform)
How to Run Verification Tests
See the Verification Tests section in README.md for commands
to manually test each layer.
Automated Testing
# 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