client-certificate-auth 1.3.3 → 1.3.4

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.
@@ -15,11 +15,14 @@ import { extractClientCertificate } from './extractor.js';
15
15
 
16
16
  /**
17
17
  * @typedef {Object} ClientCertificateAuthOptions
18
- * @property {'aws-alb' | 'envoy' | 'cloudflare' | 'traefik'} [certificateSource] - Use a preset
18
+ * @property {'aws-alb' | 'envoy' | 'cloudflare' | 'traefik'} [certificateSource] - Use a preset
19
19
  * configuration for a known reverse proxy. Header-based certs are only checked if this or
20
- * certificateHeader is set.
20
+ * certificateHeader is set. Trust boundary: the proxy must strip the preset's header from
21
+ * external requests; any source that can set it is trusted to assert client identity.
21
22
  * @property {string} [certificateHeader] - Custom header name to read certificate from.
22
- * Overrides preset header name if also using certificateSource.
23
+ * Overrides preset header name if also using certificateSource. Trust boundary: the proxy
24
+ * must strip this header from external requests; any source that can set it is trusted to
25
+ * assert client identity.
23
26
  * @property {'url-pem' | 'url-pem-aws' | 'xfcc' | 'base64-der' | 'rfc9440'} [headerEncoding] -
24
27
  * How to decode the header value. Required when using certificateHeader without certificateSource.
25
28
  * @property {boolean} [fallbackToSocket=false] - If header-based extraction is configured but
package/lib/extractor.js CHANGED
@@ -21,8 +21,12 @@ import { getCertificateFromHeaders } from './parsers.js';
21
21
 
22
22
  /**
23
23
  * @typedef {Object} ExtractorOptions
24
- * @property {'aws-alb' | 'envoy' | 'cloudflare' | 'traefik'} [certificateSource] - Preset configuration
25
- * @property {string} [certificateHeader] - Custom header name
24
+ * @property {'aws-alb' | 'envoy' | 'cloudflare' | 'traefik'} [certificateSource] - Preset
25
+ * configuration. Trust boundary: the proxy must strip the preset's header from external
26
+ * requests; any source that can set it is trusted to assert client identity.
27
+ * @property {string} [certificateHeader] - Custom header name. Trust boundary: the proxy must
28
+ * strip this header from external requests; any source that can set it is trusted to assert
29
+ * client identity.
26
30
  * @property {'url-pem' | 'url-pem-aws' | 'xfcc' | 'base64-der' | 'rfc9440'} [headerEncoding] - Header encoding
27
31
  * @property {boolean} [fallbackToSocket=false] - Try socket if header extraction fails
28
32
  * @property {boolean} [includeChain=false] - Include issuerCertificate chain
package/lib/helpers.js CHANGED
@@ -245,6 +245,9 @@ export function allowEmail(emails) {
245
245
  * )));
246
246
  */
