From 6679a93553bb5957369c6451a2b4cb87740f609f Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 29 Mar 2026 13:32:16 +0000 Subject: [PATCH] ci: HMAC signature verification for Gitea webhooks --- api/src/routes/deploy.js | 59 ++++++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/api/src/routes/deploy.js b/api/src/routes/deploy.js index 7d36b62..1e25d73 100644 --- a/api/src/routes/deploy.js +++ b/api/src/routes/deploy.js @@ -1,6 +1,7 @@ // Task Team — Deploy webhook — 2026-03-29 const { exec, execSync } = require("child_process"); const fs = require("fs"); +const crypto = require("crypto"); const DEPLOY_SECRET = process.env.DEPLOY_SECRET || "taskteam-deploy-2026"; const EXEC_ENV = { ...process.env, PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", HOME: "/root" }; @@ -11,10 +12,41 @@ function runCmd(cmd, timeout) { } async function deployRoutes(app) { + // Override JSON parser in this plugin scope to capture raw body + app.removeContentTypeParser("application/json"); + app.addContentTypeParser("application/json", { parseAs: "string" }, (req, body, done) => { + req.rawBody = body; + try { + done(null, JSON.parse(body)); + } catch (e) { + done(e); + } + }); + app.post("/deploy/webhook", async (req, reply) => { - // Verify secret - const secret = req.headers["x-gitea-secret"] || (req.body && req.body.secret); - if (secret !== DEPLOY_SECRET) { + // Verify secret - support multiple methods + const signature = req.headers["x-gitea-signature"]; + const headerSecret = req.headers["x-gitea-secret"]; + const bodySecret = req.body && req.body.secret; + + let authorized = false; + + // Method 1: Gitea HMAC-SHA256 signature + if (signature && req.rawBody) { + const hmac = crypto.createHmac("sha256", DEPLOY_SECRET).update(req.rawBody).digest("hex"); + authorized = (hmac === signature); + } + // Method 2: Plain secret header (manual/curl testing) + if (!authorized && headerSecret === DEPLOY_SECRET) { + authorized = true; + } + // Method 3: Secret in body + if (!authorized && bodySecret === DEPLOY_SECRET) { + authorized = true; + } + + if (!authorized) { + app.log.warn("Deploy webhook auth failed"); return reply.code(401).send({ error: "invalid secret" }); } @@ -25,7 +57,6 @@ async function deployRoutes(app) { app.log.info("Deploy triggered by Gitea webhook"); - // Run deploy asynchronously via shell script so we can return 200 immediately const deployScript = `#!/bin/bash set -e export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" @@ -33,18 +64,14 @@ export HOME="/root" LOG="${DEPLOY_LOG}" echo "=== Deploy started at $(date -Is) ===" >> $LOG -# Pull latest code cd /opt/task-team git pull origin master >> $LOG 2>&1 -# Install deps cd /opt/task-team/api npm install --production >> $LOG 2>&1 -# Reload API (with --force to avoid "already in progress") pm2 reload taskteam-api --force >> $LOG 2>&1 -# Build frontend (if it exists) if [ -d /opt/task-team/apps/tasks ] && [ -f /opt/task-team/apps/tasks/package.json ]; then cd /opt/task-team/apps/tasks NEXT_PUBLIC_API_URL=http://localhost:3000 npm run build >> $LOG 2>&1 @@ -53,14 +80,12 @@ fi echo "=== Deploy completed at $(date -Is) ===" >> $LOG `; - // Write and execute deploy script in background fs.writeFileSync("/opt/task-team/deploy.sh", deployScript, { mode: 0o755 }); exec("nohup /opt/task-team/deploy.sh &", { env: EXEC_ENV, detached: true }); return { status: "deploying", message: "Deploy started in background. Check /deploy/status for progress." }; }); - // Deploy status app.get("/deploy/status", async () => { try { const commit = runCmd("cd /opt/task-team && git log --oneline -1").trim(); @@ -71,7 +96,6 @@ echo "=== Deploy completed at $(date -Is) ===" >> $LOG uptime: p.pm2_env.pm_uptime, restarts: p.pm2_env.restart_time })); - // Last deploy log let lastDeploy = ""; try { const log = fs.readFileSync(DEPLOY_LOG, "utf8"); @@ -83,19 +107,6 @@ echo "=== Deploy completed at $(date -Is) ===" >> $LOG return { status: "error", message: e.message }; } }); - - // Manual trigger (GET for convenience) - app.post("/deploy/trigger", async (req, reply) => { - const secret = req.headers["x-deploy-secret"] || (req.body && req.body.secret); - if (secret !== DEPLOY_SECRET) { - return reply.code(401).send({ error: "invalid secret" }); - } - // Simulate a push to master - req.body = { ref: "refs/heads/master", secret: DEPLOY_SECRET }; - req.headers["x-gitea-secret"] = DEPLOY_SECRET; - return app.inject({ method: "POST", url: "/api/v1/deploy/webhook", payload: req.body, headers: { "x-gitea-secret": DEPLOY_SECRET } }).then(r => JSON.parse(r.body)); - }); } module.exports = deployRoutes; -// CI/CD pipeline active — 2026-03-29T13:24:58+00:00