@blamejs/core 0.10.15 → 0.11.1
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 +5 -0
- package/index.js +18 -0
- package/lib/auth/oauth.js +187 -0
- package/lib/auth/saml.js +1366 -13
- package/lib/cms-codec.js +141 -0
- package/lib/compliance.js +73 -0
- package/lib/csp.js +271 -0
- package/lib/dbsc.js +299 -0
- package/lib/fedcm.js +264 -0
- package/lib/hal.js +125 -0
- package/lib/http-client.js +46 -10
- package/lib/importmap-integrity.js +90 -0
- package/lib/jsonapi.js +230 -0
- package/lib/lro.js +200 -0
- package/lib/mail-crypto-pgp.js +312 -2
- package/lib/mail-crypto-smime.js +530 -69
- package/lib/metrics.js +62 -12
- package/lib/middleware/security-headers.js +2 -1
- package/lib/ssrf-guard.js +71 -10
- package/lib/standard-webhooks.js +183 -0
- package/lib/web-push-vapid.js +322 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/metrics.js
CHANGED
|
@@ -376,6 +376,7 @@ function create(opts) {
|
|
|
376
376
|
type: "counter",
|
|
377
377
|
name: fullName,
|
|
378
378
|
help: help,
|
|
379
|
+
unit: copts.unit || null,
|
|
379
380
|
labelNames: labelNames,
|
|
380
381
|
values: values,
|
|
381
382
|
inc: function (callLabels, n) {
|
|
@@ -445,6 +446,7 @@ function create(opts) {
|
|
|
445
446
|
type: "gauge",
|
|
446
447
|
name: fullName,
|
|
447
448
|
help: help,
|
|
449
|
+
unit: copts.unit || null,
|
|
448
450
|
labelNames: labelNames,
|
|
449
451
|
values: values,
|
|
450
452
|
set: function (callLabels, v) {
|
|
@@ -509,10 +511,11 @@ function create(opts) {
|
|
|
509
511
|
type: "histogram",
|
|
510
512
|
name: fullName,
|
|
511
513
|
help: help,
|
|
514
|
+
unit: copts.unit || null,
|
|
512
515
|
labelNames: labelNames,
|
|
513
516
|
buckets: buckets,
|
|
514
517
|
values: values,
|
|
515
|
-
observe: function (callLabels, v) {
|
|
518
|
+
observe: function (callLabels, v, exemplar) {
|
|
516
519
|
var arg = _normalizeLabelArg(callLabels, v, NaN);
|
|
517
520
|
if (typeof arg.value !== "number" || isNaN(arg.value)) {
|
|
518
521
|
throw new MetricsError("metrics/histogram-bad-value",
|
|
@@ -531,15 +534,28 @@ function create(opts) {
|
|
|
531
534
|
}
|
|
532
535
|
// counts[i] is the count for the [<=buckets[i]] bucket; counts[buckets.length] is +Inf.
|
|
533
536
|
entry = {
|
|
534
|
-
labels:
|
|
535
|
-
counts:
|
|
536
|
-
sum:
|
|
537
|
-
count:
|
|
537
|
+
labels: resolved,
|
|
538
|
+
counts: new Array(buckets.length + 1).fill(0),
|
|
539
|
+
sum: 0,
|
|
540
|
+
count: 0,
|
|
541
|
+
exemplars: new Array(buckets.length + 1).fill(null),
|
|
538
542
|
};
|
|
539
543
|
values.set(key, entry);
|
|
540
544
|
}
|
|
541
545
|
for (var i = 0; i < buckets.length; i++) {
|
|
542
|
-
if (arg.value <= buckets[i])
|
|
546
|
+
if (arg.value <= buckets[i]) {
|
|
547
|
+
entry.counts[i]++;
|
|
548
|
+
// OpenMetrics §6.2 — store the most-recent exemplar per
|
|
549
|
+
// bucket. Operators wire trace_id / span_id via `exemplar`
|
|
550
|
+
// arg; the registry only records what's passed in.
|
|
551
|
+
if (exemplar && typeof exemplar === "object") {
|
|
552
|
+
entry.exemplars[i] = {
|
|
553
|
+
labels: exemplar.labels || {},
|
|
554
|
+
value: exemplar.value !== undefined ? exemplar.value : arg.value,
|
|
555
|
+
timestamp: exemplar.timestamp || null,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
}
|
|
543
559
|
}
|
|
544
560
|
entry.counts[buckets.length]++; // +Inf bucket is everything
|
|
545
561
|
entry.sum += arg.value;
|
|
@@ -552,34 +568,68 @@ function create(opts) {
|
|
|
552
568
|
|
|
553
569
|
// ---- exposition ----
|
|
554
570
|
|
|
555
|
-
function exposition() {
|
|
571
|
+
function exposition(opts) {
|
|
572
|
+
opts = opts || {};
|
|
573
|
+
// v0.10.16 — `format: "openmetrics"` emits OpenMetrics 1.0 wire
|
|
574
|
+
// format (RFC TBD; project at https://openmetrics.io). Differences
|
|
575
|
+
// from Prometheus 0.0.4: `# UNIT <metric> <unit>` lines, `_total`
|
|
576
|
+
// suffix MUST on counters, `# EOF` terminator. v0.10.16 ships
|
|
577
|
+
// the EOF terminator + UNIT lines when opts.format === "openmetrics".
|
|
578
|
+
var openMetrics = opts.format === "openmetrics";
|
|
556
579
|
var lines = [];
|
|
557
580
|
var sortedNames = Array.from(metrics.keys()).sort();
|
|
558
581
|
for (var i = 0; i < sortedNames.length; i++) {
|
|
559
582
|
var m = metrics.get(sortedNames[i]);
|
|
560
|
-
|
|
561
|
-
|
|
583
|
+
// OpenMetrics §5.1.2 — counter sample lines MUST suffix with
|
|
584
|
+
// `_total`. The metadata `# HELP / # TYPE / # UNIT` lines MUST
|
|
585
|
+
// name the SAME family identifier the samples use, otherwise
|
|
586
|
+
// strict OpenMetrics parsers reject the family. Derive the
|
|
587
|
+
// exposition name once at the top of the loop so both the
|
|
588
|
+
// metadata lines and the sample lines agree.
|
|
589
|
+
var exposedName = m.name;
|
|
590
|
+
if (openMetrics && m.type === "counter" && !/_total$/.test(m.name)) {
|
|
591
|
+
exposedName = m.name + "_total";
|
|
592
|
+
}
|
|
593
|
+
if (m.help) lines.push("# HELP " + exposedName + " " + m.help);
|
|
594
|
+
lines.push("# TYPE " + exposedName + " " + m.type);
|
|
595
|
+
if (openMetrics && m.unit) lines.push("# UNIT " + exposedName + " " + m.unit);
|
|
562
596
|
var keys = Array.from(m.values.keys()).sort();
|
|
563
597
|
if (m.type === "histogram") {
|
|
564
598
|
for (var k = 0; k < keys.length; k++) {
|
|
565
599
|
var entry = m.values.get(keys[k]);
|
|
566
600
|
for (var bi = 0; bi < m.buckets.length; bi++) {
|
|
567
601
|
var bLabels = Object.assign({}, entry.labels, { le: String(m.buckets[bi]) });
|
|
568
|
-
|
|
602
|
+
var bucketLine = m.name + "_bucket" + _renderLabels(bLabels) + " " + entry.counts[bi];
|
|
603
|
+
// OpenMetrics 1.0 §6.2 — exemplar trace + span IDs appended
|
|
604
|
+
// as `# {trace_id="...",span_id="..."} <value> <timestamp>`.
|
|
605
|
+
if (openMetrics && entry.exemplars && entry.exemplars[bi]) {
|
|
606
|
+
var ex = entry.exemplars[bi];
|
|
607
|
+
bucketLine += " # " + _renderLabels(ex.labels || {}) + " " + ex.value;
|
|
608
|
+
if (ex.timestamp) bucketLine += " " + ex.timestamp;
|
|
609
|
+
}
|
|
610
|
+
lines.push(bucketLine);
|
|
569
611
|
}
|
|
570
612
|
var infLabels = Object.assign({}, entry.labels, { le: "+Inf" });
|
|
571
613
|
lines.push(m.name + "_bucket" + _renderLabels(infLabels) + " " + entry.counts[m.buckets.length]);
|
|
572
614
|
lines.push(m.name + "_sum" + _renderLabels(entry.labels) + " " + entry.sum);
|
|
573
615
|
lines.push(m.name + "_count" + _renderLabels(entry.labels) + " " + entry.count);
|
|
574
616
|
}
|
|
575
|
-
} else {
|
|
617
|
+
} else if (m.type === "counter" && openMetrics) {
|
|
618
|
+
// exposedName already carries the `_total` suffix when needed
|
|
619
|
+
// (derived at the top of the loop so metadata + samples agree).
|
|
576
620
|
for (var v = 0; v < keys.length; v++) {
|
|
577
621
|
var ent = m.values.get(keys[v]);
|
|
578
|
-
lines.push(
|
|
622
|
+
lines.push(exposedName + _renderLabels(ent.labels) + " " + ent.value);
|
|
623
|
+
}
|
|
624
|
+
} else {
|
|
625
|
+
for (var v2 = 0; v2 < keys.length; v2++) {
|
|
626
|
+
var ent2 = m.values.get(keys[v2]);
|
|
627
|
+
lines.push(m.name + _renderLabels(ent2.labels) + " " + ent2.value);
|
|
579
628
|
}
|
|
580
629
|
}
|
|
581
630
|
lines.push("");
|
|
582
631
|
}
|
|
632
|
+
if (openMetrics) lines.push("# EOF");
|
|
583
633
|
return lines.join("\n") + (lines.length ? "" : "\n");
|
|
584
634
|
}
|
|
585
635
|
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* Referrer-Policy: no-referrer — don't leak full URL to outbound links
|
|
11
11
|
* Permissions-Policy — disable common-attack APIs (camera, geolocation, payment, etc.)
|
|
12
12
|
* Cross-Origin-Opener-Policy: same-origin
|
|
13
|
-
* Cross-Origin-Embedder-Policy: require-corp (off by default — breaks images from CDNs
|
|
13
|
+
* Cross-Origin-Embedder-Policy: require-corp / credentialless (off by default — breaks images from CDNs; credentialless is the
|
|
14
|
+
* CR 2024-12 relaxed mode that lets cross-origin no-cors requests load without CORP markers as long as they don't carry credentials)
|
|
14
15
|
* Cross-Origin-Resource-Policy: same-origin
|
|
15
16
|
* Origin-Agent-Cluster: ?1 — origin-keyed agent cluster; extra process isolation
|
|
16
17
|
* X-DNS-Prefetch-Control: off — don't pre-resolve DNS for off-page links
|
package/lib/ssrf-guard.js
CHANGED
|
@@ -717,15 +717,76 @@ function isCloudMetadata(ip) { return classify(ip) === "cloud-metadata"; }
|
|
|
717
717
|
*/
|
|
718
718
|
function isReserved(ip) { return classify(ip) === "reserved"; }
|
|
719
719
|
|
|
720
|
+
/**
|
|
721
|
+
* @primitive b.ssrfGuard.checkUrlTextual
|
|
722
|
+
* @signature b.ssrfGuard.checkUrlTextual(url, opts?)
|
|
723
|
+
* @since 0.11.1
|
|
724
|
+
* @status stable
|
|
725
|
+
* @related b.ssrfGuard.checkUrl
|
|
726
|
+
*
|
|
727
|
+
* Text-only SSRF check for paths where the DNS lookup is
|
|
728
|
+
* intentionally deferred to a downstream resolver (e.g. an outbound
|
|
729
|
+
* HTTP proxy resolving hostnames in its own network context, or a
|
|
730
|
+
* pinned-IP transport that already knows the destination address).
|
|
731
|
+
* The hostname is checked verbatim against the cloud-metadata IP list
|
|
732
|
+
* — those addresses (`169.254.169.254`, `169.254.170.2`,
|
|
733
|
+
* `fd00:ec2::254`) are NEVER overridable, even when
|
|
734
|
+
* `allowInternal: true` and a proxy is configured. Operators short-
|
|
735
|
+
* circuiting the DNS-resolution portion of `checkUrl` MUST still call
|
|
736
|
+
* this primitive so the unconditional metadata-IP block applies at
|
|
737
|
+
* the textual layer.
|
|
738
|
+
*
|
|
739
|
+
* Returns `{ ips: null, host }` on accept. Throws `SsrfError` with
|
|
740
|
+
* `code: "ssrf-guard/blocked-cloud-metadata"` when the hostname is
|
|
741
|
+
* an IP literal matching a known cloud-metadata IP.
|
|
742
|
+
*
|
|
743
|
+
* @opts
|
|
744
|
+
* errorClass?: typeof FrameworkError, // operator-supplied error class for typed refusal
|
|
745
|
+
*
|
|
746
|
+
* @example
|
|
747
|
+
* b.ssrfGuard.checkUrlTextual("http://intranet-app/api");
|
|
748
|
+
* // → { ips: null, host: "intranet-app" }
|
|
749
|
+
*
|
|
750
|
+
* try { b.ssrfGuard.checkUrlTextual("http://169.254.169.254/x"); }
|
|
751
|
+
* catch (e) { e.code; }
|
|
752
|
+
* // → "ssrf-guard/blocked-cloud-metadata"
|
|
753
|
+
*/
|
|
754
|
+
function checkUrlTextual(url, opts) {
|
|
755
|
+
opts = opts || {};
|
|
756
|
+
var ErrorClass = opts.errorClass || SsrfError;
|
|
757
|
+
var parsed = url instanceof URL ? url : safeUrl.parse(String(url), {
|
|
758
|
+
allowedProtocols: safeUrl.ALLOW_HTTP_ALL,
|
|
759
|
+
errorClass: ErrorClass,
|
|
760
|
+
});
|
|
761
|
+
if (!parsed.hostname) {
|
|
762
|
+
throw new ErrorClass("URL '" + parsed.toString() + "' has no hostname",
|
|
763
|
+
"ssrf-guard/no-hostname", { url: parsed.toString() });
|
|
764
|
+
}
|
|
765
|
+
var host = parsed.hostname.replace(/^\[|\]$/g, "");
|
|
766
|
+
// If the textual hostname IS an IP literal AND matches a cloud-
|
|
767
|
+
// metadata IP, refuse — even with `allowInternal: true` and a proxy.
|
|
768
|
+
// Metadata IPs leak instance credentials (AWS IMDS, GCP, Azure) and
|
|
769
|
+
// are not a configuration knob.
|
|
770
|
+
if (net.isIP(host) && CLOUD_METADATA_IPS.indexOf(host) !== -1) {
|
|
771
|
+
throw new ErrorClass(
|
|
772
|
+
"URL '" + parsed.toString() + "' resolves to cloud-metadata IP " + host +
|
|
773
|
+
" — refused unconditionally (not overridable via allowInternal + proxy)",
|
|
774
|
+
"ssrf-guard/blocked-cloud-metadata",
|
|
775
|
+
{ url: parsed.toString(), ip: host, category: "cloud-metadata" });
|
|
776
|
+
}
|
|
777
|
+
return { ips: null, host: host };
|
|
778
|
+
}
|
|
779
|
+
|
|
720
780
|
module.exports = {
|
|
721
|
-
classify:
|
|
722
|
-
cidrContains:
|
|
723
|
-
checkUrl:
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
781
|
+
classify: classify,
|
|
782
|
+
cidrContains: cidrContains,
|
|
783
|
+
checkUrl: checkUrl,
|
|
784
|
+
checkUrlTextual: checkUrlTextual,
|
|
785
|
+
createAllowlist: createAllowlist,
|
|
786
|
+
isPrivate: isPrivate,
|
|
787
|
+
isLoopback: isLoopback,
|
|
788
|
+
isLinkLocal: isLinkLocal,
|
|
789
|
+
isCloudMetadata: isCloudMetadata,
|
|
790
|
+
isReserved: isReserved,
|
|
791
|
+
SsrfError: SsrfError,
|
|
731
792
|
};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.standardWebhooks
|
|
4
|
+
* @nav HTTP
|
|
5
|
+
* @title Standard Webhooks
|
|
6
|
+
* @order 235
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* StandardWebhooks (standardwebhooks.com) signing + verification —
|
|
10
|
+
* the consortium spec (Stripe / Svix / Okta / etc.) for inbound
|
|
11
|
+
* webhook authentication. Three headers:
|
|
12
|
+
*
|
|
13
|
+
* webhook-id ULID-ish unique identifier
|
|
14
|
+
* webhook-timestamp Unix seconds at send time
|
|
15
|
+
* webhook-signature `v1,<base64-of-HMAC-SHA256-result>` (multi-version)
|
|
16
|
+
*
|
|
17
|
+
* The signature payload is `<id>.<timestamp>.<body>`, signed with the
|
|
18
|
+
* shared secret. Verification reproduces the signature and uses
|
|
19
|
+
* `b.crypto.timingSafeEqual` to compare. Skew tolerated within
|
|
20
|
+
* `tolerance` seconds (default 5 minutes).
|
|
21
|
+
*
|
|
22
|
+
* @card
|
|
23
|
+
* StandardWebhooks (standardwebhooks.com) HMAC-SHA256 sign + verify for inbound + outbound webhook delivery. 5-minute skew tolerance + version-prefix header.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
var nodeCrypto = require("node:crypto");
|
|
27
|
+
var validateOpts = require("./validate-opts");
|
|
28
|
+
var numericBounds = require("./numeric-bounds");
|
|
29
|
+
var bCrypto = require("./crypto");
|
|
30
|
+
var C = require("./constants");
|
|
31
|
+
var { defineClass } = require("./framework-error");
|
|
32
|
+
|
|
33
|
+
var StandardWebhooksError = defineClass("StandardWebhooksError", { alwaysPermanent: true });
|
|
34
|
+
|
|
35
|
+
var DEFAULT_TOLERANCE_SEC = 300; // allow:raw-time-literal allow:raw-byte-literal — 5min default per StandardWebhooks §3.2
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @primitive b.standardWebhooks.sign
|
|
39
|
+
* @signature b.standardWebhooks.sign(opts)
|
|
40
|
+
* @since 0.10.16
|
|
41
|
+
* @status stable
|
|
42
|
+
*
|
|
43
|
+
* Build the three StandardWebhooks headers for an outbound delivery.
|
|
44
|
+
* Returns `{ headers, body }` where `body` is the raw request body
|
|
45
|
+
* (operators write that to the wire; the headers go on the request).
|
|
46
|
+
*
|
|
47
|
+
* @opts
|
|
48
|
+
* id: string, // auto-minted if omitted (32-byte random)
|
|
49
|
+
* timestamp: number, // Unix seconds; defaults to now
|
|
50
|
+
* body: Buffer|string, // request body bytes
|
|
51
|
+
* secret: Buffer, // shared secret (>= 32 bytes)
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* var s = b.standardWebhooks.sign({ body: bodyBuf, secret: secret });
|
|
55
|
+
* for (var k in s.headers) req.setHeader(k, s.headers[k]);
|
|
56
|
+
*/
|
|
57
|
+
function sign(opts) {
|
|
58
|
+
opts = validateOpts.requireObject(opts, "standardWebhooks.sign",
|
|
59
|
+
StandardWebhooksError, "standard-webhooks/bad-opts");
|
|
60
|
+
validateOpts(opts, ["id", "timestamp", "body", "secret"], "standardWebhooks.sign");
|
|
61
|
+
if (!Buffer.isBuffer(opts.secret) || opts.secret.length < 32) { // allow:raw-byte-literal — 32-byte HMAC secret floor
|
|
62
|
+
throw new StandardWebhooksError("standard-webhooks/bad-secret",
|
|
63
|
+
"sign: opts.secret must be a Buffer (>= 32 bytes)");
|
|
64
|
+
}
|
|
65
|
+
if (!opts.body || (!Buffer.isBuffer(opts.body) && typeof opts.body !== "string")) {
|
|
66
|
+
throw new StandardWebhooksError("standard-webhooks/bad-body",
|
|
67
|
+
"sign: opts.body must be a non-empty Buffer or string");
|
|
68
|
+
}
|
|
69
|
+
var bodyBuf = Buffer.isBuffer(opts.body) ? opts.body : Buffer.from(opts.body, "utf8");
|
|
70
|
+
var id = opts.id || ("msg_" + bCrypto.generateToken(32)); // allow:raw-byte-literal — 32-char id token
|
|
71
|
+
var timestamp = typeof opts.timestamp === "number"
|
|
72
|
+
? opts.timestamp
|
|
73
|
+
: Math.floor(Date.now() / 1000); // allow:raw-time-literal — wall-clock seconds
|
|
74
|
+
if (timestamp <= 0 || !isFinite(timestamp)) {
|
|
75
|
+
throw new StandardWebhooksError("standard-webhooks/bad-timestamp",
|
|
76
|
+
"sign: timestamp must be a positive finite integer");
|
|
77
|
+
}
|
|
78
|
+
var toSign = id + "." + timestamp + "." + bodyBuf.toString("utf8");
|
|
79
|
+
var sigB64 = nodeCrypto.createHmac("sha256", opts.secret).update(toSign).digest("base64");
|
|
80
|
+
return {
|
|
81
|
+
headers: {
|
|
82
|
+
"webhook-id": id,
|
|
83
|
+
"webhook-timestamp": String(timestamp),
|
|
84
|
+
"webhook-signature": "v1," + sigB64,
|
|
85
|
+
},
|
|
86
|
+
body: bodyBuf,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @primitive b.standardWebhooks.verify
|
|
92
|
+
* @signature b.standardWebhooks.verify(opts)
|
|
93
|
+
* @since 0.10.16
|
|
94
|
+
* @status stable
|
|
95
|
+
*
|
|
96
|
+
* Verify an inbound webhook against the StandardWebhooks spec.
|
|
97
|
+
* Refuses on missing headers, timestamp skew > tolerance, or
|
|
98
|
+
* HMAC mismatch. Returns `{ valid, id, timestamp }`.
|
|
99
|
+
*
|
|
100
|
+
* @opts
|
|
101
|
+
* headers: object, // request headers
|
|
102
|
+
* body: Buffer|string, // raw request body
|
|
103
|
+
* secret: Buffer, // shared secret
|
|
104
|
+
* toleranceSec: number, // default 300s (5 minutes)
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* var v = b.standardWebhooks.verify({
|
|
108
|
+
* headers: req.headers, body: rawBody, secret: secret,
|
|
109
|
+
* });
|
|
110
|
+
* if (!v.valid) throw 401;
|
|
111
|
+
*/
|
|
112
|
+
function verify(opts) {
|
|
113
|
+
opts = validateOpts.requireObject(opts, "standardWebhooks.verify",
|
|
114
|
+
StandardWebhooksError, "standard-webhooks/bad-opts");
|
|
115
|
+
validateOpts(opts, ["headers", "body", "secret", "toleranceSec"],
|
|
116
|
+
"standardWebhooks.verify");
|
|
117
|
+
if (!opts.headers || typeof opts.headers !== "object") {
|
|
118
|
+
throw new StandardWebhooksError("standard-webhooks/bad-headers",
|
|
119
|
+
"verify: opts.headers required");
|
|
120
|
+
}
|
|
121
|
+
if (!Buffer.isBuffer(opts.secret) || opts.secret.length < 32) { // allow:raw-byte-literal — 32-byte HMAC secret floor
|
|
122
|
+
throw new StandardWebhooksError("standard-webhooks/bad-secret",
|
|
123
|
+
"verify: opts.secret must be a Buffer (>= 32 bytes)");
|
|
124
|
+
}
|
|
125
|
+
var bodyBuf = Buffer.isBuffer(opts.body) ? opts.body
|
|
126
|
+
: typeof opts.body === "string" ? Buffer.from(opts.body, "utf8")
|
|
127
|
+
: null;
|
|
128
|
+
if (!bodyBuf) {
|
|
129
|
+
throw new StandardWebhooksError("standard-webhooks/bad-body",
|
|
130
|
+
"verify: opts.body must be a Buffer or string");
|
|
131
|
+
}
|
|
132
|
+
// Normalise header names — case-insensitive.
|
|
133
|
+
var lower = {};
|
|
134
|
+
var keys = Object.keys(opts.headers);
|
|
135
|
+
for (var i = 0; i < keys.length; i += 1) lower[keys[i].toLowerCase()] = opts.headers[keys[i]];
|
|
136
|
+
var id = lower["webhook-id"];
|
|
137
|
+
var tsStr = lower["webhook-timestamp"];
|
|
138
|
+
var sigHeader = lower["webhook-signature"];
|
|
139
|
+
if (!id || !tsStr || !sigHeader) {
|
|
140
|
+
throw new StandardWebhooksError("standard-webhooks/missing-headers",
|
|
141
|
+
"verify: webhook-id / webhook-timestamp / webhook-signature required");
|
|
142
|
+
}
|
|
143
|
+
var ts = parseInt(tsStr, 10);
|
|
144
|
+
if (!isFinite(ts) || ts <= 0) {
|
|
145
|
+
throw new StandardWebhooksError("standard-webhooks/bad-timestamp",
|
|
146
|
+
"verify: webhook-timestamp is not a positive integer");
|
|
147
|
+
}
|
|
148
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.toleranceSec, "toleranceSec",
|
|
149
|
+
StandardWebhooksError, "standard-webhooks/bad-tolerance");
|
|
150
|
+
var tolerance = typeof opts.toleranceSec === "number" ? opts.toleranceSec : DEFAULT_TOLERANCE_SEC;
|
|
151
|
+
var nowSec = Math.floor(Date.now() / 1000); // allow:raw-time-literal — wall-clock seconds
|
|
152
|
+
if (Math.abs(nowSec - ts) > tolerance) {
|
|
153
|
+
throw new StandardWebhooksError("standard-webhooks/timestamp-skew",
|
|
154
|
+
"verify: timestamp skew " + Math.abs(nowSec - ts) + "s exceeds tolerance " + tolerance + "s");
|
|
155
|
+
}
|
|
156
|
+
var toSign = id + "." + ts + "." + bodyBuf.toString("utf8");
|
|
157
|
+
var expected = nodeCrypto.createHmac("sha256", opts.secret).update(toSign).digest("base64");
|
|
158
|
+
// Multi-version: signature header is `v1,<sig> v2,<sig>` etc.
|
|
159
|
+
var parts = sigHeader.split(" ");
|
|
160
|
+
var any = false;
|
|
161
|
+
for (var p = 0; p < parts.length; p += 1) {
|
|
162
|
+
var pair = parts[p].split(",");
|
|
163
|
+
if (pair.length !== 2) continue;
|
|
164
|
+
if (pair[0] !== "v1") continue;
|
|
165
|
+
if (bCrypto.timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(pair[1], "utf8"))) {
|
|
166
|
+
any = true;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!any) {
|
|
171
|
+
throw new StandardWebhooksError("standard-webhooks/bad-signature",
|
|
172
|
+
"verify: no v1 signature matched");
|
|
173
|
+
}
|
|
174
|
+
void C; // module loaded for future tolerance defaulting; not used directly today
|
|
175
|
+
return { valid: true, id: id, timestamp: ts };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = {
|
|
179
|
+
sign: sign,
|
|
180
|
+
verify: verify,
|
|
181
|
+
DEFAULT_TOLERANCE_SEC: DEFAULT_TOLERANCE_SEC,
|
|
182
|
+
StandardWebhooksError: StandardWebhooksError,
|
|
183
|
+
};
|