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 +40 -15
- package/lib/clientCertificateAuth.d.cts +5 -4
- package/lib/clientCertificateAuth.d.ts +5 -4
- package/lib/clientCertificateAuth.js +4 -4
- package/lib/extractor.d.ts +2 -2
- package/lib/extractor.js +3 -2
- package/lib/parsers.d.ts +14 -6
- package/lib/parsers.js +58 -35
- package/package.json +2 -2
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'`, `'
|
|
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
|
-
| `
|
|
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
|
-
>
|
|
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
|
-
> **
|
|
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-
|
|
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
|
|
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:...'
|
|
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?:
|
|
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:
|
|
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:
|
|
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:
|
|
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?:
|
|
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:
|
|
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:
|
|
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:
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
package/lib/extractor.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @typedef {Object} ExtractionResult
|
|
3
3
|
* @property {boolean} success - Whether extraction succeeded
|
|
4
|
-
* @property {import('
|
|
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("
|
|
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('
|
|
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
|
-
|
|
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):
|
|
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):
|
|
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):
|
|
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):
|
|
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
|
-
):
|
|
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
|
-
):
|
|
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 {
|
|
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
|
|
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
|
|
142
|
-
*
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
186
|
+
certs.push(pemToCertificate(block));
|
|
163
187
|
} catch {
|
|
164
|
-
//
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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)
|
|
310
|
-
const certParts = headerValue.split(',').map(s => s.trim())
|
|
328
|
+
// Stryker disable next-line MethodExpression: .trim() no-op (base64 ignores whitespace)
|
|
329
|
+
const certParts = headerValue.split(',').map(s => s.trim());
|
|
311
330
|
|
|
312
|
-
//
|
|
313
|
-
|
|
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
|
-
|
|
318
|
-
const
|
|
340
|
+
const certs = [leaf];
|
|
341
|
+
for (const base64 of certParts.slice(1)) {
|
|
319
342
|
try {
|
|
320
|
-
|
|
321
|
-
return derToCertificate(derBuffer);
|
|
343
|
+
certs.push(derToCertificate(Buffer.from(base64, 'base64')));
|
|
322
344
|
} catch {
|
|
323
|
-
//
|
|
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 {
|
|
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 {
|
|
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.
|
|
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": "^
|
|
108
|
+
"@types/node": "^26.1.1",
|
|
109
109
|
"c8": "^11.0.0",
|
|
110
110
|
"eslint": "^10.2.1",
|
|
111
111
|
"globals": "^17.5.0",
|