fix: apply the certificate SPKI blocklist to Node and add entries (#55126)

Chromium carries a compiled-in list of SubjectPublicKeyInfo hashes that
must never appear in a verified chain. Electron's Node had no equivalent,
so the same chain could fail in a renderer and succeed in tls/https/fetch
from the main or a utility process.

- Node: wrap X509_verify_cert on every SecureContext and report
  CERT_REVOKED when a chain contains a blocked SPKI.
- Chromium: add entries that Chrome distributes via CRLSet, which Electron
  does not consume, to the compiled-in list; Node's list mirrors them.
- Testing binding hooks let the new spec extend Node's list at runtime and
  exercise the hook against the fixture chain.
This commit is contained in:
Samuel Attard authored and GitHub committed 2026-10-19 17:48:06 -07:00
1 parent 23c730b8b8
commit 44fdb9fee4
6 files changed
+343

No files matched your search

+1
View File
@@ -139,3 +139,4 @@ build_drop_the_unused_chrome_browser_win_dep_from_color_mixers.patch
fix_plumb_the_requesting_frame_through_pointer_lock_requests.patch
fix_avoid_misaligned_char16_t_reads_in_crdtp_string_deserialization.patch
feat_let_the_host_of_a_remote_nswindow_set_more_of_its_behavior.patch
add_entries_to_the_spki_blocklist.patch
@@ -0,0 +1,57 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Sam Attard <sattard@anthropic.com>
Date: Tue, 20 Oct 2026 00:00:20 +0000
Subject: Add entries to the SPKI blocklist
Adds public key hashes that Chrome ships to its users through the CRLSet
component, which Electron does not consume, so they are carried in the
compiled-in list instead. The comment above each entry is the SHA-256
fingerprint of the corresponding certificate.
This patch can be removed once the hashes land in the upstream blocklist.
diff --git a/net/cert/cert_verify_proc_blocklist.inc b/net/cert/cert_verify_proc_blocklist.inc
index b00fc5124ba8c70d71d1a0bc535d6db3c3f121cf..fb7920b263780b8e58cdce9018732723e80f73eb 100644
--- a/net/cert/cert_verify_proc_blocklist.inc
+++ b/net/cert/cert_verify_proc_blocklist.inc
@@ -418,7 +418,39 @@ static constexpr uint8_t
// 289ed1676f42b679f2b665bb5a3f25610d9d19ac017beafa46787f1d6022986a.pem
{0x36, 0xce, 0x75, 0xed, 0xea, 0x80, 0x6f, 0x09, 0x85, 0x14, 0x56,
0x83, 0x29, 0x4e, 0x54, 0xdb, 0xdf, 0xf0, 0x2f, 0xc8, 0x21, 0x87,
- 0x0c, 0xf5, 0x06, 0x7a, 0x30, 0x8d, 0x15, 0x04, 0xbe, 0x3a}
+ 0x0c, 0xf5, 0x06, 0x7a, 0x30, 0x8d, 0x15, 0x04, 0xbe, 0x3a},
+ // 0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19.pem
+ {0x49, 0xdd, 0x35, 0x3d, 0xc2, 0xb9, 0x19, 0xa3, 0x77, 0x06, 0x45,
+ 0x11, 0x7d, 0x0e, 0x60, 0xc1, 0x61, 0x15, 0xe7, 0xd1, 0xbc, 0x25,
+ 0x9e, 0xef, 0xe7, 0x34, 0xb0, 0x0e, 0xa4, 0x34, 0xfe, 0x5f},
+ // 432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3.pem
+ {0x5c, 0x06, 0x60, 0xa2, 0xb1, 0x64, 0x91, 0x0d, 0x8f, 0xf5, 0x89,
+ 0xc1, 0x8e, 0x14, 0x55, 0x2e, 0x9e, 0x0e, 0xc2, 0x37, 0xdc, 0xef,
+ 0xe2, 0xcb, 0x73, 0x6d, 0x41, 0xa4, 0x81, 0x1f, 0x81, 0xa0},
+ // cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f.pem
+ {0x63, 0xec, 0xef, 0xee, 0x43, 0x1d, 0x49, 0x52, 0x3b, 0xc1, 0x13,
+ 0x6c, 0x4e, 0x72, 0xd0, 0x20, 0x6a, 0x47, 0x70, 0x86, 0xca, 0xf6,
+ 0x06, 0xcd, 0x5c, 0xf0, 0x25, 0x68, 0x6c, 0x10, 0x9c, 0xf8},
+ // 4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1.pem
+ {0x92, 0xc0, 0x63, 0xd6, 0x7e, 0x8f, 0x14, 0xdb, 0x71, 0xd2, 0x88,
+ 0xe1, 0x12, 0x66, 0xff, 0xda, 0xf6, 0x9c, 0xbf, 0x11, 0x5f, 0x7b,
+ 0xab, 0x14, 0xf3, 0xbc, 0x2f, 0x68, 0xd2, 0x5e, 0x0e, 0x30},
+ // 42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493.pem
+ {0xeb, 0xed, 0x64, 0x75, 0xc2, 0xc2, 0xd8, 0x0f, 0xcb, 0x19, 0x9f,
+ 0x3f, 0xe8, 0xdd, 0x6e, 0x6b, 0x3c, 0xe6, 0x0b, 0x77, 0xeb, 0x15,
+ 0xd4, 0xe4, 0x2c, 0x3d, 0xec, 0x0c, 0x6f, 0x5b, 0x51, 0x05},
+ // 9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4.pem
+ {0xec, 0x4f, 0xfa, 0x1a, 0xd0, 0xac, 0x88, 0x08, 0x13, 0x14, 0xb7,
+ 0xaa, 0xff, 0xef, 0x6a, 0x91, 0xf9, 0x7c, 0xd5, 0x72, 0x91, 0xbb,
+ 0x5b, 0xa6, 0x86, 0xc5, 0x41, 0x5f, 0x5e, 0xb4, 0xf6, 0xb6},
+ // 98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b.pem
+ {0x43, 0xbf, 0xfa, 0x19, 0xd6, 0xef, 0x65, 0x14, 0x51, 0xf6, 0x84,
+ 0x1e, 0x01, 0x99, 0x31, 0xe9, 0x6f, 0x6f, 0x2e, 0xae, 0xff, 0x28,
+ 0xab, 0x4b, 0x6f, 0x1c, 0xe4, 0x57, 0x6d, 0xe2, 0x71, 0x7b},
+ // e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838.pem
+ {0xe0, 0x25, 0x4c, 0x7f, 0xcd, 0xc2, 0x65, 0xe2, 0xf0, 0xf8, 0xa4,
+ 0x28, 0xda, 0xbb, 0x75, 0xab, 0x4a, 0xe0, 0x39, 0xb4, 0x87, 0x2d,
+ 0x62, 0xf5, 0xee, 0x57, 0xf0, 0x87, 0x7d, 0x5a, 0x8a, 0x41}
};
// Hashes of SubjectPublicKeyInfos known to be used for interception by a
+1
View File
@@ -51,3 +51,4 @@ deps_add_missing_standard_library_includes_to_simdjson.patch
test_drop_removed_wasm_rab_integration_flag.patch
test_skip_rmsync_permission_error_test_when_running_as_root.patch
test_skip_openssl_config_test_under_boringssl.patch
tls_reject_chains_containing_blocked_public_keys.patch
@@ -0,0 +1,167 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Sam Attard <sattard@anthropic.com>
Date: Tue, 20 Oct 2026 00:10:09 +0000
Subject: tls: reject chains containing blocked public keys
Node has no equivalent of Chromium's SPKI blocklist, so a chain through a
key on that list still verifies in Node as long as it reaches a trusted
root. Wrap X509_verify_cert for every SecureContext and report
X509_V_ERR_CERT_REVOKED when any certificate in the verified chain (or,
if no chain could be built, any certificate the peer sent) has a
SubjectPublicKeyInfo hash on a small compiled-in list. The list mirrors
the entries Electron adds to Chromium's net blocklist. Test-only hooks
allow the spec suite to extend the list at runtime.
diff --git a/src/crypto/crypto_context.cc b/src/crypto/crypto_context.cc
index 4d6d9accd1970eca3ab065884e445d662e9f0c5a..13313c07f02b5846e4a3bc7b2fa32dcc63d73c99 100644
--- a/src/crypto/crypto_context.cc
+++ b/src/crypto/crypto_context.cc
@@ -17,6 +17,7 @@
#include <openssl/comp.h>
#endif
#include <openssl/rand.h>
+#include <openssl/sha.h>
#include <openssl/x509.h>
#ifdef __APPLE__
#include <Security/Security.h>
@@ -27,7 +28,10 @@
#include <wincrypt.h>
#endif
+#include <array>
+#include <cstring>
#include <set>
+#include <vector>
namespace node {
@@ -75,6 +79,105 @@ using v8::String;
using v8::Value;
namespace crypto {
+
+namespace {
+
+// SHA-256 hashes of SubjectPublicKeyInfos that must never appear in a
+// verified chain, regardless of what the trust store says.
+constexpr uint8_t kBlockedSPKIs[][SHA256_DIGEST_LENGTH] = {
+ // 0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19
+ {0x49, 0xdd, 0x35, 0x3d, 0xc2, 0xb9, 0x19, 0xa3, 0x77, 0x06, 0x45,
+ 0x11, 0x7d, 0x0e, 0x60, 0xc1, 0x61, 0x15, 0xe7, 0xd1, 0xbc, 0x25,
+ 0x9e, 0xef, 0xe7, 0x34, 0xb0, 0x0e, 0xa4, 0x34, 0xfe, 0x5f},
+ // 432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3
+ {0x5c, 0x06, 0x60, 0xa2, 0xb1, 0x64, 0x91, 0x0d, 0x8f, 0xf5, 0x89,
+ 0xc1, 0x8e, 0x14, 0x55, 0x2e, 0x9e, 0x0e, 0xc2, 0x37, 0xdc, 0xef,
+ 0xe2, 0xcb, 0x73, 0x6d, 0x41, 0xa4, 0x81, 0x1f, 0x81, 0xa0},
+ // cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f
+ {0x63, 0xec, 0xef, 0xee, 0x43, 0x1d, 0x49, 0x52, 0x3b, 0xc1, 0x13,
+ 0x6c, 0x4e, 0x72, 0xd0, 0x20, 0x6a, 0x47, 0x70, 0x86, 0xca, 0xf6,
+ 0x06, 0xcd, 0x5c, 0xf0, 0x25, 0x68, 0x6c, 0x10, 0x9c, 0xf8},
+ // 4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1
+ {0x92, 0xc0, 0x63, 0xd6, 0x7e, 0x8f, 0x14, 0xdb, 0x71, 0xd2, 0x88,
+ 0xe1, 0x12, 0x66, 0xff, 0xda, 0xf6, 0x9c, 0xbf, 0x11, 0x5f, 0x7b,
+ 0xab, 0x14, 0xf3, 0xbc, 0x2f, 0x68, 0xd2, 0x5e, 0x0e, 0x30},
+ // 42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493
+ {0xeb, 0xed, 0x64, 0x75, 0xc2, 0xc2, 0xd8, 0x0f, 0xcb, 0x19, 0x9f,
+ 0x3f, 0xe8, 0xdd, 0x6e, 0x6b, 0x3c, 0xe6, 0x0b, 0x77, 0xeb, 0x15,
+ 0xd4, 0xe4, 0x2c, 0x3d, 0xec, 0x0c, 0x6f, 0x5b, 0x51, 0x05},
+ // 9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4
+ {0xec, 0x4f, 0xfa, 0x1a, 0xd0, 0xac, 0x88, 0x08, 0x13, 0x14, 0xb7,
+ 0xaa, 0xff, 0xef, 0x6a, 0x91, 0xf9, 0x7c, 0xd5, 0x72, 0x91, 0xbb,
+ 0x5b, 0xa6, 0x86, 0xc5, 0x41, 0x5f, 0x5e, 0xb4, 0xf6, 0xb6},
+ // 98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b
+ {0x43, 0xbf, 0xfa, 0x19, 0xd6, 0xef, 0x65, 0x14, 0x51, 0xf6, 0x84,
+ 0x1e, 0x01, 0x99, 0x31, 0xe9, 0x6f, 0x6f, 0x2e, 0xae, 0xff, 0x28,
+ 0xab, 0x4b, 0x6f, 0x1c, 0xe4, 0x57, 0x6d, 0xe2, 0x71, 0x7b},
+ // e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838
+ {0xe0, 0x25, 0x4c, 0x7f, 0xcd, 0xc2, 0x65, 0xe2, 0xf0, 0xf8, 0xa4,
+ 0x28, 0xda, 0xbb, 0x75, 0xab, 0x4a, 0xe0, 0x39, 0xb4, 0x87, 0x2d,
+ 0x62, 0xf5, 0xee, 0x57, 0xf0, 0x87, 0x7d, 0x5a, 0x8a, 0x41},
+};
+
+std::vector<std::array<uint8_t, SHA256_DIGEST_LENGTH>>&
+BlockedSPKIsForTesting() {
+ static std::vector<std::array<uint8_t, SHA256_DIGEST_LENGTH>> list;
+ return list;
+}
+
+bool IsBlockedSPKI(const X509* cert) {
+ if (cert == nullptr) return false;
+ uint8_t* spki = nullptr;
+ int spki_len = i2d_X509_PUBKEY(X509_get_X509_PUBKEY(cert), &spki);
+ if (spki_len <= 0) return false;
+ uint8_t digest[SHA256_DIGEST_LENGTH];
+ SHA256(spki, spki_len, digest);
+ OPENSSL_free(spki);
+ for (const auto& blocked : kBlockedSPKIs) {
+ if (memcmp(digest, blocked, sizeof(digest)) == 0) return true;
+ }
+ for (const auto& blocked : BlockedSPKIsForTesting()) {
+ if (memcmp(digest, blocked.data(), sizeof(digest)) == 0) return true;
+ }
+ return false;
+}
+
+bool ContainsBlockedSPKI(const STACK_OF(X509)* certs) {
+ if (certs == nullptr) return false;
+ for (size_t i = 0; i < sk_X509_num(certs); i++) {
+ if (IsBlockedSPKI(sk_X509_value(certs, i))) return true;
+ }
+ return false;
+}
+
+// Wraps X509_verify_cert so that a chain through a blocked key is reported
+// as revoked even when the trust store would otherwise accept it. When no
+// verified chain is available every certificate the peer sent is checked.
+int CertVerifyCallback(X509_STORE_CTX* ctx, void* arg) {
+ int ok = X509_verify_cert(ctx);
+ bool blocked = ok == 1
+ ? ContainsBlockedSPKI(X509_STORE_CTX_get0_chain(ctx))
+ : IsBlockedSPKI(X509_STORE_CTX_get0_cert(ctx)) ||
+ ContainsBlockedSPKI(X509_STORE_CTX_get0_untrusted(ctx));
+ if (blocked) {
+ X509_STORE_CTX_set_error(ctx, X509_V_ERR_CERT_REVOKED);
+ return 0;
+ }
+ return ok;
+}
+
+} // namespace
+
+void AddBlockedSPKIForTesting(const uint8_t* sha256_hash) {
+ std::array<uint8_t, SHA256_DIGEST_LENGTH> entry;
+ memcpy(entry.data(), sha256_hash, entry.size());
+ BlockedSPKIsForTesting().push_back(entry);
+}
+
+void ClearBlockedSPKIsForTesting() {
+ BlockedSPKIsForTesting().clear();
+}
+
static const char* const root_certs[] = {
#include "node_root_certs.h" // NOLINT(build/include_order)
};
@@ -1631,6 +1734,7 @@ void SecureContext::Init(const FunctionCallbackInfo<Value>& args) {
env->external_memory_accounter()->Increase(env->isolate(), kExternalSize);
SSL_CTX_set_app_data(sc->ctx_.get(), sc);
+ SSL_CTX_set_cert_verify_callback(sc->ctx_.get(), CertVerifyCallback, nullptr);
// OpenSSL populates cert_comp_prefs with all available algorithms by
// default when compression libraries are linked. Clear them so that
diff --git a/src/crypto/crypto_context.h b/src/crypto/crypto_context.h
index c65f553e40fd88e8fc7102820355aa1b2ad09436..aa9b98ec614f37813c79a63094c3f515be86408b 100644
--- a/src/crypto/crypto_context.h
+++ b/src/crypto/crypto_context.h
@@ -29,6 +29,11 @@ X509_STORE* GetOrCreateRootCertStore(Environment* env);
ncrypto::BIOPointer LoadBIO(Environment* env, v8::Local<v8::Value> v);
+// Test-only hooks for the SPKI blocklist applied to every SecureContext. The
+// hash is the SHA-256 of a DER-encoded SubjectPublicKeyInfo.
+NODE_EXTERN void AddBlockedSPKIForTesting(const uint8_t* sha256_hash);
+NODE_EXTERN void ClearBlockedSPKIsForTesting();
+
class SecureContext final : public BaseObject {
public:
using GetSessionCb = SSL_SESSION* (*)(SSL*, const unsigned char*, int, int*);
+22
View File
@@ -18,6 +18,7 @@
#include "base/memory/ptr_util.h"
#include "base/no_destructor.h"
#include "base/power_monitor/power_monitor_source.h"
#include "base/strings/string_number_conversions.h"
#include "base/task/sequenced_task_runner.h"
#include "build/build_config.h"
#include "chrome/browser/browser_process.h"
@@ -57,6 +58,11 @@
#include <windows.h>
#endif
namespace node::crypto {
NODE_EXTERN void AddBlockedSPKIForTesting(const uint8_t* sha256_hash);
NODE_EXTERN void ClearBlockedSPKIsForTesting();
} // namespace node::crypto
namespace {
std::atomic<int> live_callback_holder_probes{0};
@@ -292,6 +298,20 @@ v8::Local<v8::Promise> SimulateNetworkServiceCrash(v8::Isolate* isolate) {
return handle;
}
void AddNodeBlockedSPKIForTesting(gin_helper::ErrorThrower thrower,
const std::string& hex) {
std::vector<uint8_t> hash;
if (!base::HexStringToBytes(hex, &hash) || hash.size() != 32) {
thrower.ThrowTypeError("expected a 64 character hex SHA-256");
return;
}
node::crypto::AddBlockedSPKIForTesting(hash.data());
}
void ClearNodeBlockedSPKIsForTesting() {
node::crypto::ClearBlockedSPKIsForTesting();
}
void HoldRepeatingCallbackForTesting(const base::RepeatingClosure& callback) {
GetCallbackTestingHelper().HoldRepeatingCallback(callback);
}
@@ -560,6 +580,8 @@ void Initialize(v8::Local<v8::Object> exports,
dict.SetMethod<&IsPlatformCaretBrowsingEnabled>(
"isPlatformCaretBrowsingEnabled");
dict.SetMethod<&SimulateNetworkServiceCrash>("simulateNetworkServiceCrash");
dict.SetMethod<&AddNodeBlockedSPKIForTesting>("addNodeBlockedSPKI");
dict.SetMethod<&ClearNodeBlockedSPKIsForTesting>("clearNodeBlockedSPKIs");
dict.SetMethod<&SimulatePowerEvent>("simulatePowerEvent");
dict.SetMethod<&SimulateWebAuthnUvLockedPinSecurityKey>(
"simulateWebAuthnUvLockedPinSecurityKey");
+95
View File
@@ -0,0 +1,95 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import * as crypto from 'node:crypto';
import { once } from 'node:events';
import * as fs from 'node:fs';
import * as https from 'node:https';
import * as path from 'node:path';
import * as tls from 'node:tls';
import { ifdescribe, isTestingBindingAvailable, listen } from './lib/spec-helpers.ts';
const certPath = path.resolve(import.meta.dirname, 'fixtures', 'certificates');
const rootCA = fs.readFileSync(path.join(certPath, 'rootCA.pem'), 'utf8');
const intermediateCA = fs.readFileSync(path.join(certPath, 'intermediateCA.pem'), 'utf8');
const serverCert = fs.readFileSync(path.join(certPath, 'server.pem'), 'utf8');
const serverKey = fs.readFileSync(path.join(certPath, 'server.key'), 'utf8');
const spkiHash = (pem: string) =>
crypto
.createHash('sha256')
.update(new crypto.X509Certificate(pem).publicKey.export({ type: 'spki', format: 'der' }))
.digest();
ifdescribe(isTestingBindingAvailable())('certificate revocation', () => {
const testing = process._linkedBinding('electron_common_testing');
let server: https.Server;
let port: number;
beforeAll(async () => {
server = https.createServer({ key: serverKey, cert: serverCert + intermediateCA }, (req, res) => {
res.setHeader('Connection', 'close');
res.end('ok');
});
port = (await listen(server)).port;
});
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
describe('Node.js TLS', () => {
afterEach(() => testing.clearNodeBlockedSPKIs());
const connect = async (options: Partial<tls.ConnectionOptions> = {}) => {
const socket = tls.connect({
host: '127.0.0.1',
port,
servername: 'localhost',
ca: [rootCA],
rejectUnauthorized: false,
...options
});
try {
await once(socket, 'secureConnect');
return socket.authorizationError ? String(socket.authorizationError) : null;
} finally {
socket.destroy();
}
};
it('reports a chain through a blocked intermediate key as revoked', async () => {
await expect(connect(), 'before').resolves.not.toBe('CERT_REVOKED');
testing.addNodeBlockedSPKI(spkiHash(intermediateCA).toString('hex'));
await expect(connect(), 'after').resolves.toBe('CERT_REVOKED');
});
it('reports a blocked leaf key as revoked', async () => {
testing.addNodeBlockedSPKI(spkiHash(serverCert).toString('hex'));
await expect(connect()).resolves.toBe('CERT_REVOKED');
});
it('fails the handshake when rejectUnauthorized is set', async () => {
testing.addNodeBlockedSPKI(spkiHash(intermediateCA).toString('hex'));
const socket = tls.connect({
host: '127.0.0.1',
port,
servername: 'localhost',
ca: [rootCA],
rejectUnauthorized: true
});
const [err] = await once(socket, 'error');
socket.destroy();
expect((err as NodeJS.ErrnoException).code).toBe('CERT_REVOKED');
});
it('applies to https.request', async () => {
testing.addNodeBlockedSPKI(spkiHash(intermediateCA).toString('hex'));
const req = https.get({ host: '127.0.0.1', port, servername: 'localhost', ca: [rootCA] });
const [err] = await once(req, 'error');
expect((err as NodeJS.ErrnoException).code).toBe('CERT_REVOKED');
});
it('does not affect chains without a blocked key', async () => {
testing.addNodeBlockedSPKI(crypto.randomBytes(32).toString('hex'));
await expect(connect()).resolves.not.toBe('CERT_REVOKED');
});
});
});