client-certificate-auth has an Open Redirect via Host Header Injection in HTTP-to-HTTPS redirect
Description
client-certificate-auth is middleware for Node.js implementing client SSL certificate authentication/authorization. Versions 0.2.1 and 0.3.0 of client-certificate-auth contain an open redirect vulnerability. The middleware unconditionally redirects HTTP requests to HTTPS using the unvalidated Host header, allowing an attacker to redirect users to arbitrary domains. This vulnerability is fixed in 1.0.0.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
An open redirect vulnerability in client-certificate-auth Node.js middleware allows attackers to redirect users to arbitrary domains via unvalidated Host header.
The vulnerability, present in versions 0.2.1 and 0.3.0 of the client-certificate-auth Node.js middleware, is an open redirect. The middleware unconditionally redirects HTTP requests to HTTPS using the unvalidated Host header, allowing an attacker to redirect users to arbitrary domains [1][2].
An attacker can exploit this by crafting an HTTP request with a manipulated Host header pointing to a malicious domain. The middleware, before performing any client certificate authentication, redirects the request to HTTPS using that Host header, effectively sending the user anywhere the attacker specifies [2]. No authentication is required to trigger the redirect.
The impact is that an attacker can redirect users to phishing sites or other malicious destinations, potentially leading to credential theft or malware installation [2].
The vulnerability is fixed in version 1.0.0, which removed the insecure redirect functionality and added options like verifyHeader and verifyValue for defense in depth [3][4]. Users should upgrade to version 1.0.0 or later to mitigate the risk [1][4].
AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
client-certificate-authnpm | >= 0.2.1, < 1.0.0 | 1.0.0 |
Affected products
20.2.1, 0.3.0+ 1 more
- (no CPE)range: 0.2.1, 0.3.0
- (no CPE)range: >= 0.2.1, < 1.0.0
Patches
18fc995e953dbfix(security): remove redirectInsecure, add verifyHeader/verifyValue
10 files changed · +219 −157
lib/clientCertificateAuth.cjs+1 −6 modified@@ -29,14 +29,9 @@ async function loadModule() { * @returns {Function} Express middleware */ function clientCertificateAuth(callback, options = {}) { - const { redirectInsecure = false, includeChain = false } = options; + const { includeChain = false } = options; return function middleware(req, res, next) { - // Optional HTTPS redirect (opt-in only due to security concerns) - if (redirectInsecure && !req.secure && req.headers['x-forwarded-proto'] !== 'https') { - return res.redirect(301, 'https://' + req.headers['host'] + req.url); - } - // Ensure that the certificate was validated at the protocol level if (!req.socket?.authorized) { const authError = req.socket?.authorizationError || 'unknown';
lib/clientCertificateAuth.d.cts+1 −7 modified@@ -47,20 +47,14 @@ export interface ClientCertResponse extends ServerResponse { } export interface ClientCertificateAuthOptions { - /** - * If true, redirect HTTP requests to HTTPS. - * WARNING: This can expose the initial request to MITM attacks. - * @default false - */ - redirectInsecure?: boolean; - /** * Use a preset configuration for a known reverse proxy. * Header-based certs are only checked if this or certificateHeader is set. * @see https://github.com/tgies/client-certificate-auth#reverse-proxy-support */ certificateSource?: CertificateSource; + /** * Custom header name to read certificate from. * Overrides preset header name if also using certificateSource.
lib/clientCertificateAuth.d.ts+14 −7 modified@@ -40,20 +40,14 @@ export interface ClientCertResponse extends ServerResponse { } export interface ClientCertificateAuthOptions { - /** - * If true, redirect HTTP requests to HTTPS. - * WARNING: This can expose the initial request to MITM attacks. - * @default false - */ - redirectInsecure?: boolean; - /** * Use a preset configuration for a known reverse proxy. * Header-based certs are only checked if this or certificateHeader is set. * @see https://github.com/tgies/client-certificate-auth#reverse-proxy-support */ certificateSource?: CertificateSource; + /** * Custom header name to read certificate from. * Overrides preset header name if also using certificateSource. @@ -79,6 +73,19 @@ export interface ClientCertificateAuthOptions { * @default false */ includeChain?: boolean; + + /** + * Header name containing certificate verification status from upstream proxy. + * Must be used together with verifyValue. Example: 'X-SSL-Client-Verify' for nginx. + */ + verifyHeader?: string; + + /** + * Expected value indicating successful certificate verification. + * If verifyHeader is set, requests are rejected unless the header matches this value. + * Example: 'SUCCESS' for nginx. + */ + verifyValue?: string; } export type ValidationCallback = (cert: PeerCertificate | DetailedPeerCertificate) => boolean | Promise<boolean>;
lib/clientCertificateAuth.js+15 −8 modified@@ -15,8 +15,6 @@ import { getCertificateFromHeaders } from './parsers.js'; /** * @typedef {Object} ClientCertificateAuthOptions - * @property {boolean} [redirectInsecure=false] - If true, redirect HTTP requests to HTTPS. - * WARNING: This can expose the initial request to MITM attacks. * @property {'aws-alb' | 'envoy' | 'cloudflare' | 'traefik'} [certificateSource] - Use a preset * configuration for a known reverse proxy. Header-based certs are only checked if this or * certificateHeader is set. @@ -28,6 +26,10 @@ import { getCertificateFromHeaders } from './parsers.js'; * fails (header absent or malformed), try socket.getPeerCertificate() instead of returning 401. * @property {boolean} [includeChain=false] - If true, include the full certificate chain via * cert.issuerCertificate. Applies to both socket and header-based extraction. + * @property {string} [verifyHeader] - Header name containing certificate verification status from + * upstream proxy (e.g., 'X-SSL-Client-Verify'). Must be used with verifyValue. + * @property {string} [verifyValue] - Expected value indicating successful verification (e.g., 'SUCCESS'). + * If verifyHeader is set, requests are rejected unless the header matches this value. */ /** @@ -63,27 +65,32 @@ import { getCertificateFromHeaders } from './parsers.js'; */ export default function clientCertificateAuth(callback, options = {}) { const { - redirectInsecure = false, certificateSource, certificateHeader, headerEncoding, fallbackToSocket = false, includeChain = false, + verifyHeader, + verifyValue, } = options; // Determine if header-based extraction is configured const useHeaders = Boolean(certificateSource || certificateHeader); return function middleware(req, res, next) { - // Optional HTTPS redirect (opt-in only due to security concerns) - if (redirectInsecure && !req.secure && req.headers['x-forwarded-proto'] !== 'https') { - return res.redirect(301, 'https://' + req.headers['host'] + req.url); - } - let cert = null; // Try header-based extraction first if configured if (useHeaders) { + // Verify upstream proxy's certificate validation if configured + if (verifyHeader && verifyValue) { + const verifyStatus = req.headers[verifyHeader.toLowerCase()]; + if (verifyStatus !== verifyValue) { + const e = new Error(`Unauthorized: Certificate verification failed (${verifyStatus || 'header missing'})`); + e.status = 401; + return next(e); + } + } cert = getCertificateFromHeaders(req.headers, { certificateSource, certificateHeader,
README.md+22 −4 modified@@ -119,12 +119,13 @@ Returns Express middleware. | Name | Type | Description | |------|------|-------------| | `callback` | `(cert) => boolean \| Promise<boolean>` | Receives the client certificate, returns `true` to allow access | -| `options.redirectInsecure` | `boolean` | If `true`, redirect HTTP → HTTPS (default: `false`) | | `options.certificateSource` | `string` | Use a preset for a known proxy: `'aws-alb'`, `'envoy'`, `'cloudflare'`, `'traefik'` | | `options.certificateHeader` | `string` | Custom header name to read certificate from | | `options.headerEncoding` | `string` | Encoding format: `'url-pem'`, `'url-pem-aws'`, `'xfcc'`, `'base64-der'`, `'rfc9440'` | | `options.fallbackToSocket` | `boolean` | If header extraction fails, try `socket.getPeerCertificate()` (default: `false`) | | `options.includeChain` | `boolean` | If `true`, include full certificate chain via `cert.issuerCertificate` (default: `false`) | +| `options.verifyHeader` | `string` | Header name containing verification status from proxy (e.g., `'X-SSL-Client-Verify'`) | +| `options.verifyValue` | `string` | Expected value indicating successful verification (e.g., `'SUCCESS'`) | **Certificate Object:** @@ -263,12 +264,29 @@ Configure your proxy to: 2. **Set** the header only for authenticated mTLS connections 3. **Never** trust certificate headers from untrusted sources +#### Verification Header (Defense in Depth) + +For additional protection, use `verifyHeader` and `verifyValue` to validate that your proxy has actually verified the certificate. This guards against proxy misconfiguration (e.g., `ssl_verify_client optional` passing unverified certs): + +```javascript +app.use(clientCertificateAuth(checkAuth, { + certificateHeader: 'X-SSL-Client-Cert', + headerEncoding: 'url-pem', + verifyHeader: 'X-SSL-Client-Verify', + verifyValue: 'SUCCESS' +})); +``` + Example nginx configuration: ```nginx -# Strip any existing header from clients +# Strip any existing headers from clients proxy_set_header X-SSL-Client-Cert ""; +proxy_set_header X-SSL-Client-Verify ""; + +# Always send verification status +proxy_set_header X-SSL-Client-Verify $ssl_client_verify; -# Set header only when client cert is verified +# Only send cert if verified if ($ssl_client_verify = SUCCESS) { proxy_set_header X-SSL-Client-Cert $ssl_client_escaped_cert; } @@ -391,9 +409,9 @@ app.use(clientCertificateAuth((cert) => cert.subject.CN === 'admin')); ## Security Notes -- **Do not use `redirectInsecure: true` in production** — the initial HTTP request can be intercepted - Set `rejectUnauthorized: false` on your HTTPS server to let this middleware provide helpful error messages, rather than dropping connections silently - **When using header-based auth**, ensure your proxy strips certificate headers from external requests +- Use `verifyHeader`/`verifyValue` as defense-in-depth when using header-based authentication ## License
test/docker/backend/server.js+17 −0 modified@@ -50,6 +50,23 @@ const HELPER_CONFIGS = { headerConfig: PATH_CONFIGS['/nginx'], validator: anyOf(allowCN(['Wrong Client']), allowCN(['Test Client'])), }, + // Verification header test routes + '/helpers/verify-header': { + headerConfig: { + ...PATH_CONFIGS['/nginx'], + verifyHeader: 'X-SSL-Client-Verify', + verifyValue: 'SUCCESS', + }, + validator: () => true, + }, + '/helpers/verify-header-wrong-value': { + headerConfig: { + ...PATH_CONFIGS['/nginx'], + verifyHeader: 'X-SSL-Client-Verify', + verifyValue: 'WRONG_VALUE', // This should always fail + }, + validator: () => true, + }, }; const server = http.createServer((req, res) => {
test/docker/nginx/nginx-optional.conf+20 −0 modified@@ -18,13 +18,33 @@ http { ssl_client_certificate /certs/ca.pem; ssl_verify_client optional_no_ca; + # Helper routes (preserve path for verification header testing) + location /helpers/ { + proxy_pass http://backend:3000; + + # Forward client certificate as URL-encoded PEM (if present) + proxy_set_header X-SSL-Client-Cert $ssl_client_escaped_cert; + + # Forward certificate verification status (SUCCESS, FAILED, NONE) + proxy_set_header X-SSL-Client-Verify $ssl_client_verify; + + # Standard headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location / { # Forward to backend with /nginx path for routing proxy_pass http://backend:3000/nginx; # Forward client certificate as URL-encoded PEM (if present) proxy_set_header X-SSL-Client-Cert $ssl_client_escaped_cert; + # Forward certificate verification status (SUCCESS, FAILED, NONE) + proxy_set_header X-SSL-Client-Verify $ssl_client_verify; + # Standard headers proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr;
test/test-e2e-proxies.js+29 −0 modified@@ -472,6 +472,35 @@ describeIfDocker('Reverse Proxy Integration Tests', () => { }); }); }); + + // Verification Header E2E Tests (using nginx-optional which sends X-SSL-Client-Verify) + describe('Verification Header', () => { + it('should accept request when verifyHeader matches verifyValue', async () => { + const response = await makeRequest( + `https://localhost:${NGINX_OPTIONAL_PORT}/helpers/verify-header`, + certs.client.cert, + certs.client.key, + certs.ca.cert + ); + + expect(response.success).toBe(true); + expect(response.clientCN).toBe('Test Client'); + expect(response.helperPath).toBe('/helpers/verify-header'); + }); + + it('should reject request when verifyValue does not match', async () => { + const response = await makeRequest( + `https://localhost:${NGINX_OPTIONAL_PORT}/helpers/verify-header-wrong-value`, + certs.client.cert, + certs.client.key, + certs.ca.cert + ); + + expect(response.success).toBe(false); + expect(response.status).toBe(401); + expect(response.error).toContain('Certificate verification failed'); + }); + }); }); function makeRequestWithoutCert(url, caCert) {
test/test-unit-clientCertificateAuth.cjs+0 −62 modified@@ -63,11 +63,6 @@ describe('clientCertificateAuth (CommonJS)', () => { headers: {} }; - const mockUnsecureReq = { - secure: false, - socket: { authorized: false, getPeerCertificate: () => ({}) }, - headers: {} - }; const mockUnauthReq = { secure: true, @@ -258,63 +253,6 @@ describe('clientCertificateAuth (CommonJS)', () => { }); }); - describe('redirectInsecure option', () => { - it('should NOT redirect by default', done => { - let redirectCalled = false; - const res = { - redirect: () => { redirectCalled = true; } - }; - const middleware = clientCertificateAuth(() => true); - middleware(mockUnsecureReq, res, (err) => { - assert.equal(redirectCalled, false); - assert.ok(err instanceof Error); - done(); - }); - }); - - it('should redirect when redirectInsecure is true', () => { - let redirectUrl = null; - let redirectStatus = null; - const req = { - secure: false, - socket: {}, - headers: { - 'host': 'example.com' - }, - url: '/protected' - }; - const res = { - redirect: (status, url) => { - redirectStatus = status; - redirectUrl = url; - } - }; - const middleware = clientCertificateAuth(() => true, { redirectInsecure: true }); - middleware(req, res, () => { }); - - assert.equal(redirectStatus, 301); - assert.equal(redirectUrl, 'https://example.com/protected'); - }); - - it('should NOT redirect if x-forwarded-proto is https', done => { - const req = { - secure: false, - socket: { authorized: true, getPeerCertificate: getMockPeerCertificate }, - headers: { - 'x-forwarded-proto': 'https' - } - }; - let redirectCalled = false; - const res = { - redirect: () => { redirectCalled = true; } - }; - const middleware = clientCertificateAuth(() => true, { redirectInsecure: true }); - middleware(req, res, () => { - assert.equal(redirectCalled, false); - done(); - }); - }); - }); describe('includeChain option', () => { const getMockIssuerCertificate = () => ({
test/test-unit-clientCertificateAuth.js+100 −63 modified@@ -49,11 +49,6 @@ describe('clientCertificateAuth', () => { headers: {} }; - const mockUnsecureReq = { - secure: false, - socket: { authorized: false, getPeerCertificate: () => ({}) }, - headers: {} - }; const mockUnauthReq = { secure: true, @@ -244,64 +239,6 @@ describe('clientCertificateAuth', () => { }); }); - describe('redirectInsecure option', () => { - it('should NOT redirect by default', done => { - let redirectCalled = false; - const res = { - redirect: () => { redirectCalled = true; } - }; - const middleware = clientCertificateAuth(() => true); - middleware(mockUnsecureReq, res, (err) => { - assert.equal(redirectCalled, false); - // Should fail due to unauthorized, not redirect - assert.ok(err instanceof Error); - done(); - }); - }); - - it('should redirect when redirectInsecure is true', () => { - let redirectUrl = null; - let redirectStatus = null; - const req = { - secure: false, - socket: {}, - headers: { - 'host': 'example.com' - }, - url: '/protected' - }; - const res = { - redirect: (status, url) => { - redirectStatus = status; - redirectUrl = url; - } - }; - const middleware = clientCertificateAuth(() => true, { redirectInsecure: true }); - middleware(req, res, () => { }); - - assert.equal(redirectStatus, 301); - assert.equal(redirectUrl, 'https://example.com/protected'); - }); - - it('should NOT redirect if x-forwarded-proto is https', done => { - const req = { - secure: false, - socket: { authorized: true, getPeerCertificate: getMockPeerCertificate }, - headers: { - 'x-forwarded-proto': 'https' - } - }; - let redirectCalled = false; - const res = { - redirect: () => { redirectCalled = true; } - }; - const middleware = clientCertificateAuth(() => true, { redirectInsecure: true }); - middleware(req, res, () => { - assert.equal(redirectCalled, false); - done(); - }); - }); - }); describe('header-based certificate extraction', () => { let testPem; @@ -408,6 +345,106 @@ describe('clientCertificateAuth', () => { done(); }); }); + + describe('verifyHeader/verifyValue options', () => { + it('should reject if verifyHeader is set but header is missing', done => { + const encodedCert = encodeURIComponent(testPem); + const req = { + secure: false, + socket: { authorized: false }, + headers: { + 'x-ssl-client-cert': encodedCert + // X-SSL-Client-Verify header is missing + } + }; + + const middleware = clientCertificateAuth(() => true, { + certificateHeader: 'X-SSL-Client-Cert', + headerEncoding: 'url-pem', + verifyHeader: 'X-SSL-Client-Verify', + verifyValue: 'SUCCESS' + }); + + middleware(req, mockRes, (err) => { + assert.ok(err instanceof Error); + assert.equal(err.status, 401); + assert.ok(err.message.includes('Certificate verification failed')); + assert.ok(err.message.includes('header missing')); + done(); + }); + }); + + it('should reject if verifyHeader value does not match verifyValue', done => { + const encodedCert = encodeURIComponent(testPem); + const req = { + secure: false, + socket: { authorized: false }, + headers: { + 'x-ssl-client-cert': encodedCert, + 'x-ssl-client-verify': 'FAILED:unable to verify' + } + }; + + const middleware = clientCertificateAuth(() => true, { + certificateHeader: 'X-SSL-Client-Cert', + headerEncoding: 'url-pem', + verifyHeader: 'X-SSL-Client-Verify', + verifyValue: 'SUCCESS' + }); + + middleware(req, mockRes, (err) => { + assert.ok(err instanceof Error); + assert.equal(err.status, 401); + assert.ok(err.message.includes('Certificate verification failed')); + assert.ok(err.message.includes('FAILED:unable to verify')); + done(); + }); + }); + + it('should allow request if verifyHeader matches verifyValue', done => { + const encodedCert = encodeURIComponent(testPem); + const req = { + secure: false, + socket: { authorized: false }, + headers: { + 'x-ssl-client-cert': encodedCert, + 'x-ssl-client-verify': 'SUCCESS' + } + }; + + const middleware = clientCertificateAuth((cert) => { + assert.equal(cert.subject.CN, 'Header Test Client'); + return true; + }, { + certificateHeader: 'X-SSL-Client-Cert', + headerEncoding: 'url-pem', + verifyHeader: 'X-SSL-Client-Verify', + verifyValue: 'SUCCESS' + }); + + middleware(req, mockRes, (err) => { + assert.equal(err, undefined); + done(); + }); + }); + + it('should not check verifyHeader for socket-based extraction', done => { + // Socket-based auth should ignore verifyHeader + const middleware = clientCertificateAuth((cert) => { + assert.equal(cert.subject.CN, 'Proctor Davenport'); + return true; + }, { + verifyHeader: 'X-SSL-Client-Verify', + verifyValue: 'SUCCESS' + // No certificateSource or certificateHeader = socket-based + }); + + middleware(mockGoodReq, mockRes, (err) => { + assert.equal(err, undefined); + done(); + }); + }); + }); }); describe('req.clientCertificate', () => {
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-m4w9-gch5-c2g4ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-25651ghsaADVISORY
- github.com/tgies/client-certificate-auth/commit/8fc995e953db483495be46862965e50fe9e1cc52ghsaWEB
- github.com/tgies/client-certificate-auth/releases/tag/v1.0.0ghsax_refsource_MISCWEB
- github.com/tgies/client-certificate-auth/security/advisories/GHSA-m4w9-gch5-c2g4ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.