@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.
@@ -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
- var _STATEMENT_CLASS_RE = /^\s*(?:\/\*[\s\S]*?\*\/\s*|--[^\n]*\n\s*)*([A-Za-z]+)/;
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
- _emit("system.externaldb.transaction", "success", {
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") {
@@ -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, EAI / SMTPUTF8 support, label
10
- * length caps, IP-literal denial, and sub-address handling.
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, EAI / SMTPUTF8 support, label length caps, IP-literal denial, and sub-address handling.
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",
@@ -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.resume();
1425
- return _reject(_makeError(opts.errorClass, "HTTP_ERROR",
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.resume();
1643
- return _reject(_makeError(opts.errorClass, "HTTP_ERROR",
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();
@@ -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 s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
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>. Skipped when
23
- * response is already past headers OR not
24
- * text/html.
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>` and a `<meta>` inside `<head>` for HTML responses.
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
- chunk && Buffer.isBuffer(chunk) === false &&
143
- typeof chunk === "string") {
144
- var bannerHtml = aiActMod().transparency.htmlBanner({
145
- kind: opts.kind || "ai-interaction",
146
- lang: opts.lang || "en",
147
- });
148
- // Inject after <body> if present, else prepend.
149
- var bodyOpen = chunk.indexOf("<body");
150
- if (bodyOpen !== -1) {
151
- var afterTag = chunk.indexOf(">", bodyOpen);
152
- if (afterTag !== -1) {
153
- chunk = chunk.slice(0, afterTag + 1) + bannerHtml + chunk.slice(afterTag + 1);
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)";