@blamejs/core 0.7.79 → 0.7.81
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/lib/db.js +9 -0
- package/lib/middleware/host-allowlist.js +159 -0
- package/lib/middleware/index.js +6 -0
- package/lib/middleware/security-txt.js +132 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.7.x
|
|
10
10
|
|
|
11
|
+
- **0.7.81** (2026-05-06) — `b.middleware.hostAllowlist` — DNS rebinding defense. Refuses requests whose `Host` header doesn't match the operator-supplied allowlist; the DNS rebinding chain (attacker DNS flips evil.com → 127.0.0.1, browser still believes the URL string says "evil.com" so same-origin policy lets the JS read the response, but the operator's localhost is what actually serves) is closed by checking the post-DNS-resolution `Host` header on the framework's side. **`b.middleware.hostAllowlist({ hosts, denyStatus?, denyBody?, audit? })`** — operators pass an allowlist of canonical Host values (with or without port). Wildcard-leading entries (`*.example.com`) match any single label; `app.sub.example.com` does NOT match `*.example.com` (multi-label rejected by design — a wildcard certificate authority issues only single-label intermediate). Entries without a port match any port; entries with a port require exact match. Default `denyStatus: 421` (RFC 7540 §9.1.2 "Misdirected Request"); default `denyBody: "Misdirected Request"`. Audit emits `network.host_allowlist.denied` with the reason (`missing-host` / `host-not-in-allowlist`) and the actual Host value for triage. Operators running explicitly-public services that accept arbitrary subdomains (multi-tenant forum shapes) skip this middleware entirely; there's no per-request opt-out.
|
|
12
|
+
|
|
13
|
+
- **0.7.80** (2026-05-06) — `b.middleware.securityTxt` (RFC 9116) + SQLite `secure_delete=ON` at DB boot. **`b.middleware.securityTxt({ contact, expires, encryption?, policy?, ack?, preferredLanguages?, hiring?, canonical?, alsoAtRoot?, audit? })`** serves a static body at `/.well-known/security.txt` (and root `/security.txt` when `alsoAtRoot: true`) per RFC 9116. Operators wire it on their app so security researchers know where to find the disclosure policy. The `Contact:` and `Expires:` fields are REQUIRED per §2.5; the framework throws at config-time when either is missing AND when `expires` is in the past (RFC 9116 §2.5.5). All field values are CR/LF/NUL-screened (header injection defense). Body is built once at create() and served with `Content-Length` + `Cache-Control: public, max-age=86400` + `X-Content-Type-Options: nosniff`. **SQLite `PRAGMA secure_delete=ON`** is now applied at `b.db.init` time alongside the existing PRAGMA block. SQLite normally just unlinks rows from the B-tree; the underlying page bytes survive on disk until a new write reuses the slot. With `secure_delete=ON`, freed pages are overwritten with zeros so a forensic recovery against the encrypted database file can't reconstruct deleted rows. The cost is one extra write per delete — already dominated by the framework's audit-chain emissions on every DSR erase / cascade fan-out.
|
|
14
|
+
|
|
11
15
|
- **0.7.79** (2026-05-06) — PQC TLS handshake key shares + DB hardening sweep (3 items). **`b.network.tls.pqc`** — operator-facing TLS 1.3 key-share configuration. The framework's app-layer envelope has been PQC-first since v0.7.28 (ML-KEM-1024 + X25519 hybrid for sealed records); this slice extends PQC posture down to the **TLS handshake itself**. `b.network.tls.pqc.setKeyShares(["X25519MLKEM768", "X25519", "secp256r1"])` configures the TLS 1.3 key-share groups the framework's `https.Server` / `https.Agent` advertises. The first listed group is the operator priority; the peer picks the first mutually supported entry. **`X25519MLKEM768`** is the IETF draft-kwiatkowski-tls-ecdhe-mlkem-02 hybrid KEM that negotiates post-quantum + classical in one handshake — forward-secrecy survives both classical-CRQC and future quantum cryptanalysis. Default list is `["X25519MLKEM768", "X25519", "secp256r1"]` so the framework attempts hybrid first, falls back to classical X25519 with peers that don't support the hybrid (most of the public web today), and to `secp256r1` for legacy peers. **`b.network.tls.applyToContext({ base })`** now threads the configured key-share list through as the `groups` option to Node's TLS context (operators who explicitly set `groups` in their `base` config keep the override). Operators wanting classical-only call `b.network.tls.pqc.setKeyShares(["X25519"])`; calling `.resetKeyShares()` restores the default. Requires Node 24+ with OpenSSL 3.5+ for the X25519MLKEM768 group; older Node falls back silently to the classical entries. **DB hardening: PRAGMA integrity_check at boot** — `b.db.init` now runs `PRAGMA integrity_check` after the existing PRAGMA block and refuses boot if SQLite reports anything other than `"ok"`. Catches B-tree corruption at boot rather than letting it surface mid-query when the engine stumbles on a bad page. Skip via `opts.skipIntegrityCheck: true` for tmpfs-only fixtures (audited reason). **DB hardening: migration-lock holder ID boot token** — `lib/external-db-migrate.js:_lockHolderId()` now appends a per-process random 8-byte boot token, so a recycled PID-on-hostname slot after a container restart can't be misattributed back to the new boot when reading stale lock rows. Closes the PID-reuse-across-container-restart concurrent-ownership window flagged in the v0.7.67 audit batch.
|
|
12
16
|
|
|
13
17
|
- **0.7.78** (2026-05-06) — `b.cloudEvents.wrap` / `.parse` — CloudEvents 1.0 envelope (cloudevents.io/spec/v1.0). Vendor-neutral event-format spec adopted by AWS EventBridge, Knative, Azure Event Grid, Google Eventarc, Datadog, and the broader CNCF event ecosystem; operators wrap outbound events from webhook / pubsub / queue boundaries to interop with these consumers without each consumer learning a bespoke shape. **`b.cloudEvents.wrap({ source, type, data?, subject?, time?, id?, datacontenttype?, dataschema?, extensions? })`** produces a CloudEvents 1.0 envelope: required attributes (`id` auto-minted as RFC 4122 v4 UUID when omitted, `source`, `specversion="1.0"`, `type`, `time` auto-set to `new Date().toISOString()`), optional attributes (`subject`, `datacontenttype` auto-set to `"application/json"` when `data` is JSON-serializable or `"application/octet-stream"` when `data` is a `Buffer` — base64-encoded into `data_base64`, `dataschema`), plus operator-defined extension attributes that conform to the §3.1 naming rules (lowercase ASCII alnum, 1-20 chars). **`b.cloudEvents.parse(envelope)`** validates the envelope shape and returns a structured form with `extensions` surfaced as a separate object so consumers can route on operator-defined fields without grepping the envelope. Refuses `data` + `data_base64` together (CloudEvents §3.1.1), unsupported specversion, missing required attributes, malformed extension names. **Test cleanup**: the `testAuditSafeEmitRedacts` smoke fixture from v0.7.75 now registers the `test` audit namespace before emitting so the audit handler's noise log line ("namespace 'test' is not registered") doesn't appear in CI smoke output.
|
package/lib/db.js
CHANGED
|
@@ -681,6 +681,15 @@ async function init(opts) {
|
|
|
681
681
|
// structured `foreignKeys` declarations actually constrain writes.
|
|
682
682
|
runSql(database, "PRAGMA foreign_keys=ON");
|
|
683
683
|
|
|
684
|
+
// PRAGMA secure_delete=ON — SQLite normally just unlinks rows from
|
|
685
|
+
// the B-tree; the underlying page bytes survive on disk until a new
|
|
686
|
+
// write reuses the slot. With secure_delete=ON, freed pages are
|
|
687
|
+
// overwritten with zeros so a forensic recovery against the file
|
|
688
|
+
// can't reconstruct deleted rows. The cost is one extra write per
|
|
689
|
+
// delete, which the framework's audit-and-DSR-erase path already
|
|
690
|
+
// dominates with audit-chain emissions and cascade fan-out.
|
|
691
|
+
runSql(database, "PRAGMA secure_delete=ON");
|
|
692
|
+
|
|
684
693
|
// PRAGMA integrity_check — refuse boot on B-tree corruption (per
|
|
685
694
|
// audit-batch finding). SQLite returns "ok" for a healthy database;
|
|
686
695
|
// any other result means corruption. Catching it at boot beats
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* host-allowlist — DNS rebinding defense.
|
|
4
|
+
*
|
|
5
|
+
* Refuses requests whose `Host` header doesn't match the operator-
|
|
6
|
+
* supplied allowlist. The DNS rebinding attack chain is:
|
|
7
|
+
*
|
|
8
|
+
* 1. Attacker sets a short-TTL DNS record for evil.com pointing
|
|
9
|
+
* at their own server.
|
|
10
|
+
* 2. Victim's browser visits attacker's page; the page issues
|
|
11
|
+
* fetch() requests to evil.com.
|
|
12
|
+
* 3. Attacker's DNS now answers evil.com → 127.0.0.1 (or the
|
|
13
|
+
* operator's internal IP).
|
|
14
|
+
* 4. Browser, applying same-origin policy on the URL string,
|
|
15
|
+
* thinks fetch() is hitting evil.com — but the connection
|
|
16
|
+
* lands on the operator's localhost / internal service.
|
|
17
|
+
* 5. The operator's service serves whatever it would serve to
|
|
18
|
+
* its own admin UI; the JS reads the response.
|
|
19
|
+
*
|
|
20
|
+
* Defense: refuse the request unless the `Host` header (the part
|
|
21
|
+
* the operator's server actually sees) matches a known origin.
|
|
22
|
+
*
|
|
23
|
+
* var allow = b.middleware.hostAllowlist({
|
|
24
|
+
* hosts: ["app.example.com", "app.example.com:443"],
|
|
25
|
+
* denyStatus: 421, // RFC 7540 §9.1.2 "Misdirected Request"
|
|
26
|
+
* denyBody: "Misdirected Request",
|
|
27
|
+
* audit: true,
|
|
28
|
+
* });
|
|
29
|
+
* router.use(allow);
|
|
30
|
+
*
|
|
31
|
+
* Operators behind a CDN / proxy that rewrites the Host header set
|
|
32
|
+
* `hosts` to the post-rewrite values. Wildcard-leading entries
|
|
33
|
+
* (`*.example.com`) match any single label. Localhost is explicitly
|
|
34
|
+
* allowed only when the operator lists it.
|
|
35
|
+
*
|
|
36
|
+
* Operators running an explicitly-public service (anyone-can-host-
|
|
37
|
+
* the-domain shapes — e.g. a forum that serves arbitrary subdomains)
|
|
38
|
+
* skip this middleware entirely; there's no opt-out per request.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
var lazyRequire = require("../lazy-require");
|
|
42
|
+
var requestHelpers = require("../request-helpers");
|
|
43
|
+
var validateOpts = require("../validate-opts");
|
|
44
|
+
var { defineClass } = require("../framework-error");
|
|
45
|
+
|
|
46
|
+
var HostAllowlistError = defineClass("HostAllowlistError", { alwaysPermanent: true });
|
|
47
|
+
|
|
48
|
+
var audit = lazyRequire(function () { return require("../audit"); });
|
|
49
|
+
|
|
50
|
+
function _normalizeHostEntry(s) {
|
|
51
|
+
// Lowercase + strip whitespace. Per RFC 7230 §5.4 the Host header is
|
|
52
|
+
// case-insensitive on the host portion.
|
|
53
|
+
if (typeof s !== "string") return null;
|
|
54
|
+
var t = s.trim().toLowerCase();
|
|
55
|
+
if (t.length === 0) return null;
|
|
56
|
+
return t;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Match a single allowlist entry against an actual Host header value.
|
|
60
|
+
// Wildcards: `*.example.com` matches `app.example.com` but not
|
|
61
|
+
// `app.sub.example.com` (single-label only). Exact host:port match
|
|
62
|
+
// is supported when the entry includes a port; entries without a port
|
|
63
|
+
// match any port.
|
|
64
|
+
function _matches(entry, actual) {
|
|
65
|
+
if (entry === actual) return true;
|
|
66
|
+
// Wildcard prefix
|
|
67
|
+
if (entry.indexOf("*.") === 0) {
|
|
68
|
+
var suffix = entry.slice(1); // ".example.com"
|
|
69
|
+
var actualHost = actual.split(":")[0];
|
|
70
|
+
if (actualHost.length <= suffix.length) return false;
|
|
71
|
+
if (actualHost.slice(-suffix.length) !== suffix) return false;
|
|
72
|
+
var prefix = actualHost.slice(0, actualHost.length - suffix.length);
|
|
73
|
+
if (prefix.indexOf(".") !== -1) return false; // single-label
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
// Port-stripped equality — entry without port matches any port
|
|
77
|
+
if (entry.indexOf(":") === -1 && actual.indexOf(":") !== -1) {
|
|
78
|
+
var actualNoPort = actual.split(":")[0];
|
|
79
|
+
return entry === actualNoPort;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function create(opts) {
|
|
85
|
+
validateOpts.requireObject(opts, "middleware.hostAllowlist", HostAllowlistError);
|
|
86
|
+
validateOpts(opts, [
|
|
87
|
+
"hosts", "denyStatus", "denyBody", "audit",
|
|
88
|
+
], "middleware.hostAllowlist");
|
|
89
|
+
|
|
90
|
+
if (!Array.isArray(opts.hosts) || opts.hosts.length === 0) {
|
|
91
|
+
throw new HostAllowlistError("host-allowlist/no-hosts",
|
|
92
|
+
"middleware.hostAllowlist: opts.hosts must be a non-empty array of allowed Host header values");
|
|
93
|
+
}
|
|
94
|
+
var hosts = [];
|
|
95
|
+
for (var i = 0; i < opts.hosts.length; i += 1) {
|
|
96
|
+
var n = _normalizeHostEntry(opts.hosts[i]);
|
|
97
|
+
if (!n) {
|
|
98
|
+
throw new HostAllowlistError("host-allowlist/bad-host",
|
|
99
|
+
"middleware.hostAllowlist: hosts[" + i + "] is not a non-empty string");
|
|
100
|
+
}
|
|
101
|
+
hosts.push(n);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
var denyStatus = (typeof opts.denyStatus === "number") ? opts.denyStatus : 421; // allow:raw-byte-literal — HTTP 421 status
|
|
105
|
+
var denyBody = typeof opts.denyBody === "string" ? opts.denyBody : "Misdirected Request";
|
|
106
|
+
var auditOn = opts.audit !== false;
|
|
107
|
+
|
|
108
|
+
return function hostAllowlistMiddleware(req, res, next) {
|
|
109
|
+
var raw = req.headers && req.headers.host;
|
|
110
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
111
|
+
// RFC 7230 §5.4 — a request without a Host header is malformed
|
|
112
|
+
// for HTTP/1.1; HTTP/2 maps :authority into req.headers.host
|
|
113
|
+
// automatically. Reject either shape.
|
|
114
|
+
_deny(res, denyStatus, denyBody);
|
|
115
|
+
_emitDenied(req, "missing-host");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
var actual = raw.toLowerCase();
|
|
119
|
+
var matched = false;
|
|
120
|
+
for (var hi = 0; hi < hosts.length; hi += 1) {
|
|
121
|
+
if (_matches(hosts[hi], actual)) { matched = true; break; }
|
|
122
|
+
}
|
|
123
|
+
if (!matched) {
|
|
124
|
+
_deny(res, denyStatus, denyBody);
|
|
125
|
+
_emitDenied(req, "host-not-in-allowlist", actual);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
return next();
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function _deny(res, status, body) {
|
|
132
|
+
if (res.headersSent) return;
|
|
133
|
+
res.writeHead(status, {
|
|
134
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
135
|
+
"Content-Length": Buffer.byteLength(body),
|
|
136
|
+
});
|
|
137
|
+
res.end(body);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function _emitDenied(req, reason, actual) {
|
|
141
|
+
if (!auditOn) return;
|
|
142
|
+
try {
|
|
143
|
+
audit().safeEmit({
|
|
144
|
+
action: "network.host_allowlist.denied",
|
|
145
|
+
outcome: "fail",
|
|
146
|
+
actor: { clientIp: requestHelpers.clientIp(req) },
|
|
147
|
+
metadata: {
|
|
148
|
+
reason: reason,
|
|
149
|
+
host: actual || null,
|
|
150
|
+
route: req.url,
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
} catch (_e) { /* drop-silent — observability sink failure */ }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = {
|
|
158
|
+
create: create,
|
|
159
|
+
};
|
package/lib/middleware/index.js
CHANGED
|
@@ -33,6 +33,7 @@ var fetchMetadata = require("./fetch-metadata");
|
|
|
33
33
|
var gpc = require("./gpc");
|
|
34
34
|
var headers = require("./headers");
|
|
35
35
|
var health = require("./health");
|
|
36
|
+
var hostAllowlist = require("./host-allowlist");
|
|
36
37
|
var networkAllowlist = require("./network-allowlist");
|
|
37
38
|
var rateLimit = require("./rate-limit");
|
|
38
39
|
var requestId = require("./request-id");
|
|
@@ -40,6 +41,7 @@ var requestLog = require("./request-log");
|
|
|
40
41
|
var requireAal = require("./require-aal");
|
|
41
42
|
var requireAuth = require("./require-auth");
|
|
42
43
|
var securityHeaders = require("./security-headers");
|
|
44
|
+
var securityTxt = require("./security-txt");
|
|
43
45
|
var sse = require("./sse");
|
|
44
46
|
|
|
45
47
|
module.exports = {
|
|
@@ -62,11 +64,13 @@ module.exports = {
|
|
|
62
64
|
compression: compression.create,
|
|
63
65
|
cookies: cookies.create,
|
|
64
66
|
cspNonce: cspNonce.create,
|
|
67
|
+
securityTxt: securityTxt.create,
|
|
65
68
|
sse: sse.create,
|
|
66
69
|
requestLog: requestLog.create,
|
|
67
70
|
apiEncrypt: apiEncrypt,
|
|
68
71
|
dbRoleFor: dbRoleFor.create,
|
|
69
72
|
dpop: dpop.create,
|
|
73
|
+
hostAllowlist: hostAllowlist.create,
|
|
70
74
|
networkAllowlist: networkAllowlist.create,
|
|
71
75
|
|
|
72
76
|
// Module exports for advanced use (constants, raw factory access)
|
|
@@ -87,11 +91,13 @@ module.exports = {
|
|
|
87
91
|
health: health,
|
|
88
92
|
compression: compression,
|
|
89
93
|
cspNonce: cspNonce,
|
|
94
|
+
securityTxt: securityTxt,
|
|
90
95
|
sse: sse,
|
|
91
96
|
requestLog: requestLog,
|
|
92
97
|
apiEncrypt: apiEncrypt,
|
|
93
98
|
dbRoleFor: dbRoleFor,
|
|
94
99
|
dpop: dpop,
|
|
100
|
+
hostAllowlist: hostAllowlist,
|
|
95
101
|
networkAllowlist: networkAllowlist,
|
|
96
102
|
},
|
|
97
103
|
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* security-txt middleware — RFC 9116 /.well-known/security.txt emitter.
|
|
4
|
+
*
|
|
5
|
+
* Operators wire this on their app so security researchers know where
|
|
6
|
+
* to find the disclosure policy. The middleware serves a static body
|
|
7
|
+
* at `/.well-known/security.txt` (and root `/security.txt` when
|
|
8
|
+
* opts.alsoAtRoot is true) per RFC 9116 §3 ("Format" — text/plain
|
|
9
|
+
* with one field per line, "Field: value" pairs).
|
|
10
|
+
*
|
|
11
|
+
* var txt = b.middleware.securityTxt({
|
|
12
|
+
* contact: ["mailto:security@example.com", "https://example.com/security/report"],
|
|
13
|
+
* expires: "2027-01-01T00:00:00Z",
|
|
14
|
+
* encryption:["https://example.com/pgp.asc"],
|
|
15
|
+
* policy: "https://example.com/security/policy",
|
|
16
|
+
* ack: "https://example.com/security/hall-of-fame",
|
|
17
|
+
* preferredLanguages: ["en"],
|
|
18
|
+
* });
|
|
19
|
+
* router.use(txt);
|
|
20
|
+
*
|
|
21
|
+
* Per RFC 9116 §2.5, `Contact:` and `Expires:` are REQUIRED. The
|
|
22
|
+
* middleware throws at config-time when either is missing.
|
|
23
|
+
*
|
|
24
|
+
* Per §2.5.1, `Expires:` MUST be a future timestamp; the framework
|
|
25
|
+
* also throws when the operator-supplied `expires` is in the past.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
var lazyRequire = require("../lazy-require");
|
|
29
|
+
var validateOpts = require("../validate-opts");
|
|
30
|
+
var { defineClass } = require("../framework-error");
|
|
31
|
+
|
|
32
|
+
var SecurityTxtError = defineClass("SecurityTxtError", { alwaysPermanent: true });
|
|
33
|
+
|
|
34
|
+
var observability = lazyRequire(function () { return require("../observability"); });
|
|
35
|
+
|
|
36
|
+
function _arrayOfStrings(value, label) {
|
|
37
|
+
if (value === undefined || value === null) return [];
|
|
38
|
+
var arr = Array.isArray(value) ? value : [value];
|
|
39
|
+
for (var i = 0; i < arr.length; i += 1) {
|
|
40
|
+
if (typeof arr[i] !== "string" || arr[i].length === 0) {
|
|
41
|
+
throw new SecurityTxtError("security-txt/bad-" + label,
|
|
42
|
+
label + "[" + i + "] must be a non-empty string");
|
|
43
|
+
}
|
|
44
|
+
if (/[\r\n\0]/.test(arr[i])) {
|
|
45
|
+
throw new SecurityTxtError("security-txt/bad-" + label,
|
|
46
|
+
label + "[" + i + "] contains forbidden CR/LF/NUL");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return arr;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function _isoFuture(s) {
|
|
53
|
+
if (typeof s !== "string" || s.length === 0) return false;
|
|
54
|
+
var d = new Date(s);
|
|
55
|
+
if (isNaN(d.getTime())) return false;
|
|
56
|
+
return d.getTime() > Date.now();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function create(opts) {
|
|
60
|
+
validateOpts.requireObject(opts, "middleware.securityTxt", SecurityTxtError);
|
|
61
|
+
validateOpts(opts, [
|
|
62
|
+
"contact", "expires", "encryption", "policy", "ack",
|
|
63
|
+
"preferredLanguages", "hiring", "canonical",
|
|
64
|
+
"alsoAtRoot", "audit",
|
|
65
|
+
], "middleware.securityTxt");
|
|
66
|
+
|
|
67
|
+
var contact = _arrayOfStrings(opts.contact, "contact");
|
|
68
|
+
if (contact.length === 0) {
|
|
69
|
+
throw new SecurityTxtError("security-txt/no-contact",
|
|
70
|
+
"middleware.securityTxt: contact is required (RFC 9116 §2.5.3)");
|
|
71
|
+
}
|
|
72
|
+
validateOpts.requireNonEmptyString(opts.expires,
|
|
73
|
+
"middleware.securityTxt: expires", SecurityTxtError, "security-txt/no-expires");
|
|
74
|
+
if (!_isoFuture(opts.expires)) {
|
|
75
|
+
throw new SecurityTxtError("security-txt/expires-in-past",
|
|
76
|
+
"middleware.securityTxt: expires must be a future ISO 8601 timestamp (got '" + opts.expires + "')");
|
|
77
|
+
}
|
|
78
|
+
var encryption = _arrayOfStrings(opts.encryption, "encryption");
|
|
79
|
+
var policy = _arrayOfStrings(opts.policy, "policy");
|
|
80
|
+
var ack = _arrayOfStrings(opts.ack, "ack");
|
|
81
|
+
var canonical = _arrayOfStrings(opts.canonical, "canonical");
|
|
82
|
+
var hiring = _arrayOfStrings(opts.hiring, "hiring");
|
|
83
|
+
var prefLangs = _arrayOfStrings(opts.preferredLanguages, "preferredLanguages");
|
|
84
|
+
|
|
85
|
+
// Build the body once at create time — the response is identical
|
|
86
|
+
// for every request and the Content-Length is known up front.
|
|
87
|
+
var lines = [];
|
|
88
|
+
for (var i = 0; i < contact.length; i += 1) lines.push("Contact: " + contact[i]);
|
|
89
|
+
lines.push("Expires: " + opts.expires);
|
|
90
|
+
for (var ei = 0; ei < encryption.length; ei += 1) lines.push("Encryption: " + encryption[ei]);
|
|
91
|
+
for (var pi = 0; pi < policy.length; pi += 1) lines.push("Policy: " + policy[pi]);
|
|
92
|
+
for (var ai = 0; ai < ack.length; ai += 1) lines.push("Acknowledgments: " + ack[ai]);
|
|
93
|
+
for (var ci = 0; ci < canonical.length; ci += 1) lines.push("Canonical: " + canonical[ci]);
|
|
94
|
+
for (var hi = 0; hi < hiring.length; hi += 1) lines.push("Hiring: " + hiring[hi]);
|
|
95
|
+
if (prefLangs.length > 0) lines.push("Preferred-Languages: " + prefLangs.join(", "));
|
|
96
|
+
var body = lines.join("\n") + "\n";
|
|
97
|
+
var bodyBuf = Buffer.from(body, "utf8");
|
|
98
|
+
var alsoAtRoot = opts.alsoAtRoot === true;
|
|
99
|
+
|
|
100
|
+
return function securityTxtMiddleware(req, res, next) {
|
|
101
|
+
var url = req.url || "";
|
|
102
|
+
// Strip query string for the path comparison.
|
|
103
|
+
var qIdx = url.indexOf("?");
|
|
104
|
+
var path = qIdx === -1 ? url : url.slice(0, qIdx);
|
|
105
|
+
var matches = (path === "/.well-known/security.txt") ||
|
|
106
|
+
(alsoAtRoot && path === "/security.txt");
|
|
107
|
+
if (!matches) return next();
|
|
108
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
109
|
+
res.writeHead(405, { // allow:raw-byte-literal — HTTP 405 status
|
|
110
|
+
"Allow": "GET, HEAD",
|
|
111
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
112
|
+
"Content-Length": 18, // allow:raw-byte-literal — len of "Method Not Allowed"
|
|
113
|
+
});
|
|
114
|
+
res.end("Method Not Allowed");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
res.writeHead(200, { // allow:raw-byte-literal — HTTP 200 status
|
|
118
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
119
|
+
"Content-Length": bodyBuf.length,
|
|
120
|
+
"Cache-Control": "public, max-age=86400",
|
|
121
|
+
"X-Content-Type-Options": "nosniff",
|
|
122
|
+
});
|
|
123
|
+
if (req.method === "HEAD") { res.end(); return; }
|
|
124
|
+
res.end(bodyBuf);
|
|
125
|
+
try { observability().safeEvent("middleware.securityTxt.served", 1, { path: path }); }
|
|
126
|
+
catch (_e) { /* obs best-effort */ }
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = {
|
|
131
|
+
create: create,
|
|
132
|
+
};
|
package/package.json
CHANGED
package/sbom.cyclonedx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:cff2cdad-8420-45be-b8ae-a4b9ec79c49a",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-06T05:
|
|
8
|
+
"timestamp": "2026-05-06T05:43:30.523Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.7.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.7.81",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.7.
|
|
25
|
+
"version": "0.7.81",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.7.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.7.81",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.7.
|
|
57
|
+
"ref": "@blamejs/core@0.7.81",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|