HTTP route script with signature verification template

Script windmill Verified

by dieriba.pro916 · 3/21/2025

The script

Submitted by dieriba.pro916 Bun
Verified 361 days ago
1
import * as crypto from 'crypto';
2
import * as wmill from "windmill-client";
3

4
const SECRET_KEY_VARIABLE_PATH = "secret_key_path";
5

6
/**
7
 * Trigger Preprocessor
8
 *
9
 * ⚠️ This function runs BEFORE the main function.
10
 *
11
 * Windmill allows you to define a `preprocessor` for any trigger type (HTTP, WebSocket, Kafka, Email, etc.).
12
 * The preprocessor gives you the ability to perform custom logic such as validation, transformation, or authentication
13
 * before the `main()` function is executed.
14
 *
15
 * In this example:
16
 * - The trigger kind is `http`, which means we have access to HTTP-specific metadata.
17
 * - We use `wm_trigger.http.headers` to extract custom headers from the incoming HTTP request, such as:
18
 *   - `x-signature` for verifying the integrity and authenticity of the request.
19
 *   - `x-timestamp` to guard against replay attacks by validating the request time.
20
 * - The `raw_string` argument contains the **raw JSON body** of the request as a string — which is crucial for verifying the HMAC signature.
21
 *
22
 * ⚠️ **Important:** `raw_string` is required for signature verification in this example. 
23
 * Make sure the **"raw body"** option is enabled in your HTTP route configuration. 
24
 * If it's not enabled, `raw_string` will be undefined and the script will throw an error.
25
 *
26
 * Once the signature and timestamp are verified, we parse the raw JSON body and return the parsed payload as `body`, which is passed directly into the `main()` function as a named argument.
27
 *
28
 * Learn more: https://www.windmill.dev/docs/core_concepts/preprocessors
29
 */
30
export async function preprocessor(
31
  event: {
32
    kind: 'http';
33
    body: any;
34
    raw_string: string | null;
35
    route: string;
36
    path: string;
37
    method: string;
38
    params: Record<string, string>;
39
    query: Record<string, string>;
40
    headers: Record<string, string>;
41
  },
42
) {
43
  if (event.kind === 'http') {
44

45
    if (!event.raw_string) {
46
      throw new Error("Missing raw body. Ensure the 'raw body' option is enabled in the HTTP route configuration.");
47
    }
48

49
    // Extract signature from headers
50
    const signature = event.headers['x-signature'] || event.headers['signature'];
51
    if (!signature) {
52
      throw new Error('Missing signature in request headers.');
53
    }
54

55
    // Check timestamp if present to prevent replay attacks
56
    const timestamp = event.headers['x-timestamp'] || event.headers['timestamp'];
57
    if (timestamp) {
58
      const timestampValue = parseInt(timestamp, 10);
59
      const currentTime = Math.floor(Date.now() / 1000); // Current time in seconds
60
      const TIME_WINDOW_SECONDS = 5 * 60; // 5 minutes
61

62
      if (isNaN(timestampValue)) {
63
        throw new Error('Invalid timestamp format.');
64
      }
65

66
      if (Math.abs(currentTime - timestampValue) > TIME_WINDOW_SECONDS) {
67
        throw new Error('Request timestamp is outside the acceptable time window.');
68
      }
69
    }
70

71
    // Verify the signature
72
    const isValidSignature = await verifySignature(signature, event.raw_string, timestamp);
73
    if (!isValidSignature) {
74
      throw new Error('Invalid signature.');
75
    }
76

77
    // Parse the body if it's JSON (with error handling)
78
    let parsedBody: any = {};
79
    try {
80
      parsedBody = JSON.parse(event.raw_string);
81
    } catch (error: unknown) {
82
      const errorMessage = error instanceof Error ? error.message : 'Unknown parsing error';
83
      throw new Error(`Failed to parse request body: ${errorMessage}`);
84
    }
85

86
    // Return both HTTP details and the parsed body to main
87
    return {
88
      body: parsedBody
89
    };
90
  }
91

92
  throw new Error(`Expected trigger of kind 'http', but received: ${event.kind}`);
93
}
94

95
/**
96
 * Verifies the HMAC-SHA256 signature against the raw body and optional timestamp.
97
 *
98
 * @param signature - The signature from request headers
99
 * @param body - Raw request body as a string
100
 * @param timestamp - Optional timestamp from request headers
101
 * @returns boolean indicating if the signature is valid
102
 */
103
async function verifySignature(signature: string, body?: string, timestamp?: string): Promise<boolean> {
104
  const dataToVerify = timestamp
105
    ? `${body || ''}${timestamp}`
106
    : (body || '');
107

108
  const secretKey = await wmill.getVariable(SECRET_KEY_VARIABLE_PATH);
109

110
  const expectedSignature = crypto
111
    .createHmac('sha256', secretKey || '')
112
    .update(dataToVerify)
113
    .digest('hex');
114

115
  try {
116
    return crypto.timingSafeEqual(
117
      Buffer.from(signature),
118
      Buffer.from(expectedSignature)
119
    );
120
  } catch (error: unknown) {
121
    console.error('Signature comparison error:', error);
122
    return false;
123
  }
124

125
  // NOTE: Modify this logic if your provider uses a different signing mechanism (e.g., Base64, RSA, etc.)
126
}
127

128
/**
129
 * Main Function - Handles processed trigger events
130
 *
131
 * ⚠️ Called AFTER `preprocessor()`, with its return values.
132
 *
133
 * @param body - Parsed request body
134
 */
135
export async function main(body: any) {
136
  // At this point, the request has been authenticated (signature + timestamp) and body safely parsed
137
  return {
138
    statusCode: 200,
139
    body: {
140
      message: "Request authenticated successfully",
141
      receivedData: body,
142
    }
143
  };
144
}