@blamejs/core 0.14.17 → 0.14.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +3 -3
- 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/archive.js +206 -52
- package/lib/auth/oauth.js +58 -0
- package/lib/auth/oid4vci.js +84 -27
- 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 +88 -8
- package/lib/external-db.js +164 -13
- package/lib/guard-email.js +36 -3
- package/lib/mail-auth.js +554 -55
- 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/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 +22 -7
- package/lib/middleware/openapi-serve.js +56 -4
- package/lib/middleware/scim-server.js +301 -14
- package/lib/openapi-paths-builder.js +105 -29
- package/lib/openapi.js +225 -100
- package/lib/queue-local.js +148 -38
- package/lib/queue.js +41 -11
- package/lib/render.js +21 -3
- package/lib/safe-buffer.js +55 -0
- 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",
|