Explorar el Código

feat: implement HMAC-SHA256 webhook signature verification #14

- Added crypto module import for HMAC operations
- Modified _parseBody to return both raw and parsed payload
- Implemented _verifySignature with HMAC-SHA256 verification
- Uses timing-safe comparison to prevent timing attacks
- Signature verification is optional (only when WEBHOOK_SECRET is configured)
- Returns 401 for invalid/missing signatures when secret is configured
- Added comprehensive security documentation in README.md
- Updated CLAUDE.md with implementation details
- Tested with valid/invalid signatures and backward compatibility

Closes #14

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude hace 9 meses
padre
commit
8a1dc01a99
Se han modificado 3 ficheros con 95 adiciones y 13 borrados
  1. 3 1
      CLAUDE.md
  2. 48 1
      README.md
  3. 44 11
      src/server.js

+ 3 - 1
CLAUDE.md

@@ -27,10 +27,12 @@ index.js (Entry Point)
 ### Key Components
 
 **WebhookServer (src/server.js)**
-- HTTP server using Node.js `http` module
+- HTTP server using Node.js `http` and `crypto` modules
 - Routes: `POST /webhook`, `GET /health`
 - Provides `getHandler()` to access WebhookHandler for callback registration
 - Binds to configurable host (0.0.0.0 by default) and port (3000 by default)
+- Implements HMAC-SHA256 signature verification for webhook security
+- Uses timing-safe comparison to prevent timing attacks
 
 **WebhookHandler (src/webhookHandler.js)**
 - Event-driven callback system

+ 48 - 1
README.md

@@ -96,7 +96,7 @@ WEBHOOK_SECRET=your-secret-here
 - `HOST` - The network interface to bind to (default: `0.0.0.0` for all interfaces, use `127.0.0.1` for localhost only)
 - `PORT` - The port number to listen on (default: `3000`)
 - `WEBHOOK_PATH` - The URL path for webhooks (default: `/webhook`)
-- `WEBHOOK_SECRET` - Optional secret for webhook verification
+- `WEBHOOK_SECRET` - Optional secret for webhook signature verification (see Security section below)
 
 #### Enable/Disable Events (`.env`)
 
@@ -191,6 +191,53 @@ Commands are configured in a JSON file with the following structure:
 - `POST /webhook` - Webhook endpoint
 - `GET /health` - Health check endpoint
 
+## Security
+
+### Webhook Signature Verification
+
+The server supports HMAC-SHA256 signature verification to ensure webhooks are authentic and haven't been tampered with.
+
+#### How It Works
+
+When you configure a secret in Gogs webhook settings and in your `.env` file, Gogs will sign each webhook request with an HMAC-SHA256 signature and send it in the `X-Gogs-Signature` header. The server verifies this signature before processing the webhook.
+
+#### Setup
+
+1. **Configure Secret in .env**:
+```env
+WEBHOOK_SECRET=your-secure-random-string
+```
+
+2. **Configure Secret in Gogs**:
+- Go to your repository settings → Webhooks
+- Edit or create a webhook
+- Set the same secret value in the "Secret" field
+- Set Content Type to `application/json`
+
+#### Behavior
+
+- **Secret Configured**: The server requires a valid `X-Gogs-Signature` header and rejects webhooks with missing or invalid signatures (HTTP 401)
+- **No Secret Configured**: Signature verification is skipped, and all webhooks are accepted (useful for development)
+
+#### Security Best Practices
+
+- Use a strong, random secret (at least 32 characters)
+- Keep your secret secure and never commit it to version control
+- Use different secrets for different environments (development, staging, production)
+- Rotate secrets periodically for enhanced security
+
+#### Verification Process
+
+The server:
+1. Computes an HMAC-SHA256 hash of the raw request body using the configured secret
+2. Compares it with the signature from the `X-Gogs-Signature` header using a timing-safe comparison
+3. Accepts the webhook if signatures match, rejects otherwise
+
+This ensures:
+- **Authenticity**: The webhook came from Gogs with the correct secret
+- **Integrity**: The payload hasn't been modified in transit
+- **Replay Protection**: Combined with Gogs' delivery ID tracking
+
 ## Job Queue System
 
 The webhook server uses a persistent job queue to ensure reliable and sequential command execution:

