openclaw_oauth_sync/docs/SECURITY-AUDIT.md
sol 2db7d7d90a feat: merge Gitea webhook security into setup wizard (issue #2)
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
2026-03-01 08:43:02 +00:00

8.4 KiB

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