@blamejs/core 0.14.16 → 0.14.18
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 +2 -2
- package/lib/agent-orchestrator.js +10 -4
- package/lib/ai-prompt.js +1 -1
- package/lib/app-shutdown.js +28 -0
- package/lib/archive-read.js +215 -16
- package/lib/breach-deadline.js +166 -1
- package/lib/cloud-events.js +3 -1
- package/lib/codepoint-class.js +21 -0
- package/lib/db-schema.js +120 -3
- package/lib/db.js +10 -3
- package/lib/error-page.js +93 -9
- package/lib/external-db.js +164 -13
- package/lib/guard-email.js +36 -3
- package/lib/http-client.js +37 -7
- package/lib/mail-send-deliver.js +15 -5
- package/lib/mail-sieve.js +2 -1
- package/lib/middleware/ai-act-disclosure.js +88 -19
- package/lib/middleware/api-encrypt.js +58 -11
- package/lib/middleware/asyncapi-serve.js +56 -4
- package/lib/middleware/attach-user.js +45 -10
- package/lib/middleware/body-parser.js +70 -14
- package/lib/middleware/csp-report.js +30 -2
- package/lib/middleware/deny-response.js +29 -9
- package/lib/middleware/openapi-serve.js +56 -4
- package/lib/middleware/scim-server.js +7 -4
- package/lib/problem-details.js +15 -3
- package/lib/queue-local.js +148 -38
- package/lib/queue.js +41 -11
- package/lib/render.js +21 -3
- package/lib/router.js +13 -6
- package/lib/safe-buffer.js +55 -0
- package/lib/sse.js +7 -5
- package/lib/static.js +46 -17
- package/lib/uri-template.js +3 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/external-db.js
CHANGED
|
@@ -44,6 +44,7 @@ var lazyRequire = require("./lazy-require");
|
|
|
44
44
|
var { boot } = require("./log");
|
|
45
45
|
var safeAsync = require("./safe-async");
|
|
46
46
|
var safeSql = require("./safe-sql");
|
|
47
|
+
var validateOpts = require("./validate-opts");
|
|
47
48
|
var { ExternalDbError } = require("./framework-error");
|
|
48
49
|
|
|
49
50
|
var log = boot("external-db");
|
|
@@ -61,7 +62,13 @@ function _emitMetric(name, value, labels) {
|
|
|
61
62
|
// the leading keyword only so an attacker-controlled trailing fragment
|
|
62
63
|
// can't smuggle a false classification. Skips leading whitespace plus
|
|
63
64
|
// SQL line / block comments before reading the keyword.
|
|
64
|
-
|
|
65
|
+
// Linear (non-backtracking) comment/whitespace skip: each iteration of
|
|
66
|
+
// the outer group consumes exactly one whitespace char, one complete
|
|
67
|
+
// block comment (matched with the star-not-slash form, never a lazy
|
|
68
|
+
// `[\s\S]*?`), or one complete line comment — disjoint by first char,
|
|
69
|
+
// so there is no ambiguous repetition for a crafted SQL string of
|
|
70
|
+
// nested `/**/` or `*/--` runs to backtrack on (CWE-1333 ReDoS).
|
|
71
|
+
var _STATEMENT_CLASS_RE = /^(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/|--[^\n]*\n)*([A-Za-z]+)/;
|
|
65
72
|
var _STATEMENT_CLASS_MAP = Object.freeze({
|
|
66
73
|
SELECT: "SELECT", WITH: "SELECT", VALUES: "SELECT", TABLE: "SELECT",
|
|
67
74
|
INSERT: "DML", UPDATE: "DML", DELETE: "DML", MERGE: "DML", UPSERT: "DML",
|
|
@@ -83,6 +90,61 @@ function _classifyStatement(sql) {
|
|
|
83
90
|
return _STATEMENT_CLASS_MAP[m[1].toUpperCase()] || "OTHER";
|
|
84
91
|
}
|
|
85
92
|
|
|
93
|
+
// ---- OpenTelemetry database-client semantic conventions ----
|
|
94
|
+
//
|
|
95
|
+
// db.* span / metric attributes on the query / transaction / read emit
|
|
96
|
+
// paths, so dashboards built on the OTel semconv correlate external-db
|
|
97
|
+
// activity without a per-framework adapter. The DDL-audit side already
|
|
98
|
+
// stamps these on db.ddl.executed; this mirrors the shape on the data
|
|
99
|
+
// path. Reference: OpenTelemetry semantic conventions for database
|
|
100
|
+
// client calls (db.system / db.operation / db.statement / db.name).
|
|
101
|
+
//
|
|
102
|
+
// db.system is the OTel-registered identifier for the DBMS — it is NOT
|
|
103
|
+
// the framework's dialect string (Postgres is "postgresql" in the
|
|
104
|
+
// registry, not "postgres"). Unknown dialects fall through to the
|
|
105
|
+
// "other_sql" registry value.
|
|
106
|
+
var _OTEL_DB_SYSTEM = Object.freeze({
|
|
107
|
+
postgres: "postgresql",
|
|
108
|
+
mysql: "mysql",
|
|
109
|
+
sqlite: "sqlite",
|
|
110
|
+
mongodb: "mongodb",
|
|
111
|
+
other: "other_sql",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// db.operation is the leading SQL keyword (SELECT / INSERT / BEGIN /
|
|
115
|
+
// ...), uppercased — the OTel-conventional operation name, distinct
|
|
116
|
+
// from the coarser forensic statement CLASS (_classifyStatement).
|
|
117
|
+
// Reuses the comment-skipping leading-keyword regex; defensive reader,
|
|
118
|
+
// returns null on anything unparseable so the attribute is simply
|
|
119
|
+
// omitted rather than carrying a partial fragment.
|
|
120
|
+
function _otelOperation(sql) {
|
|
121
|
+
if (typeof sql !== "string" || sql.length === 0) return null;
|
|
122
|
+
var m = _STATEMENT_CLASS_RE.exec(sql);
|
|
123
|
+
if (!m) return null;
|
|
124
|
+
return m[1].toUpperCase();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// db.system / db.operation / db.name carry no statement text and are
|
|
128
|
+
// always emitted. db.statement is the SQL text — bound parameter values
|
|
129
|
+
// are passed out-of-band to the driver (never folded in), but a caller
|
|
130
|
+
// that inlines literals can still embed PII / secrets in the statement.
|
|
131
|
+
// So db.statement is gated behind the SAME opts.includeSqlInAudit
|
|
132
|
+
// opt-out that governs the raw `sql` audit field: the OTel attribute
|
|
133
|
+
// must never re-expose statement text the operator opted out of. When
|
|
134
|
+
// included it is truncated to the framework's 256-char log length.
|
|
135
|
+
function _otelDbAttributes(b, sql, includeStatement) {
|
|
136
|
+
var attrs = {
|
|
137
|
+
"db.system": _OTEL_DB_SYSTEM[b.dialect] || "other_sql",
|
|
138
|
+
"db.name": b.name,
|
|
139
|
+
};
|
|
140
|
+
var op = _otelOperation(sql);
|
|
141
|
+
if (op !== null) attrs["db.operation"] = op;
|
|
142
|
+
if (includeStatement) {
|
|
143
|
+
attrs["db.statement"] = String(sql == null ? "" : sql).slice(0, 256); // log-truncation length, not bytes
|
|
144
|
+
}
|
|
145
|
+
return attrs;
|
|
146
|
+
}
|
|
147
|
+
|
|
86
148
|
// Best-effort target-relation extractor for auth-failure forensics: the
|
|
87
149
|
// table the denied role attempted to touch, so the audit row records
|
|
88
150
|
// the OBJECT (SOC2 CC7.2 / NIST SP 800-53 AU-3 "what was accessed"),
|
|
@@ -321,6 +383,16 @@ class Pool {
|
|
|
321
383
|
* `residencyTag` in the allowed-region list — refused with
|
|
322
384
|
* `RESIDENCY_VIOLATION` when not.
|
|
323
385
|
*
|
|
386
|
+
* Opt-in transport posture: set `requireTls: true` on a backend to
|
|
387
|
+
* refuse it at config time (`TLS_REQUIRED`) unless its declared
|
|
388
|
+
* transport is encrypted (`tls: true`, an `ssl` object, or
|
|
389
|
+
* `sslmode: "require" | "verify-ca" | "verify-full"`). `sslmode` values
|
|
390
|
+
* that permit a plaintext fallback (`prefer` / `allow` / `disable`) are
|
|
391
|
+
* refused. The gate is OFF by default — a backend that omits
|
|
392
|
+
* `requireTls` is used exactly as supplied, with no transport check.
|
|
393
|
+
* Mandated for cardholder data by PCI-DSS v4.0 Req 4 and for ePHI by
|
|
394
|
+
* HIPAA §164.312(e).
|
|
395
|
+
*
|
|
324
396
|
* @opts
|
|
325
397
|
* backends: { [name]: BackendConfig }, // required; one or more named backends
|
|
326
398
|
* defaultBackend?: string, // pool used when no opts.backend / classification / role match (defaults to first)
|
|
@@ -333,6 +405,8 @@ class Pool {
|
|
|
333
405
|
* // ping(client): async → void (optional; default `SELECT 1`)
|
|
334
406
|
* // beginTx / commit / rollback(client): async → void (optional; default `BEGIN`/`COMMIT`/`ROLLBACK`)
|
|
335
407
|
* // dialect: "postgres" | "mysql" | "sqlite" | "mongodb" | "other" (default "postgres")
|
|
408
|
+
* // requireTls: boolean (opt-in TLS posture gate; default off — see below)
|
|
409
|
+
* // tls / ssl / sslmode: transport-TLS declaration consulted by requireTls (tls:true | ssl:<obj> | sslmode:"require"|"verify-ca"|"verify-full")
|
|
336
410
|
* // applicationName: string ≤ 63 bytes, no CR/LF/NUL (Postgres pg_stat_activity tag; default null)
|
|
337
411
|
* // pool: { min, max, idleTimeoutMs } (defaults: 1 / 10 / C.TIME.minutes(1))
|
|
338
412
|
* // classifications: string[] (defaults to ["*"])
|
|
@@ -387,6 +461,8 @@ function init(opts) {
|
|
|
387
461
|
"backend '" + name + "': dialect must be one of " +
|
|
388
462
|
"'postgres' | 'mysql' | 'sqlite' | 'mongodb' | 'other', got '" + dialect + "'", true);
|
|
389
463
|
}
|
|
464
|
+
// requireTls posture gate (opt-in; default OFF → no behavior change).
|
|
465
|
+
_assertConnectionTls(name, cfg);
|
|
390
466
|
// OWASP-3 — application_name normalization for Postgres backends.
|
|
391
467
|
// Always set on every fresh connection (not just connectAs branch)
|
|
392
468
|
// so pg_stat_activity / log_line_prefix / audit log surfaces show
|
|
@@ -506,6 +582,76 @@ function init(opts) {
|
|
|
506
582
|
initialized = true;
|
|
507
583
|
}
|
|
508
584
|
|
|
585
|
+
// ---- requireTls posture gate (opt-in) ----
|
|
586
|
+
//
|
|
587
|
+
// PCI-DSS v4.0 Requirement 4 (protect cardholder data with strong
|
|
588
|
+
// cryptography during transmission over open networks) and HIPAA
|
|
589
|
+
// §164.312(e)(1) (transmission security — encrypt ePHI in transit)
|
|
590
|
+
// both require that the connection between the app and an external
|
|
591
|
+
// database is encrypted. The framework does not open the connection
|
|
592
|
+
// itself — the operator supplies the driver via connect() — so the
|
|
593
|
+
// posture is declared on the backend config and enforced at config
|
|
594
|
+
// time.
|
|
595
|
+
//
|
|
596
|
+
// Default: OFF. A backend that does not set requireTls behaves exactly
|
|
597
|
+
// as before — the operator-supplied connection is used as-is with no
|
|
598
|
+
// gate. When requireTls is true, init() refuses the backend unless its
|
|
599
|
+
// declared transport is TLS, surfacing the misconfiguration at boot
|
|
600
|
+
// rather than letting plaintext credentials/PHI ride an open network.
|
|
601
|
+
//
|
|
602
|
+
// TLS posture is declared via any of (mirroring libpq SSLMODE / common
|
|
603
|
+
// driver shapes):
|
|
604
|
+
// - tls: true — explicit boolean
|
|
605
|
+
// - ssl: <truthy> — node-postgres / mysql2 ssl object
|
|
606
|
+
// - sslmode: "require" | "verify-ca" | "verify-full"
|
|
607
|
+
//
|
|
608
|
+
// libpq SSLMODE semantics: only require / verify-ca / verify-full
|
|
609
|
+
// GUARANTEE an encrypted channel. "prefer" and "allow" fall back to
|
|
610
|
+
// plaintext when the server declines TLS, and "disable" never
|
|
611
|
+
// encrypts — none of those satisfy a "must be encrypted" posture, so
|
|
612
|
+
// they are refused under requireTls.
|
|
613
|
+
var _TLS_GUARANTEED_SSLMODES = Object.freeze({
|
|
614
|
+
require: true,
|
|
615
|
+
"verify-ca": true,
|
|
616
|
+
"verify-full": true,
|
|
617
|
+
});
|
|
618
|
+
var _TLS_PLAINTEXT_SSLMODES = Object.freeze({
|
|
619
|
+
disable: true,
|
|
620
|
+
allow: true,
|
|
621
|
+
prefer: true,
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
function _declaresTls(cfg) {
|
|
625
|
+
if (cfg.tls === true) return true;
|
|
626
|
+
if (cfg.ssl !== undefined && cfg.ssl !== null && cfg.ssl !== false) return true;
|
|
627
|
+
if (typeof cfg.sslmode === "string") {
|
|
628
|
+
return _TLS_GUARANTEED_SSLMODES[cfg.sslmode.toLowerCase()] === true;
|
|
629
|
+
}
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function _assertConnectionTls(name, cfg) {
|
|
634
|
+
// Opt-in: absent requireTls → no gate, no behavior change.
|
|
635
|
+
if (cfg.requireTls === undefined || cfg.requireTls === null) return;
|
|
636
|
+
validateOpts.optionalBoolean(cfg.requireTls,
|
|
637
|
+
"backend '" + name + "': requireTls", ExternalDbError, "INVALID_CONFIG");
|
|
638
|
+
if (cfg.requireTls !== true) return;
|
|
639
|
+
if (_declaresTls(cfg)) return;
|
|
640
|
+
var declared;
|
|
641
|
+
if (typeof cfg.sslmode === "string" && _TLS_PLAINTEXT_SSLMODES[cfg.sslmode.toLowerCase()]) {
|
|
642
|
+
declared = "sslmode '" + cfg.sslmode +
|
|
643
|
+
"' permits a plaintext fallback (only 'require' / 'verify-ca' / 'verify-full' guarantee encryption)";
|
|
644
|
+
} else if (cfg.tls === false || cfg.ssl === false) {
|
|
645
|
+
declared = "transport is declared non-TLS (tls/ssl is false)";
|
|
646
|
+
} else {
|
|
647
|
+
declared = "no TLS transport is declared (set tls: true, an ssl object, or sslmode: 'require' / 'verify-ca' / 'verify-full')";
|
|
648
|
+
}
|
|
649
|
+
throw _err("TLS_REQUIRED",
|
|
650
|
+
"backend '" + name + "': requireTls is set but " + declared +
|
|
651
|
+
". PCI-DSS v4.0 Req 4 / HIPAA §164.312(e) require an encrypted channel " +
|
|
652
|
+
"for cardholder data / ePHI in transit.", true);
|
|
653
|
+
}
|
|
654
|
+
|
|
509
655
|
function _validateResidency() {
|
|
510
656
|
var residency;
|
|
511
657
|
try { residency = db().getDataResidency(); } catch (_e) { residency = null; }
|
|
@@ -634,7 +780,7 @@ async function query(sql, params, opts) {
|
|
|
634
780
|
}, b.retryConfig);
|
|
635
781
|
|
|
636
782
|
var durationMs = Date.now() - t0;
|
|
637
|
-
_emit("system.externaldb.query", "success", {
|
|
783
|
+
_emit("system.externaldb.query", "success", Object.assign({
|
|
638
784
|
backend: b.name,
|
|
639
785
|
role: role,
|
|
640
786
|
durationMs: durationMs,
|
|
@@ -645,7 +791,7 @@ async function query(sql, params, opts) {
|
|
|
645
791
|
// metadata pass opts.includeSqlInAudit: true (then sealed via
|
|
646
792
|
// field-crypto on the audit row).
|
|
647
793
|
sql: opts.includeSqlInAudit ? sql : null,
|
|
648
|
-
});
|
|
794
|
+
}, _otelDbAttributes(b, sql, opts.includeSqlInAudit)));
|
|
649
795
|
_emitMetric("externaldb.query.success", 1,
|
|
650
796
|
{ backend: b.name, role: role || "(none)" });
|
|
651
797
|
_emitMetric("externaldb.query.duration_ms", durationMs,
|
|
@@ -654,13 +800,13 @@ async function query(sql, params, opts) {
|
|
|
654
800
|
return result;
|
|
655
801
|
} catch (e) {
|
|
656
802
|
var failureMs = Date.now() - t0;
|
|
657
|
-
_emit("system.externaldb.query", "failure", {
|
|
803
|
+
_emit("system.externaldb.query", "failure", Object.assign({
|
|
658
804
|
backend: b.name,
|
|
659
805
|
role: role,
|
|
660
806
|
durationMs: failureMs,
|
|
661
807
|
classification: opts.classification || null,
|
|
662
808
|
errorCode: e.code || null,
|
|
663
|
-
}, (e && e.message) || String(e));
|
|
809
|
+
}, _otelDbAttributes(b, sql, opts.includeSqlInAudit)), (e && e.message) || String(e));
|
|
664
810
|
_emitMetric("externaldb.query.failure", 1,
|
|
665
811
|
{ backend: b.name, role: role || "(none)", errorCode: e.code || "(none)" });
|
|
666
812
|
_emitSlowQuery(b.name, role, failureMs, _classifyStatement(sql));
|
|
@@ -787,10 +933,15 @@ async function transaction(fn, opts) {
|
|
|
787
933
|
await b.commit(client);
|
|
788
934
|
committed = true;
|
|
789
935
|
var durationMs = Date.now() - t0;
|
|
790
|
-
|
|
936
|
+
// OTel db.* semconv on the transaction span. The body runs N
|
|
937
|
+
// statements via tx.query; the span describes the unit of work,
|
|
938
|
+
// so db.operation reads "BEGIN" (the OTel-conventional marker
|
|
939
|
+
// for a transaction-scoped span) rather than any one inner
|
|
940
|
+
// statement.
|
|
941
|
+
_emit("system.externaldb.transaction", "success", Object.assign({
|
|
791
942
|
backend: b.name, role: role, durationMs: durationMs,
|
|
792
943
|
classification: opts.classification || null,
|
|
793
|
-
});
|
|
944
|
+
}, _otelDbAttributes(b, "BEGIN", opts.includeSqlInAudit)));
|
|
794
945
|
_emitMetric("externaldb.transaction.success", 1,
|
|
795
946
|
{ backend: b.name, role: role || "(none)" });
|
|
796
947
|
_emitMetric("externaldb.transaction.duration_ms", durationMs,
|
|
@@ -807,11 +958,11 @@ async function transaction(fn, opts) {
|
|
|
807
958
|
continue;
|
|
808
959
|
}
|
|
809
960
|
var failureMs = Date.now() - t0;
|
|
810
|
-
_emit("system.externaldb.transaction", "failure", {
|
|
961
|
+
_emit("system.externaldb.transaction", "failure", Object.assign({
|
|
811
962
|
backend: b.name, role: role, durationMs: failureMs,
|
|
812
963
|
classification: opts.classification || null,
|
|
813
964
|
errorCode: txErr.code || null,
|
|
814
|
-
}, (txErr && txErr.message) || String(txErr));
|
|
965
|
+
}, _otelDbAttributes(b, "BEGIN", opts.includeSqlInAudit)), (txErr && txErr.message) || String(txErr));
|
|
815
966
|
_emitMetric("externaldb.transaction.failure", 1,
|
|
816
967
|
{ backend: b.name, role: role || "(none)", errorCode: txErr.code || "(none)" });
|
|
817
968
|
if (txErr && txErr.code === "42501") {
|
|
@@ -1202,13 +1353,13 @@ async function _readQuery(sql, params, opts) {
|
|
|
1202
1353
|
replica.pool.release(client);
|
|
1203
1354
|
replica.consecutiveFailures = 0;
|
|
1204
1355
|
var durationMs = Date.now() - t0;
|
|
1205
|
-
_emit("system.externaldb.read", "success", {
|
|
1356
|
+
_emit("system.externaldb.read", "success", Object.assign({
|
|
1206
1357
|
backend: b.name,
|
|
1207
1358
|
role: role,
|
|
1208
1359
|
replicaIdx: replica.index,
|
|
1209
1360
|
durationMs: durationMs,
|
|
1210
1361
|
rowCount: res && res.rowCount,
|
|
1211
|
-
});
|
|
1362
|
+
}, _otelDbAttributes(b, sql, opts.includeSqlInAudit)));
|
|
1212
1363
|
_emitMetric("externaldb.read.success", 1,
|
|
1213
1364
|
{ backend: b.name, role: role || "(none)", replicaIdx: replica.index });
|
|
1214
1365
|
_emitMetric("externaldb.read.duration_ms", durationMs,
|
|
@@ -1228,13 +1379,13 @@ async function _readQuery(sql, params, opts) {
|
|
|
1228
1379
|
throw e;
|
|
1229
1380
|
}
|
|
1230
1381
|
} catch (e) {
|
|
1231
|
-
_emit("system.externaldb.read", "failure", {
|
|
1382
|
+
_emit("system.externaldb.read", "failure", Object.assign({
|
|
1232
1383
|
backend: b.name,
|
|
1233
1384
|
role: role,
|
|
1234
1385
|
replicaIdx: replica.index,
|
|
1235
1386
|
durationMs: Date.now() - t0,
|
|
1236
1387
|
errorCode: e.code || null,
|
|
1237
|
-
}, (e && e.message) || String(e));
|
|
1388
|
+
}, _otelDbAttributes(b, sql, opts.includeSqlInAudit)), (e && e.message) || String(e));
|
|
1238
1389
|
_emitMetric("externaldb.read.failure", 1,
|
|
1239
1390
|
{ backend: b.name, role: role || "(none)", errorCode: e.code || "(none)" });
|
|
1240
1391
|
if (e && e.code === "42501") {
|
package/lib/guard-email.js
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @intro
|
|
8
8
|
* RFC 822 / 5322 single-address validator + RFC 5322 message gate
|
|
9
|
-
* with header-injection defense,
|
|
10
|
-
*
|
|
9
|
+
* with header-injection defense, domain-side IDN / Punycode
|
|
10
|
+
* handling, mixed-script confusable detection, label length caps,
|
|
11
|
+
* IP-literal denial, and sub-address handling.
|
|
11
12
|
*
|
|
12
13
|
* Two entry shapes:
|
|
13
14
|
* - `validateAddress(addr, opts)` — single mailbox (RFC 5321
|
|
@@ -15,6 +16,20 @@
|
|
|
15
16
|
* domain 255 / address 320. Flags multi-`@`, IP literals,
|
|
16
17
|
* Punycode, mixed-script confusables, and codepoint-class
|
|
17
18
|
* threats (BIDI / control / null / zero-width).
|
|
19
|
+
*
|
|
20
|
+
* Scope of Unicode handling: the DOMAIN side recognizes IDN /
|
|
21
|
+
* Punycode (`xn--`) labels and mixed-script confusables, gated by
|
|
22
|
+
* `allowedScripts` (RFC 5890 / RFC 5891). The LOCAL part is
|
|
23
|
+
* ASCII atext only (RFC 5321 §4.1.2 / RFC 5322 §3.2.3) — a unicode
|
|
24
|
+
* mailbox (RFC 6531 SMTPUTF8 / EAI) is NOT accepted and surfaces as
|
|
25
|
+
* an `address-syntax` issue. This is deliberate: a unicode
|
|
26
|
+
* local-part widens the homograph / confusable attack surface
|
|
27
|
+
* beyond the domain (where registry IDN policy and Punycode
|
|
28
|
+
* normalization apply) into the unregulated mailbox name, where no
|
|
29
|
+
* equivalent normalization authority exists. RFC 6531 local-part
|
|
30
|
+
* acceptance re-opens behind an explicit `allowUnicodeLocalPart`
|
|
31
|
+
* opt-in when operator demand for genuine EAI mailboxes lands;
|
|
32
|
+
* until then the conservative ASCII contract holds by default.
|
|
18
33
|
* - `validateMessage(rfc822, opts)` — full RFC 5322 message.
|
|
19
34
|
* Splits header section, unfolds folded headers, walks every
|
|
20
35
|
* single-line header for embedded CR/LF, drives address checks
|
|
@@ -32,7 +47,7 @@
|
|
|
32
47
|
* sanitization is safe.
|
|
33
48
|
*
|
|
34
49
|
* @card
|
|
35
|
-
* RFC 822 / 5322 single-address validator + RFC 5322 message gate with header-injection defense,
|
|
50
|
+
* RFC 822 / 5322 single-address validator + RFC 5322 message gate with header-injection defense, domain-side IDN / Punycode and mixed-script confusable detection (ASCII-only local-part), label length caps, IP-literal denial, and sub-address handling.
|
|
36
51
|
*/
|
|
37
52
|
|
|
38
53
|
var codepointClass = require("./codepoint-class");
|
|
@@ -99,6 +114,15 @@ function _hasCrlfInHeaderValue(value) {
|
|
|
99
114
|
// can produce a useful local-part-cap issue (instead of failing the
|
|
100
115
|
// regex first and surfacing address-syntax). RFC 5321 cap is enforced
|
|
101
116
|
// downstream via opts.maxLocalPartBytes.
|
|
117
|
+
//
|
|
118
|
+
// The local-part class is ASCII atext only — the printable-ASCII set
|
|
119
|
+
// of RFC 5321 §4.1.2 / RFC 5322 §3.2.3. A unicode (non-ASCII)
|
|
120
|
+
// local-part per RFC 6531 (SMTPUTF8 / EAI) is intentionally NOT
|
|
121
|
+
// matched: it fails this regex and surfaces as an `address-syntax`
|
|
122
|
+
// issue. Domain-side Unicode is handled separately (Punycode + mixed-
|
|
123
|
+
// script detection, gated by allowedScripts). Keeping the local-part
|
|
124
|
+
// ASCII avoids extending homograph / confusable exposure into the
|
|
125
|
+
// mailbox name, which has no registry-level normalization authority.
|
|
102
126
|
var _LOCAL = "[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+";
|
|
103
127
|
var _LABEL = "[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?";
|
|
104
128
|
var _DOMAIN = "(?:" + _LABEL + "(?:\\." + _LABEL + ")+)";
|
|
@@ -445,6 +469,15 @@ function _detectAddressIssues(input, opts) {
|
|
|
445
469
|
* Cyrillic / Greek / Armenian / Cherokee), strict-ASCII regex shape,
|
|
446
470
|
* and codepoint-class threats (BIDI / null / control / zero-width).
|
|
447
471
|
*
|
|
472
|
+
* The local-part is validated as ASCII atext only (RFC 5321 §4.1.2 /
|
|
473
|
+
* RFC 5322 §3.2.3). A unicode local-part (RFC 6531 SMTPUTF8 / EAI)
|
|
474
|
+
* is rejected as an `address-syntax` issue — keeping the mailbox name
|
|
475
|
+
* ASCII bounds homograph / confusable exposure to the domain side,
|
|
476
|
+
* where Punycode normalization and `allowedScripts` gating apply.
|
|
477
|
+
* RFC 6531 local-part acceptance re-opens behind a future explicit
|
|
478
|
+
* `allowUnicodeLocalPart` opt-in on operator demand. Domain-side
|
|
479
|
+
* IDN / Punycode and mixed-script handling are already supported.
|
|
480
|
+
*
|
|
448
481
|
* @opts
|
|
449
482
|
* profile: "strict" | "balanced" | "permissive",
|
|
450
483
|
* compliancePosture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
package/lib/http-client.js
CHANGED
|
@@ -378,6 +378,39 @@ function _isPermanentStatus(statusCode) {
|
|
|
378
378
|
return false;
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
+
// Reject a streamed non-2xx response, preserving a bounded prefix of the
|
|
382
|
+
// error body (problem+json / encrypted error) on err.body instead of
|
|
383
|
+
// silently draining it.
|
|
384
|
+
function _rejectStreamHttpError(stream, errorClass, statusCode, statusMessage, reject) {
|
|
385
|
+
var cap = C.BYTES.kib(16);
|
|
386
|
+
var collector = safeBuffer.boundedChunkCollector({ maxBytes: cap });
|
|
387
|
+
var done = false;
|
|
388
|
+
function finish() {
|
|
389
|
+
if (done) return;
|
|
390
|
+
done = true;
|
|
391
|
+
var e = _makeError(errorClass, "HTTP_ERROR",
|
|
392
|
+
"HTTP " + statusCode + (statusMessage ? " " + statusMessage : ""),
|
|
393
|
+
_isPermanentStatus(statusCode), statusCode);
|
|
394
|
+
e.body = collector.result();
|
|
395
|
+
reject(e);
|
|
396
|
+
}
|
|
397
|
+
// Collect at most `cap` bytes of the error body, slicing each chunk to the
|
|
398
|
+
// remaining room so the bounded collector never overflows. As soon as the
|
|
399
|
+
// prefix is full, reject + destroy the stream — don't leave the request
|
|
400
|
+
// promise pending while a large / slow error body drains to its close.
|
|
401
|
+
stream.on("data", function (c) {
|
|
402
|
+
if (done) return;
|
|
403
|
+
var room = cap - collector.bytesCollected();
|
|
404
|
+
if (room > 0) collector.push(c.length > room ? c.subarray(0, room) : c);
|
|
405
|
+
if (collector.bytesCollected() >= cap) {
|
|
406
|
+
if (typeof stream.destroy === "function") stream.destroy();
|
|
407
|
+
finish();
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
stream.on("end", finish);
|
|
411
|
+
stream.on("error", finish);
|
|
412
|
+
}
|
|
413
|
+
|
|
381
414
|
// h2 sends headers as lowercased keys plus :method / :path / :scheme /
|
|
382
415
|
// :authority pseudo-headers. Convert from h1-shaped headers.
|
|
383
416
|
function _toH2Headers(method, u, headers) {
|
|
@@ -1421,10 +1454,8 @@ function _requestH1(transport, u, opts) {
|
|
|
1421
1454
|
|
|
1422
1455
|
if (responseMode === "stream") {
|
|
1423
1456
|
if (res.statusCode >= 400 && responseMode !== "always-resolve") {
|
|
1424
|
-
res.
|
|
1425
|
-
return
|
|
1426
|
-
"HTTP " + res.statusCode + " " + (res.statusMessage || ""),
|
|
1427
|
-
_isPermanentStatus(res.statusCode), res.statusCode));
|
|
1457
|
+
_rejectStreamHttpError(res, opts.errorClass, res.statusCode, res.statusMessage || "", _reject);
|
|
1458
|
+
return;
|
|
1428
1459
|
}
|
|
1429
1460
|
if (onDownloadProgress || onChunk) {
|
|
1430
1461
|
// Wrap the stream so chunks emit progress + onChunk to the
|
|
@@ -1639,9 +1670,8 @@ function _requestH2(transport, u, opts) {
|
|
|
1639
1670
|
|
|
1640
1671
|
if (responseMode === "stream") {
|
|
1641
1672
|
if (statusCode >= 400 && responseMode !== "always-resolve") {
|
|
1642
|
-
stream.
|
|
1643
|
-
return
|
|
1644
|
-
"HTTP " + statusCode, _isPermanentStatus(statusCode), statusCode));
|
|
1673
|
+
_rejectStreamHttpError(stream, opts.errorClass, statusCode, "", _reject);
|
|
1674
|
+
return;
|
|
1645
1675
|
}
|
|
1646
1676
|
if (onChunkH2) {
|
|
1647
1677
|
var passthroughH2 = new nodeStream.PassThrough();
|
package/lib/mail-send-deliver.js
CHANGED
|
@@ -256,10 +256,10 @@ async function _applyMtaStsPolicy(domain, mxs, policyMode, auditEmit) {
|
|
|
256
256
|
// The primitive composes the lookup; per-cert chain verification is
|
|
257
257
|
// the operator's responsibility (or future b.network.smtp.policy.dane.
|
|
258
258
|
// verifyChain extension).
|
|
259
|
-
async function _fetchDaneTlsa(mxHost, daneMode, auditEmit) {
|
|
259
|
+
async function _fetchDaneTlsa(mxHost, port, daneMode, auditEmit) {
|
|
260
260
|
if (daneMode === "off") return null;
|
|
261
261
|
try {
|
|
262
|
-
var tlsa = await smtpPolicy().dane.tlsa(mxHost, DEFAULT_PORT_SMTP);
|
|
262
|
+
var tlsa = await smtpPolicy().dane.tlsa(mxHost, port || DEFAULT_PORT_SMTP);
|
|
263
263
|
return tlsa && tlsa.length > 0 ? tlsa : null;
|
|
264
264
|
} catch (e) {
|
|
265
265
|
auditEmit("mail.send.deliver.dane.skip", "warn",
|
|
@@ -281,7 +281,7 @@ async function _tryHost(envelope, mxHost, hostnameLocal, opts) {
|
|
|
281
281
|
var factory = opts.transportFactory || mailModule().smtpTransport;
|
|
282
282
|
var transport = factory({
|
|
283
283
|
host: mxHost,
|
|
284
|
-
port: DEFAULT_PORT_SMTP,
|
|
284
|
+
port: opts.port || DEFAULT_PORT_SMTP,
|
|
285
285
|
ehloName: hostnameLocal,
|
|
286
286
|
timeoutMs: opts.perHostTimeoutMs || DEFAULT_PER_HOST_TIMEOUT_MS,
|
|
287
287
|
requireTls: envelope.requireTls === true,
|
|
@@ -327,7 +327,7 @@ async function _deliverOne(envelope, recipient, ctx) {
|
|
|
327
327
|
// composes directly into smtpTransport.dane); this branch carries
|
|
328
328
|
// the discovery so the audit chain records the policy posture
|
|
329
329
|
// applied to each delivery attempt.
|
|
330
|
-
await _fetchDaneTlsa(mx.exchange, ctx.policy.dane, ctx.auditEmit);
|
|
330
|
+
await _fetchDaneTlsa(mx.exchange, ctx.port, ctx.policy.dane, ctx.auditEmit);
|
|
331
331
|
try {
|
|
332
332
|
var rv = await _tryHost({
|
|
333
333
|
from: envelope.from,
|
|
@@ -396,6 +396,7 @@ async function _deliverOne(envelope, recipient, ctx) {
|
|
|
396
396
|
*
|
|
397
397
|
* @opts
|
|
398
398
|
* hostname: string, // required — local hostname for HELO/EHLO + DSN Reporting-MTA
|
|
399
|
+
* port: number, // default 25 (IANA SMTP, RFC 5321) — set 587 (RFC 6409 submission) or 465 (RFC 8314 implicit-TLS) for a smarthost relay
|
|
399
400
|
* resolver: object | null, // optional — b.network.dns.resolver handle; falls back to node:dns when omitted
|
|
400
401
|
* policy: {
|
|
401
402
|
* mtaSts: "enforce" | "testing" | "off", // default "enforce" — RFC 8461 posture
|
|
@@ -438,11 +439,19 @@ function create(opts) {
|
|
|
438
439
|
throw new DeliverError("deliver/bad-opts", "mail.send.deliver.create: opts is required");
|
|
439
440
|
}
|
|
440
441
|
validateOpts(opts,
|
|
441
|
-
["hostname", "resolver", "policy", "retry", "dsn", "timeouts", "audit", "transportFactory"],
|
|
442
|
+
["hostname", "resolver", "policy", "retry", "dsn", "timeouts", "audit", "transportFactory", "port"],
|
|
442
443
|
"mail.send.deliver.create");
|
|
443
444
|
validateOpts.requireNonEmptyString(opts.hostname,
|
|
444
445
|
"mail.send.deliver.create: hostname (local HELO/EHLO + DSN Reporting-MTA)",
|
|
445
446
|
DeliverError, "deliver/bad-hostname");
|
|
447
|
+
// Submission/smarthost relays listen on 587 (RFC 6409) or implicit-TLS
|
|
448
|
+
// 465 (RFC 8314) rather than the IANA SMTP port 25 (RFC 5321 §2.3.4)
|
|
449
|
+
// that direct MX delivery uses. Operators routing through such a relay
|
|
450
|
+
// set opts.port; the value is range-checked here (RFC 6335 §6) so a
|
|
451
|
+
// typo fails at config time, not on the first connect attempt.
|
|
452
|
+
validateOpts.optionalPort(opts.port,
|
|
453
|
+
"mail.send.deliver.create: port", DeliverError, "deliver/bad-port");
|
|
454
|
+
var port = opts.port || DEFAULT_PORT_SMTP;
|
|
446
455
|
|
|
447
456
|
var policy = opts.policy || {};
|
|
448
457
|
validateOpts(policy, ["mtaSts", "dane"], "mail.send.deliver.create.policy");
|
|
@@ -516,6 +525,7 @@ function create(opts) {
|
|
|
516
525
|
resolver: opts.resolver || null,
|
|
517
526
|
policy: { mtaSts: policyMtaSts, dane: policyDane },
|
|
518
527
|
hostname: opts.hostname,
|
|
528
|
+
port: port,
|
|
519
529
|
mxLookupTimeoutMs: mxLookupTimeoutMs,
|
|
520
530
|
perHostTimeoutMs: perHostTimeoutMs,
|
|
521
531
|
transportFactory: opts.transportFactory || null,
|
package/lib/mail-sieve.js
CHANGED
|
@@ -61,6 +61,7 @@ var safeSieve = require("./safe-sieve");
|
|
|
61
61
|
var { defineClass } = require("./framework-error");
|
|
62
62
|
var numericBounds = require("./numeric-bounds");
|
|
63
63
|
var validateOpts = require("./validate-opts");
|
|
64
|
+
var codepointClass = require("./codepoint-class");
|
|
64
65
|
|
|
65
66
|
var MailSieveError = defineClass("MailSieveError", { alwaysPermanent: true });
|
|
66
67
|
|
|
@@ -122,7 +123,7 @@ function _envelopeAddresses(env, key) {
|
|
|
122
123
|
// ---- match-type ---------------------------------------------------------
|
|
123
124
|
|
|
124
125
|
function _escapeRe(s) {
|
|
125
|
-
return
|
|
126
|
+
return codepointClass.escapeRegExp(s);
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
function _wildcardToRe(pattern, caseInsensitive) {
|
|
@@ -19,9 +19,14 @@
|
|
|
19
19
|
* - "html" — when the response Content-Type is HTML,
|
|
20
20
|
* injects a <div role="status" ...> banner
|
|
21
21
|
* immediately after the <body> tag plus a
|
|
22
|
-
* <meta> tag inside <head>.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* <meta> tag inside <head>. Handles both a
|
|
23
|
+
* string and a Buffer body (the common server-
|
|
24
|
+
* render path); a Buffer is decoded under the
|
|
25
|
+
* response charset, injected, and re-encoded
|
|
26
|
+
* for utf-8 / ascii / latin1. Other charsets
|
|
27
|
+
* warn once and serve the original bytes (the
|
|
28
|
+
* disclosure headers still carry the notice).
|
|
29
|
+
* Skipped when the response is not text/html.
|
|
25
30
|
*
|
|
26
31
|
* The middleware does NOT alter the response when:
|
|
27
32
|
* - response status >= 400 (operator's error pages stay clean)
|
|
@@ -41,6 +46,24 @@ var requestHelpers = require("../request-helpers");
|
|
|
41
46
|
|
|
42
47
|
var aiActMod = lazyRequire(function () { return require("../compliance-ai-act"); });
|
|
43
48
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
49
|
+
var logger = lazyRequire(function () { return require("../log").boot("ai-act-disclosure"); });
|
|
50
|
+
|
|
51
|
+
// Charsets whose byte<->string round-trip is lossless for the inject
|
|
52
|
+
// operation: utf-8 (and its ascii / latin1 subsets, which Node decodes
|
|
53
|
+
// byte-for-byte). Other charsets (utf-16le, big5, gb18030, …) are not
|
|
54
|
+
// safe to decode→inject→re-encode without a transcoder we don't vendor,
|
|
55
|
+
// so the Buffer path warns once and serves the original bytes untouched
|
|
56
|
+
// rather than risk corrupting the page.
|
|
57
|
+
var SAFE_INJECT_ENCODINGS = { "utf-8": "utf8", "utf8": "utf8", "us-ascii": "utf8", "ascii": "utf8", "latin1": "latin1", "iso-8859-1": "latin1" };
|
|
58
|
+
|
|
59
|
+
// Read the charset token out of a Content-Type header, lowercased and
|
|
60
|
+
// stripped of surrounding quotes. Returns "" when absent (the caller
|
|
61
|
+
// treats a missing charset as the HTML default, utf-8).
|
|
62
|
+
function _charsetOf(contentType) {
|
|
63
|
+
if (typeof contentType !== "string") return "";
|
|
64
|
+
var m = /;\s*charset\s*=\s*"?([^";]+)"?/i.exec(contentType);
|
|
65
|
+
return m ? m[1].trim().toLowerCase() : "";
|
|
66
|
+
}
|
|
44
67
|
|
|
45
68
|
/**
|
|
46
69
|
* @primitive b.middleware.aiActDisclosure
|
|
@@ -53,7 +76,10 @@ var audit = lazyRequire(function () { return require("../audit"); });
|
|
|
53
76
|
* responses. In `mode: "header"` (default) it sets `AI-Act-Notice` and
|
|
54
77
|
* `AI-Act-Article` response headers — cheapest, works for both JSON
|
|
55
78
|
* and HTML. In `mode: "html"` it additionally inserts a status banner
|
|
56
|
-
* after `<body>`
|
|
79
|
+
* after `<body>` for HTML responses, handling both a string and a
|
|
80
|
+
* Buffer body (a Buffer is decoded under the response charset, injected,
|
|
81
|
+
* and re-encoded for utf-8 / ascii / latin1; other charsets warn once
|
|
82
|
+
* and serve the original bytes with the disclosure headers still set).
|
|
57
83
|
* Skips error pages, redirects, requests bearing the configured
|
|
58
84
|
* skip-header, and responses opted out via `res.locals.aiActSkip`.
|
|
59
85
|
* Emits `compliance.aiact.disclosed` audits on success.
|
|
@@ -138,22 +164,29 @@ function create(opts) {
|
|
|
138
164
|
res.end = function (chunk, encoding) {
|
|
139
165
|
try {
|
|
140
166
|
var ctype = (res.getHeader && res.getHeader("Content-Type")) || "";
|
|
141
|
-
if (typeof ctype === "string" && ctype.indexOf("text/html") !== -1 &&
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
var
|
|
152
|
-
if (
|
|
153
|
-
|
|
167
|
+
if (typeof ctype === "string" && ctype.indexOf("text/html") !== -1 && chunk) {
|
|
168
|
+
if (typeof chunk === "string") {
|
|
169
|
+
chunk = _injectBanner(chunk, opts);
|
|
170
|
+
} else if (Buffer.isBuffer(chunk)) {
|
|
171
|
+
// res.end(Buffer.from(html)) is the common server-render path
|
|
172
|
+
// (b.render serves a Buffer). Decode under the response charset,
|
|
173
|
+
// inject the Art. 50 banner, re-encode — but only for charsets
|
|
174
|
+
// whose round-trip is lossless. Unknown charsets warn once and
|
|
175
|
+
// serve the original bytes (no transcoder is vendored).
|
|
176
|
+
var charset = _charsetOf(ctype) || "utf-8";
|
|
177
|
+
var nodeEnc = SAFE_INJECT_ENCODINGS[charset];
|
|
178
|
+
if (nodeEnc) {
|
|
179
|
+
var injected = _injectBanner(chunk.toString(nodeEnc), opts);
|
|
180
|
+
chunk = Buffer.from(injected, nodeEnc);
|
|
181
|
+
// Content-Length, if the operator pre-set it, now understates
|
|
182
|
+
// the body — clear it so the runtime recomputes / chunks.
|
|
183
|
+
if (res.getHeader && res.getHeader("Content-Length") != null &&
|
|
184
|
+
typeof res.removeHeader === "function") {
|
|
185
|
+
res.removeHeader("Content-Length");
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
_warnUnsafeCharset(charset);
|
|
154
189
|
}
|
|
155
|
-
} else {
|
|
156
|
-
chunk = bannerHtml + chunk;
|
|
157
190
|
}
|
|
158
191
|
}
|
|
159
192
|
} catch (_e) { /* injection best-effort */ }
|
|
@@ -186,6 +219,42 @@ function create(opts) {
|
|
|
186
219
|
};
|
|
187
220
|
}
|
|
188
221
|
|
|
222
|
+
// Insert the EU AI Act Art. 50 status banner into an HTML string. The
|
|
223
|
+
// banner goes immediately after the opening <body> tag when present, else
|
|
224
|
+
// it is prepended. Returns the original string unchanged on any builder
|
|
225
|
+
// error (best-effort injection — the disclosure header still carries the
|
|
226
|
+
// machine-readable notice).
|
|
227
|
+
function _injectBanner(html, opts) {
|
|
228
|
+
var bannerHtml = aiActMod().transparency.htmlBanner({
|
|
229
|
+
kind: opts.kind || "ai-interaction",
|
|
230
|
+
lang: opts.lang || "en",
|
|
231
|
+
});
|
|
232
|
+
var bodyOpen = html.indexOf("<body");
|
|
233
|
+
if (bodyOpen !== -1) {
|
|
234
|
+
var afterTag = html.indexOf(">", bodyOpen);
|
|
235
|
+
if (afterTag !== -1) {
|
|
236
|
+
return html.slice(0, afterTag + 1) + bannerHtml + html.slice(afterTag + 1);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return bannerHtml + html;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Warn once per process per charset that a Buffer HTML body in an
|
|
243
|
+
// unsupported charset was served without the banner injected, so an
|
|
244
|
+
// operator can switch the response to utf-8 (or accept the header-only
|
|
245
|
+
// disclosure). Drop-silent if the logger is unavailable.
|
|
246
|
+
var _warnedCharsets = Object.create(null);
|
|
247
|
+
function _warnUnsafeCharset(charset) {
|
|
248
|
+
if (_warnedCharsets[charset]) return;
|
|
249
|
+
_warnedCharsets[charset] = true;
|
|
250
|
+
try {
|
|
251
|
+
logger().warn("ai-act-disclosure: HTML response body is a Buffer in charset '" +
|
|
252
|
+
charset + "'; the Art. 50 banner was not injected (no transcoder for that " +
|
|
253
|
+
"charset). The disclosure headers are still set. Serve text/html as utf-8 to " +
|
|
254
|
+
"get the in-page banner.");
|
|
255
|
+
} catch (_e) { /* drop-silent — logger optional */ }
|
|
256
|
+
}
|
|
257
|
+
|
|
189
258
|
function _articleFor(kind) {
|
|
190
259
|
switch (kind) {
|
|
191
260
|
case "ai-interaction": return "Art. 50(1)";
|