Skip to content

mTLS revocation checking fails open when the issuer certificate is unavailable, which Node 26.8.x makes the normal case #2380

Description

@kriszyp

Summary

verifyCertificate() returns { valid: true, status: 'no-issuer-cert' } whenever it cannot see an issuer certificate for the presented client certificate — regardless of failureMode, including fail-closed. Revocation checking is then silently skipped and the connection is accepted.

Until now that branch was effectively unreachable in practice. On Node 26.8.x it becomes the normal case: socket.getPeerCertificate(true) no longer yields the issuer for Harper's mTLS connections, so every client certificate — including a revoked one — is accepted with revocation checking off.

Evidence

integrationTests/security/ocsp-verification.test.ts + crl-verification.test.ts, same build, same certificates, only the runtime changing:

Node undici OpenSSL result
26.5.0 8.7.0 3.5.7 12 pass / 0 fail
26.5.1 8.9.0 3.5.7 12 pass / 0 fail
26.7.0 8.9.0 3.5.7 12 pass / 0 fail
26.8.1 8.10.0 3.5.7 8 pass / 4 fail (deterministic, 2 consecutive runs)

The four failures are should reject revoked certificate with OCSP check, should reject connection in fail-closed mode when OCSP times out, and the two CRL equivalents — each Expected 401 …, got: 404, i.e. the connection was accepted rather than refused.

Reproduced independently in CI on a floating Node 26 dispatch: run 33190835468 resolved Node 26.8.1 and failed integration shards 1/6 and 6/6, while three full-matrix runs of the identical commit on the pinned 26.5.0 shard passed (33190089908, 33190079114, 33190074619).

OpenSSL is unchanged across the flip, so this is not a bundled-OpenSSL bump.

Mechanism

Request.peerCertificate (server/serverHelpers/Request.ts:126) reads socket.getPeerCertificate(true). extractCertificateChain() (security/certificateVerification/verificationUtils.ts:38) walks issuerCertificate links to build the chain. verifyCertificate() (security/certificateVerification/index.ts:60) then does:

if (certChain.length < 2 || !certChain[0].issuer) {
    logger.debug?.('Certificate chain insufficient for revocation checking - need certificate and issuer');
    return { valid: true, status: 'no-issuer-cert', method: 'disabled' };
}

On 26.7.0 the debug log for the same test run reads Attempting OCSP verificationOCSP verification result: revoked. On 26.8.1 it reads Certificate chain insufficient for revocation checking - need certificate and issuer for every connection, valid and revoked alike.

Why this is the defect, not just a Node change

Two separate problems, and the first is ours:

  1. Fail-open on an unevaluable chain. A configured failureMode: 'fail-closed' promises that a certificate whose revocation status cannot be established is refused. no-issuer-cert bypasses that promise entirely and returns valid: true. Any condition that hides the issuer — a runtime change, a proxy path, a resumed session — silently turns the control off, at debug log level, with no metric and no status surface. That is the invariant worth enforcing regardless of which Node version is in play.
  2. The Node 26.8.x trigger. Something in 26.8.0/26.8.1 stops Harper's mTLS sockets from exposing issuerCertificate. Not yet isolated to a minimal case: a standalone node:https server with requestCert — including with the suite's own Ed25519 CA/leaf pair, a leaf-only client chain, and an SNICallback context — still reports a depth-2 chain identically on 26.7.0 and 26.8.1. So the trigger involves something in Harper's own TLS server setup, and isolating it is the first step toward an upstream Node report.

Repro

npm run build
nvm use 26.8.1
HARPER_INTEGRATION_TEST_INSTALL_SCRIPT=dist/bin/harper.js \
  npm run test:integration -- "integrationTests/security/ocsp-verification.test.ts" \
                              "integrationTests/security/crl-verification.test.ts"

Passes on 26.7.0 and below; fails 4/12 on 26.8.1.

Scope

Harper's package.json engine range (^22.18.0 || >=24.0.0) admits Node 26.8.x, but nothing Harper ships or tests currently runs it: CI's Node 26 shard is pinned to 26.5.0 and the published node26 images build on 26.5.0. Reaching this requires an operator to choose Node 26.8.x and enable certificateVerification.

This is what now blocks removing the Node 26 CI pin — see #2025 and #2378. The original reason for that pin (an undici fetch() stall) is fixed in undici 8.10.0 / Node 26.8.1; this is a different, later regression on the same runtime, and 26.5.0 is currently the only Node 26 release clean on both.

Suggested fix

  • Make no-issuer-cert honour failureMode: reject under fail-closed, accept-with-warning under fail-open. Raise the log above debug either way — a security control disabling itself should not be a debug line.
  • Separately, isolate the Node 26.8.x trigger to a minimal node:tls case and report upstream.

🤖 Found and filed by Claude Opus 5 (dispatch agent), via Claude Code.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    Priority

    P1

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions