openclaw_oauth_sync/docs/WEBHOOK-SECURITY.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

9.6 KiB

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

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

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

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

gitea-approve-repo owner/repo-name

Add all repos from a trusted owner

Edit /etc/nginx/gitea-repo-allowlist.json directly:

{
  "repos": ["owner/specific-repo"],
  "trusted_owners": ["trusted-org"]
}

View current allowlist

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

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

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:

gitea-approve-repo owner/repo-name

nginx -t fails after installation

The njs module may not be loaded. Check:

# 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):

chown root:www-data /etc/nginx/gitea-webhook-secret
chmod 640 /etc/nginx/gitea-webhook-secret

Reference