+ 44 - 11
src/server.js

@@ -2,6 +2,7 @@
  * HTTP server for receiving Gogs webhooks
  */
 import http from 'http';
+import crypto from 'crypto';
 import logger from './logger.js';
 import { WebhookHandler } from './webhookHandler.js';
 
@@ -17,6 +18,7 @@ export class WebhookServer {
 
   /**
    * Parse the request body
+   * Returns both the raw body string and parsed JSON
    */
   _parseBody(req) {
     return new Promise((resolve, reject) => {
@@ -26,7 +28,8 @@ export class WebhookServer {
       });
       req.on('end', () => {
         try {
-          resolve(body ? JSON.parse(body) : {});
+          const parsed = body ? JSON.parse(body) : {};
+          resolve({ raw: body, parsed });
         } catch (error) {
           reject(new Error('Invalid JSON payload'));
         }
@@ -37,16 +40,45 @@ export class WebhookServer {
 
   /**
    * Verify webhook signature if secret is configured
+   * @param {string} signature - The signature from X-Gogs-Signature header
+   * @param {string} rawPayload - The raw request body as string
+   * @returns {boolean} - True if signature is valid or no secret is configured
    */
-  _verifySignature(signature, payload) {
+  _verifySignature(signature, rawPayload) {
+    // If no secret is configured, skip verification
     if (!this.secret) {
       return true;
     }
-    // Gogs uses HMAC-SHA256 for signatures
-    // Format: X-Gogs-Signature: <hmac-sha256-hex>
-    // Implementation can be added when secret verification is needed
-    logger.info('Signature verification skipped (not implemented)');
-    return true;
+
+    // Signature is required when secret is configured
+    if (!signature) {
+      logger.error('Signature verification failed: X-Gogs-Signature header missing');
+      return false;
+    }
+
+    try {
+      // Compute HMAC-SHA256 of the raw payload using the secret
+      const hmac = crypto.createHmac('sha256', this.secret);
+      hmac.update(rawPayload, 'utf8');
+      const computedSignature = hmac.digest('hex');
+
+      // Compare signatures using timing-safe comparison
+      const isValid = crypto.timingSafeEqual(
+        Buffer.from(signature),
+        Buffer.from(computedSignature)
+      );
+
+      if (isValid) {
+        logger.info('Signature verification successful');
+      } else {
+        logger.error('Signature verification failed: signature mismatch');
+      }
+
+      return isValid;
+    } catch (error) {
+      logger.error('Signature verification failed:', error);
+      return false;
+    }
   }
 
   /**
@@ -65,7 +97,7 @@ export class WebhookServer {
     // Webhook endpoint
     if (url === this.path && method === 'POST') {
       try {
-        const payload = await this._parseBody(req);
+        const { raw, parsed } = await this._parseBody(req);
         const eventType = req.headers['x-gogs-event'] || 'unknown';
         const signature = req.headers['x-gogs-signature'];
         const delivery = req.headers['x-gogs-delivery'];
@@ -74,18 +106,19 @@ export class WebhookServer {
         logger.info('Received webhook', {
           event: eventType,
           delivery: delivery,
-          hasSignature: !!signature
+          hasSignature: !!signature,
+          hasSecret: !!this.secret
         });
 
         // Verify signature if configured
-        if (!this._verifySignature(signature, payload)) {
+        if (!this._verifySignature(signature, raw)) {
           res.writeHead(401, { 'Content-Type': 'application/json' });
           res.end(JSON.stringify({ error: 'Invalid signature' }));
           return;
         }
 
         // Process webhook
-        await this.webhookHandler.handle(eventType, payload, req.headers);
+        await this.webhookHandler.handle(eventType, parsed, req.headers);
 
         // Send success response
         res.writeHead(200, { 'Content-Type': 'application/json' });