client-certificate-auth 1.3.3 → 1.3.5
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/lib/clientCertificateAuth.cjs +1 -2
- package/lib/clientCertificateAuth.js +10 -11
- package/lib/extractor.js +6 -2
- package/lib/helpers.js +3 -0
- package/lib/parsers.js +107 -61
- package/package.json +6 -2
|
@@ -85,8 +85,7 @@ function clientCertificateAuth(callback, options = {}) {
|
|
|
85
85
|
// Ensure that the certificate was validated at the protocol level
|
|
86
86
|
if (!req.socket?.authorized) {
|
|
87
87
|
safeCallHook(onRejected, null, req, 'socket_not_authorized');
|
|
88
|
-
const
|
|
89
|
-
const e = new Error(`Unauthorized: Client certificate required (${authError})`);
|
|
88
|
+
const e = new Error('Unauthorized: Client certificate required');
|
|
90
89
|
e.status = 401;
|
|
91
90
|
return next(e);
|
|
92
91
|
}
|
|
@@ -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
|
|
@@ -133,19 +136,15 @@ export default function clientCertificateAuth(callback, options = {}) {
|
|
|
133
136
|
let statusCode = 401;
|
|
134
137
|
|
|
135
138
|
switch (result.reason) {
|
|
136
|
-
case 'verification_header_mismatch':
|
|
137
|
-
|
|
138
|
-
errorMessage = `Unauthorized: Certificate verification failed (${verifyStatus || 'header missing'})`;
|
|
139
|
+
case 'verification_header_mismatch':
|
|
140
|
+
errorMessage = 'Unauthorized: Certificate verification failed';
|
|
139
141
|
break;
|
|
140
|
-
}
|
|
141
142
|
case 'header_missing_or_malformed':
|
|
142
143
|
errorMessage = 'Unauthorized: Client certificate header missing or malformed';
|
|
143
144
|
break;
|
|
144
|
-
case 'socket_not_authorized':
|
|
145
|
-
|
|
146
|
-
errorMessage = `Unauthorized: Client certificate required (${authError})`;
|
|
145
|
+
case 'socket_not_authorized':
|
|
146
|
+
errorMessage = 'Unauthorized: Client certificate required';
|
|
147
147
|
break;
|
|
148
|
-
}
|
|
149
148
|
case 'certificate_not_retrievable':
|
|
150
149
|
errorMessage = 'Client certificate was authenticated but certificate information could not be retrieved.';
|
|
151
150
|
statusCode = 500;
|
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
|
|
25
|
-
*
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
+
"version": "1.3.5",
|
|
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": {
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
"node": ">= 20"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
|
+
"@arethetypeswrong/cli": "^0.18.2",
|
|
75
76
|
"@commitlint/cli": "^20.4.3",
|
|
76
77
|
"@commitlint/config-conventional": "^20.5.0",
|
|
77
78
|
"@stryker-mutator/core": "^9.6.0",
|
|
@@ -142,5 +143,8 @@
|
|
|
142
143
|
"lib/**/*.cjs",
|
|
143
144
|
"lib/**/*.d.ts",
|
|
144
145
|
"lib/**/*.d.cts"
|
|
145
|
-
]
|
|
146
|
+
],
|
|
147
|
+
"overrides": {
|
|
148
|
+
"fflate": "0.8.2"
|
|
149
|
+
}
|
|
146
150
|
}
|