Skip to content

Commit 7295fb6

Browse files
authored
Merge pull request #71 from FacturAPI/FAC-1299/update/validate-signatures-locally
Prefer validating webhook events locally
2 parents 03ea457 + 1d1f3e9 commit 7295fb6

File tree

5 files changed

+77
-7
lines changed

5 files changed

+77
-7
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
66
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
77

8+
## [4.8.3] 2025-05-19
9+
10+
### Fixed
11+
12+
- Webhook validation. Now the signature is validated locally in Node and web environments (but not in React Native, where the API is still used).
13+
- Type fixes for signature validation.
14+
815
## [4.8.2] 2025-04-23
916

1017
### Fixed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "facturapi",
3-
"version": "4.8.2",
3+
"version": "4.8.3",
44
"description": "Librería oficial de Facturapi. Crea CFDIs timbrados y enviados al SAT, XML y PDF",
55
"main": "dist/index.cjs.js",
66
"module": "dist/index.es.js",

src/tools/webhooks.ts

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isNode, isReactNative } from '../constants';
12
import { SearchResult, Webhook, ApiEvent, ApiEventType } from '../types';
23
import { WrapperClient } from '../wrapper';
34

@@ -65,11 +66,73 @@ export default class Webhooks {
6566
* @param payload - Received event object to validate
6667
* @returns When the signature is valid, it returns the event object
6768
*/
68-
async validateSignature<T extends ApiEventType | '' = ''>(data: {
69+
async validateSignature<T extends ApiEventType = any>(data: {
6970
secret: string;
7071
signature: string;
71-
payload: ApiEvent<T>;
72+
payload: string | Buffer | ApiEvent<T>;
7273
}): Promise<ApiEvent<T>> {
73-
return this.client.post('/webhooks/validate-signature', { body: data });
74+
// Validated locally
75+
const { secret, signature, payload } = data;
76+
let payloadString: string;
77+
if (typeof payload === 'string') {
78+
payloadString = payload;
79+
} else if (Buffer.isBuffer(payload)) {
80+
payloadString = payload.toString('utf8');
81+
} else if (typeof payload === 'object') {
82+
payloadString = JSON.stringify(payload);
83+
} else {
84+
throw new Error('Invalid payload type');
85+
}
86+
87+
if (isReactNative) {
88+
// Call the API to validate signature in React Native
89+
return this.client.post('/webhooks/validate-signature', {
90+
body: {
91+
secret,
92+
signature,
93+
payload: payloadString,
94+
},
95+
});
96+
} else if (isNode) {
97+
const crypto = await import('crypto');
98+
const hmac = crypto.createHmac('sha256', secret);
99+
const digestBuffer = hmac
100+
.update(payloadString)
101+
.digest();
102+
// Compare the digest with the signature and prevent timing attacks
103+
// by using a constant-time comparison
104+
const signatureBuffer = Buffer.from(signature, 'hex');
105+
if (digestBuffer.length !== signatureBuffer.length) {
106+
throw new Error('Invalid signature');
107+
}
108+
const isValid = crypto.timingSafeEqual(digestBuffer, signatureBuffer);
109+
if (!isValid) {
110+
throw new Error('Invalid signature');
111+
}
112+
return JSON.parse(payloadString) as ApiEvent<T>;
113+
} else { // Web browsers
114+
const encoder = new TextEncoder();
115+
const encodedData = encoder.encode(payloadString);
116+
const encodedSecret = encoder.encode(secret);
117+
const digest = await crypto.subtle.sign(
118+
'HMAC',
119+
await crypto.subtle.importKey(
120+
'raw',
121+
encodedSecret,
122+
{ name: 'HMAC', hash: 'SHA-256' },
123+
false,
124+
['sign'],
125+
),
126+
encodedData,
127+
);
128+
const hexDigest = Array.from(new Uint8Array(digest))
129+
.map((b) => b.toString(16).padStart(2, '0'))
130+
.join('')
131+
.toLowerCase();
132+
if (signature !== hexDigest) {
133+
throw new Error('Invalid signature');
134+
}
135+
}
136+
return JSON.parse(payloadString) as ApiEvent<T>;
74137
}
75138
}

vite.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export default defineConfig({
88
fileName: (format) => `index.${format}.js`,
99
},
1010
rollupOptions: {
11-
external: ['stream'],
11+
external: ['stream', 'crypto'],
1212
output: {
1313
exports: 'named', // Use named exports to avoid the warning
1414
},

0 commit comments

Comments
 (0)