247
247
  export function allOf(...callbacks) {
248
+ if (callbacks.length === 0) {
249
+ return () => false;
250
+ }
248
251
  return async (cert, req) => {
249
252
  const results = await Promise.all(callbacks.map((cb) => cb(cert, req)));
250
253
  return results.every((r) => r === true);
package/lib/parsers.js CHANGED
@@ -60,7 +60,7 @@ export const PRESETS = {
60
60
  * @returns {PeerCertificate | null} Parsed certificate or null on failure
61
61
  */
62
62
  export function parseUrlPem(headerValue) {
63
- // Stryker disable next-line BlockStatement: falsy input falls through to try-catch → same null return
63
+ // Stryker disable next-line BlockStatement,ConditionalExpression: falsy input falls through to try-catch → same null return
64
64
  if (!headerValue) {
65
65
  return null;
66
66
  }
@@ -73,17 +73,88 @@ export function parseUrlPem(headerValue) {
73
73
  }
74
74
  }
75
75
 
76
+ /**
77
+ * Split a string of concatenated PEM CERTIFICATE blocks into an array of
78
+ * individual PEM strings. Uses indexOf scanning (O(N) in input length) rather
79
+ * than regex to avoid polynomial-time backtracking on adversarial input
80
+ * (e.g., many unterminated BEGIN markers).
81
+ *
82
+ * @param {string} pem - Concatenated PEM blocks
83
+ * @returns {string[]} Individual PEM blocks (empty array if none found)
84
+ */
85
+ function splitPemBlocks(pem) {
86
+ // Stryker disable next-line ArrayDeclaration: sentinel-array mutant pushes a non-PEM string that pemToCertificate rejects and .filter(Boolean) drops downstream — same chain output
87
+ const blocks = [];
88
+ // Stryker disable next-line StringLiteral: empty beginMarker → indexOf returns scanPos every iteration; END markers still bracket the same blocks correctly
89
+ const beginMarker = '-----BEGIN CERTIFICATE-----';
90
+ const endMarker = '-----END CERTIFICATE-----';
91
+ let scanPos = 0;
92
+ while (true) {
93
+ const begin = pem.indexOf(beginMarker, scanPos);
94
+ if (begin === -1) {break;}
95
+ const end = pem.indexOf(endMarker, begin + beginMarker.length);
96
+ if (end === -1) {break;}
97
+ blocks.push(pem.substring(begin, end + endMarker.length));
98
+ // Stryker disable next-line ArithmeticOperator: end-endMarker.length backs up before the END marker, but the next BEGIN is past it so indexOf still advances correctly
99
+ scanPos = end + endMarker.length;
100
+ }
101
+ return blocks;
102
+ }
103
+
104
+ /**
105
+ * Parse a multi-block PEM blob into a chained PeerCertificate. Splits the
106
+ * input into individual blocks, parses each, drops blocks that fail to
107
+ * parse, and links the remainder via issuerCertificate.
108
+ *
109
+ * Used by parsers whose proxies forward the full chain in a single field
110
+ * (parseUrlPemAws, parseXfcc Chain). Without explicit chain linking, calling
111
+ * X509Certificate() on a multi-block PEM returns just the leaf with no
112
+ * issuerCertificate (toLegacyObject drops the property), silently losing
113
+ * chain information for `includeChain: true` consumers.
114
+ *
115
+ * @param {string} pem - Concatenated PEM blocks
116
+ * @returns {PeerCertificate | null} Leaf cert with chain via issuerCertificate, or null if no blocks parse
117
+ */
118
+ function chainFromMultiBlockPem(pem) {
119
+ const pemBlocks = splitPemBlocks(pem);
120
+ // Stryker disable next-line BlockStatement,ConditionalExpression: short-circuit; falling through hits the next certs.length===0 check with the same null result
121
+ if (pemBlocks.length === 0) {
122
+ return null;
123
+ }
124
+
125
+ const certs = pemBlocks.map(block => {
126
+ try {
127
+ return pemToCertificate(block);
128
+ } catch {
129
+ // Stryker disable next-line BlockStatement: empty catch returns undefined which .filter(Boolean) drops alongside null — same chain output
130
+ return null;
131
+ }
132
+ }).filter(Boolean);
133
+
134
+ if (certs.length === 0) {
135
+ return null;
136
+ }
137
+
138
+ // Stryker disable next-line EqualityOperator: setting issuerCertificate = undefined on last cert is same as not setting it
139
+ for (let i = 0; i < certs.length - 1; i++) {
140
+ certs[i].issuerCertificate = certs[i + 1];
141
+ }
142
+
143
+ return certs[0];
144
+ }
145
+
76
146
  /**
77
147
  * Parse URL-encoded PEM certificate with AWS ALB safe character handling.
78
148
  * AWS ALB uses +, =, / as safe characters (not encoded), but decodeURIComponent
79
- * interprets + as space, so we must escape it first.
80
- *
149
+ * interprets + as space, so we must escape it first. AWS sends the full chain
150
+ * as concatenated PEM blocks, which we split and link via issuerCertificate.
151
+ *
81
152
  * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/mutual-authentication.html
82
153
  * @param {string} headerValue - AWS ALB URL-encoded PEM certificate
83
154
  * @returns {PeerCertificate | null} Parsed certificate or null on failure
84
155
  */
85
156
  export function parseUrlPemAws(headerValue) {
86
- // Stryker disable next-line BlockStatement: falsy input falls through to try-catch → same null return
157
+ // Stryker disable next-line BlockStatement,ConditionalExpression: falsy input falls through to try-catch → same null return
87
158
  if (!headerValue) {
88
159
  return null;
89
160
  }
@@ -93,51 +164,7 @@ export function parseUrlPemAws(headerValue) {
93
164
  // Must escape before decodeURIComponent or + becomes space
94
165
  const escaped = headerValue.replace(/\+/g, '%2B');
95
166
  const pem = decodeURIComponent(escaped);
96
-
97
- // AWS sends the full chain as concatenated PEM blocks. Split into
98
- // individual blocks and parse each, then link via issuerCertificate
99
- // (mirrors parseBase64Der's chain handling for Traefik/Cloudflare).
100
- // Node's X509Certificate throws on multi-block input, so without this
101
- // split the entire request fails when AWS forwards a real chain.
102
- //
103
- // Uses indexOf scanning rather than regex to avoid polynomial-time
104
- // backtracking on adversarial input (e.g., many unterminated BEGIN
105
- // markers). Total work is O(N) in the input length.
106
- const pemBlocks = [];
107
- const beginMarker = '-----BEGIN CERTIFICATE-----';
108
- const endMarker = '-----END CERTIFICATE-----';
109
- let scanPos = 0;
110
- while (true) {
111
- const begin = pem.indexOf(beginMarker, scanPos);
112
- if (begin === -1) {break;}
113
- const end = pem.indexOf(endMarker, begin + beginMarker.length);
114
- if (end === -1) {break;}
115
- pemBlocks.push(pem.substring(begin, end + endMarker.length));
116
- scanPos = end + endMarker.length;
117
- }
118
-
119
- if (pemBlocks.length === 0) {
120
- return null;
121
- }
122
-
123
- const certs = pemBlocks.map(block => {
124
- try {
125
- return pemToCertificate(block);
126
- } catch {
127
- return null;
128
- }
129
- }).filter(Boolean);
130
-
131
- if (certs.length === 0) {
132
- return null;
133
- }
134
-
135
- // Link the cert chain via issuerCertificate
136
- for (let i = 0; i < certs.length - 1; i++) {
137
- certs[i].issuerCertificate = certs[i + 1];
138
- }
139
-
140
- return certs[0];
167
+ return chainFromMultiBlockPem(pem);
141
168
  } catch {
142
169
  return null;
143
170
  }
@@ -145,14 +172,17 @@ export function parseUrlPemAws(headerValue) {
145
172
 
146
173
  /**
147
174
  * Parse Envoy XFCC (X-Forwarded-Client-Cert) structured header format.
148
- * Format: Key=Value;Key=Value;... where Cert or Chain contains URL-encoded PEM.
149
- *
175
+ * Format: Key=Value;Key=Value;... where Cert (single URL-encoded PEM) or
176
+ * Chain (URL-encoded multi-block PEM with the full chain incl. leaf) carry
177
+ * the certificate. When both are present we prefer Chain because it carries
178
+ * more information, and link the chain via issuerCertificate.
179
+ *
150
180
  * @see https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers#x-forwarded-client-cert
151
181
  * @param {string} headerValue - XFCC formatted header value
152
182
  * @returns {PeerCertificate | null} Parsed certificate or null on failure
153
183
  */
154
184
  export function parseXfcc(headerValue) {
155
- // Stryker disable next-line BlockStatement: falsy input falls through to try-catch → same null return
185
+ // Stryker disable next-line BlockStatement,ConditionalExpression: falsy input falls through to try-catch → same null return
156
186
  if (!headerValue) {
157
187
  return null;
158
188
  }
@@ -176,8 +206,11 @@ export function parseXfcc(headerValue) {
176
206
  }
177
207
  const firstElement = headerValue.substring(0, endOfFirst);
178
208
 
179
- // Parse key=value pairs separated by semicolons
209
+ // Parse key=value pairs separated by semicolons. Collect Cert and
210
+ // Chain values; prefer Chain when both are present.
180
211
  const pairs = firstElement.split(';');
212
+ let certValue = null;
213
+ let chainValue = null;
181
214
 
182
215
  for (const pair of pairs) {
183
216
  const eqIndex = pair.indexOf('=');
@@ -191,19 +224,32 @@ export function parseXfcc(headerValue) {
191
224
  // Stryker disable next-line MethodExpression: defensive normalization, no real proxy sends whitespace
192
225
  let value = pair.substring(eqIndex + 1).trim();
193
226
 
194
- // Cert or Chain contain the certificate
195
227
  if (key === 'Cert' || key === 'Chain') {
196
- // Remove surrounding quotes if present
228
+ // Stryker disable next-line LogicalOperator,MethodExpression,StringLiteral,BlockStatement: quote-stripping mutations are equivalent given the secondary unbalanced-quote reject below and lenient PEM marker scan downstream — broken quotes either trigger reject or produce a stripped value that fails to parse, both yielding null
197
229
  if (value.startsWith('"') && value.endsWith('"')) {
230
+ // Stryker disable next-line MethodExpression: skipping the slice leaves quotes around the value; PEM marker scan still finds BEGIN/END inside them and parses the cert content correctly — equivalent for tests
198
231
  value = value.slice(1, -1);
232
+ } else if (value.startsWith('"') || value.endsWith('"')) {
233
+ // Unbalanced quote indicates malformed XFCC. Reject rather
234
+ // than parse the cert content out of the broken wrapping.
235
+ return null;
236
+ }
237
+ if (key === 'Chain') {
238
+ chainValue = value;
239
+ } else {
240
+ certValue = value;
199
241
  }
200
-
201
- const pem = decodeURIComponent(value);
202
- return pemToCertificate(pem);
203
242
  }
204
243
  }
205
244
 
206
- return null;
245
+ const value = chainValue ?? certValue;
246
+ // Stryker disable next-line BlockStatement,ConditionalExpression: short-circuit; falling through with null hands "null" string to chainFromMultiBlockPem which finds no PEM markers and returns null
247
+ if (!value) {
248
+ return null;
249
+ }
250
+
251
+ const pem = decodeURIComponent(value);
252
+ return chainFromMultiBlockPem(pem);
207
253
  } catch {
208
254
  return null;
209
255
  }
@@ -219,7 +265,7 @@ export function parseXfcc(headerValue) {
219
265
  * @returns {PeerCertificate | null} Parsed certificate or null on failure
220
266
  */
221
267
  export function parseBase64Der(headerValue) {
222
- // Stryker disable next-line BlockStatement: falsy input falls through to try-catch → same null return
268
+ // Stryker disable next-line BlockStatement,ConditionalExpression: falsy input falls through to try-catch → same null return
223
269
  if (!headerValue) {
224
270
  return null;
225
271
  }
@@ -266,7 +312,7 @@ export function parseBase64Der(headerValue) {
266
312
  * @returns {PeerCertificate | null} Parsed certificate or null on failure
267
313
  */
268
314
  export function parseRfc9440(headerValue) {
269
- // Stryker disable next-line BlockStatement: falsy input falls through to try-catch → same null return
315
+ // Stryker disable next-line BlockStatement,ConditionalExpression: falsy input falls through to try-catch → same null return
270
316
  if (!headerValue) {
271
317
  return null;
272
318
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "client-certificate-auth",
3
- "version": "1.3.3",
3
+ "version": "1.3.4",
4
4
  "description": "Express/Connect middleware for mTLS client certificate authentication with reverse proxy support (AWS ALB, Envoy, Cloudflare, Traefik)",
5
5
  "homepage": "https://github.com/tgies/client-certificate-auth",
6
6
  "bugs": {