client-certificate-auth 2.1.2 → 2.2.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/README.md CHANGED
@@ -191,8 +191,9 @@ Returns Express middleware.
191
191
  | Name | Type | Description |
192
192
  |------|------|-------------|
193
193
  | `callback` | `(cert, req?) => boolean \| PromiseLike<boolean>` | Receives the client certificate and request, returns `true` to allow access |
194
- | `options.certificateSource` | `string` | Use a preset for a known proxy: `'aws-alb'`, `'envoy'`, `'cloudflare'`, `'traefik'` |
194
+ | `options.certificateSource` | `string` | Use a preset for a known proxy: `'aws-alb'`, `'aws-alb-verify'`, `'azure-app-service'`, `'cloudflare'`, `'cloudflare-rfc9440'`, `'envoy'`, `'traefik'` |
195
195
  | `options.certificateHeader` | `string` | Custom header name to read certificate from |
196
+ | `options.chainHeader` | `string` | Second header carrying the certificate chain alongside the leaf (RFC 9440 style) |
196
197
  | `options.headerEncoding` | `string` | Encoding format: `'url-pem'`, `'url-pem-aws'`, `'xfcc'`, `'base64-der'`, `'rfc9440'` |
197
198
  | `options.fallbackToSocket` | `boolean` | If header extraction fails, try `socket.getPeerCertificate()` (default: `false`) |
198
199
  | `options.includeChain` | `boolean` | If `true`, include full certificate chain via `cert.issuerCertificate` (default: `false`) |
