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
This commit is contained in:
64
scripts/webhook-security/gitea-hmac-verify.js
Normal file
64
scripts/webhook-security/gitea-hmac-verify.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import fs from 'fs';
|
||||
import crypto from 'crypto';
|
||||
|
||||
var ALLOWLIST_PATH = '/etc/nginx/gitea-repo-allowlist.json';
|
||||
var SECRET_PATH = '/etc/nginx/gitea-webhook-secret';
|
||||
|
||||
function getSecret() {
|
||||
try { return fs.readFileSync(SECRET_PATH).toString().trim(); }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
|
||||
function loadAllowlist() {
|
||||
try { return JSON.parse(fs.readFileSync(ALLOWLIST_PATH).toString()); }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
|
||||
function isRepoAllowed(repoFullName, allowlist) {
|
||||
if (!allowlist || !repoFullName) return false;
|
||||
if ((allowlist.repos || []).indexOf(repoFullName) !== -1) return true;
|
||||
var owners = allowlist.trusted_owners || [];
|
||||
for (var i = 0; i < owners.length; i++) {
|
||||
if (repoFullName.startsWith(owners[i] + '/')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function constantTimeEqual(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
var result = 0;
|
||||
for (var i = 0; i < a.length; i++) result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
return result === 0;
|
||||
}
|
||||
|
||||
async function verifyAndProxy(r) {
|
||||
var secret = getSecret();
|
||||
if (!secret) { r.error('gitea-hmac: failed to read secret'); r.return(500, 'Config error'); return; }
|
||||
|
||||
var giteaSig = r.headersIn['X-Gitea-Signature'];
|
||||
if (!giteaSig) { r.error('gitea-hmac: missing X-Gitea-Signature'); r.return(403, 'Missing signature'); return; }
|
||||
|
||||
var body = r.requestText || '';
|
||||
var hmac = crypto.createHmac('sha256', secret); hmac.update(body);
|
||||
if (!constantTimeEqual(hmac.digest('hex'), giteaSig)) {
|
||||
r.error('gitea-hmac: signature mismatch'); r.return(403, 'Invalid signature'); return;
|
||||
}
|
||||
|
||||
var allowlist = loadAllowlist();
|
||||
if (!allowlist) { r.error('gitea-hmac: cannot read allowlist'); r.return(403, 'Authorization unavailable'); return; }
|
||||
|
||||
var repoFullName = '';
|
||||
try { repoFullName = (JSON.parse(body).repository || {}).full_name || ''; }
|
||||
catch (e) { r.error('gitea-hmac: invalid JSON'); r.return(403, 'Invalid body'); return; }
|
||||
|
||||
if (!repoFullName) { r.error('gitea-hmac: no repo name'); r.return(403, 'Missing repo'); return; }
|
||||
if (!isRepoAllowed(repoFullName, allowlist)) {
|
||||
r.error('gitea-hmac: BLOCKED ' + repoFullName); r.return(403, 'Not authorized'); return;
|
||||
}
|
||||
|
||||
var res = await r.subrequest('/hooks/gitea-upstream', { method: r.method, body: body });
|
||||
var ct = res.headersOut['Content-Type']; if (ct) r.headersOut['Content-Type'] = ct;
|
||||
r.return(res.status, res.responseBody);
|
||||
}
|
||||
|
||||
export default { verifyAndProxy };
|
||||
Reference in New Issue
Block a user