Path: blob/main/src/vs/platform/browserView/test/electron-main/browserSessionTrust.test.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import * as sinon from 'sinon';7import { EventEmitter } from 'events';8import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js';9import { StorageScope, StorageTarget } from '../../../storage/common/storage.js';10import { IApplicationStorageMainService } from '../../../storage/electron-main/storageMainService.js';11import { BrowserSessionTrust } from '../../electron-main/browserSessionTrust.js';12import type { BrowserSession } from '../../electron-main/browserSession.js';13import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';1415const STORAGE_KEY = 'browserView.sessionTrustData';16const TRUST_DURATION_MS = 7 * 24 * 60 * 60 * 1000;1718type CertificateVerifyProc = Parameters<Electron.Session['setCertificateVerifyProc']>[0];19type CertificateVerifyRequest = Parameters<NonNullable<CertificateVerifyProc>>[0];2021class TestElectronSession {22readonly closeAllConnections = sinon.stub().resolves();23certificateVerifyProc: CertificateVerifyProc | undefined;2425setCertificateVerifyProc(callback: CertificateVerifyProc): void {26this.certificateVerifyProc = callback;27}2829asSession(): Electron.Session {30return this as unknown as Electron.Session;31}32}3334class TestBrowserSession {35constructor(36readonly id: string,37readonly electronSession: Electron.Session,38) { }3940asBrowserSession(): BrowserSession {41return this as unknown as BrowserSession;42}43}4445class TestApplicationStorageMainService {46private readonly data = new Map<string, string>();47readonly store = sinon.stub<[string, string | number | boolean | object | null | undefined, StorageScope, StorageTarget], void>().callsFake((key, value) => {48this.data.set(key, String(value));49});50readonly remove = sinon.stub<[string, StorageScope], void>().callsFake(key => {51this.data.delete(key);52});5354get(key: string, _scope: StorageScope, fallbackValue?: string): string | undefined {55return this.data.get(key) ?? fallbackValue;56}5758seed(key: string, value: string): void {59this.data.set(key, value);60}6162read(key: string): string | undefined {63return this.data.get(key);64}6566asService(): IApplicationStorageMainService {67return this as unknown as IApplicationStorageMainService;68}69}7071class TestWebContents extends EventEmitter {72asWebContents(): Electron.WebContents {73return this as unknown as Electron.WebContents;74}75}7677function createTrust(sessionId = 'test-session'): {78trust: BrowserSessionTrust;79electronSession: TestElectronSession;80storage: TestApplicationStorageMainService;81} {82const electronSession = new TestElectronSession();83const browserSession = new TestBrowserSession(sessionId, electronSession.asSession());84const trust = new BrowserSessionTrust(browserSession.asBrowserSession());85const storage = new TestApplicationStorageMainService();8687return { trust, electronSession, storage };88}8990function createCertificate(fingerprint: string, extra?: Partial<Electron.Certificate>): Electron.Certificate {91return { fingerprint, issuerName: 'Test CA', subjectName: 'test.example.com', validStart: 0, validExpiry: 0, ...extra } as Electron.Certificate;92}9394function invokeVerifyProc(95electronSession: TestElectronSession,96request: Partial<CertificateVerifyRequest> & { hostname: string; certificate: Electron.Certificate }97): number {98assert.ok(electronSession.certificateVerifyProc);99100let result: number | undefined;101electronSession.certificateVerifyProc!({102errorCode: 0,103verificationResult: 'OK',104...request105} as CertificateVerifyRequest, value => {106result = value;107});108109assert.notStrictEqual(result, undefined);110return result!;111}112113suite('BrowserSessionTrust', () => {114teardown(() => {115sinon.restore();116});117118test('installs certificate verify proc and tracks certificate errors', () => {119const { trust, electronSession } = createTrust();120121const verificationResult = invokeVerifyProc(electronSession, {122hostname: 'example.com',123errorCode: -202,124verificationResult: 'net::ERR_CERT_AUTHORITY_INVALID',125certificate: createCertificate('abc123')126});127128assert.strictEqual(verificationResult, -3);129assert.deepStrictEqual(trust.getCertificateError('https://example.com/path'), {130host: 'example.com',131fingerprint: 'abc123',132error: 'net::ERR_CERT_AUTHORITY_INVALID',133url: 'https://example.com/path',134hasTrustedException: false,135issuerName: 'Test CA',136subjectName: 'test.example.com',137validStart: 0,138validExpiry: 0,139});140141invokeVerifyProc(electronSession, {142hostname: 'example.com',143certificate: createCertificate('abc123')144});145146assert.strictEqual(trust.getCertificateError('https://example.com/path'), undefined);147});148149test('trustCertificate persists data under the trust storage key', async () => {150const { trust, storage } = createTrust();151trust.connectStorage(storage.asService());152153await trust.trustCertificate('example.com', 'abc123');154155assert.strictEqual(storage.store.calledOnce, true);156assert.deepStrictEqual(storage.store.firstCall.args.slice(0, 4), [STORAGE_KEY, storage.read(STORAGE_KEY), StorageScope.APPLICATION, StorageTarget.MACHINE]);157158const persisted = JSON.parse(storage.read(STORAGE_KEY)!);159assert.deepStrictEqual(persisted['test-session'].trustedCerts.map((entry: { host: string; fingerprint: string }) => ({ host: entry.host, fingerprint: entry.fingerprint })), [{ host: 'example.com', fingerprint: 'abc123' }]);160});161162test('trustCertificate stores expiresAt relative to current time', async () => {163const clock = sinon.useFakeTimers({ now: Date.parse('2026-03-01T00:00:00.000Z') });164const { trust, storage } = createTrust();165trust.connectStorage(storage.asService());166167await trust.trustCertificate('example.com', 'abc123');168169const persisted = JSON.parse(storage.read(STORAGE_KEY)!);170const [entry] = persisted['test-session'].trustedCerts as { host: string; fingerprint: string; expiresAt: number }[];171assert.strictEqual(entry.host, 'example.com');172assert.strictEqual(entry.fingerprint, 'abc123');173assert.strictEqual(entry.expiresAt, Date.now() + TRUST_DURATION_MS);174175clock.restore();176});177178test('trust is valid at expiration and invalid after expiration', async () => {179const clock = sinon.useFakeTimers({ now: Date.parse('2026-03-01T00:00:00.000Z') });180const { trust, electronSession, storage } = createTrust();181const webContents = new TestWebContents();182trust.installCertErrorHandler(webContents.asWebContents());183trust.connectStorage(storage.asService());184await trust.trustCertificate('example.com', 'abc123');185electronSession.closeAllConnections.resetHistory();186187// Prior to the expiration boundary, trust should still be valid188clock.tick(TRUST_DURATION_MS - 10);189let callbackResult: boolean | undefined;190const firstEvent = { preventDefault: sinon.spy() };191webContents.emit('certificate-error', firstEvent, 'https://example.com', 'ERR_CERT', createCertificate('abc123'), (value: boolean) => {192callbackResult = value;193});194assert.strictEqual(callbackResult, true);195196// After expiration, trust should be revoked197clock.tick(20);198const secondEvent = { preventDefault: sinon.spy() };199webContents.emit('certificate-error', secondEvent, 'https://example.com', 'ERR_CERT', createCertificate('abc123'), (value: boolean) => {200callbackResult = value;201});202assert.strictEqual(callbackResult, false);203204clock.restore();205});206207test('connectStorage restores valid trust entries and prunes expired ones', () => runWithFakedTimers({ useFakeTimers: true }, async () => {208const { trust, storage } = createTrust();209const webContents = new TestWebContents();210trust.installCertErrorHandler(webContents.asWebContents());211storage.seed(STORAGE_KEY, JSON.stringify({212'test-session': {213trustedCerts: [214{ host: 'valid.example.com', fingerprint: 'valid', expiresAt: Date.now() + 1000 },215{ host: 'expired.example.com', fingerprint: 'expired', expiresAt: Date.now() - 1000 }216]217}218}));219220trust.connectStorage(storage.asService());221222let callbackResult: boolean | undefined;223const validEvent = { preventDefault: sinon.spy() };224webContents.emit('certificate-error', validEvent, 'https://valid.example.com', 'ERR_CERT', createCertificate('valid'), (value: boolean) => {225callbackResult = value;226});227assert.strictEqual(callbackResult, true);228229const expiredEvent = { preventDefault: sinon.spy() };230webContents.emit('certificate-error', expiredEvent, 'https://expired.example.com', 'ERR_CERT', createCertificate('expired'), (value: boolean) => {231callbackResult = value;232});233assert.strictEqual(callbackResult, false);234235const persisted = JSON.parse(storage.read(STORAGE_KEY)!);236assert.deepStrictEqual(persisted['test-session'].trustedCerts.map((entry: { host: string; fingerprint: string }) => ({ host: entry.host, fingerprint: entry.fingerprint })), [{ host: 'valid.example.com', fingerprint: 'valid' }]);237}));238239test('stored and reloaded trust expires and is pruned', async () => {240const clock = sinon.useFakeTimers({ now: Date.parse('2026-03-01T00:00:00.000Z') });241242const storage = new TestApplicationStorageMainService();243const firstSession = new TestElectronSession();244const firstBrowserSession = new TestBrowserSession('test-session', firstSession.asSession());245const firstTrust = new BrowserSessionTrust(firstBrowserSession.asBrowserSession());246firstTrust.connectStorage(storage.asService());247await firstTrust.trustCertificate('reload.example.com', 'reload-fingerprint');248249clock.tick(TRUST_DURATION_MS + 1);250251const secondSession = new TestElectronSession();252const secondBrowserSession = new TestBrowserSession('test-session', secondSession.asSession());253const secondTrust = new BrowserSessionTrust(secondBrowserSession.asBrowserSession());254const webContents = new TestWebContents();255secondTrust.installCertErrorHandler(webContents.asWebContents());256secondTrust.connectStorage(storage.asService());257258let callbackResult: boolean | undefined;259const event = { preventDefault: sinon.spy() };260webContents.emit('certificate-error', event, 'https://reload.example.com', 'ERR_CERT', createCertificate('reload-fingerprint'), (value: boolean) => {261callbackResult = value;262});263assert.strictEqual(callbackResult, false);264assert.strictEqual(storage.read(STORAGE_KEY), undefined);265266clock.restore();267});268269test('untrustCertificate removes persisted trust and closes connections', async () => {270const { trust, electronSession, storage } = createTrust();271trust.connectStorage(storage.asService());272await trust.trustCertificate('example.com', 'abc123');273electronSession.closeAllConnections.resetHistory();274storage.store.resetHistory();275276await trust.untrustCertificate('example.com', 'abc123');277278assert.strictEqual(electronSession.closeAllConnections.calledOnce, true);279assert.strictEqual(storage.remove.calledOnceWithExactly(STORAGE_KEY, StorageScope.APPLICATION), true);280assert.strictEqual(storage.read(STORAGE_KEY), undefined);281});282283test('untrustCertificate throws when certificate is not found', async () => {284const { trust, electronSession, storage } = createTrust();285trust.connectStorage(storage.asService());286287await assert.rejects(288() => trust.untrustCertificate('missing.example.com', 'missing-fingerprint'),289error => {290assert.ok(error instanceof Error);291assert.strictEqual(error.message, 'Certificate not found: host=missing.example.com fingerprint=missing-fingerprint');292return true;293}294);295assert.strictEqual(electronSession.closeAllConnections.called, false);296});297298test('clear removes trust, clears cert errors, and closes connections', async () => {299const { trust, electronSession, storage } = createTrust();300trust.connectStorage(storage.asService());301await trust.trustCertificate('example.com', 'abc123');302invokeVerifyProc(electronSession, {303hostname: 'example.com',304errorCode: -202,305verificationResult: 'net::ERR_CERT_COMMON_NAME_INVALID',306certificate: createCertificate('abc123')307});308309await trust.clear();310311assert.strictEqual(electronSession.closeAllConnections.calledOnce, true);312assert.strictEqual(trust.getCertificateError('https://example.com'), undefined);313assert.strictEqual(storage.read(STORAGE_KEY), undefined);314});315316test('installCertErrorHandler only allows trusted certificates', async () => {317const { trust } = createTrust();318const webContents = new TestWebContents();319trust.installCertErrorHandler(webContents.asWebContents());320321let callbackResult: boolean | undefined;322const firstEvent = { preventDefault: sinon.spy() };323webContents.emit('certificate-error', firstEvent, 'https://example.com', 'ERR_CERT', createCertificate('abc123'), (value: boolean) => {324callbackResult = value;325});326assert.strictEqual(callbackResult, false);327assert.strictEqual(firstEvent.preventDefault.calledOnce, true);328329await trust.trustCertificate('example.com', 'abc123');330const secondEvent = { preventDefault: sinon.spy() };331webContents.emit('certificate-error', secondEvent, 'https://example.com', 'ERR_CERT', createCertificate('abc123'), (value: boolean) => {332callbackResult = value;333});334assert.strictEqual(callbackResult, true);335assert.strictEqual(secondEvent.preventDefault.calledOnce, true);336});337338ensureNoDisposablesAreLeakedInTestSuite();339});340341342