Skip to content

Commit d242c8c

Browse files
authored
feat: Optional Security Headers added for webhook outputs (#379)
* feat: Optional Security Headers added for webhook outputs - optional security header can be enabled by adding the following to your config: -"webhookHmacSharedKey": "SomeRandomHMACKey" (Replace "SomeRandomHMACKey" with your private HMAC secret key) - when ENABLED, the following headers will be included in the webhook request: x-hmac-time 1747750828 x-hmac-signature 7420964e60045e716a9b1d4fabcbc6a9cc913c7e63ac653b313d56a097a36d1a x-request-id 769164d0-5592-4a67-9932-038573732fdc (example values shown) -- NOTE: - THIS MUST USE **SHA-256** HASHING ALG - MESSAGE TO HASH is **x-hmac-time + x-request-id** (UTF-8 string, no seperators) - DIGEST OUTPUT is **HEX STRING** - x-hmac-time is **UNIX EPOCH TIME** - signature is computed using your shared **`webhookHmacSharedKey`** - You MUST validate that the timestamp is within an acceptable range (e.g. 5 minutes) *before* comparing the HMAC (do this on your backend) * Update statusService.ts remove redudant code. * Update types.ts made webhookHmacSharedKey optional
1 parent 5503618 commit d242c8c

5 files changed

Lines changed: 60 additions & 3 deletions

File tree

model/src/data-model/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ export type FormDefinition = {
214214
jwtKey?: string | undefined;
215215
toggle?: boolean | string | undefined;
216216
retryTimeoutSeconds?: number | undefined;
217+
webhookHmacSharedKey?: string | undefined;
217218
fullStartPage?: string | undefined;
218219
serviceName?: string | undefined;
219220
};

model/src/schema/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ export const Schema = joi
346346
toggle: joi.alternatives().try(joi.boolean(), joi.string()).optional(),
347347
toggleRedirect: joi.string().optional(),
348348
retryTimeoutSeconds: joi.number().optional(),
349+
webhookHmacSharedKey: joi.string().optional(),
349350
fullStartPage: joi.string().optional(),
350351
serviceName: joi.string().optional(),
351352
});

runner/src/server/services/statusService.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { HapiRequest, HapiServer } from "../types";
2+
import { createHmacRaw } from "../utils/hmac";
23
import {
34
CacheService,
45
NotifyService,
@@ -130,6 +131,29 @@ export class StatusService {
130131

131132
let newReference;
132133

134+
/**
135+
* If the OPTIONAL config contains webhookHmacSharedKey, then we send HMAC Auth headers
136+
* This is used to confirm ONLY X-Gov's backend is sending data to our API
137+
* Everyone else will be Rejected
138+
*/
139+
const id = request.params?.id;
140+
const forms = request.server?.app?.forms;
141+
const model = id && forms?.[id];
142+
const hmacKey = model?.def?.webhookHmacSharedKey;
143+
let customSecurityHeaders: Record<string, string> = {};
144+
145+
if (hmacKey) {
146+
const [hmacSignature, requestTime, hmacExpiryTime] = await createHmacRaw(
147+
request.yar.id,
148+
hmacKey
149+
);
150+
customSecurityHeaders = {
151+
"X-Request-ID": request.yar.id.toString(),
152+
"X-HMAC-Signature": hmacSignature.toString(),
153+
"X-HMAC-Time": requestTime.toString(),
154+
};
155+
}
156+
133157
if (callback) {
134158
this.logger.info(
135159
["StatusService", "outputRequests"],
@@ -153,7 +177,8 @@ export class StatusService {
153177
firstWebhook.outputData.url,
154178
{ ...formData },
155179
"POST",
156-
firstWebhook.outputData.sendAdditionalPayMetadata
180+
firstWebhook.outputData.sendAdditionalPayMetadata,
181+
customSecurityHeaders
157182
);
158183
await this.cacheService.mergeState(request, {
159184
reference: newReference,
@@ -178,7 +203,8 @@ export class StatusService {
178203
...formData,
179204
},
180205
"POST",
181-
sendAdditionalPayMetadata
206+
sendAdditionalPayMetadata,
207+
customSecurityHeaders
182208
)
183209
),
184210
];

runner/src/server/services/webhookService.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,26 @@ export class WebhookService {
2727
url: string,
2828
data: object,
2929
method: "POST" | "PUT" = "POST",
30-
sendAdditionalPayMetadata: boolean = false
30+
sendAdditionalPayMetadata: boolean = false,
31+
authHeaders?: Record<string, string>
3132
) {
3233
// Commented out due to potential for logging PII
3334
// this.logger.info(
3435
// ["WebhookService", "postRequest body"],
3536
// JSON.stringify(data)
3637
// );
38+
3739
let request = method === "POST" ? post : put;
3840
try {
3941
if (!sendAdditionalPayMetadata) {
4042
delete data?.metadata?.pay;
4143
}
4244
const { payload } = await request(url, {
4345
...DEFAULT_OPTIONS,
46+
headers: {
47+
...DEFAULT_OPTIONS.headers,
48+
...(authHeaders || {}),
49+
},
4450
payload: JSON.stringify(data),
4551
});
4652

runner/src/server/utils/hmac.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,29 @@ export async function createHmac(email: string, hmacKey: string) {
8080
}
8181
}
8282

83+
/**
84+
* Similar to the above but returns raw epoch timestamps,
85+
* making it preferable for cryptographic logic.
86+
* The other function may benefit from refactoring
87+
* to separate display logic from core logic. */
88+
export async function createHmacRaw(message: string, hmacKey: string) {
89+
try {
90+
const currentTimestamp = Math.floor(Date.now() / 1000);
91+
const dataToHash = message + currentTimestamp;
92+
const hmac = crypto
93+
.createHmac("sha256", hmacKey)
94+
.update(dataToHash)
95+
.digest("hex");
96+
97+
const expiryTimestamp = currentTimestamp + TIME_THRESHOLD;
98+
99+
return [hmac, currentTimestamp, expiryTimestamp];
100+
} catch (error) {
101+
console.error("Error creating HMAC (raw):", error);
102+
throw error;
103+
}
104+
}
105+
83106
/**
84107
* Validates an HMAC signature
85108
*/

0 commit comments

Comments
 (0)