@hookflo/tern 2.2.9 → 2.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { WebhookPlatform, WebhookVerificationResult, NormalizeOptions } from
|
|
2
|
-
import { MinimalNodeRequest } from
|
|
1
|
+
import { WebhookPlatform, WebhookVerificationResult, NormalizeOptions } from "../types";
|
|
2
|
+
import { MinimalNodeRequest } from "./shared";
|
|
3
3
|
export interface ExpressLikeResponse {
|
|
4
4
|
status: (code: number) => ExpressLikeResponse;
|
|
5
5
|
json: (payload: unknown) => unknown;
|
|
@@ -7,7 +7,7 @@ export interface ExpressLikeResponse {
|
|
|
7
7
|
export interface ExpressLikeRequest extends MinimalNodeRequest {
|
|
8
8
|
webhook?: WebhookVerificationResult;
|
|
9
9
|
}
|
|
10
|
-
export type ExpressLikeNext = () => void;
|
|
10
|
+
export type ExpressLikeNext = (err?: unknown) => void;
|
|
11
11
|
export interface ExpressWebhookMiddlewareOptions {
|
|
12
12
|
platform: WebhookPlatform;
|
|
13
13
|
secret: string;
|
package/dist/adapters/express.js
CHANGED
|
@@ -9,7 +9,12 @@ function createWebhookMiddleware(options) {
|
|
|
9
9
|
const webRequest = await (0, shared_1.toWebRequest)(req);
|
|
10
10
|
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(webRequest, options.platform, options.secret, options.toleranceInSeconds, options.normalize);
|
|
11
11
|
if (!result.isValid) {
|
|
12
|
-
res.status(400).json({
|
|
12
|
+
res.status(400).json({
|
|
13
|
+
error: result.error,
|
|
14
|
+
errorCode: result.errorCode,
|
|
15
|
+
platform: result.platform,
|
|
16
|
+
metadata: result.metadata,
|
|
17
|
+
});
|
|
13
18
|
return;
|
|
14
19
|
}
|
|
15
20
|
req.webhook = result;
|
|
@@ -17,7 +22,7 @@ function createWebhookMiddleware(options) {
|
|
|
17
22
|
}
|
|
18
23
|
catch (error) {
|
|
19
24
|
options.onError?.(error);
|
|
20
|
-
|
|
25
|
+
next(error);
|
|
21
26
|
}
|
|
22
27
|
};
|
|
23
28
|
}
|
|
@@ -6,8 +6,8 @@ export interface MinimalNodeRequest {
|
|
|
6
6
|
get?: (name: string) => string | undefined;
|
|
7
7
|
originalUrl?: string;
|
|
8
8
|
url?: string;
|
|
9
|
-
on?: (event: string, cb: (chunk?:
|
|
9
|
+
on?: (event: string, cb: (chunk?: unknown) => void) => void;
|
|
10
10
|
}
|
|
11
|
-
export declare function extractRawBody(request: MinimalNodeRequest): Promise<
|
|
11
|
+
export declare function extractRawBody(request: MinimalNodeRequest): Promise<Uint8Array>;
|
|
12
12
|
export declare function toHeadersInit(headers: Record<string, string | string[] | undefined>): HeadersInit;
|
|
13
13
|
export declare function toWebRequest(request: MinimalNodeRequest): Promise<Request>;
|
package/dist/adapters/shared.js
CHANGED
|
@@ -6,39 +6,65 @@ exports.toWebRequest = toWebRequest;
|
|
|
6
6
|
function getHeaderValue(headers, name) {
|
|
7
7
|
const value = headers[name.toLowerCase()] ?? headers[name];
|
|
8
8
|
if (Array.isArray(value)) {
|
|
9
|
-
return value.join(
|
|
9
|
+
return value.join(",");
|
|
10
10
|
}
|
|
11
11
|
return value;
|
|
12
12
|
}
|
|
13
|
-
async function
|
|
14
|
-
if (!request.on)
|
|
15
|
-
return
|
|
16
|
-
}
|
|
13
|
+
async function readIncomingMessageBodyAsBuffer(request) {
|
|
14
|
+
if (!request.on)
|
|
15
|
+
return new Uint8Array(0);
|
|
17
16
|
const chunks = [];
|
|
18
17
|
return new Promise((resolve, reject) => {
|
|
19
|
-
request.on?.(
|
|
20
|
-
if (
|
|
18
|
+
request.on?.("data", (chunk) => {
|
|
19
|
+
if (chunk instanceof Uint8Array) {
|
|
21
20
|
chunks.push(chunk);
|
|
22
|
-
return;
|
|
23
21
|
}
|
|
24
|
-
|
|
22
|
+
else if (typeof chunk === "string") {
|
|
23
|
+
chunks.push(new TextEncoder().encode(chunk));
|
|
24
|
+
}
|
|
25
|
+
else if (chunk != null) {
|
|
26
|
+
try {
|
|
27
|
+
chunks.push(new TextEncoder().encode(String(chunk)));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// skip unreadable chunk silently
|
|
31
|
+
}
|
|
32
|
+
}
|
|
25
33
|
});
|
|
26
|
-
request.on?.(
|
|
27
|
-
|
|
34
|
+
request.on?.("end", () => {
|
|
35
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.byteLength, 0);
|
|
36
|
+
const result = new Uint8Array(totalLength);
|
|
37
|
+
let offset = 0;
|
|
38
|
+
for (const chunk of chunks) {
|
|
39
|
+
result.set(chunk, offset);
|
|
40
|
+
offset += chunk.byteLength;
|
|
41
|
+
}
|
|
42
|
+
resolve(result);
|
|
43
|
+
});
|
|
44
|
+
request.on?.("error", (err) => reject(err));
|
|
28
45
|
});
|
|
29
46
|
}
|
|
30
47
|
async function extractRawBody(request) {
|
|
31
48
|
const body = request.body;
|
|
32
|
-
if (
|
|
49
|
+
if (body instanceof Uint8Array) {
|
|
33
50
|
return body;
|
|
34
51
|
}
|
|
35
|
-
if (
|
|
36
|
-
return
|
|
52
|
+
if (typeof body === "string") {
|
|
53
|
+
return new TextEncoder().encode(body);
|
|
37
54
|
}
|
|
38
|
-
if (body && typeof body ===
|
|
39
|
-
|
|
55
|
+
if (body !== null && body !== undefined && typeof body === "object") {
|
|
56
|
+
console.warn("[Tern] Warning: request body is already parsed as JSON. " +
|
|
57
|
+
"Signature verification may fail. " +
|
|
58
|
+
'Add express.raw({ type: "*/*" }) before Tern on webhook routes, ' +
|
|
59
|
+
"or register webhook routes before app.use(express.json()).");
|
|
60
|
+
return new TextEncoder().encode(JSON.stringify(body));
|
|
40
61
|
}
|
|
41
|
-
|
|
62
|
+
// nothing ran — read raw stream directly
|
|
63
|
+
return readIncomingMessageBodyAsBuffer(request);
|
|
64
|
+
}
|
|
65
|
+
// converts Uint8Array to a plain ArrayBuffer that all TS versions accept as BodyInit
|
|
66
|
+
function toArrayBuffer(uint8) {
|
|
67
|
+
return uint8.buffer.slice(uint8.byteOffset, uint8.byteOffset + uint8.byteLength);
|
|
42
68
|
}
|
|
43
69
|
function toHeadersInit(headers) {
|
|
44
70
|
const normalized = new Headers();
|
|
@@ -47,7 +73,7 @@ function toHeadersInit(headers) {
|
|
|
47
73
|
continue;
|
|
48
74
|
}
|
|
49
75
|
if (Array.isArray(value)) {
|
|
50
|
-
normalized.set(key, value.join(
|
|
76
|
+
normalized.set(key, value.join(","));
|
|
51
77
|
continue;
|
|
52
78
|
}
|
|
53
79
|
normalized.set(key, value);
|
|
@@ -55,13 +81,17 @@ function toHeadersInit(headers) {
|
|
|
55
81
|
return normalized;
|
|
56
82
|
}
|
|
57
83
|
async function toWebRequest(request) {
|
|
58
|
-
const protocol = request.protocol ||
|
|
59
|
-
const host = request.get?.(
|
|
60
|
-
|
|
84
|
+
const protocol = request.protocol || "https";
|
|
85
|
+
const host = request.get?.("host") ||
|
|
86
|
+
getHeaderValue(request.headers, "host") ||
|
|
87
|
+
"localhost";
|
|
88
|
+
const path = request.originalUrl || request.url || "/";
|
|
61
89
|
const rawBody = await extractRawBody(request);
|
|
62
|
-
|
|
63
|
-
method: request.method ||
|
|
90
|
+
const init = {
|
|
91
|
+
method: request.method || "POST",
|
|
64
92
|
headers: toHeadersInit(request.headers),
|
|
65
|
-
body: rawBody,
|
|
66
|
-
|
|
93
|
+
body: toArrayBuffer(rawBody),
|
|
94
|
+
duplex: "half",
|
|
95
|
+
};
|
|
96
|
+
return new Request(`${protocol}://${host}${path}`, init);
|
|
67
97
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
7
|
+
const shared_1 = require("./shared");
|
|
8
|
+
const __1 = __importDefault(require(".."));
|
|
9
|
+
const secret = "whsec_test_secret";
|
|
10
|
+
const payload = JSON.stringify({ type: "payment_intent.succeeded", data: { amount: 2000 } }) +
|
|
11
|
+
"\n";
|
|
12
|
+
function buildStripeSignature(body, secret) {
|
|
13
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
14
|
+
const signed = `${timestamp}.${body}`;
|
|
15
|
+
const hmac = crypto_1.default
|
|
16
|
+
.createHmac("sha256", secret.replace("whsec_", ""))
|
|
17
|
+
.update(signed)
|
|
18
|
+
.digest("hex");
|
|
19
|
+
return `t=${timestamp},v1=${hmac}`;
|
|
20
|
+
}
|
|
21
|
+
// ── Test 1: raw stream — no middleware ran ───────────────────
|
|
22
|
+
async function testRawStream() {
|
|
23
|
+
const sig = buildStripeSignature(payload, secret); // ✓ fixed: was missing secret arg
|
|
24
|
+
const chunks = [Buffer.from(payload, "utf8")];
|
|
25
|
+
const req = {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: {
|
|
28
|
+
"stripe-signature": sig,
|
|
29
|
+
"content-type": "application/json",
|
|
30
|
+
},
|
|
31
|
+
body: undefined,
|
|
32
|
+
on: (event, cb) => {
|
|
33
|
+
if (event === "data") {
|
|
34
|
+
for (const chunk of chunks)
|
|
35
|
+
cb(chunk);
|
|
36
|
+
}
|
|
37
|
+
if (event === "end") {
|
|
38
|
+
cb();
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
const webReq = await (0, shared_1.toWebRequest)(req);
|
|
43
|
+
const result = await __1.default.verifyWithPlatformConfig(webReq, "stripe", secret);
|
|
44
|
+
console.log("Test 1 Raw stream (no middleware):", result.isValid ? "✓ PASS" : `✗ FAIL — ${result.error}`);
|
|
45
|
+
}
|
|
46
|
+
// ── Test 2: Buffer body — express.raw() ran ──────────────────
|
|
47
|
+
async function testBufferBody() {
|
|
48
|
+
const sig = buildStripeSignature(payload, secret);
|
|
49
|
+
const req = {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: {
|
|
52
|
+
"content-type": "application/json",
|
|
53
|
+
"stripe-signature": sig,
|
|
54
|
+
},
|
|
55
|
+
body: Buffer.from(payload, "utf8"),
|
|
56
|
+
};
|
|
57
|
+
const webReq = await (0, shared_1.toWebRequest)(req);
|
|
58
|
+
const result = await __1.default.verifyWithPlatformConfig(webReq, "stripe", secret);
|
|
59
|
+
console.log("Test 2 Buffer body (express.raw):", result.isValid ? "✓ PASS" : `✗ FAIL — ${result.error}`);
|
|
60
|
+
}
|
|
61
|
+
// ── Test 3: parsed object — express.json() ran ───────────────
|
|
62
|
+
async function testParsedObject() {
|
|
63
|
+
// signature computed against raw payload including \n
|
|
64
|
+
const sig = buildStripeSignature(payload, secret);
|
|
65
|
+
const req = {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: {
|
|
68
|
+
"content-type": "application/json",
|
|
69
|
+
"stripe-signature": sig,
|
|
70
|
+
},
|
|
71
|
+
body: JSON.parse(payload), // \n gone, bytes lost
|
|
72
|
+
};
|
|
73
|
+
const webReq = await (0, shared_1.toWebRequest)(req);
|
|
74
|
+
const result = await __1.default.verifyWithPlatformConfig(webReq, "stripe", secret);
|
|
75
|
+
// should FAIL — \n was in original signature but lost after JSON.parse
|
|
76
|
+
console.log("Test 3 Parsed object (express.json):", !result.isValid ? "✓ FAILS AS EXPECTED" : "✗ SHOULD HAVE FAILED");
|
|
77
|
+
}
|
|
78
|
+
async function run() {
|
|
79
|
+
await testRawStream(); // no middleware → should PASS
|
|
80
|
+
await testBufferBody(); // express.raw() → should PASS
|
|
81
|
+
await testParsedObject(); // express.json() → should FAIL as expected
|
|
82
|
+
console.log("\nDone. Test 3 failing is correct behavior.");
|
|
83
|
+
}
|
|
84
|
+
run();
|
package/package.json
CHANGED