@@ -353,6 +354,8 @@ app.use(clientCertificateAuth((cert) => {
353
354
 
354
355
  When `includeChain: true`, the certificate object includes `issuerCertificate` linking to the issuer's certificate (and so on up the chain). This works consistently for both socket-based and header-based extraction.
355
356
 
357
+ For header-based extraction the first certificate in the header is the leaf. If it is empty or unparseable the header is rejected with `header_missing_or_malformed` rather than promoting the next certificate into the leaf position. See [Reverse Proxy Setup](https://tgies.github.io/client-certificate-auth/guide/reverse-proxy#chains-in-a-single-header) for the per-encoding details.
358
+
356
359
  ### User Login
357
360
 
358
361
  Client certificates provide cryptographically-verified identity, making them ideal for user authentication. Map certificate fields to user accounts in your database:
@@ -413,21 +416,36 @@ When your application runs behind a TLS-terminating reverse proxy, the client ce
413
416
  For common proxies, use the `certificateSource` option:
414
417
 
415
418
  ```javascript
416
- // AWS Application Load Balancer
419
+ // AWS Application Load Balancer (mTLS passthrough)
417
420
  app.use(clientCertificateAuth(checkAuth, {
418
421
  certificateSource: 'aws-alb'
419
422
  }));
420
423
 
424
+ // AWS Application Load Balancer (mTLS verify mode)
425
+ app.use(clientCertificateAuth(checkAuth, {
426
+ certificateSource: 'aws-alb-verify'
427
+ }));
428
+
429
+ // Azure App Service
430
+ app.use(clientCertificateAuth(checkAuth, {
431
+ certificateSource: 'azure-app-service'
432
+ }));
433
+
421
434
  // Envoy / Istio
422
435
  app.use(clientCertificateAuth(checkAuth, {
423
436
  certificateSource: 'envoy'
424
437
  }));
425
438
 
426
- // Cloudflare
439
+ // Cloudflare (legacy Cf-Client-Cert-Der-Base64 header)
427
440
  app.use(clientCertificateAuth(checkAuth, {
428
441
  certificateSource: 'cloudflare'
429
442
  }));
430
443
 
444
+ // Cloudflare (RFC 9440 Client-Cert / Client-Cert-Chain headers, March 2026+)
445
+ app.use(clientCertificateAuth(checkAuth, {
446
+ certificateSource: 'cloudflare-rfc9440'
447
+ }));
448
+
431
449
  // Traefik
432
450
  app.use(clientCertificateAuth(checkAuth, {
433
451
  certificateSource: 'traefik'
@@ -436,16 +454,21 @@ app.use(clientCertificateAuth(checkAuth, {
436
454
 
437
455
  ### Preset Details
438
456
 
439
- | Preset | Header | Encoding |
440
- |--------|--------|----------|
457
+ | Preset | Header(s) | Encoding |
458
+ |--------|-----------|----------|
441
459
  | `aws-alb` | `X-Amzn-Mtls-Clientcert` | URL-encoded PEM (AWS variant) |
442
- | `envoy` | `X-Forwarded-Client-Cert` | XFCC structured format |
460
+ | `aws-alb-verify` | `X-Amzn-Mtls-Clientcert-Leaf` | URL-encoded PEM (AWS variant) |
461
+ | `azure-app-service` | `X-ARR-ClientCert` | Base64-encoded DER |
443
462
  | `cloudflare` | `Cf-Client-Cert-Der-Base64` | Base64-encoded DER |
463
+ | `cloudflare-rfc9440` | `Client-Cert` + `Client-Cert-Chain` | RFC 9440 |
464
+ | `envoy` | `X-Forwarded-Client-Cert` | XFCC structured format |
444
465
  | `traefik` | `X-Forwarded-Tls-Client-Cert` | Base64-encoded DER \* |
445
466
 
446
- > \* **Traefik note:** The `traefik` preset targets Traefik v3's `PassTLSClientCert` middleware with `pem: true`. Despite Traefik's docs describing this as "PEM format", the wire format is the base64 body without PEM headers equivalent to base64-encoded DER. Behavior may differ in Traefik v2.
467
+ > ⚠️ **Passthrough presets do not validate the certificate.** ALB mTLS passthrough mode (`aws-alb`) and Azure App Service (`azure-app-service`) forward whatever certificate the client presented during the handshake, without checking it against a trust store. The middleware parses the certificate, but your callback is responsible for trust verification: matching the issuer against an expected CA, checking the validity window, and checking revocation if applicable. A callback that only checks `cert.subject.CN` (including the `allowCN` helper) will accept any well-formed certificate a client presents. To rely on proxy-side validation instead, use ALB verify mode (`aws-alb-verify`), which validates against a configured trust store, or pin the certificate with `allowFingerprints`.
447
468
 
448
- > **Cloudflare note:** Cloudflare also provides certificates via the `CF-Client-Cert-PEM` header (URL-encoded PEM). If you use that header instead, configure manually with `certificateHeader: 'CF-Client-Cert-PEM'` and `headerEncoding: 'url-pem'`.
469
+ > \* **Traefik note:** The `traefik` preset targets Traefik v3's `PassTLSClientCert` middleware with `pem: true`. Despite Traefik's docs describing this as "PEM format", the wire format is the base64 body without PEM headers, equivalent to base64-encoded DER. This applies to Traefik v2.9.4 and later and all of v3; Traefik 2.8 through 2.9.1 URL-escaped the value, which this preset does not decode.
470
+
471
+ > **Cloudflare note:** Cloudflare also provides certificates via the `CF-Client-Cert-PEM` header (URL-encoded PEM). If you use that header instead, configure manually with `certificateHeader: 'CF-Client-Cert-PEM'` and `headerEncoding: 'url-pem'`. For the RFC 9440 forwarding feature (March 2026 and later), use the `cloudflare-rfc9440` preset, which pairs the `Client-Cert` header with `Client-Cert-Chain` via the `chainHeader` option.
449
472
 
450
473
  ### Custom Headers
451
474
 
@@ -458,9 +481,9 @@ app.use(clientCertificateAuth(checkAuth, {
458
481
  headerEncoding: 'url-pem'
459
482
  }));
460
483
 
461
- // Google Cloud Load Balancer (RFC 9440)
484
+ // Google Cloud Load Balancer (custom header populated from {client_cert_leaf}, RFC 9440)
462
485
  app.use(clientCertificateAuth(checkAuth, {
463
- certificateHeader: 'X-SSL-Whatever-You-Use',
486
+ certificateHeader: 'X-Client-Cert-Leaf',
464
487
  headerEncoding: 'rfc9440'
465
488
  }));
466
489
 
@@ -475,11 +498,13 @@ app.use(clientCertificateAuth(checkAuth, {
475
498
 
476
499
  | Encoding | Description | Used By |
477
500
  |----------|-------------|---------|
478
- | `url-pem` | URL-encoded PEM certificate | nginx, HAProxy |
501
+ | `url-pem` | URL-encoded PEM certificate | nginx (`$ssl_client_escaped_cert`) |
479
502
  | `url-pem-aws` | URL-encoded PEM (AWS variant, `+` as safe char) | AWS ALB |
480
503
  | `xfcc` | Envoy's structured `Key=Value;...` format | Envoy, Istio |
481
- | `base64-der` | Base64-encoded DER certificate | Cloudflare, Traefik |
482
- | `rfc9440` | RFC 9440 format: `:base64-der:` | Google Cloud LB |
504
+ | `base64-der` | Base64-encoded DER certificate | Cloudflare, Traefik, Azure App Service, HAProxy (`ssl_c_der,base64`) |
505
+ | `rfc9440` | RFC 9440 format: `:base64-der:` | Cloudflare (RFC 9440 forwarding), Google Cloud LB |
506
+
507
+ HAProxy's native certificate forwarding (`http-request set-header ... %[ssl_c_der,base64]`) is base64 DER, so pair it with `base64-der`. HAProxy has no built-in URL-encoded-PEM output; `url-pem` from HAProxy requires a custom Lua `url_enc` converter.
483
508
 
484
509
  ### Fallback Mode
485
510
 
@@ -646,8 +671,8 @@ app.use(clientCertificateAuth(allowCN(['service-a', 'service-b'])));
646
671
 
647
672
  // Allowlist by fingerprint
648
673
  app.use(clientCertificateAuth(allowFingerprints([
649
- 'SHA256:AB:CD:EF:...',
650
- 'AB:CD:EF:...' // SHA256: prefix optional
674
+ 'SHA256:AB:CD:EF:...', // matched against cert.fingerprint256
675
+ 'AB:CD:EF:...' // SHA-1, matched against cert.fingerprint
651
676
  ])));
652
677
 
653
678
  // Allowlist by Organization
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'http';
2
2
  import type { Socket } from 'net';
3
3
  import type { PeerCertificate, DetailedPeerCertificate } from 'tls';
4
+ import type { ChainedPeerCertificate } from './parsers.js';
4
5
  import type {
5
6
  ClientCertificateAuthOptions as EsmOptions,
6
7
  ValidationCallback as EsmValidationCallback,
@@ -56,7 +57,7 @@ export interface ClientCertRequest extends IncomingMessage {
56
57
  * Available after successful certificate extraction.
57
58
  * Contains issuerCertificate chain if includeChain option is true.
58
59
  */
59
- clientCertificate?: PeerCertificate | DetailedPeerCertificate;
60
+ clientCertificate?: ChainedPeerCertificate;
60
61
  }
61
62
 
62
63
  /**
@@ -88,7 +89,7 @@ export interface ClientCertificateAuthOptions {
88
89
  * @param req - The HTTP request object
89
90
  */
90
91
  onAuthenticated?: (
91
- cert: PeerCertificate | DetailedPeerCertificate,
92
+ cert: ChainedPeerCertificate,
92
93
  req: ClientCertRequest
93
94
  ) => void | Promise<void>;
94
95
 
@@ -100,14 +101,14 @@ export interface ClientCertificateAuthOptions {
100
101
  * @param reason - Why authentication was rejected
101
102
  */
102
103
  onRejected?: (
103
- cert: PeerCertificate | DetailedPeerCertificate | null,
104
+ cert: ChainedPeerCertificate | null,
104
105
  req: ClientCertRequest,
105
106
  reason: string
106
107
  ) => void | Promise<void>;
107
108
  }
108
109
 
109
110
  export type ValidationCallback = (
110
- cert: PeerCertificate | DetailedPeerCertificate,
111
+ cert: ChainedPeerCertificate,
111
112
  req?: ClientCertRequest
112
113
  ) => boolean | PromiseLike<boolean>;
113
114
 
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'http';
2
2
  import type { Socket } from 'net';
3
3
  import type { PeerCertificate, DetailedPeerCertificate } from 'tls';
4
+ import type { ChainedPeerCertificate } from './parsers.js';
4
5
  import type { CertificateSource, HeaderEncoding } from './parsers.js';
5
6
 
6
7
  export type { CertificateSource, HeaderEncoding };
@@ -54,7 +55,7 @@ export interface ClientCertRequest extends IncomingMessage {
54
55
  * Available after successful certificate extraction, before authorization callback.
55
56
  * Contains issuerCertificate chain if includeChain option is true.
56
57
  */
57
- clientCertificate?: PeerCertificate | DetailedPeerCertificate;
58
+ clientCertificate?: ChainedPeerCertificate;
58
59
  }
59
60
 
60
61
  /**
@@ -127,7 +128,7 @@ export interface ClientCertificateAuthOptions {
127
128
  * @param req - The HTTP request object
128
129
  */
129
130
  onAuthenticated?: (
130
- cert: PeerCertificate | DetailedPeerCertificate,
131
+ cert: ChainedPeerCertificate,
131
132
  req: ClientCertRequest
132
133
  ) => void | Promise<void>;
133
134
 
@@ -139,14 +140,14 @@ export interface ClientCertificateAuthOptions {
139
140
  * @param reason - Why authentication was rejected
140
141
  */
141
142
  onRejected?: (
142
- cert: PeerCertificate | DetailedPeerCertificate | null,
143
+ cert: ChainedPeerCertificate | null,
143
144
  req: ClientCertRequest,
144
145
  reason: string
145
146
  ) => void | Promise<void>;
146
147
  }
147
148
 
148
149
  export type ValidationCallback = (
149
- cert: PeerCertificate | DetailedPeerCertificate,
150
+ cert: ChainedPeerCertificate,
150
151
  req?: ClientCertRequest
151
152
  ) => boolean | PromiseLike<boolean>;
152
153
 
@@ -34,7 +34,7 @@ function normalizeCallbackError(err) {
34
34
  }
35
35
 
36
36
  /**
37
- * @typedef {import('http').IncomingMessage & { secure?: boolean; socket: import('net').Socket & { authorized?: boolean; authorizationError?: Error | string; getPeerCertificate?: (detailed?: boolean) => import('tls').PeerCertificate | import('tls').DetailedPeerCertificate }; clientCertificate?: import('tls').PeerCertificate }} ClientCertRequest
37
+ * @typedef {import('http').IncomingMessage & { secure?: boolean; socket: import('net').Socket & { authorized?: boolean; authorizationError?: Error | string; getPeerCertificate?: (detailed?: boolean) => import('tls').PeerCertificate | import('tls').DetailedPeerCertificate }; clientCertificate?: import('./parsers.js').ChainedPeerCertificate }} ClientCertRequest
38
38
  * @typedef {import('http').ServerResponse & { redirect: (statusOrUrl: number | string, url?: string) => void }} ClientCertResponse
39
39
  * @typedef {(req: ClientCertRequest, res: ClientCertResponse, next: (err?: Error) => void) => void} Middleware
40
40
  */
@@ -64,9 +64,9 @@ function normalizeCallbackError(err) {
64
64
  * @property {string} [verifyValue] - Expected value indicating successful verification (e.g., 'SUCCESS').
65
65
  * If verifyHeader is set, requests are rejected unless the header matches this value.
66
66
  * Comparison is exact (case-sensitive, no whitespace trimming); set this to the exact string your proxy emits.
67
- * @property {(cert: import('tls').PeerCertificate, req: ClientCertRequest) => void | Promise<void>} [onAuthenticated] -
67
+ * @property {(cert: import('./parsers.js').ChainedPeerCertificate, req: ClientCertRequest) => void | Promise<void>} [onAuthenticated] -
68
68
  * Called when a client is successfully authenticated. Fire-and-forget.
69
- * @property {(cert: import('tls').PeerCertificate | null, req: ClientCertRequest, reason: string) => void | Promise<void>} [onRejected] -
69
+ * @property {(cert: import('./parsers.js').ChainedPeerCertificate | null, req: ClientCertRequest, reason: string) => void | Promise<void>} [onRejected] -
70
70
  * Called when authentication is rejected. Fire-and-forget.
71
71
  */
72
72
 
@@ -78,7 +78,7 @@ function normalizeCallbackError(err) {
78
78
  * `req.socket.getPeerCertificate()` or extracted from headers) and must
79
79
  * return `true` (or a thenable resolving to `true`) for the request to proceed.
80
80
  *
81
- * @param {(cert: import('tls').PeerCertificate, req: ClientCertRequest) => boolean | PromiseLike<boolean>} callback
81
+ * @param {(cert: import('./parsers.js').ChainedPeerCertificate, req: ClientCertRequest) => boolean | PromiseLike<boolean>} callback
82
82
  * Validation function that receives the client certificate and the request
83
83
  * object. Returns true/false (sync) or a `PromiseLike<boolean>` (async,
84
84
  * including native Promises and any thenable resolving to a boolean) to
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @typedef {Object} ExtractionResult
3
3
  * @property {boolean} success - Whether extraction succeeded
4
- * @property {import('tls').PeerCertificate | null} certificate - Extracted certificate (null on failure)
4
+ * @property {import('./parsers.js').ChainedPeerCertificate | null} certificate - Extracted certificate (null on failure)
5
5
  * @property {string | null} reason - Rejection reason code (null on success)
6
6
  *
7
7
  * Rejection reasons:
@@ -74,7 +74,7 @@ export type ExtractionResult = {
74
74
  /**
75
75
  * - Extracted certificate (null on failure)
76
76
  */
77
- certificate: import("tls").PeerCertificate | null;
77
+ certificate: import("./parsers.js").ChainedPeerCertificate | null;
78
78
  /**
79
79
  * - Rejection reason code (null on success)
80
80
  *
package/lib/extractor.js CHANGED
@@ -56,7 +56,7 @@ export function validateExtractorOptions(options = {}) {
56
56
  /**
57
57
  * @typedef {Object} ExtractionResult
58
58
  * @property {boolean} success - Whether extraction succeeded
59
- * @property {import('tls').PeerCertificate | null} certificate - Extracted certificate (null on failure)
59
+ * @property {import('./parsers.js').ChainedPeerCertificate | null} certificate - Extracted certificate (null on failure)
60
60
  * @property {string | null} reason - Rejection reason code (null on success)
61
61
  *
62
62
  * Rejection reasons:
@@ -163,7 +163,8 @@ export function extractClientCertificate(req, options = {}) {
163
163
  const chainEncoding = headerEncoding ?? preset?.encoding;
164
164
  if (chainHeaderName && chainEncoding) {
165
165
  const chainHeaderValue = req.headers[chainHeaderName];
166
- if (chainHeaderValue && !Array.isArray(chainHeaderValue)) {
166
+ // Only a non-empty string is parseable; anything else leaves the leaf unchained.
167
+ if (typeof chainHeaderValue === 'string' && chainHeaderValue) {
167
168
  const chainCerts = chainHeaderValue
168
169
  .split(',')
169
170
  .map(item => item.trim())
package/lib/parsers.d.ts CHANGED
@@ -6,6 +6,14 @@
6
6
 
7
7
  import type { PeerCertificate } from 'tls';
8
8
 
9
+ /**
10
+ * A certificate with an optional issuer chain. Header-extracted chains end at
11
+ * the topmost certificate, while Node's socket chains self-reference at the root.
12
+ */
13
+ export type ChainedPeerCertificate = PeerCertificate & {
14
+ issuerCertificate?: ChainedPeerCertificate;
15
+ };
16
+
9
17
  /**
10
18
  * Supported header encoding formats.
11
19
  */
@@ -60,26 +68,26 @@ export interface CertificateHeaderConfig {
60
68
  * Parse URL-encoded PEM certificate (nginx, HAProxy format).
61
69
  * @see https://nginx.org/en/docs/http/ngx_http_ssl_module.html#var_ssl_client_escaped_cert
62
70
  */
63
- export declare function parseUrlPem(headerValue: string): PeerCertificate | null;
71
+ export declare function parseUrlPem(headerValue: string): ChainedPeerCertificate | null;
64
72
 
65
73
  /**
66
74
  * Parse URL-encoded PEM certificate with AWS ALB safe character handling.
67
75
  * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/mutual-authentication.html
68
76
  */
69
- export declare function parseUrlPemAws(headerValue: string): PeerCertificate | null;
77
+ export declare function parseUrlPemAws(headerValue: string): ChainedPeerCertificate | null;
70
78
 
71
79
  /**
72
80
  * Parse Envoy XFCC (X-Forwarded-Client-Cert) structured header format.
73
81
  * @see https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers#x-forwarded-client-cert
74
82
  */
75
- export declare function parseXfcc(headerValue: string): PeerCertificate | null;
83
+ export declare function parseXfcc(headerValue: string): ChainedPeerCertificate | null;
76
84
 
77
85
  /**
78
86
  * Parse base64-encoded DER certificate (Cloudflare, Traefik format).
79
87
  * Also handles Traefik's comma-separated cert chains.
80
88
  * @see https://developers.cloudflare.com/api-shield/security/mtls/configure/
81
89
  */
82
- export declare function parseBase64Der(headerValue: string): PeerCertificate | null;
90
+ export declare function parseBase64Der(headerValue: string): ChainedPeerCertificate | null;
83
91
 
84
92
  /**
85
93
  * Parse RFC 9440 format certificate (used by Google Cloud Load Balancer).
@@ -103,7 +111,7 @@ export declare function derToCertificate(der: Buffer): PeerCertificate;
103
111
  export declare function parseHeaderValue(
104
112
  headerValue: string,
105
113
  encoding: HeaderEncoding
106
- ): PeerCertificate | null;
114
+ ): ChainedPeerCertificate | null;
107
115
 
108
116
  /**
109
117
  * Get certificate from request headers using configuration.
@@ -111,4 +119,4 @@ export declare function parseHeaderValue(
111
119
  export declare function getCertificateFromHeaders(
112
120
  headers: Record<string, string | string[] | undefined>,
113
121
  config: CertificateHeaderConfig
114
- ): PeerCertificate | null;
122
+ ): ChainedPeerCertificate | null;
package/lib/parsers.js CHANGED
@@ -11,6 +11,12 @@ import { X509Certificate } from 'node:crypto';
11
11
  * @typedef {import('tls').PeerCertificate} PeerCertificate
12
12
  */
13
13
 
14
+ /**
15
+ * A certificate with an optional issuer chain. Header-extracted chains end at
16
+ * the topmost certificate, while Node's socket chains self-reference at the root.
17
+ * @typedef {PeerCertificate & { issuerCertificate?: ChainedPeerCertificate }} ChainedPeerCertificate
18
+ */
19
+
14
20
  /**
15
21
  * Preset configurations for common reverse proxies.
16
22
  * Maps preset name to { header, encoding } configuration, with optional
@@ -89,10 +95,11 @@ export const PRESETS = {
89
95
  /**
90
96
  * Parse URL-encoded PEM certificate (nginx, HAProxy format).
91
97
  * Uses standard URL encoding via $ssl_client_escaped_cert or similar.
98
+ * Concatenated PEM blocks are split and linked via issuerCertificate.
92
99
  *
93
100
  * @see https://nginx.org/en/docs/http/ngx_http_ssl_module.html#var_ssl_client_escaped_cert
94
101
  * @param {string} headerValue - URL-encoded PEM certificate
95
- * @returns {PeerCertificate | null} Parsed certificate or null on failure
102
+ * @returns {ChainedPeerCertificate | null} Parsed certificate or null on failure
96
103
  */
97
104
  export function parseUrlPem(headerValue) {
98
105
  // Stryker disable next-line BlockStatement,ConditionalExpression: falsy input falls through to try-catch → same null return
@@ -102,7 +109,7 @@ export function parseUrlPem(headerValue) {
102
109
 
103
110
  try {
104
111
  const pem = decodeURIComponent(headerValue);
105
- return pemToCertificate(pem);
112
+ return chainFromMultiBlockPem(pem);
106
113
  } catch {
107
114
  return null;
108
115
  }
@@ -138,36 +145,48 @@ function splitPemBlocks(pem) {
138
145
 
139
146
  /**
140
147
  * Parse a multi-block PEM blob into a chained PeerCertificate. Splits the
141
- * input into individual blocks, parses each, drops blocks that fail to
142
- * parse, and links the remainder via issuerCertificate.
148
+ * input into individual blocks and links them via issuerCertificate. The
149
+ * first block is the leaf and must parse; later blocks that fail to parse
150
+ * are dropped and the chain links past them.
143
151
  *
144
152
  * Used by parsers whose proxies forward the full chain in a single field
145
- * (parseUrlPemAws, parseXfcc Chain). Without explicit chain linking, calling
146
- * X509Certificate() on a multi-block PEM returns just the leaf with no
153
+ * (parseUrlPem, parseUrlPemAws, parseXfcc Chain). Without explicit chain
154
+ * linking, calling X509Certificate() on a multi-block PEM returns just the leaf with no
147
155
  * issuerCertificate (toLegacyObject drops the property), silently losing
148
156
  * chain information for `includeChain: true` consumers.
149
157
  *
150
158
  * @param {string} pem - Concatenated PEM blocks
151
- * @returns {PeerCertificate | null} Leaf cert with chain via issuerCertificate, or null if no blocks parse
159
+ * @returns {ChainedPeerCertificate | null} Leaf cert with chain via issuerCertificate, or null if the leaf does not parse
152
160
  */
153
161
  function chainFromMultiBlockPem(pem) {
154
162
  const pemBlocks = splitPemBlocks(pem);
155
- // Stryker disable next-line BlockStatement,ConditionalExpression: short-circuit; falling through hits the next certs.length===0 check with the same null result
163
+ // Stryker disable next-line BlockStatement,ConditionalExpression: short-circuit; falling through hands undefined to pemToCertificate, which throws for the same null result
156
164
  if (pemBlocks.length === 0) {
157
165
  return null;
158
166
  }
159
167
 
160
- const certs = pemBlocks.map(block => {
168
+ // Junk ahead of the leaf block means the leaf was damaged past recognition;
169
+ // the next block must not slide into its place. Leading whitespace is fine.
170
+ if (pem.slice(0, pem.indexOf(pemBlocks[0])).trim() !== '') {
171
+ return null;
172
+ }
173
+
174
+ // An unparseable leaf rejects the whole blob. Promoting the next block
175
+ // would authenticate the request as whichever certificate parsed first.
176
+ let leaf;
177
+ try {
178
+ leaf = pemToCertificate(pemBlocks[0]);
179
+ } catch {
180
+ return null;
181
+ }
182
+
183
+ const certs = [leaf];
184
+ for (const block of pemBlocks.slice(1)) {
161
185
  try {
162
- return pemToCertificate(block);
186
+ certs.push(pemToCertificate(block));
163
187
  } catch {
164
- // Equivalent mutant (undefined also filtered by .filter(Boolean)) — catch-body BlockStatement unsuppressible via Stryker comments
165
- return null;
188
+ // Dropped; the chain links past it.
166
189
  }
167
- }).filter(Boolean);
168
-
169
- if (certs.length === 0) {
170
- return null;
171
190
  }
172
191
 
173
192
  // Stryker disable next-line EqualityOperator: setting issuerCertificate = undefined on last cert is same as not setting it
@@ -186,7 +205,7 @@ function chainFromMultiBlockPem(pem) {
186
205
  *
187
206
  * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/mutual-authentication.html
188
207
  * @param {string} headerValue - AWS ALB URL-encoded PEM certificate
189
- * @returns {PeerCertificate | null} Parsed certificate or null on failure
208
+ * @returns {ChainedPeerCertificate | null} Parsed certificate or null on failure
190
209
  */
191
210
  export function parseUrlPemAws(headerValue) {
192
211
  // Stryker disable next-line BlockStatement,ConditionalExpression: falsy input falls through to try-catch → same null return
@@ -214,7 +233,7 @@ export function parseUrlPemAws(headerValue) {
214
233
  *
215
234
  * @see https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers#x-forwarded-client-cert
216
235
  * @param {string} headerValue - XFCC formatted header value
217
- * @returns {PeerCertificate | null} Parsed certificate or null on failure
236
+ * @returns {ChainedPeerCertificate | null} Parsed certificate or null on failure
218
237
  */
219
238
  export function parseXfcc(headerValue) {
220
239
  // Stryker disable next-line BlockStatement,ConditionalExpression: falsy input falls through to try-catch → same null return
@@ -297,7 +316,7 @@ export function parseXfcc(headerValue) {
297
316
  *
298
317
  * @see https://developers.cloudflare.com/api-shield/security/mtls/configure/
299
318
  * @param {string} headerValue - Base64-encoded DER certificate(s)
300
- * @returns {PeerCertificate | null} Parsed certificate or null on failure
319
+ * @returns {ChainedPeerCertificate | null} Parsed certificate or null on failure
301
320
  */
302
321
  export function parseBase64Der(headerValue) {
303
322
  // Stryker disable next-line BlockStatement,ConditionalExpression: falsy input falls through to try-catch → same null return
@@ -306,27 +325,25 @@ export function parseBase64Der(headerValue) {
306
325
  }
307
326
 
308
327
  // Handle comma-separated cert chains (Traefik format)
309
- // Stryker disable next-line MethodExpression: .trim() no-op (base64 ignores whitespace); .filter(Boolean) redundant (empty strings throw in X509Certificate, caught below)
310
- const certParts = headerValue.split(',').map(s => s.trim()).filter(Boolean);
328
+ // Stryker disable next-line MethodExpression: .trim() no-op (base64 ignores whitespace)
329
+ const certParts = headerValue.split(',').map(s => s.trim());
311
330
 
312
- // Stryker disable next-line BlockStatement,ConditionalExpression: →false equivalent (empty array → zero certs → certs.length===0 catches it); →true killed by valid base64-der tests
313
- if (certParts.length === 0) {
331
+ // An empty or unparseable leaf rejects the whole header. Promoting the next
332
+ // entry would authenticate the request as whichever certificate parsed first.
333
+ let leaf;
334
+ try {
335
+ leaf = derToCertificate(Buffer.from(certParts[0], 'base64'));
336
+ } catch {
314
337
  return null;
315
338
  }
316
339
 
317
- // Parse all certs in the chain
318
- const certs = certParts.map(base64 => {
340
+ const certs = [leaf];
341
+ for (const base64 of certParts.slice(1)) {
319
342
  try {
320
- const derBuffer = Buffer.from(base64, 'base64');
321
- return derToCertificate(derBuffer);
343
+ certs.push(derToCertificate(Buffer.from(base64, 'base64')));
322
344
  } catch {
323
- // Equivalent mutant (undefined also filtered by .filter(Boolean)) — catch-body BlockStatement unsuppressible via Stryker comments
324
- return null;
345
+ // Dropped; the chain links past it.
325
346
  }
326
- }).filter(Boolean);
327
-
328
- if (certs.length === 0) {
329
- return null;
330
347
  }
331
348
 
332
349
  // Link the cert chain via issuerCertificate
@@ -400,9 +417,14 @@ export function derToCertificate(der) {
400
417
  *
401
418
  * @param {string} headerValue - Raw header value
402
419
  * @param {'url-pem' | 'url-pem-aws' | 'xfcc' | 'base64-der' | 'rfc9440'} encoding - Encoding format
403
- * @returns {PeerCertificate | null} Parsed certificate or null on failure
420
+ * @returns {ChainedPeerCertificate | null} Parsed certificate or null on failure
404
421
  */
405
422
  export function parseHeaderValue(headerValue, encoding) {
423
+ // Only strings are parseable; a header name like 'constructor' resolves
424
+ // to a function on plain-object header maps.
425
+ if (typeof headerValue !== 'string') {
426
+ return null;
427
+ }
406
428
  switch (encoding) {
407
429
  // Stryker disable next-line ConditionalExpression: parseUrlPem/parseUrlPemAws are equivalent for inputs without + chars
408
430
  case 'url-pem':
@@ -428,7 +450,7 @@ export function parseHeaderValue(headerValue, encoding) {
428
450
  * @param {string} [config.certificateSource] - Preset name (aws-alb, envoy, cloudflare, traefik)
429
451
  * @param {string} [config.certificateHeader] - Custom header name (overrides preset)
430
452
  * @param {string} [config.headerEncoding] - Encoding format (required if certificateHeader is set)
431
- * @returns {PeerCertificate | null} Parsed certificate or null if not found/invalid
453
+ * @returns {ChainedPeerCertificate | null} Parsed certificate or null if not found/invalid
432
454
  */
433
455
  export function getCertificateFromHeaders(headers, config) {
434
456
  let headerName;
@@ -468,6 +490,7 @@ export function getCertificateFromHeaders(headers, config) {
468
490
 
469
491
  // Node.js HTTP consolidates duplicate headers into string[].
470
492
  // Parsers expect a single string; reject arrays to fail safely.
493
+ // Stryker disable next-line ConditionalExpression,BlockStatement: →false/{} equivalent (parseHeaderValue rejects non-strings); →true killed by valid header extraction tests
471
494
  if (Array.isArray(headerValue)) {
472
495
  return null;
473
496
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "client-certificate-auth",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
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": {
@@ -105,7 +105,7 @@
105
105
  "@stryker-mutator/core": "^9.6.0",
106
106
  "@stryker-mutator/jest-runner": "^9.6.1",
107
107
  "@types/express": "^5.0.6",
108
- "@types/node": "^25.6.0",
108
+ "@types/node": "^26.1.1",
109
109
  "c8": "^11.0.0",
110
110
  "eslint": "^10.2.1",
111
111
  "globals": "^17.5.0",