@blamejs/core 0.18.7 → 0.18.9
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.
- package/CHANGELOG.md +4 -0
- package/README.md +3 -1
- package/index.js +4 -0
- package/lib/auth/ciba.js +4 -1
- package/lib/auth/oauth.js +313 -19
- package/lib/crypto-field.js +1 -1
- package/lib/crypto.js +55 -24
- package/lib/db-collection.js +7 -0
- package/lib/http-client.js +9 -12
- package/lib/local-http.js +347 -0
- package/lib/request-helpers.js +126 -4
- package/lib/webhook-hmac.js +206 -0
- package/lib/webhook.js +2 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/request-helpers.js
CHANGED
|
@@ -289,9 +289,12 @@ function clientIp(req, opts) {
|
|
|
289
289
|
// socket peer is not a trusted proxy — cannot forge the result: the
|
|
290
290
|
// forgeable header is ignored and we fall through to the socket
|
|
291
291
|
// address. This is the only form safe for an access-control decision.
|
|
292
|
-
|
|
292
|
+
// Require an EXACT boolean true from the operator predicate — an async
|
|
293
|
+
// (Promise) or truthy-non-boolean result must NOT trust a hop (it would
|
|
294
|
+
// otherwise fall through to the forgeable hops[0] for access control).
|
|
295
|
+
if (socketAddr && trust(socketAddr) === true) {
|
|
293
296
|
for (var i = hops.length - 1; i >= 0; i--) {
|
|
294
|
-
if (
|
|
297
|
+
if (trust(hops[i]) !== true) return hops[i];
|
|
295
298
|
}
|
|
296
299
|
return hops[0]; // entire chain trusted — earliest claimed client
|
|
297
300
|
}
|
|
@@ -394,6 +397,124 @@ function trustedClientIp(opts) {
|
|
|
394
397
|
};
|
|
395
398
|
}
|
|
396
399
|
|
|
400
|
+
function _socketAddr(req) {
|
|
401
|
+
return (req.socket && typeof req.socket.remoteAddress === "string" && req.socket.remoteAddress) ? req.socket.remoteAddress
|
|
402
|
+
: (req.connection && typeof req.connection.remoteAddress === "string" && req.connection.remoteAddress) ? req.connection.remoteAddress
|
|
403
|
+
: null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* @primitive b.requestHelpers.trustedIdentityHeaders
|
|
408
|
+
* @signature b.requestHelpers.trustedIdentityHeaders(opts)
|
|
409
|
+
* @since 0.18.8
|
|
410
|
+
* @status stable
|
|
411
|
+
* @related b.requestHelpers.trustedClientIp, b.requestHelpers.clientIp
|
|
412
|
+
*
|
|
413
|
+
* Resolve an identity-injecting reverse proxy's headers under the SAME
|
|
414
|
+
* peer-gate as `trustedClientIp` — the mirror of the `X-Forwarded-For`
|
|
415
|
+
* discipline for identity-header families (Cloudflare Access `Cf-Access-*`,
|
|
416
|
+
* oauth2-proxy `X-Forwarded-User`, Tailscale Serve `Tailscale-User-*`). A
|
|
417
|
+
* configured header family is trusted ONLY when the immediate socket peer is a
|
|
418
|
+
* trusted proxy; from every OTHER peer the family is defensively stripped from
|
|
419
|
+
* `req.headers` so downstream code cannot read a forged value. A naive trust of
|
|
420
|
+
* these headers is a full impersonation bypass — so this reuses the
|
|
421
|
+
* `trustedProxies` gate rather than opening a second, looser trust path.
|
|
422
|
+
*
|
|
423
|
+
* Returns `{ resolve(req), middleware, headerNames, peerGated }`. `resolve(req)`
|
|
424
|
+
* → `{ trusted, identity }` (`identity` is `{}` unless the peer is trusted).
|
|
425
|
+
* `middleware(req, res, next)` sets `req[as]` to the identity when trusted and
|
|
426
|
+
* DELETES every family header from `req.headers` when not. With no
|
|
427
|
+
* `trustedProxies`/`peerTrust` the peer is never trusted (fail-closed: the
|
|
428
|
+
* family is always stripped and `peerGated` is false).
|
|
429
|
+
*
|
|
430
|
+
* Header VALUES are surfaced raw — RFC 2047 name decoding and capability-JSON
|
|
431
|
+
* parsing are the consumer's job, not the trust boundary's.
|
|
432
|
+
*
|
|
433
|
+
* @opts
|
|
434
|
+
* headers: object, // { field: "Header-Name", ... } — the family to trust (required)
|
|
435
|
+
* trustedProxies: string | string[], // CIDRs of the reverse proxies — peer-gate the family
|
|
436
|
+
* peerTrust: function(req): boolean, // own the peer-trust decision entirely (instead of trustedProxies)
|
|
437
|
+
* as: string, // req property to set the identity on (default: "proxyIdentity")
|
|
438
|
+
*
|
|
439
|
+
* @example
|
|
440
|
+
* var ident = b.requestHelpers.trustedIdentityHeaders({
|
|
441
|
+
* trustedProxies: ["127.0.0.1/32"],
|
|
442
|
+
* headers: { login: "Tailscale-User-Login", name: "Tailscale-User-Name" },
|
|
443
|
+
* });
|
|
444
|
+
* app.use(ident.middleware);
|
|
445
|
+
* // req.proxyIdentity = { login, name } from the trusted sidecar; a forged
|
|
446
|
+
* // Tailscale-User-Login from a direct client is stripped, never trusted.
|
|
447
|
+
*/
|
|
448
|
+
function trustedIdentityHeaders(opts) {
|
|
449
|
+
opts = opts || {};
|
|
450
|
+
if (!opts.headers || typeof opts.headers !== "object" || Array.isArray(opts.headers)) {
|
|
451
|
+
throw new TypeError("trustedIdentityHeaders: opts.headers must be an object mapping field → header name");
|
|
452
|
+
}
|
|
453
|
+
var fieldNames = Object.keys(opts.headers);
|
|
454
|
+
if (fieldNames.length === 0) {
|
|
455
|
+
throw new TypeError("trustedIdentityHeaders: opts.headers must map at least one field");
|
|
456
|
+
}
|
|
457
|
+
var map = {}; // field → lowercased header name
|
|
458
|
+
var headerNames = []; // lowercased header names (the family to strip)
|
|
459
|
+
for (var i = 0; i < fieldNames.length; i++) {
|
|
460
|
+
var hn = opts.headers[fieldNames[i]];
|
|
461
|
+
if (typeof hn !== "string" || hn.length === 0) {
|
|
462
|
+
throw new TypeError("trustedIdentityHeaders: header name for field '" + fieldNames[i] + "' must be a non-empty string");
|
|
463
|
+
}
|
|
464
|
+
var lhn = hn.toLowerCase();
|
|
465
|
+
map[fieldNames[i]] = lhn;
|
|
466
|
+
headerNames.push(lhn);
|
|
467
|
+
}
|
|
468
|
+
var peerTrust = opts.peerTrust;
|
|
469
|
+
if (peerTrust != null && typeof peerTrust !== "function") {
|
|
470
|
+
throw new TypeError("trustedIdentityHeaders: peerTrust must be a function(req) => boolean");
|
|
471
|
+
}
|
|
472
|
+
var predicate = _trustedProxyPredicate(_normTrustedProxies(opts), "trustedIdentityHeaders");
|
|
473
|
+
var asProp = (typeof opts.as === "string" && opts.as.length) ? opts.as : "proxyIdentity";
|
|
474
|
+
|
|
475
|
+
function _peerTrusted(req) {
|
|
476
|
+
// Require an EXACT synchronous `true`. A `!!` would treat a Promise (an async
|
|
477
|
+
// predicate) — or any truthy non-boolean — as trusted, so an untrusted peer
|
|
478
|
+
// could be impersonated; a non-true / thenable result fails closed.
|
|
479
|
+
if (peerTrust) return peerTrust(req) === true;
|
|
480
|
+
if (!predicate) return false; // no gate configured → never trust (fail-closed)
|
|
481
|
+
var addr = _socketAddr(req);
|
|
482
|
+
return !!(addr && predicate(addr));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function resolve(req) {
|
|
486
|
+
if (!req || !req.headers || !_peerTrusted(req)) return { trusted: false, identity: {} };
|
|
487
|
+
var identity = {};
|
|
488
|
+
for (var f = 0; f < fieldNames.length; f++) {
|
|
489
|
+
var v = req.headers[map[fieldNames[f]]];
|
|
490
|
+
if (typeof v === "string") identity[fieldNames[f]] = v;
|
|
491
|
+
}
|
|
492
|
+
return { trusted: true, identity: identity };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function middleware(req, res, next) {
|
|
496
|
+
var r = resolve(req);
|
|
497
|
+
if (r.trusted) {
|
|
498
|
+
req[asProp] = r.identity;
|
|
499
|
+
} else {
|
|
500
|
+
// Defensive strip — a non-trusted peer must not deliver a family header
|
|
501
|
+
// that downstream reads as trusted identity.
|
|
502
|
+
if (req && req.headers) {
|
|
503
|
+
for (var h = 0; h < headerNames.length; h++) delete req.headers[headerNames[h]];
|
|
504
|
+
}
|
|
505
|
+
if (req) req[asProp] = null;
|
|
506
|
+
}
|
|
507
|
+
if (typeof next === "function") next();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
resolve: resolve,
|
|
512
|
+
middleware: middleware,
|
|
513
|
+
headerNames: headerNames.slice(),
|
|
514
|
+
peerGated: !!(peerTrust || predicate),
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
397
518
|
// IP-prefix masking constants — named so the bit-arithmetic stays readable.
|
|
398
519
|
// /24 IPv4 is the original IP-geolocation bucket and matches the legacy
|
|
399
520
|
// carrier-NAT pool stride; /64 IPv6 is the customer LAN every RIR allocates
|
|
@@ -682,7 +803,7 @@ function requestProtocol(req, opts) {
|
|
|
682
803
|
(req.socket && typeof req.socket.remoteAddress === "string" && req.socket.remoteAddress) ? req.socket.remoteAddress
|
|
683
804
|
: (req.connection && typeof req.connection.remoteAddress === "string" && req.connection.remoteAddress) ? req.connection.remoteAddress
|
|
684
805
|
: null;
|
|
685
|
-
if (peer && trust(peer)) return hops[0];
|
|
806
|
+
if (peer && trust(peer) === true) return hops[0]; // require an exact boolean true (no async/truthy trust)
|
|
686
807
|
// peer not a trusted proxy → ignore forgeable header, fall through
|
|
687
808
|
} else {
|
|
688
809
|
return hops[0]; // legacy true/number — spoofable, see docstring
|
|
@@ -772,7 +893,7 @@ function requestHost(req, opts) {
|
|
|
772
893
|
(req.socket && typeof req.socket.remoteAddress === "string" && req.socket.remoteAddress) ? req.socket.remoteAddress
|
|
773
894
|
: (req.connection && typeof req.connection.remoteAddress === "string" && req.connection.remoteAddress) ? req.connection.remoteAddress
|
|
774
895
|
: null;
|
|
775
|
-
if (peer && trust(peer)) return hops[0];
|
|
896
|
+
if (peer && trust(peer) === true) return hops[0]; // require an exact boolean true (no async/truthy trust)
|
|
776
897
|
// peer not a trusted proxy → ignore forgeable header, fall through
|
|
777
898
|
} else {
|
|
778
899
|
return hops[0]; // legacy true — spoofable, see docstring
|
|
@@ -1371,6 +1492,7 @@ module.exports = {
|
|
|
1371
1492
|
// proxy-trust primitives (default refuses forwarded headers)
|
|
1372
1493
|
clientIp: clientIp,
|
|
1373
1494
|
trustedClientIp: trustedClientIp,
|
|
1495
|
+
trustedIdentityHeaders: trustedIdentityHeaders,
|
|
1374
1496
|
ipPrefix: ipPrefix,
|
|
1375
1497
|
ipKey: ipKey,
|
|
1376
1498
|
requestProtocol: requestProtocol,
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
/**
|
|
5
|
+
* @module b.webhookHmac
|
|
6
|
+
* @nav HTTP
|
|
7
|
+
* @title HMAC Webhooks
|
|
8
|
+
* @order 236
|
|
9
|
+
*
|
|
10
|
+
* @intro
|
|
11
|
+
* Inbound verification for the timestamped-HMAC webhook scheme — a single
|
|
12
|
+
* header carrying a Unix timestamp and one or more HMAC signatures:
|
|
13
|
+
*
|
|
14
|
+
* <sig-header>: t=<unix-seconds>,v1=<hmac-sha256-hex>[,v1=<rotated>]
|
|
15
|
+
*
|
|
16
|
+
* The signed payload is <code><timestamp>.<raw-body></code>, keyed on the
|
|
17
|
+
* endpoint signing secret. This is the scheme Stripe and Tailscale (among
|
|
18
|
+
* others) use. It is DISTINCT from the StandardWebhooks scheme that
|
|
19
|
+
* <code>b.standardWebhooks</code> verifies, which uses three separate headers
|
|
20
|
+
* and an <code><id>.<ts>.<body></code> payload.
|
|
21
|
+
*
|
|
22
|
+
* Verification refuses a timestamp outside the tolerance window (replay
|
|
23
|
+
* defense), checks EVERY signature value under the version field (so a
|
|
24
|
+
* rotated secret verifies with no downtime), compares with
|
|
25
|
+
* <code>b.crypto.timingSafeEqual</code>, and ignores signature versions it
|
|
26
|
+
* does not understand. Always verify against the EXACT received bytes —
|
|
27
|
+
* never a re-serialized JSON body.
|
|
28
|
+
*
|
|
29
|
+
* @card
|
|
30
|
+
* Verify the Stripe-style <code>t=<ts>,v1=<hmac></code> timestamped-HMAC webhook scheme (Stripe / Tailscale / …) — replay window, key-rotation multi-signature, constant-time compare.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
var bCrypto = require("./crypto");
|
|
34
|
+
var safeBuffer = require("./safe-buffer");
|
|
35
|
+
var numericBounds = require("./numeric-bounds");
|
|
36
|
+
var validateOpts = require("./validate-opts");
|
|
37
|
+
var { defineClass } = require("./framework-error");
|
|
38
|
+
|
|
39
|
+
var WebhookHmacError = defineClass("WebhookHmacError", { alwaysPermanent: true });
|
|
40
|
+
|
|
41
|
+
var DEFAULT_TOLERANCE_SEC = 300; // 5 minutes — the Stripe/Tailscale default replay window
|
|
42
|
+
var DEFAULT_TS_FIELD = "t";
|
|
43
|
+
var DEFAULT_SIG_FIELD = "v1";
|
|
44
|
+
var DEFAULT_ALG = "hmac-sha256";
|
|
45
|
+
|
|
46
|
+
// alg string → node HMAC name. SHA-2 only; SHA-1 is refused (collision-weak,
|
|
47
|
+
// and no webhook provider using this scheme needs it).
|
|
48
|
+
var ALG_MAP = {
|
|
49
|
+
"hmac-sha256": "sha256",
|
|
50
|
+
"hmac-sha512": "sha512",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Named presets for providers using this exact single-header scheme. Explicit
|
|
54
|
+
// opts always override a profile.
|
|
55
|
+
var PROFILES = {
|
|
56
|
+
stripe: { tsField: "t", sigField: "v1", alg: "hmac-sha256" },
|
|
57
|
+
tailscale: { tsField: "t", sigField: "v1", alg: "hmac-sha256" },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function _mkErr(code, message) { return new WebhookHmacError(code, message); }
|
|
61
|
+
|
|
62
|
+
function _requireNonEmptyString(val, name) {
|
|
63
|
+
if (typeof val !== "string" || val.length === 0) {
|
|
64
|
+
throw new WebhookHmacError("webhook-hmac/bad-" + name,
|
|
65
|
+
"verify: opts." + name + " must be a non-empty string");
|
|
66
|
+
}
|
|
67
|
+
return val;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @primitive b.webhookHmac.verify
|
|
72
|
+
* @signature b.webhookHmac.verify(opts)
|
|
73
|
+
* @since 0.18.8
|
|
74
|
+
* @status stable
|
|
75
|
+
* @related b.standardWebhooks.verify, b.crypto.hmac, b.crypto.timingSafeEqual
|
|
76
|
+
*
|
|
77
|
+
* Verify an inbound webhook signed with the timestamped-HMAC scheme
|
|
78
|
+
* (<code>t=<ts>,v1=<hmac></code>). Refuses on a missing/garbled header, a
|
|
79
|
+
* timestamp outside the tolerance window (replay), or an HMAC mismatch;
|
|
80
|
+
* returns <code>{ valid: true, timestamp }</code> when a signature matches.
|
|
81
|
+
*
|
|
82
|
+
* The signed payload is <code><timestamp>.<raw-body></code> — pass the EXACT
|
|
83
|
+
* bytes received, not a parsed-then-re-serialized JSON body, or the HMAC will
|
|
84
|
+
* not reproduce. Every value under the signature field is checked, so a
|
|
85
|
+
* rotated secret (two <code>v1=</code> values) verifies without downtime.
|
|
86
|
+
* Comparison is constant-time; unrecognized signature versions are ignored.
|
|
87
|
+
*
|
|
88
|
+
* @opts
|
|
89
|
+
* header: string, // the raw signature header value ("t=...,v1=...")
|
|
90
|
+
* rawBody: Buffer | string, // the exact received body bytes
|
|
91
|
+
* secret: Buffer | string, // the endpoint signing secret
|
|
92
|
+
* profile: string, // "stripe" | "tailscale" — sets tsField/sigField/alg
|
|
93
|
+
* tsField: string, // default: "t"
|
|
94
|
+
* sigField: string, // default: "v1"
|
|
95
|
+
* alg: string, // default: "hmac-sha256" (also "hmac-sha512")
|
|
96
|
+
* toleranceSec: number, // default: 300 (5 minutes)
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* var v = b.webhookHmac.verify({
|
|
100
|
+
* header: req.headers["stripe-signature"],
|
|
101
|
+
* rawBody: rawBody,
|
|
102
|
+
* secret: process.env.WHSEC,
|
|
103
|
+
* });
|
|
104
|
+
* // → { valid: true, timestamp: 1614556828 }
|
|
105
|
+
*/
|
|
106
|
+
function verify(opts) {
|
|
107
|
+
opts = validateOpts.requireObject(opts, "webhookHmac.verify",
|
|
108
|
+
WebhookHmacError, "webhook-hmac/bad-opts");
|
|
109
|
+
validateOpts(opts,
|
|
110
|
+
["header", "rawBody", "secret", "profile", "tsField", "sigField", "alg", "toleranceSec"],
|
|
111
|
+
"webhookHmac.verify");
|
|
112
|
+
|
|
113
|
+
// Resolve profile → field/alg defaults; explicit opts win.
|
|
114
|
+
var prof = {};
|
|
115
|
+
if (opts.profile !== undefined) {
|
|
116
|
+
if (typeof opts.profile !== "string" || !Object.prototype.hasOwnProperty.call(PROFILES, opts.profile)) {
|
|
117
|
+
throw new WebhookHmacError("webhook-hmac/bad-profile",
|
|
118
|
+
"verify: unknown profile '" + opts.profile + "' (known: " + Object.keys(PROFILES).join(", ") + ")");
|
|
119
|
+
}
|
|
120
|
+
prof = PROFILES[opts.profile];
|
|
121
|
+
}
|
|
122
|
+
var tsField = opts.tsField !== undefined ? opts.tsField : (prof.tsField || DEFAULT_TS_FIELD);
|
|
123
|
+
var sigField = opts.sigField !== undefined ? opts.sigField : (prof.sigField || DEFAULT_SIG_FIELD);
|
|
124
|
+
var algName = opts.alg !== undefined ? opts.alg : (prof.alg || DEFAULT_ALG);
|
|
125
|
+
_requireNonEmptyString(tsField, "tsField");
|
|
126
|
+
_requireNonEmptyString(sigField, "sigField");
|
|
127
|
+
var nodeAlg = Object.prototype.hasOwnProperty.call(ALG_MAP, algName) ? ALG_MAP[algName] : null;
|
|
128
|
+
if (!nodeAlg) {
|
|
129
|
+
throw new WebhookHmacError("webhook-hmac/bad-alg",
|
|
130
|
+
"verify: unsupported alg '" + String(algName) + "' (supported: " + Object.keys(ALG_MAP).join(", ") + ")");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
_requireNonEmptyString(opts.header, "header");
|
|
134
|
+
var bodyBuf = safeBuffer.toBuffer(opts.rawBody, { typeCode: "webhook-hmac/bad-body", errorFactory: _mkErr });
|
|
135
|
+
var secretBuf = safeBuffer.toBuffer(opts.secret, { typeCode: "webhook-hmac/bad-secret", errorFactory: _mkErr });
|
|
136
|
+
if (secretBuf.length === 0) {
|
|
137
|
+
throw new WebhookHmacError("webhook-hmac/bad-secret", "verify: opts.secret must be non-empty");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.toleranceSec, "toleranceSec",
|
|
141
|
+
WebhookHmacError, "webhook-hmac/bad-tolerance");
|
|
142
|
+
var tolerance = typeof opts.toleranceSec === "number" ? opts.toleranceSec : DEFAULT_TOLERANCE_SEC;
|
|
143
|
+
|
|
144
|
+
// Parse "t=<ts>,v1=<sig>,v1=<rotated>" — comma-separated k=v. Collect the ts
|
|
145
|
+
// and EVERY sigField value; ignore any other version keys.
|
|
146
|
+
var tsRaw = null;
|
|
147
|
+
var sigs = [];
|
|
148
|
+
var items = opts.header.split(",");
|
|
149
|
+
for (var i = 0; i < items.length; i += 1) {
|
|
150
|
+
var eq = items[i].indexOf("=");
|
|
151
|
+
if (eq < 0) continue;
|
|
152
|
+
var k = items[i].slice(0, eq).trim();
|
|
153
|
+
var v = items[i].slice(eq + 1).trim();
|
|
154
|
+
if (k === tsField) { if (tsRaw === null) tsRaw = v; }
|
|
155
|
+
else if (k === sigField) { sigs.push(v); }
|
|
156
|
+
}
|
|
157
|
+
if (tsRaw === null) {
|
|
158
|
+
throw new WebhookHmacError("webhook-hmac/missing-timestamp",
|
|
159
|
+
"verify: no '" + tsField + "=' field in the signature header");
|
|
160
|
+
}
|
|
161
|
+
if (sigs.length === 0) {
|
|
162
|
+
throw new WebhookHmacError("webhook-hmac/missing-signature",
|
|
163
|
+
"verify: no '" + sigField + "=' field in the signature header");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Strict-integer timestamp (reject "12.3", "0x1", leading zeros, whitespace).
|
|
167
|
+
var ts = parseInt(tsRaw, 10);
|
|
168
|
+
if (!isFinite(ts) || ts <= 0 || String(ts) !== tsRaw) {
|
|
169
|
+
throw new WebhookHmacError("webhook-hmac/bad-timestamp",
|
|
170
|
+
"verify: '" + tsField + "' is not a positive integer");
|
|
171
|
+
}
|
|
172
|
+
var nowSec = Math.floor(Date.now() / 1000);
|
|
173
|
+
var skew = Math.abs(nowSec - ts);
|
|
174
|
+
if (skew > tolerance) {
|
|
175
|
+
throw new WebhookHmacError("webhook-hmac/timestamp-skew",
|
|
176
|
+
"verify: timestamp skew " + skew + "s exceeds tolerance " + tolerance + "s (replay window)");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Signed payload is the raw timestamp string + "." + the exact body bytes.
|
|
180
|
+
var signed = Buffer.concat([Buffer.from(tsRaw + ".", "utf8"), bodyBuf]);
|
|
181
|
+
var expected = bCrypto.hmac(secretBuf, signed, nodeAlg);
|
|
182
|
+
var expectedBuf = Buffer.from(expected, "utf8");
|
|
183
|
+
var matched = false;
|
|
184
|
+
for (var s = 0; s < sigs.length; s += 1) {
|
|
185
|
+
// timingSafeEqual requires equal-length inputs; a wrong-length candidate
|
|
186
|
+
// cannot be the digest (the hex length is fixed by the algorithm, and is
|
|
187
|
+
// not secret), so the length pre-check leaks nothing.
|
|
188
|
+
if (sigs[s].length === expected.length &&
|
|
189
|
+
bCrypto.timingSafeEqual(expectedBuf, Buffer.from(sigs[s], "utf8"))) {
|
|
190
|
+
matched = true;
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!matched) {
|
|
195
|
+
throw new WebhookHmacError("webhook-hmac/bad-signature",
|
|
196
|
+
"verify: no '" + sigField + "' signature matched");
|
|
197
|
+
}
|
|
198
|
+
return { valid: true, timestamp: ts };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
verify: verify,
|
|
203
|
+
PROFILES: PROFILES,
|
|
204
|
+
DEFAULT_TOLERANCE_SEC: DEFAULT_TOLERANCE_SEC,
|
|
205
|
+
WebhookHmacError: WebhookHmacError,
|
|
206
|
+
};
|
package/lib/webhook.js
CHANGED
|
@@ -236,12 +236,12 @@ function _composeSignedString(algo, kid, timestamp, id, body) {
|
|
|
236
236
|
// ---- Sign / verify primitives ----
|
|
237
237
|
|
|
238
238
|
function _hmacSign(key, data) {
|
|
239
|
-
return bCrypto.
|
|
239
|
+
return bCrypto.hmac(key, data); // hex string
|
|
240
240
|
}
|
|
241
241
|
|
|
242
242
|
function _hmacVerify(key, data, expectedHex) {
|
|
243
243
|
if (!safeBuffer.isHex(expectedHex)) return false;
|
|
244
|
-
var actualHex = bCrypto.
|
|
244
|
+
var actualHex = bCrypto.hmac(key, data);
|
|
245
245
|
return bCrypto.timingSafeEqual(actualHex, expectedHex);
|
|
246
246
|
}
|
|
247
247
|
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:d511bc0a-e738-46e4-be3a-f731ed144487",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
8
|
+
"timestamp": "2026-08-02T13:57:50.020Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.18.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.18.9",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.18.
|
|
25
|
+
"version": "0.18.9",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.18.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.18.9",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.18.
|
|
57
|
+
"ref": "@blamejs/core@0.18.9",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|