@daloyjs/core 0.36.0 → 0.38.0
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/LICENSE +21 -0
- package/README.md +34 -3
- package/bin/daloy.mjs +2 -0
- package/dist/adapters/bun.js +16 -9
- package/dist/adapters/deno.js +7 -1
- package/dist/adapters/node.d.ts +25 -0
- package/dist/adapters/node.js +32 -0
- package/dist/app.d.ts +200 -6
- package/dist/app.js +235 -50
- package/dist/asyncapi.d.ts +98 -0
- package/dist/asyncapi.js +212 -0
- package/dist/auto-ban.d.ts +205 -0
- package/dist/auto-ban.js +222 -0
- package/dist/bot-guard.d.ts +209 -0
- package/dist/bot-guard.js +291 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +113 -4
- package/dist/client.d.ts +23 -0
- package/dist/client.js +16 -0
- package/dist/concurrency-limit.d.ts +135 -0
- package/dist/concurrency-limit.js +254 -0
- package/dist/docs.d.ts +57 -6
- package/dist/docs.js +34 -3
- package/dist/errors.d.ts +43 -0
- package/dist/errors.js +57 -0
- package/dist/fetch-guard.js +4 -0
- package/dist/fetch-resilience.d.ts +295 -0
- package/dist/fetch-resilience.js +485 -0
- package/dist/geo-block.d.ts +184 -0
- package/dist/geo-block.js +153 -0
- package/dist/hashing.d.ts +2 -1
- package/dist/hashing.js +12 -1
- package/dist/http-signatures.d.ts +303 -0
- package/dist/http-signatures.js +782 -0
- package/dist/idempotency.d.ts +204 -0
- package/dist/idempotency.js +341 -0
- package/dist/index.d.ts +39 -5
- package/dist/index.js +19 -2
- package/dist/ip-reputation.d.ts +198 -0
- package/dist/ip-reputation.js +253 -0
- package/dist/jwk.d.ts +15 -0
- package/dist/jwk.js +24 -2
- package/dist/load-shedding.d.ts +5 -0
- package/dist/logger.js +6 -2
- package/dist/metrics.d.ts +208 -0
- package/dist/metrics.js +452 -0
- package/dist/middleware.js +0 -10
- package/dist/mtls.d.ts +266 -0
- package/dist/mtls.js +488 -0
- package/dist/multipart.js +1 -1
- package/dist/openapi-diff.d.ts +79 -0
- package/dist/openapi-diff.js +246 -0
- package/dist/openapi.js +4 -1
- package/dist/pagination.d.ts +210 -0
- package/dist/pagination.js +353 -0
- package/dist/rate-limit-redis.d.ts +8 -0
- package/dist/rate-limit-redis.js +8 -0
- package/dist/request-decompression.d.ts +200 -0
- package/dist/request-decompression.js +363 -0
- package/dist/response-cache.d.ts +205 -0
- package/dist/response-cache.js +374 -0
- package/dist/router.d.ts +22 -0
- package/dist/router.js +64 -7
- package/dist/safe-redirect.d.ts +2 -2
- package/dist/safe-redirect.js +3 -8
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/scheduler.d.ts +315 -0
- package/dist/scheduler.js +546 -0
- package/dist/security.d.ts +61 -7
- package/dist/security.js +75 -8
- package/dist/session.js +3 -3
- package/dist/types.d.ts +33 -0
- package/dist/waf.d.ts +213 -0
- package/dist/waf.js +334 -0
- package/dist/webhook-delivery.d.ts +263 -0
- package/dist/webhook-delivery.js +311 -0
- package/dist/websocket.d.ts +52 -0
- package/dist/websocket.js +13 -0
- package/package.json +79 -3
package/dist/mtls.js
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutual-TLS / client-certificate authentication.
|
|
3
|
+
*
|
|
4
|
+
* The Web-standard {@link Request} does not surface the TLS peer certificate,
|
|
5
|
+
* so — exactly like {@link "./conn-info.js"} does for the peer IP — adapters
|
|
6
|
+
* (or a trusted TLS-terminating reverse proxy) attach a normalized
|
|
7
|
+
* {@link ClientCertificate} to the request, and the {@link clientCertAuth}
|
|
8
|
+
* middleware reads it back to enforce a client-certificate identity for
|
|
9
|
+
* zero-trust / service-to-service deployments.
|
|
10
|
+
*
|
|
11
|
+
* Two population paths are supported, both runtime-portable and dependency-free:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Native TLS** — when the runtime terminates TLS itself (the Node adapter
|
|
14
|
+
* reads `tls.TLSSocket#getPeerCertificate()`), the adapter stashes a lazy
|
|
15
|
+
* thunk via {@link setClientCertificate}; the certificate is only normalized
|
|
16
|
+
* if a `clientCertAuth()`-guarded route actually reads it, so plain requests
|
|
17
|
+
* pay nothing.
|
|
18
|
+
* 2. **Forwarded by a trusted proxy** — when TLS is terminated upstream
|
|
19
|
+
* (Envoy, nginx, HAProxy, Traefik, a cloud load balancer), the proxy forwards
|
|
20
|
+
* the verified client identity in request headers. {@link clientCertAuth}
|
|
21
|
+
* can parse Envoy's `X-Forwarded-Client-Cert` (XFCC) or a set of operator-named
|
|
22
|
+
* structured headers (nginx `$ssl_client_*`, etc.). Because those headers are
|
|
23
|
+
* spoofable by anything that can reach the app directly, the header path is
|
|
24
|
+
* opt-in and must be paired with a `behindProxy` posture that guarantees the
|
|
25
|
+
* app is only reachable through the terminating proxy.
|
|
26
|
+
*
|
|
27
|
+
* @module
|
|
28
|
+
* @since 0.37.0
|
|
29
|
+
*/
|
|
30
|
+
import { ForbiddenError } from "./errors.js";
|
|
31
|
+
import { timingSafeEqual } from "./security.js";
|
|
32
|
+
const CLIENT_CERT_SYMBOL = Symbol.for("daloyjs.clientCertificate");
|
|
33
|
+
/**
|
|
34
|
+
* @internal Adapter helper — attach a {@link ClientCertificate} (or a lazy
|
|
35
|
+
* thunk producing one) to a `Request`. Mirrors {@link "./conn-info.js".setConnInfo}.
|
|
36
|
+
* Pass a thunk to defer the read until {@link getClientCertificate} is first
|
|
37
|
+
* called; the resolved value is cached back onto the request.
|
|
38
|
+
*
|
|
39
|
+
* @since 0.37.0
|
|
40
|
+
*/
|
|
41
|
+
export function setClientCertificate(request, source) {
|
|
42
|
+
request[CLIENT_CERT_SYMBOL] = source;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Read the {@link ClientCertificate} an adapter attached to this request, or
|
|
46
|
+
* `undefined` when the connection presented no client certificate (or the
|
|
47
|
+
* adapter does not expose TLS peer info). If a lazy thunk was stashed, it is
|
|
48
|
+
* resolved once and the result cached.
|
|
49
|
+
*
|
|
50
|
+
* @since 0.37.0
|
|
51
|
+
*/
|
|
52
|
+
export function getClientCertificate(request) {
|
|
53
|
+
const store = request;
|
|
54
|
+
const source = store[CLIENT_CERT_SYMBOL];
|
|
55
|
+
if (typeof source === "function") {
|
|
56
|
+
const resolved = source();
|
|
57
|
+
store[CLIENT_CERT_SYMBOL] = resolved;
|
|
58
|
+
return resolved;
|
|
59
|
+
}
|
|
60
|
+
return source;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Normalize a Node `getPeerCertificate(true)` result into a
|
|
64
|
+
* {@link ClientCertificate}. Returns `undefined` for the empty object Node
|
|
65
|
+
* returns when the peer presented no certificate.
|
|
66
|
+
*
|
|
67
|
+
* @param raw - The structured peer-certificate object from the TLS socket.
|
|
68
|
+
* @param verified - Whether the socket reported `authorized === true` (the
|
|
69
|
+
* chain was verified against the configured CA).
|
|
70
|
+
* @since 0.37.0
|
|
71
|
+
*/
|
|
72
|
+
export function normalizePeerCertificate(raw, verified) {
|
|
73
|
+
if (!raw || typeof raw !== "object")
|
|
74
|
+
return undefined;
|
|
75
|
+
const subjectDN = dnFromRecord(raw.subject);
|
|
76
|
+
const issuerDN = dnFromRecord(raw.issuer);
|
|
77
|
+
const hasAnyField = subjectDN !== undefined ||
|
|
78
|
+
issuerDN !== undefined ||
|
|
79
|
+
raw.fingerprint256 !== undefined ||
|
|
80
|
+
raw.serialNumber !== undefined;
|
|
81
|
+
if (!hasAnyField)
|
|
82
|
+
return undefined;
|
|
83
|
+
return {
|
|
84
|
+
subjectDN,
|
|
85
|
+
subjectCN: cnFromRecord(raw.subject),
|
|
86
|
+
issuerDN,
|
|
87
|
+
issuerCN: cnFromRecord(raw.issuer),
|
|
88
|
+
serialNumber: raw.serialNumber,
|
|
89
|
+
fingerprint256: normalizeFingerprint(raw.fingerprint256),
|
|
90
|
+
subjectAltNames: parseNodeSubjectAltName(raw.subjectaltname),
|
|
91
|
+
notBefore: parseCertDate(raw.valid_from),
|
|
92
|
+
notAfter: parseCertDate(raw.valid_to),
|
|
93
|
+
verified,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function dnFromRecord(rec) {
|
|
97
|
+
if (!rec || typeof rec !== "object")
|
|
98
|
+
return undefined;
|
|
99
|
+
const parts = [];
|
|
100
|
+
for (const key of Object.keys(rec)) {
|
|
101
|
+
const value = rec[key];
|
|
102
|
+
if (Array.isArray(value)) {
|
|
103
|
+
for (const v of value)
|
|
104
|
+
parts.push(`${key}=${v}`);
|
|
105
|
+
}
|
|
106
|
+
else if (value !== undefined) {
|
|
107
|
+
parts.push(`${key}=${value}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return parts.length > 0 ? parts.join(",") : undefined;
|
|
111
|
+
}
|
|
112
|
+
function cnFromRecord(rec) {
|
|
113
|
+
if (!rec || typeof rec !== "object")
|
|
114
|
+
return undefined;
|
|
115
|
+
const cn = rec["CN"];
|
|
116
|
+
if (Array.isArray(cn))
|
|
117
|
+
return cn[0];
|
|
118
|
+
return cn;
|
|
119
|
+
}
|
|
120
|
+
function normalizeFingerprint(fp) {
|
|
121
|
+
if (typeof fp !== "string" || fp.length === 0)
|
|
122
|
+
return undefined;
|
|
123
|
+
// Strip colon separators and uppercase so XFCC `Hash=` (no separators) and
|
|
124
|
+
// Node `fingerprint256` (colon-delimited) compare identically.
|
|
125
|
+
let out = "";
|
|
126
|
+
for (let i = 0; i < fp.length; i++) {
|
|
127
|
+
const c = fp.charCodeAt(i);
|
|
128
|
+
if (c === 58 /* ':' */ || c === 32 /* space */)
|
|
129
|
+
continue;
|
|
130
|
+
out += fp[i];
|
|
131
|
+
}
|
|
132
|
+
return out.toUpperCase();
|
|
133
|
+
}
|
|
134
|
+
function parseCertDate(value) {
|
|
135
|
+
if (typeof value !== "string" || value.length === 0)
|
|
136
|
+
return undefined;
|
|
137
|
+
const ms = Date.parse(value);
|
|
138
|
+
return Number.isNaN(ms) ? undefined : new Date(ms);
|
|
139
|
+
}
|
|
140
|
+
function parseNodeSubjectAltName(san) {
|
|
141
|
+
if (typeof san !== "string" || san.length === 0)
|
|
142
|
+
return [];
|
|
143
|
+
// Node renders SANs as `DNS:a, IP Address:1.2.3.4, URI:spiffe://...`.
|
|
144
|
+
const out = [];
|
|
145
|
+
for (const piece of san.split(",")) {
|
|
146
|
+
const trimmed = piece.trim();
|
|
147
|
+
if (trimmed.length === 0)
|
|
148
|
+
continue;
|
|
149
|
+
out.push(trimmed.replace(/^IP Address:/i, "IP:"));
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Split a string on a single-character separator while ignoring separators that
|
|
155
|
+
* appear inside double-quoted spans. No backtracking — single linear scan.
|
|
156
|
+
*
|
|
157
|
+
* @internal
|
|
158
|
+
*/
|
|
159
|
+
function splitRespectingQuotes(value, sep) {
|
|
160
|
+
const out = [];
|
|
161
|
+
let current = "";
|
|
162
|
+
let inQuotes = false;
|
|
163
|
+
for (let i = 0; i < value.length; i++) {
|
|
164
|
+
const ch = value[i];
|
|
165
|
+
if (ch === '"') {
|
|
166
|
+
inQuotes = !inQuotes;
|
|
167
|
+
current += ch;
|
|
168
|
+
}
|
|
169
|
+
else if (ch === sep && !inQuotes) {
|
|
170
|
+
out.push(current);
|
|
171
|
+
current = "";
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
current += ch;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
out.push(current);
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
function unquote(value) {
|
|
181
|
+
const trimmed = value.trim();
|
|
182
|
+
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
183
|
+
return trimmed.slice(1, -1);
|
|
184
|
+
}
|
|
185
|
+
return trimmed;
|
|
186
|
+
}
|
|
187
|
+
function cnFromDN(dn) {
|
|
188
|
+
if (!dn)
|
|
189
|
+
return undefined;
|
|
190
|
+
for (const rdn of splitRespectingQuotes(dn, ",")) {
|
|
191
|
+
const eq = rdn.indexOf("=");
|
|
192
|
+
if (eq === -1)
|
|
193
|
+
continue;
|
|
194
|
+
const key = rdn.slice(0, eq).trim();
|
|
195
|
+
if (key.toUpperCase() === "CN")
|
|
196
|
+
return unquote(rdn.slice(eq + 1));
|
|
197
|
+
}
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Parse an Envoy `X-Forwarded-Client-Cert` (XFCC) header value into a
|
|
202
|
+
* {@link ClientCertificate}. XFCC is a comma-separated list of proxy elements,
|
|
203
|
+
* each a `;`-delimited set of `Key=Value` pairs (`Hash`, `Subject`, `URI`,
|
|
204
|
+
* `DNS`, `Cert`, …). The **first** element is the client closest to the origin
|
|
205
|
+
* and is the one returned. Because Envoy only emits XFCC for connections it
|
|
206
|
+
* mutually authenticated, the result is marked `verified: true`.
|
|
207
|
+
*
|
|
208
|
+
* Returns `undefined` for an empty or unparseable header.
|
|
209
|
+
*
|
|
210
|
+
* @since 0.37.0
|
|
211
|
+
*/
|
|
212
|
+
export function parseForwardedClientCert(headerValue) {
|
|
213
|
+
if (typeof headerValue !== "string" || headerValue.trim().length === 0) {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
const firstElement = splitRespectingQuotes(headerValue, ",")[0];
|
|
217
|
+
if (!firstElement || firstElement.trim().length === 0)
|
|
218
|
+
return undefined;
|
|
219
|
+
let subjectDN;
|
|
220
|
+
let fingerprint256;
|
|
221
|
+
let serialNumber;
|
|
222
|
+
let pem;
|
|
223
|
+
const sans = [];
|
|
224
|
+
for (const pair of splitRespectingQuotes(firstElement, ";")) {
|
|
225
|
+
const eq = pair.indexOf("=");
|
|
226
|
+
if (eq === -1)
|
|
227
|
+
continue;
|
|
228
|
+
const key = pair.slice(0, eq).trim().toLowerCase();
|
|
229
|
+
const value = unquote(pair.slice(eq + 1));
|
|
230
|
+
if (value.length === 0)
|
|
231
|
+
continue;
|
|
232
|
+
switch (key) {
|
|
233
|
+
case "subject":
|
|
234
|
+
subjectDN = value;
|
|
235
|
+
break;
|
|
236
|
+
case "hash":
|
|
237
|
+
fingerprint256 = normalizeFingerprint(value);
|
|
238
|
+
break;
|
|
239
|
+
case "serial":
|
|
240
|
+
serialNumber = value;
|
|
241
|
+
break;
|
|
242
|
+
case "dns":
|
|
243
|
+
sans.push(`DNS:${value}`);
|
|
244
|
+
break;
|
|
245
|
+
case "uri":
|
|
246
|
+
sans.push(`URI:${value}`);
|
|
247
|
+
break;
|
|
248
|
+
case "cert":
|
|
249
|
+
pem = decodeURIComponentSafe(value);
|
|
250
|
+
break;
|
|
251
|
+
default:
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (subjectDN === undefined &&
|
|
256
|
+
fingerprint256 === undefined &&
|
|
257
|
+
sans.length === 0 &&
|
|
258
|
+
pem === undefined) {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
subjectDN,
|
|
263
|
+
subjectCN: cnFromDN(subjectDN),
|
|
264
|
+
serialNumber,
|
|
265
|
+
fingerprint256,
|
|
266
|
+
subjectAltNames: sans,
|
|
267
|
+
verified: true,
|
|
268
|
+
pem,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function decodeURIComponentSafe(value) {
|
|
272
|
+
try {
|
|
273
|
+
return decodeURIComponent(value);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const MISSING_CERT_BODY = JSON.stringify({
|
|
280
|
+
type: "https://daloyjs.dev/errors/client-certificate-required",
|
|
281
|
+
title: "Client certificate required",
|
|
282
|
+
status: 401,
|
|
283
|
+
});
|
|
284
|
+
/**
|
|
285
|
+
* Middleware enforcing mutual-TLS client-certificate authentication. Reads the
|
|
286
|
+
* normalized {@link ClientCertificate} attached by the adapter (native TLS) or
|
|
287
|
+
* parsed from a trusted-proxy header, enforces verification + optional
|
|
288
|
+
* allow-lists + validity window + a custom hook, and stamps the accepted
|
|
289
|
+
* certificate on `ctx.state` for downstream handlers.
|
|
290
|
+
*
|
|
291
|
+
* Rejection semantics:
|
|
292
|
+
* - **No certificate presented** → `401` `application/problem+json` with
|
|
293
|
+
* `Cache-Control: no-store`.
|
|
294
|
+
* - **Unverified / not allow-listed / expired / custom-rejected** → `403`
|
|
295
|
+
* {@link ForbiddenError} (never echoes certificate details).
|
|
296
|
+
*
|
|
297
|
+
* @example Native TLS (Node adapter terminates mTLS):
|
|
298
|
+
* ```ts
|
|
299
|
+
* app.use(clientCertAuth({
|
|
300
|
+
* allowIssuerCNs: ["acme-internal-ca"],
|
|
301
|
+
* allowSANs: ["URI:spiffe://acme/svc-a"],
|
|
302
|
+
* }));
|
|
303
|
+
* ```
|
|
304
|
+
*
|
|
305
|
+
* @example Behind an Envoy proxy forwarding XFCC:
|
|
306
|
+
* ```ts
|
|
307
|
+
* app.use(clientCertAuth({
|
|
308
|
+
* header: { format: "xfcc" },
|
|
309
|
+
* allowFingerprints: [process.env.PEER_FINGERPRINT!],
|
|
310
|
+
* }));
|
|
311
|
+
* ```
|
|
312
|
+
*
|
|
313
|
+
* @param opts - Verification, allow-list, header-source, and hook options.
|
|
314
|
+
* @returns A {@link Hooks} bundle for `app.use(...)`.
|
|
315
|
+
* @since 0.37.0
|
|
316
|
+
*/
|
|
317
|
+
export function clientCertAuth(opts = {}) {
|
|
318
|
+
const requireVerified = opts.requireVerified !== false;
|
|
319
|
+
const checkValidity = opts.checkValidity !== false;
|
|
320
|
+
const message = opts.message ?? "Client certificate not permitted";
|
|
321
|
+
const stateKey = opts.stateKey ?? "clientCertificate";
|
|
322
|
+
const now = opts.now ?? Date.now;
|
|
323
|
+
const allowFingerprints = (opts.allowFingerprints ?? []).map((f) => normalizeFingerprint(f) ?? "");
|
|
324
|
+
const allowSubjectCNs = opts.allowSubjectCNs;
|
|
325
|
+
const allowIssuerCNs = opts.allowIssuerCNs;
|
|
326
|
+
const allowSANs = opts.allowSANs;
|
|
327
|
+
const headerConfig = opts.header;
|
|
328
|
+
if (headerConfig !== undefined) {
|
|
329
|
+
assertHeaderConfig(headerConfig);
|
|
330
|
+
}
|
|
331
|
+
const resolve = opts.resolve ??
|
|
332
|
+
((ctx) => {
|
|
333
|
+
const native = getClientCertificate(ctx.request);
|
|
334
|
+
if (native)
|
|
335
|
+
return native;
|
|
336
|
+
if (headerConfig)
|
|
337
|
+
return certFromHeaders(ctx.request, headerConfig);
|
|
338
|
+
return undefined;
|
|
339
|
+
});
|
|
340
|
+
return {
|
|
341
|
+
async beforeHandle(ctx) {
|
|
342
|
+
const cert = resolve(ctx);
|
|
343
|
+
if (!cert) {
|
|
344
|
+
return new Response(MISSING_CERT_BODY, {
|
|
345
|
+
status: 401,
|
|
346
|
+
headers: {
|
|
347
|
+
"content-type": "application/problem+json",
|
|
348
|
+
"cache-control": "no-store",
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
if (requireVerified && !cert.verified) {
|
|
353
|
+
throw new ForbiddenError(message);
|
|
354
|
+
}
|
|
355
|
+
if (checkValidity && !isWithinValidity(cert, now())) {
|
|
356
|
+
throw new ForbiddenError(message);
|
|
357
|
+
}
|
|
358
|
+
if (allowSubjectCNs && !matchesAllowedCN(cert.subjectCN, allowSubjectCNs)) {
|
|
359
|
+
throw new ForbiddenError(message);
|
|
360
|
+
}
|
|
361
|
+
if (allowIssuerCNs && !matchesAllowedCN(cert.issuerCN, allowIssuerCNs)) {
|
|
362
|
+
throw new ForbiddenError(message);
|
|
363
|
+
}
|
|
364
|
+
if (allowFingerprints.length > 0 && !matchesFingerprint(cert, allowFingerprints)) {
|
|
365
|
+
throw new ForbiddenError(message);
|
|
366
|
+
}
|
|
367
|
+
if (allowSANs && !matchesSAN(cert.subjectAltNames, allowSANs)) {
|
|
368
|
+
throw new ForbiddenError(message);
|
|
369
|
+
}
|
|
370
|
+
if (opts.verify) {
|
|
371
|
+
const ok = await opts.verify(cert, ctx);
|
|
372
|
+
if (ok === false)
|
|
373
|
+
throw new ForbiddenError(message);
|
|
374
|
+
}
|
|
375
|
+
ctx.state[stateKey] = cert;
|
|
376
|
+
return undefined;
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
function assertHeaderConfig(cfg) {
|
|
381
|
+
if (cfg.format === "xfcc")
|
|
382
|
+
return;
|
|
383
|
+
if (cfg.format === "structured") {
|
|
384
|
+
if (!cfg.subjectDN &&
|
|
385
|
+
!cfg.fingerprint &&
|
|
386
|
+
!cfg.san &&
|
|
387
|
+
!cfg.serialNumber &&
|
|
388
|
+
!cfg.issuerDN) {
|
|
389
|
+
throw new Error("clientCertAuth(): structured header config must name at least one of subjectDN/issuerDN/fingerprint/serialNumber/san.");
|
|
390
|
+
}
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
throw new Error('clientCertAuth(): header.format must be "xfcc" or "structured".');
|
|
394
|
+
}
|
|
395
|
+
function certFromHeaders(request, cfg) {
|
|
396
|
+
if (cfg.format === "xfcc") {
|
|
397
|
+
const name = cfg.name ?? "x-forwarded-client-cert";
|
|
398
|
+
return parseForwardedClientCert(request.headers.get(name));
|
|
399
|
+
}
|
|
400
|
+
const subjectDN = readHeader(request, cfg.subjectDN);
|
|
401
|
+
const issuerDN = readHeader(request, cfg.issuerDN);
|
|
402
|
+
const fingerprint = normalizeFingerprint(readHeader(request, cfg.fingerprint));
|
|
403
|
+
const serialNumber = readHeader(request, cfg.serialNumber);
|
|
404
|
+
const sanRaw = readHeader(request, cfg.san);
|
|
405
|
+
const verifyRaw = cfg.verify ? readHeader(request, cfg.verify) : undefined;
|
|
406
|
+
const successValue = (cfg.verifySuccessValue ?? "SUCCESS").toLowerCase();
|
|
407
|
+
const verified = cfg.verify === undefined
|
|
408
|
+
? true
|
|
409
|
+
: (verifyRaw ?? "").toLowerCase() === successValue;
|
|
410
|
+
const sans = [];
|
|
411
|
+
if (sanRaw) {
|
|
412
|
+
for (const piece of sanRaw.split(",")) {
|
|
413
|
+
const trimmed = piece.trim();
|
|
414
|
+
if (trimmed.length > 0)
|
|
415
|
+
sans.push(trimmed);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (subjectDN === undefined &&
|
|
419
|
+
issuerDN === undefined &&
|
|
420
|
+
fingerprint === undefined &&
|
|
421
|
+
serialNumber === undefined &&
|
|
422
|
+
sans.length === 0) {
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
subjectDN,
|
|
427
|
+
subjectCN: cnFromDN(subjectDN),
|
|
428
|
+
issuerDN,
|
|
429
|
+
issuerCN: cnFromDN(issuerDN),
|
|
430
|
+
serialNumber,
|
|
431
|
+
fingerprint256: fingerprint,
|
|
432
|
+
subjectAltNames: sans,
|
|
433
|
+
verified,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
function readHeader(request, name) {
|
|
437
|
+
if (!name)
|
|
438
|
+
return undefined;
|
|
439
|
+
const value = request.headers.get(name);
|
|
440
|
+
if (value === null)
|
|
441
|
+
return undefined;
|
|
442
|
+
const trimmed = value.trim();
|
|
443
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
444
|
+
}
|
|
445
|
+
function isWithinValidity(cert, nowMs) {
|
|
446
|
+
if (cert.notBefore && nowMs < cert.notBefore.getTime())
|
|
447
|
+
return false;
|
|
448
|
+
if (cert.notAfter && nowMs > cert.notAfter.getTime())
|
|
449
|
+
return false;
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
function matchesAllowedCN(cn, allowed) {
|
|
453
|
+
if (!cn)
|
|
454
|
+
return false;
|
|
455
|
+
for (const a of allowed) {
|
|
456
|
+
if (a === cn)
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
function matchesFingerprint(cert, allowed) {
|
|
462
|
+
const fp = cert.fingerprint256;
|
|
463
|
+
if (!fp)
|
|
464
|
+
return false;
|
|
465
|
+
let matched = false;
|
|
466
|
+
// Constant-time per comparison; do not early-return so we don't leak which
|
|
467
|
+
// allow-list entry matched via timing.
|
|
468
|
+
for (const a of allowed) {
|
|
469
|
+
if (timingSafeEqual(fp, a))
|
|
470
|
+
matched = true;
|
|
471
|
+
}
|
|
472
|
+
return matched;
|
|
473
|
+
}
|
|
474
|
+
function matchesSAN(sans, allowed) {
|
|
475
|
+
if (sans.length === 0)
|
|
476
|
+
return false;
|
|
477
|
+
for (const want of allowed) {
|
|
478
|
+
for (const have of sans) {
|
|
479
|
+
if (have === want)
|
|
480
|
+
return true;
|
|
481
|
+
// Allow matching a bare value against the `TYPE:value` form.
|
|
482
|
+
const colon = have.indexOf(":");
|
|
483
|
+
if (colon !== -1 && have.slice(colon + 1) === want)
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return false;
|
|
488
|
+
}
|
package/dist/multipart.js
CHANGED
|
@@ -236,8 +236,8 @@ async function verifyMagicBytes(file, signatures) {
|
|
|
236
236
|
/** Shared implementation for the `fileField` overloads above. */
|
|
237
237
|
export function fileField(options = {}) {
|
|
238
238
|
const opts = {
|
|
239
|
-
format: options.format ?? "binary",
|
|
240
239
|
...options,
|
|
240
|
+
format: options.format ?? "binary",
|
|
241
241
|
};
|
|
242
242
|
const magicSignatures = normalizeMagicBytesOption(opts.magicBytes, opts.accept);
|
|
243
243
|
const scriptableImagesEnabled = opts.rejectScriptableImages === undefined
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@daloyjs/core/openapi-diff` — pure, dependency-free OpenAPI 3.x diffing.
|
|
3
|
+
*
|
|
4
|
+
* Compares two OpenAPI documents (a published *baseline* and a freshly
|
|
5
|
+
* generated *current* spec) and classifies every structural change as either
|
|
6
|
+
* **breaking** (a consumer relying on the baseline could now fail) or
|
|
7
|
+
* **non-breaking** (purely additive / informational). This is the engine
|
|
8
|
+
* behind the `daloy diff` CLI command and the `verify:breaking-changes` CI
|
|
9
|
+
* gate, answering the single question a contract-first framework should make
|
|
10
|
+
* trivial: *"did this change break my published API?"*
|
|
11
|
+
*
|
|
12
|
+
* The implementation walks plain JSON and never imports a schema validator or
|
|
13
|
+
* any runtime dependency, so it can run in any environment that can read two
|
|
14
|
+
* JSON files.
|
|
15
|
+
*
|
|
16
|
+
* @module
|
|
17
|
+
* @since 0.37.0
|
|
18
|
+
*/
|
|
19
|
+
/** Severity classification for a single detected change. */
|
|
20
|
+
export type ChangeSeverity = "breaking" | "non-breaking";
|
|
21
|
+
/**
|
|
22
|
+
* A single structural difference between two OpenAPI documents.
|
|
23
|
+
*/
|
|
24
|
+
export interface OpenAPIChange {
|
|
25
|
+
/** Whether this change can break an existing consumer. */
|
|
26
|
+
severity: ChangeSeverity;
|
|
27
|
+
/**
|
|
28
|
+
* Stable machine-readable category, e.g. `"operation.removed"`,
|
|
29
|
+
* `"response.removed"`, `"parameter.required.added"`.
|
|
30
|
+
*/
|
|
31
|
+
kind: string;
|
|
32
|
+
/** Human-readable pointer, e.g. `"GET /books/{id}"` or `"GET /books → 404"`. */
|
|
33
|
+
location: string;
|
|
34
|
+
/** Short prose describing the change. */
|
|
35
|
+
detail: string;
|
|
36
|
+
}
|
|
37
|
+
/** Structured result of {@link diffOpenAPI}. */
|
|
38
|
+
export interface OpenAPIDiffResult {
|
|
39
|
+
/** Changes that may break an existing consumer. */
|
|
40
|
+
breaking: OpenAPIChange[];
|
|
41
|
+
/** Additive or informational changes that are safe for consumers. */
|
|
42
|
+
nonBreaking: OpenAPIChange[];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Compare a baseline OpenAPI document against a current one and classify the
|
|
46
|
+
* differences. The comparison is intentionally conservative: anything that
|
|
47
|
+
* could cause a request that succeeded against the baseline to fail against
|
|
48
|
+
* the current spec is reported as **breaking**; additive and metadata-only
|
|
49
|
+
* changes are reported as **non-breaking**.
|
|
50
|
+
*
|
|
51
|
+
* Detected breaking changes:
|
|
52
|
+
* - a path or operation (HTTP method) present in the baseline is removed;
|
|
53
|
+
* - a documented response status code is removed from an operation;
|
|
54
|
+
* - a new `required` parameter is added to an existing operation;
|
|
55
|
+
* - an existing optional parameter becomes `required`;
|
|
56
|
+
* - an operation's request body becomes required when it was not.
|
|
57
|
+
*
|
|
58
|
+
* Detected non-breaking changes:
|
|
59
|
+
* - new paths, operations, response codes, or optional parameters;
|
|
60
|
+
* - a parameter is removed (the server no longer reads it);
|
|
61
|
+
* - an operation becomes `deprecated`;
|
|
62
|
+
* - the document `info.version` changes.
|
|
63
|
+
*
|
|
64
|
+
* @param baseline - The previously published OpenAPI document (JSON).
|
|
65
|
+
* @param current - The freshly generated OpenAPI document (JSON).
|
|
66
|
+
* @returns Structured lists of breaking and non-breaking changes.
|
|
67
|
+
* @since 0.37.0
|
|
68
|
+
*/
|
|
69
|
+
export declare function diffOpenAPI(baseline: unknown, current: unknown): OpenAPIDiffResult;
|
|
70
|
+
/**
|
|
71
|
+
* Convenience predicate over {@link diffOpenAPI}: `true` when the current
|
|
72
|
+
* document introduces at least one breaking change versus the baseline.
|
|
73
|
+
*
|
|
74
|
+
* @param baseline - The previously published OpenAPI document (JSON).
|
|
75
|
+
* @param current - The freshly generated OpenAPI document (JSON).
|
|
76
|
+
* @returns `true` if any breaking change was detected.
|
|
77
|
+
* @since 0.37.0
|
|
78
|
+
*/
|
|
79
|
+
export declare function hasBreakingChanges(baseline: unknown, current: unknown): boolean;
|