@blamejs/core 0.8.52 → 0.8.58
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 +6 -0
- package/index.js +8 -0
- package/lib/audit.js +4 -0
- package/lib/auth/fido-mds3.js +624 -0
- package/lib/auth/passkey.js +214 -2
- package/lib/auth-bot-challenge.js +1 -1
- package/lib/credential-hash.js +2 -2
- package/lib/db-collection.js +290 -0
- package/lib/db-query.js +245 -0
- package/lib/db.js +173 -67
- package/lib/framework-error.js +55 -0
- package/lib/guard-cidr.js +2 -1
- package/lib/guard-jwt.js +2 -2
- package/lib/guard-oauth.js +2 -2
- package/lib/http-client-cache.js +916 -0
- package/lib/http-client.js +242 -0
- package/lib/mail-arf.js +343 -0
- package/lib/mail-auth.js +265 -40
- package/lib/mail-bimi.js +948 -33
- package/lib/mail-bounce.js +386 -4
- package/lib/mail-mdn.js +424 -0
- package/lib/mail-unsubscribe.js +265 -25
- package/lib/mail.js +403 -21
- package/lib/middleware/bearer-auth.js +1 -1
- package/lib/middleware/clear-site-data.js +122 -0
- package/lib/middleware/dpop.js +1 -1
- package/lib/middleware/index.js +9 -0
- package/lib/middleware/nel.js +214 -0
- package/lib/middleware/security-headers.js +56 -4
- package/lib/middleware/speculation-rules.js +323 -0
- package/lib/mime-parse.js +198 -0
- package/lib/mtls-ca.js +15 -5
- package/lib/network-dns.js +890 -27
- package/lib/network-tls.js +745 -0
- package/lib/object-store/sigv4.js +54 -0
- package/lib/public-suffix.js +414 -0
- package/lib/safe-buffer.js +7 -0
- package/lib/safe-json.js +1 -1
- package/lib/static.js +120 -0
- package/lib/storage.js +11 -0
- package/lib/vendor/MANIFEST.json +33 -0
- package/lib/vendor/bimi-trust-anchors.pem +33 -0
- package/lib/vendor/public-suffix-list.dat +16376 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/http-client.js
CHANGED
|
@@ -732,6 +732,18 @@ function request(opts) {
|
|
|
732
732
|
"jar must be a b.httpClient.cookieJar.create() instance", true));
|
|
733
733
|
}
|
|
734
734
|
}
|
|
735
|
+
// RFC 9111 outbound HTTP cache. Validate shape at the entry-point;
|
|
736
|
+
// the cache hot path itself is drop-silent (any failure falls back
|
|
737
|
+
// to the network so caching is never a request-failure surface).
|
|
738
|
+
if (opts.cache !== undefined && opts.cache !== null) {
|
|
739
|
+
if (typeof opts.cache !== "object" ||
|
|
740
|
+
typeof opts.cache._lookup !== "function" ||
|
|
741
|
+
typeof opts.cache._evaluateStorage !== "function" ||
|
|
742
|
+
typeof opts.cache._store !== "function") {
|
|
743
|
+
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
744
|
+
"cache must be a b.httpClient.cache.create() instance", true));
|
|
745
|
+
}
|
|
746
|
+
}
|
|
735
747
|
|
|
736
748
|
// before interceptors — run in array order. Each may return a modified
|
|
737
749
|
// opts object (or return nothing to leave the running opts as-is).
|
|
@@ -808,6 +820,14 @@ function request(opts) {
|
|
|
808
820
|
return res;
|
|
809
821
|
}
|
|
810
822
|
|
|
823
|
+
// Cache layer wraps the redirect-aware path. Cache wiring is a no-op
|
|
824
|
+
// for non-GET/HEAD methods (per RFC 9111 §3) and bypassed entirely
|
|
825
|
+
// when the request opts include a body (the request mutates state on
|
|
826
|
+
// the upstream, can't be a cache hit).
|
|
827
|
+
if (opts.cache && _cacheEligibleMethod(opts.method) && opts.body == null) {
|
|
828
|
+
return _runWithCache(opts, maxRedirects, _runAfter);
|
|
829
|
+
}
|
|
830
|
+
|
|
811
831
|
if (maxRedirects === null || maxRedirects === 0) {
|
|
812
832
|
return _requestSingle(opts).then(function (res) { return _runAfter(opts, res); });
|
|
813
833
|
}
|
|
@@ -817,6 +837,228 @@ function request(opts) {
|
|
|
817
837
|
});
|
|
818
838
|
}
|
|
819
839
|
|
|
840
|
+
// Cache-method gate. RFC 9111 §3 — method must be GET or HEAD for the
|
|
841
|
+
// outbound cache to consider a response. Any other method shortcircuits
|
|
842
|
+
// straight to the network path (and operator code that mistakenly
|
|
843
|
+
// passed a cache instance to a POST sees the same network behaviour as
|
|
844
|
+
// without the cache, no surprise).
|
|
845
|
+
function _cacheEligibleMethod(method) {
|
|
846
|
+
var m = String(method || "GET").toUpperCase();
|
|
847
|
+
return m === "GET" || m === "HEAD";
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Wrap an outbound headers object with the framework's cache-decision
|
|
851
|
+
// markers. Mutates a copy; never the original.
|
|
852
|
+
function _withCacheHeaders(res, status, ageSeconds) {
|
|
853
|
+
var headers = Object.assign({}, res.headers || {});
|
|
854
|
+
headers["x-blamejs-cache"] = status;
|
|
855
|
+
if (typeof ageSeconds === "number" && ageSeconds >= 0) {
|
|
856
|
+
headers["age"] = String(Math.floor(ageSeconds));
|
|
857
|
+
}
|
|
858
|
+
return Object.assign({}, res, { headers: headers });
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function _runWithCache(opts, maxRedirects, runAfter) {
|
|
862
|
+
var cache = opts.cache;
|
|
863
|
+
var method = String(opts.method || "GET").toUpperCase();
|
|
864
|
+
var requestHeaders = opts.headers || {};
|
|
865
|
+
var nowMs = Date.now();
|
|
866
|
+
|
|
867
|
+
// 1. Lookup. Cache lookups themselves are drop-silent; on store
|
|
868
|
+
// failure we treat the call as a miss.
|
|
869
|
+
var got = null;
|
|
870
|
+
try { got = cache._lookup(method, opts.url, requestHeaders); }
|
|
871
|
+
catch (_e) { got = null; }
|
|
872
|
+
|
|
873
|
+
function _doNetwork(extraReqHeaders) {
|
|
874
|
+
var nextOpts = opts;
|
|
875
|
+
if (extraReqHeaders) {
|
|
876
|
+
nextOpts = Object.assign({}, opts, {
|
|
877
|
+
headers: Object.assign({}, opts.headers || {}, extraReqHeaders),
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
if (maxRedirects === null || maxRedirects === 0) {
|
|
881
|
+
return _requestSingle(nextOpts).then(function (res) {
|
|
882
|
+
return { finalOpts: nextOpts, res: res };
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
return _requestWithRedirects(nextOpts, maxRedirects);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// 2. Miss → network → maybe store.
|
|
889
|
+
if (!got) {
|
|
890
|
+
try { cache._emit("httpclient.cache.miss", "allowed", { url: String(opts.url), method: method }); }
|
|
891
|
+
catch (_e) { /* drop-silent */ }
|
|
892
|
+
try { cache._obsEvent("httpclient.cache.miss", 1, { method: method }); }
|
|
893
|
+
catch (_e) { /* drop-silent */ }
|
|
894
|
+
return _doNetwork(null).then(function (boxed) {
|
|
895
|
+
_maybeStore(cache, method, opts.url, requestHeaders, boxed.res);
|
|
896
|
+
return runAfter(boxed.finalOpts, _withCacheHeaders(boxed.res, "MISS"));
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// 3. Hit. Decide fresh / stale / revalidate.
|
|
901
|
+
var entry = got.entry;
|
|
902
|
+
var evaluation;
|
|
903
|
+
try { evaluation = cache._evaluateStored(entry, nowMs); }
|
|
904
|
+
catch (_e) {
|
|
905
|
+
// Malformed entry — drop it, treat as miss.
|
|
906
|
+
try { cache.invalidate(method, opts.url, requestHeaders); }
|
|
907
|
+
catch (_e2) { /* drop-silent */ }
|
|
908
|
+
return _doNetwork(null).then(function (boxed) {
|
|
909
|
+
_maybeStore(cache, method, opts.url, requestHeaders, boxed.res);
|
|
910
|
+
return runAfter(boxed.finalOpts, _withCacheHeaders(boxed.res, "MISS"));
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (evaluation.fresh && !evaluation.mustRevalidate) {
|
|
915
|
+
var age = cache._serveAgeSeconds(entry, nowMs);
|
|
916
|
+
try { cache._emit("httpclient.cache.hit", "allowed", { url: String(opts.url), method: method, ageMs: evaluation.ageMs }); }
|
|
917
|
+
catch (_e) { /* drop-silent */ }
|
|
918
|
+
try { cache._obsEvent("httpclient.cache.hit", 1, { method: method }); }
|
|
919
|
+
catch (_e) { /* drop-silent */ }
|
|
920
|
+
var hitRes = {
|
|
921
|
+
statusCode: entry.statusCode,
|
|
922
|
+
headers: Object.assign({}, entry.headers),
|
|
923
|
+
body: Buffer.isBuffer(entry.body) ? Buffer.from(entry.body) : entry.body,
|
|
924
|
+
cacheStatus: "HIT",
|
|
925
|
+
};
|
|
926
|
+
return Promise.resolve(runAfter(opts, _withCacheHeaders(hitRes, "HIT", age)));
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// 4. Stale or must-revalidate. Within stale-while-revalidate or
|
|
930
|
+
// defaultMaxStale grace, we serve stale + kick off background
|
|
931
|
+
// revalidation. Otherwise we revalidate inline.
|
|
932
|
+
var ageOverFresh = Math.max(0, evaluation.ageMs - evaluation.freshnessMs);
|
|
933
|
+
var swrApplies = !evaluation.mustRevalidate &&
|
|
934
|
+
ageOverFresh < Math.max(evaluation.swrWindowMs, evaluation.defaultStaleMs);
|
|
935
|
+
|
|
936
|
+
if (swrApplies && cache.revalidateInBackground) {
|
|
937
|
+
// Serve stale immediately, kick off background revalidation. We
|
|
938
|
+
// explicitly DON'T await the background revalidation Promise so
|
|
939
|
+
// the caller gets the stale response immediately. We also catch
|
|
940
|
+
// its error so an unhandled rejection doesn't escape.
|
|
941
|
+
var ageStale = cache._serveAgeSeconds(entry, nowMs);
|
|
942
|
+
try { cache._emit("httpclient.cache.stale", "allowed", { url: String(opts.url), method: method, ageMs: evaluation.ageMs, mode: "swr" }); }
|
|
943
|
+
catch (_e) { /* drop-silent */ }
|
|
944
|
+
try { cache._obsEvent("httpclient.cache.stale", 1, { method: method, mode: "swr" }); }
|
|
945
|
+
catch (_e) { /* drop-silent */ }
|
|
946
|
+
var staleRes = {
|
|
947
|
+
statusCode: entry.statusCode,
|
|
948
|
+
headers: Object.assign({}, entry.headers),
|
|
949
|
+
body: Buffer.isBuffer(entry.body) ? Buffer.from(entry.body) : entry.body,
|
|
950
|
+
cacheStatus: "STALE",
|
|
951
|
+
};
|
|
952
|
+
// Background revalidation — fire-and-forget, errors swallowed (the
|
|
953
|
+
// next caller observes the stale entry until either the upstream
|
|
954
|
+
// recovers or stale-if-error / s-w-r windows expire).
|
|
955
|
+
setImmediate(function () {
|
|
956
|
+
_revalidate(cache, method, opts, entry, requestHeaders).catch(function () {
|
|
957
|
+
/* background revalidation best-effort; swallow */
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
return Promise.resolve(runAfter(opts, _withCacheHeaders(staleRes, "STALE", ageStale)));
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// 5. Inline conditional revalidation. Build If-None-Match /
|
|
964
|
+
// If-Modified-Since from the stored entry, fire the network
|
|
965
|
+
// request, branch on 304 vs anything-else.
|
|
966
|
+
return _revalidate(cache, method, opts, entry, requestHeaders).then(function (rev) {
|
|
967
|
+
if (rev.kind === "not-modified") {
|
|
968
|
+
var ageRev = cache._serveAgeSeconds(rev.refreshed || entry, Date.now());
|
|
969
|
+
var revRes = {
|
|
970
|
+
statusCode: (rev.refreshed || entry).statusCode,
|
|
971
|
+
headers: Object.assign({}, (rev.refreshed || entry).headers),
|
|
972
|
+
body: Buffer.isBuffer((rev.refreshed || entry).body)
|
|
973
|
+
? Buffer.from((rev.refreshed || entry).body)
|
|
974
|
+
: (rev.refreshed || entry).body,
|
|
975
|
+
cacheStatus: "REVALIDATED",
|
|
976
|
+
};
|
|
977
|
+
return runAfter(opts, _withCacheHeaders(revRes, "REVALIDATED", ageRev));
|
|
978
|
+
}
|
|
979
|
+
if (rev.kind === "fresh-response") {
|
|
980
|
+
_maybeStore(cache, method, opts.url, requestHeaders, rev.res);
|
|
981
|
+
return runAfter(rev.finalOpts || opts, _withCacheHeaders(rev.res, "MISS"));
|
|
982
|
+
}
|
|
983
|
+
// rev.kind === "error" — try stale-if-error.
|
|
984
|
+
var sieMs = (evaluation.sieWindowMs || 0);
|
|
985
|
+
if (sieMs > 0 && ageOverFresh < sieMs) {
|
|
986
|
+
var ageErr = cache._serveAgeSeconds(entry, Date.now());
|
|
987
|
+
try { cache._emit("httpclient.cache.stale", "allowed", { url: String(opts.url), method: method, ageMs: evaluation.ageMs, mode: "sie", error: rev.error && rev.error.message }); }
|
|
988
|
+
catch (_e) { /* drop-silent */ }
|
|
989
|
+
try { cache._obsEvent("httpclient.cache.stale", 1, { method: method, mode: "sie" }); }
|
|
990
|
+
catch (_e) { /* drop-silent */ }
|
|
991
|
+
var sieRes = {
|
|
992
|
+
statusCode: entry.statusCode,
|
|
993
|
+
headers: Object.assign({}, entry.headers),
|
|
994
|
+
body: Buffer.isBuffer(entry.body) ? Buffer.from(entry.body) : entry.body,
|
|
995
|
+
cacheStatus: "STALE",
|
|
996
|
+
};
|
|
997
|
+
return runAfter(opts, _withCacheHeaders(sieRes, "STALE", ageErr));
|
|
998
|
+
}
|
|
999
|
+
return Promise.reject(rev.error);
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// Build conditional headers for revalidation per RFC 9110 §13.
|
|
1004
|
+
function _conditionalHeaders(entry) {
|
|
1005
|
+
var out = {};
|
|
1006
|
+
if (entry.etag) out["If-None-Match"] = entry.etag;
|
|
1007
|
+
if (entry.lastModified) out["If-Modified-Since"] = entry.lastModified;
|
|
1008
|
+
return out;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Run a revalidation request. Returns one of:
|
|
1012
|
+
// { kind: "not-modified", refreshed } — upstream returned 304
|
|
1013
|
+
// { kind: "fresh-response", res, finalOpts } — upstream returned 2xx/...
|
|
1014
|
+
// { kind: "error", error } — network or upstream error
|
|
1015
|
+
function _revalidate(cache, method, opts, entry, requestHeaders) {
|
|
1016
|
+
var conditional = _conditionalHeaders(entry);
|
|
1017
|
+
var nextOpts = Object.assign({}, opts, {
|
|
1018
|
+
headers: Object.assign({}, requestHeaders, conditional),
|
|
1019
|
+
// Stream-mode bypass: revalidation always uses buffer mode so the
|
|
1020
|
+
// 304 / fresh-response branches both have buffered body in hand
|
|
1021
|
+
// ready to merge / store.
|
|
1022
|
+
responseMode: "always-resolve",
|
|
1023
|
+
// Ensure we don't recurse into the cache layer on the revalidation
|
|
1024
|
+
// request itself. Pass cache as null/undefined.
|
|
1025
|
+
cache: undefined,
|
|
1026
|
+
});
|
|
1027
|
+
var maxRedirects = (opts.maxRedirects === undefined || opts.maxRedirects === null)
|
|
1028
|
+
? null : opts.maxRedirects;
|
|
1029
|
+
var p = (maxRedirects === null || maxRedirects === 0)
|
|
1030
|
+
? _requestSingle(nextOpts).then(function (res) { return { finalOpts: nextOpts, res: res }; })
|
|
1031
|
+
: _requestWithRedirects(nextOpts, maxRedirects);
|
|
1032
|
+
|
|
1033
|
+
return p.then(function (boxed) {
|
|
1034
|
+
var res = boxed.res;
|
|
1035
|
+
if (res.statusCode === 304) { // allow:raw-byte-literal — HTTP 304 Not Modified status code, not bytes
|
|
1036
|
+
// Merge 304 headers into the stored entry.
|
|
1037
|
+
var refreshed;
|
|
1038
|
+
try { refreshed = cache._refreshFrom304(entry, res.headers); }
|
|
1039
|
+
catch (_e) { refreshed = entry; }
|
|
1040
|
+
try { cache._emit("httpclient.cache.revalidated", "allowed", { url: String(opts.url), method: method }); }
|
|
1041
|
+
catch (_e) { /* drop-silent */ }
|
|
1042
|
+
try { cache._obsEvent("httpclient.cache.revalidated", 1, { method: method }); }
|
|
1043
|
+
catch (_e) { /* drop-silent */ }
|
|
1044
|
+
return { kind: "not-modified", refreshed: refreshed };
|
|
1045
|
+
}
|
|
1046
|
+
return { kind: "fresh-response", res: res, finalOpts: boxed.finalOpts };
|
|
1047
|
+
}, function (err) {
|
|
1048
|
+
return { kind: "error", error: err };
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Decide whether to store, then store. Drop-silent on any internal
|
|
1053
|
+
// throw so caching cannot surface as a request failure.
|
|
1054
|
+
function _maybeStore(cache, method, url, requestHeaders, res) {
|
|
1055
|
+
try {
|
|
1056
|
+
var evaluation = cache._evaluateStorage(method, res.statusCode, res.headers || {});
|
|
1057
|
+
if (!evaluation.cacheable) return;
|
|
1058
|
+
cache._store(method, url, requestHeaders, res.statusCode, res.headers || {}, res.body, evaluation);
|
|
1059
|
+
} catch (_e) { /* drop-silent — caching never breaks the request */ }
|
|
1060
|
+
}
|
|
1061
|
+
|
|
820
1062
|
function _requestWithRedirects(opts, hopsLeft) {
|
|
821
1063
|
var originalUrl = opts.url;
|
|
822
1064
|
var originalOrigin = null;
|
package/lib/mail-arf.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.mailArf
|
|
4
|
+
* @nav Communication
|
|
5
|
+
* @title Mail ARF
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* RFC 5965 Abuse Reporting Format ingest. ESPs (Yahoo, AOL,
|
|
9
|
+
* Microsoft, Google, etc.) post these via webhook when a user marks
|
|
10
|
+
* one of the operator's messages as spam. The format is
|
|
11
|
+
* multipart/report with three required parts:
|
|
12
|
+
*
|
|
13
|
+
* 1. text/plain — human-readable description (ignored)
|
|
14
|
+
* 2. message/feedback-report — the structured report itself, a
|
|
15
|
+
* block of header:value lines (Feedback-Type, User-Agent,
|
|
16
|
+
* Original-Mail-From, Source-IP, Reported-Domain, Arrival-Date,
|
|
17
|
+
* Authentication-Results, Auth-Failure, etc.)
|
|
18
|
+
* 3. message/rfc822 (or text/rfc822-headers) — the original message
|
|
19
|
+
* being reported, in full or just the headers
|
|
20
|
+
*
|
|
21
|
+
* `b.mailArf.parse` consumes the raw multipart/report bytes and
|
|
22
|
+
* returns a normalized event shape suitable for an
|
|
23
|
+
* abuse-reconciliation pipeline (suppression list, abuse-score
|
|
24
|
+
* tracking, complaint-rate dashboards). Required fields per
|
|
25
|
+
* RFC 5965 §3.1 are Feedback-Type and User-Agent — `parse` refuses
|
|
26
|
+
* anything missing them. Reports without a `message/feedback-report`
|
|
27
|
+
* subpart are also refused.
|
|
28
|
+
*
|
|
29
|
+
* This is a parse-only primitive — operators wire it into their
|
|
30
|
+
* own webhook endpoint and emit the audit trail / suppression-list
|
|
31
|
+
* updates from there. The framework's `b.mailBounce.handler` is the
|
|
32
|
+
* reference shape for the surrounding plumbing; ARF rides
|
|
33
|
+
* alongside it because the wire format and lifecycle differ
|
|
34
|
+
* (multipart/report vs JSON; no vendor-specific parser needed).
|
|
35
|
+
*
|
|
36
|
+
* @card
|
|
37
|
+
* RFC 5965 ARF (Abuse Reporting Format) ingest — parse a message/feedback-report multipart payload from an ESP's user-marked-as-spam webhook into a structured event for the suppression pipeline.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
var lazyRequire = require("./lazy-require");
|
|
41
|
+
var mimeParse = require("./mime-parse");
|
|
42
|
+
var C = require("./constants");
|
|
43
|
+
var { MailArfError } = require("./framework-error");
|
|
44
|
+
|
|
45
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
46
|
+
|
|
47
|
+
// RFC 5965 ceilings — a feedback report is small in practice (a few
|
|
48
|
+
// kilobytes plus the original message, which is itself capped by the
|
|
49
|
+
// MTA's max-message-size). 8 MiB matches the b.mail.dmarc aggregate
|
|
50
|
+
// report cap so operators have one mental model for "what fits".
|
|
51
|
+
var ARF_MAX_REPORT_BYTES = C.BYTES.mib(8);
|
|
52
|
+
|
|
53
|
+
// RFC 5965 §3.1 — required fields. The spec lists Feedback-Type +
|
|
54
|
+
// User-Agent + Version as REQUIRED; we match major ESPs by also
|
|
55
|
+
// requiring Feedback-Type and User-Agent (Version defaults to 1 when
|
|
56
|
+
// omitted, which is what every real-world report sends).
|
|
57
|
+
var ARF_REQUIRED_FIELDS = ["feedback-type", "user-agent"];
|
|
58
|
+
|
|
59
|
+
// RFC 5965 §3.1 — Feedback-Type registry. Unknown values pass through
|
|
60
|
+
// (the IANA registry grows; this list documents the spec's launch
|
|
61
|
+
// vocabulary so operators can route on it).
|
|
62
|
+
var ARF_KNOWN_FEEDBACK_TYPES = {
|
|
63
|
+
abuse: 1,
|
|
64
|
+
"auth-failure": 1,
|
|
65
|
+
fraud: 1,
|
|
66
|
+
"not-spam": 1,
|
|
67
|
+
other: 1,
|
|
68
|
+
virus: 1,
|
|
69
|
+
// RFC 6650 (post-launch) — list-unsubscribe complaint feedback loop.
|
|
70
|
+
"opt-out": 1,
|
|
71
|
+
"opt-out-list": 1,
|
|
72
|
+
};
|
|
73
|
+
void ARF_KNOWN_FEEDBACK_TYPES;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @primitive b.mailArf.parse
|
|
77
|
+
* @signature b.mailArf.parse(rawMessage, opts)
|
|
78
|
+
* @since 0.8.53
|
|
79
|
+
* @status stable
|
|
80
|
+
* @related b.mailBounce.parse, b.mailBounce.handler
|
|
81
|
+
*
|
|
82
|
+
* Parse an RFC 5965 Abuse Reporting Format multipart/report payload
|
|
83
|
+
* into a normalized abuse-event shape. Refuses on missing
|
|
84
|
+
* `message/feedback-report` subpart, missing required `Feedback-Type`
|
|
85
|
+
* or `User-Agent` fields, or report bytes exceeding the 8 MiB ceiling.
|
|
86
|
+
*
|
|
87
|
+
* Returns:
|
|
88
|
+
*
|
|
89
|
+
* {
|
|
90
|
+
* feedbackType, // "abuse" | "auth-failure" | "fraud" | …
|
|
91
|
+
* userAgent, // "SomeESP-Feedback/1.0"
|
|
92
|
+
* version, // "1" (default) — per RFC 5965 §3.1
|
|
93
|
+
* originalFrom, // string — Original-Mail-From
|
|
94
|
+
* originalRcptTo, // [string] — every Original-Rcpt-To
|
|
95
|
+
* arrivalDate, // ISO 8601 string when parseable, else raw
|
|
96
|
+
* reportedDomain, // string — Reported-Domain
|
|
97
|
+
* sourceIp, // string — Source-IP
|
|
98
|
+
* authenticationResults, // string — verbatim Authentication-Results
|
|
99
|
+
* authFailure, // "dkim" | "spf" | "dmarc" | … (optional)
|
|
100
|
+
* reportedUri, // string — Reported-URI (phishing reports)
|
|
101
|
+
* incidents, // number — Incidents (when present)
|
|
102
|
+
* originalMessage, // string — the message/rfc822 part body
|
|
103
|
+
* extraFields, // { [name: string]: string } — operator-
|
|
104
|
+
* // visible fields the spec doesn't
|
|
105
|
+
* // normalize
|
|
106
|
+
* }
|
|
107
|
+
*
|
|
108
|
+
* Audit emission: the framework emits `system.mailarf.parsed` on
|
|
109
|
+
* success and `system.mailarf.malformed` on refusal. Operators wire
|
|
110
|
+
* `audit: false` to suppress when the upstream webhook handler emits
|
|
111
|
+
* its own audit row.
|
|
112
|
+
*
|
|
113
|
+
* @opts
|
|
114
|
+
* maxBytes: number, // default: 8 MiB
|
|
115
|
+
* audit: boolean, // default: true
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* var b = require("@blamejs/core");
|
|
119
|
+
* var event = b.mailArf.parse(rawWebhookBody);
|
|
120
|
+
* if (event.feedbackType === "abuse") suppressionList.add(event.originalFrom);
|
|
121
|
+
* // → typeof event.userAgent === "string"
|
|
122
|
+
*/
|
|
123
|
+
function parse(rawMessage, opts) {
|
|
124
|
+
opts = opts || {};
|
|
125
|
+
var auditOn = opts.audit !== false;
|
|
126
|
+
var maxBytes = (typeof opts.maxBytes === "number" && isFinite(opts.maxBytes) &&
|
|
127
|
+
opts.maxBytes > 0)
|
|
128
|
+
? opts.maxBytes
|
|
129
|
+
: ARF_MAX_REPORT_BYTES;
|
|
130
|
+
|
|
131
|
+
if (typeof rawMessage !== "string" && !Buffer.isBuffer(rawMessage)) {
|
|
132
|
+
_emitMalformed(auditOn, "input must be string or Buffer");
|
|
133
|
+
throw new MailArfError("mailarf/parse-failed",
|
|
134
|
+
"mailArf.parse: rawMessage must be a string or Buffer");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
var asString = Buffer.isBuffer(rawMessage)
|
|
138
|
+
? rawMessage.toString("utf8")
|
|
139
|
+
: rawMessage;
|
|
140
|
+
|
|
141
|
+
if (asString.length > maxBytes) {
|
|
142
|
+
_emitMalformed(auditOn, "report exceeds maxBytes");
|
|
143
|
+
throw new MailArfError("mailarf/parse-failed",
|
|
144
|
+
"mailArf.parse: report exceeds " + maxBytes + " bytes (got " + asString.length + ")");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 1. Bisect headers / body, parse Content-Type for boundary.
|
|
148
|
+
var top;
|
|
149
|
+
try { top = mimeParse.splitHeadersAndBody(asString); }
|
|
150
|
+
catch (e) {
|
|
151
|
+
_emitMalformed(auditOn, "split-failed");
|
|
152
|
+
throw new MailArfError("mailarf/parse-failed",
|
|
153
|
+
"mailArf.parse: header/body split failed: " + ((e && e.message) || String(e)));
|
|
154
|
+
}
|
|
155
|
+
var ctRaw = mimeParse.findHeader(top.headers, "Content-Type") || "";
|
|
156
|
+
var ct = mimeParse.parseContentType(ctRaw);
|
|
157
|
+
if (ct.type !== "multipart/report") {
|
|
158
|
+
_emitMalformed(auditOn, "wrong-content-type");
|
|
159
|
+
throw new MailArfError("mailarf/parse-failed",
|
|
160
|
+
"mailArf.parse: top-level Content-Type must be multipart/report (got '" + ct.type + "')");
|
|
161
|
+
}
|
|
162
|
+
// RFC 5965 §2 — multipart/report must carry report-type=feedback-report.
|
|
163
|
+
// Tolerate omitted report-type for shipping ESPs that send a plain
|
|
164
|
+
// multipart/report; refuse mismatched values.
|
|
165
|
+
if (ct.params["report-type"] && ct.params["report-type"].toLowerCase() !== "feedback-report") {
|
|
166
|
+
_emitMalformed(auditOn, "wrong-report-type");
|
|
167
|
+
throw new MailArfError("mailarf/parse-failed",
|
|
168
|
+
"mailArf.parse: report-type must be feedback-report (got '" +
|
|
169
|
+
ct.params["report-type"] + "')");
|
|
170
|
+
}
|
|
171
|
+
if (!ct.params.boundary) {
|
|
172
|
+
_emitMalformed(auditOn, "missing-boundary");
|
|
173
|
+
throw new MailArfError("mailarf/parse-failed",
|
|
174
|
+
"mailArf.parse: multipart/report Content-Type lacks boundary parameter");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 2. Walk the multipart body. Find the message/feedback-report part
|
|
178
|
+
// and (optionally) the message/rfc822 / text/rfc822-headers part.
|
|
179
|
+
var parts = mimeParse.splitMimeParts(top.body, ct.params.boundary);
|
|
180
|
+
if (parts.length === 0) {
|
|
181
|
+
_emitMalformed(auditOn, "no-parts");
|
|
182
|
+
throw new MailArfError("mailarf/parse-failed",
|
|
183
|
+
"mailArf.parse: multipart/report body contains no parts");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
var feedbackPart = null;
|
|
187
|
+
var originalPart = null;
|
|
188
|
+
for (var pi = 0; pi < parts.length; pi += 1) {
|
|
189
|
+
var partRaw = parts[pi];
|
|
190
|
+
var split;
|
|
191
|
+
try { split = mimeParse.splitHeadersAndBody(partRaw); }
|
|
192
|
+
catch (_e) { continue; }
|
|
193
|
+
var partCt = mimeParse.parseContentType(
|
|
194
|
+
mimeParse.findHeader(split.headers, "Content-Type") || ""
|
|
195
|
+
);
|
|
196
|
+
if (partCt.type === "message/feedback-report" && !feedbackPart) {
|
|
197
|
+
feedbackPart = split;
|
|
198
|
+
} else if ((partCt.type === "message/rfc822" ||
|
|
199
|
+
partCt.type === "text/rfc822-headers") && !originalPart) {
|
|
200
|
+
originalPart = split;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!feedbackPart) {
|
|
205
|
+
_emitMalformed(auditOn, "missing-feedback-report-part");
|
|
206
|
+
throw new MailArfError("mailarf/parse-failed",
|
|
207
|
+
"mailArf.parse: missing message/feedback-report subpart (RFC 5965 §2)");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 3. Parse the feedback-report body — header:value lines per
|
|
211
|
+
// RFC 5965 §3.1. The body itself is structured headers, NOT
|
|
212
|
+
// HTML / JSON / free-form prose.
|
|
213
|
+
var reportFields = mimeParse.parseHeaderBlock(feedbackPart.body);
|
|
214
|
+
var fieldMap = {};
|
|
215
|
+
var extraFields = {};
|
|
216
|
+
// RFC 5965 §3.1 normalizes some fields; everything else passes
|
|
217
|
+
// through as extraFields so operators with vendor-specific tags
|
|
218
|
+
// (X-HmailServer-…, X-Yahoo-Newman-Property) don't lose them.
|
|
219
|
+
var KNOWN_FIELDS = {
|
|
220
|
+
"feedback-type": "feedbackType",
|
|
221
|
+
"user-agent": "userAgent",
|
|
222
|
+
"version": "version",
|
|
223
|
+
"original-mail-from": "originalFrom",
|
|
224
|
+
"original-rcpt-to": "originalRcptTo",
|
|
225
|
+
"arrival-date": "arrivalDate",
|
|
226
|
+
"reported-domain": "reportedDomain",
|
|
227
|
+
"source-ip": "sourceIp",
|
|
228
|
+
"authentication-results":"authenticationResults",
|
|
229
|
+
"auth-failure": "authFailure",
|
|
230
|
+
"reported-uri": "reportedUri",
|
|
231
|
+
"incidents": "incidents",
|
|
232
|
+
"delivery-result": "deliveryResult",
|
|
233
|
+
"original-envelope-id": "originalEnvelopeId",
|
|
234
|
+
};
|
|
235
|
+
for (var fi = 0; fi < reportFields.length; fi += 1) {
|
|
236
|
+
var f = reportFields[fi];
|
|
237
|
+
if (!f || !f.name) continue;
|
|
238
|
+
var lcName = f.name.toLowerCase();
|
|
239
|
+
fieldMap[lcName] = f.value;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 4. Required fields (RFC 5965 §3.1).
|
|
243
|
+
for (var ri = 0; ri < ARF_REQUIRED_FIELDS.length; ri += 1) {
|
|
244
|
+
var req = ARF_REQUIRED_FIELDS[ri];
|
|
245
|
+
if (typeof fieldMap[req] !== "string" || fieldMap[req].length === 0) {
|
|
246
|
+
_emitMalformed(auditOn, "missing-" + req);
|
|
247
|
+
throw new MailArfError("mailarf/missing-required-field",
|
|
248
|
+
"mailArf.parse: required field '" + req + "' is missing");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 5. Build the normalized shape.
|
|
253
|
+
var rcptToList = [];
|
|
254
|
+
for (var di = 0; di < reportFields.length; di += 1) {
|
|
255
|
+
var df = reportFields[di];
|
|
256
|
+
if (df && df.name && df.name.toLowerCase() === "original-rcpt-to") {
|
|
257
|
+
rcptToList.push(df.value);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
var arrivalRaw = fieldMap["arrival-date"] || null;
|
|
261
|
+
var arrivalIso = null;
|
|
262
|
+
if (arrivalRaw) {
|
|
263
|
+
var d = new Date(arrivalRaw);
|
|
264
|
+
if (!isNaN(d.getTime())) arrivalIso = d.toISOString();
|
|
265
|
+
}
|
|
266
|
+
var incidentsRaw = fieldMap.incidents;
|
|
267
|
+
var incidents = null;
|
|
268
|
+
if (typeof incidentsRaw === "string") {
|
|
269
|
+
var parsed = parseInt(incidentsRaw, 10);
|
|
270
|
+
if (isFinite(parsed) && parsed >= 0) incidents = parsed;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Surface non-normalized fields under extraFields for operator
|
|
274
|
+
// visibility (X-* tags ESPs add for routing diagnostics, etc.).
|
|
275
|
+
Object.keys(fieldMap).forEach(function (k) {
|
|
276
|
+
if (!KNOWN_FIELDS[k]) extraFields[k] = fieldMap[k];
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
var event = {
|
|
280
|
+
feedbackType: fieldMap["feedback-type"],
|
|
281
|
+
userAgent: fieldMap["user-agent"],
|
|
282
|
+
version: fieldMap.version || "1",
|
|
283
|
+
originalFrom: fieldMap["original-mail-from"] || null,
|
|
284
|
+
originalRcptTo: rcptToList,
|
|
285
|
+
arrivalDate: arrivalIso || arrivalRaw,
|
|
286
|
+
reportedDomain: fieldMap["reported-domain"] || null,
|
|
287
|
+
sourceIp: fieldMap["source-ip"] || null,
|
|
288
|
+
authenticationResults: fieldMap["authentication-results"] || null,
|
|
289
|
+
authFailure: fieldMap["auth-failure"] || null,
|
|
290
|
+
reportedUri: fieldMap["reported-uri"] || null,
|
|
291
|
+
incidents: incidents,
|
|
292
|
+
originalMessage: originalPart
|
|
293
|
+
? (originalPart.body || _reassemblePart(originalPart))
|
|
294
|
+
: null,
|
|
295
|
+
extraFields: extraFields,
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
if (auditOn) {
|
|
299
|
+
try {
|
|
300
|
+
audit().safeEmit({
|
|
301
|
+
action: "system.mailarf.parsed",
|
|
302
|
+
outcome: "success",
|
|
303
|
+
metadata: {
|
|
304
|
+
feedbackType: event.feedbackType,
|
|
305
|
+
userAgent: event.userAgent,
|
|
306
|
+
reportedDomain: event.reportedDomain,
|
|
307
|
+
sourceIp: event.sourceIp,
|
|
308
|
+
authFailure: event.authFailure,
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
} catch (_e) { /* drop-silent — by design */ }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return event;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function _reassemblePart(part) {
|
|
318
|
+
// Reassemble headers + body for callers that want the full original
|
|
319
|
+
// message bytes (some ESPs strip the body and ship just headers).
|
|
320
|
+
var hdrs = "";
|
|
321
|
+
for (var i = 0; i < part.headers.length; i += 1) {
|
|
322
|
+
hdrs += part.headers[i].name + ": " + part.headers[i].value + "\r\n";
|
|
323
|
+
}
|
|
324
|
+
return hdrs + "\r\n" + (part.body || "");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function _emitMalformed(auditOn, reason) {
|
|
328
|
+
if (!auditOn) return;
|
|
329
|
+
try {
|
|
330
|
+
audit().safeEmit({
|
|
331
|
+
action: "system.mailarf.malformed",
|
|
332
|
+
outcome: "denied",
|
|
333
|
+
metadata: { reason: reason },
|
|
334
|
+
});
|
|
335
|
+
} catch (_e) { /* drop-silent — by design */ }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
module.exports = {
|
|
339
|
+
parse: parse,
|
|
340
|
+
MailArfError: MailArfError,
|
|
341
|
+
ARF_MAX_REPORT_BYTES: ARF_MAX_REPORT_BYTES,
|
|
342
|
+
ARF_REQUIRED_FIELDS: ARF_REQUIRED_FIELDS,
|
|
343
|
+
};
|