Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ The options below are used to configure the security policies of the `HardenedHt

| **Property** | **Type** | **Required / Variants** | **Helper(s)** |
| ------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `ca` | `string \| Buffer \| Array<string \| Buffer>` | Required. Custom trust store that replaces Node.js defaults. Accepts PEM string, `Buffer`, or an array of either. | `embeddedCfsslCaBundle`, `useNodeDefaultCABundle()` |
| `ca` | `string \| Buffer \| Array<string \| Buffer>` | Required. Custom trust store that replaces Node.js defaults. Accepts PEM string, `Buffer`, or an array of either. | `embeddedCfsslCaBundle`, `useNodeDefaultCaBundle()` |
| `ctPolicy` | `CertificateTransparencyPolicy` | Optional. Enables CT when present. Fields: `logList: UnifiedCTLogList`, `minEmbeddedScts?: number`, `minDistinctOperators?: number`. | `basicCtPolicy()`, `embeddedUnifiedCtLogList` |
| `ocspPolicy` | `OCSPPolicy` | Optional. Enables OCSP when present. Fields: `mode: 'mixed' \| 'stapling' \| 'direct'`, `failHard: boolean`. | `basicStaplingOcspPolicy()`, `basicDirectOcspPolicy()` |
| `crlSetPolicy` | `CRLSetPolicy` | Optional. Enables CRLSet when present. Fields: `crlSet?: CRLSet`, `verifySignature?: boolean`, `updateStrategy?: 'always' \| 'on-expiry'`. | `basicCrlSetPolicy()` |
Expand All @@ -199,7 +199,7 @@ Import convenience presets and building blocks as needed, to help you construct
```typescript
import {
defaultAgentOptions,
useNodeDefaultCABundle,
useNodeDefaultCaBundle,
embeddedCfsslCaBundle,
embeddedUnifiedCtLogList,
basicCtPolicy,
Expand All @@ -216,7 +216,7 @@ The default helpers such as `embeddedCfsslCaBundle` and `embeddedUnifiedCtLogLis

- These embedded resources are refreshed via an automated weekly GitHub Action that fetches the latest upstream data and opens a pull request to update the repository. Updates are executed on GitHub’s infrastructure and are fully auditable in pull request diffs and timestamps.
- If you choose to rely on embedded resources, you are responsible for updating the library in your project to receive the refreshed data at your desired cadence.
- Alternatively, you can opt into Node's default CA bundle with `useNodeDefaultCABundle()` if that trust model better suits your environment.
- Alternatively, you can opt into Node's default CA bundle with `useNodeDefaultCaBundle()` if that trust model better suits your environment.
- You can also choose not to rely on embedded resources at all: provide your own CA bundle, your own unified CT log list, and configure every other property yourself. Everything is fully customizable.

### `HardenedHttpsAgent` customization (quick recipes)
Expand All @@ -230,7 +230,7 @@ new HardenedHttpsAgent({ ...defaultAgentOptions(), ca: myPemStringOrBuffer });
Use Node default CA bundle:

```typescript
new HardenedHttpsAgent({ ...defaultAgentOptions(), ca: useNodeDefaultCABundle() });
new HardenedHttpsAgent({ ...defaultAgentOptions(), ca: useNodeDefaultCaBundle() });
```

Tune standard `https.Agent` behavior:
Expand Down
2 changes: 1 addition & 1 deletion examples/custom-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ async function main() {
const agent = new HardenedHttpsAgent(
{
...httpsAgentOptions,
ca: embeddedCfsslCaBundle, // or *your custom ca bundle* | useNodeDefaultCABundle()
ca: embeddedCfsslCaBundle, // or *your custom ca bundle* | useNodeDefaultCaBundle()
ctPolicy: {
logList: embeddedUnifiedCtLogList, // or *your custom log list*
minEmbeddedScts: 2,
Expand Down
14 changes: 10 additions & 4 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,25 @@ export class HardenedHttpsAgent extends Agent {
): Duplex {
this.#logger?.log('Initiating new TLS connection...');

// Handle validation success
this.#kit.once('validation:success', (tlsSocket) => {
callback(null, tlsSocket);
});

// Allow validators to modify the connection options
const finalOptions = this.#kit.applyBeforeConnect(options);

// Create the socket
const socket = tls.connect(finalOptions);
// Attach the validation kit to the socket
// The socket will be passed back to the callback from the validation kit
this.#kit.attachToSocket(socket, callback);

// Handle socket errors
socket.on('error', (err: Error) => {
this.#logger?.error('A socket error occurred during connection setup.', err);
callback(err, undefined as any);
});

// Attach the validation kit to the socket
this.#kit.attachToSocket(socket);

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Connection Validation Fails with Concurrent Requests

The createConnection method's event handling has several issues. Using once listeners on the shared validation kit means concurrent connections can result in callbacks being invoked incorrectly or not at all. Additionally, validation:error events are not handled, causing validation failures to hang connections instead of propagating errors.

Fix in Cursor Fix in Web

return undefined as any;
}
}
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type {
} from './interfaces';

export {
useNodeDefaultCABundle,
useNodeDefaultCaBundle,
embeddedCfsslCaBundle,
embeddedUnifiedCtLogList,
basicCtPolicy,
Expand All @@ -19,4 +19,4 @@ export {
defaultAgentOptions,
} from './options';

export { HardenedHttpsValidationKit } from './validation-kit';
export { HardenedHttpsValidationKit, type ValidationKitEvents } from './validation-kit';
2 changes: 1 addition & 1 deletion src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const NODE_DEFAULT_CA_SENTINEL = '__USE_NODE_DEFAULT_CA_BUNDLE__';
* bundled Mozilla store, not necessarily the OS store, unless `--use-system-ca` or a
* platform-specific build enables it.
*/
export function useNodeDefaultCABundle(): string {
export function useNodeDefaultCaBundle(): string {
return NODE_DEFAULT_CA_SENTINEL;
}

Expand Down
46 changes: 35 additions & 11 deletions src/validation-kit.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import tls from 'node:tls';
import https from 'node:https';
import http from 'node:http';
import https from 'node:https';
import tls from 'node:tls';
import { Logger, LogSink } from './logger';
import type { HardenedHttpsValidationKitOptions } from './interfaces';
import { BaseValidator } from './validators/base';
Expand All @@ -11,15 +11,36 @@ import {
OCSPMixedValidator,
CRLSetValidator,
} from './validators';
import { Duplex } from 'node:stream';
import { EventEmitter } from 'node:events';

export type ValidationKitEvents = {
'validation:success': (tlsSocket: tls.TLSSocket) => void;
'validation:error': (error: Error) => void;
};

class TypedEventEmitter<Events extends Record<string, (...args: any[]) => void>> extends EventEmitter {
public override on<K extends keyof Events & string>(eventName: K, listener: Events[K]): this {
return super.on(eventName, listener as (...args: any[]) => void);
}
public override once<K extends keyof Events & string>(eventName: K, listener: Events[K]): this {
return super.once(eventName, listener as (...args: any[]) => void);
}
public override off<K extends keyof Events & string>(eventName: K, listener: Events[K]): this {
return super.off(eventName, listener as (...args: any[]) => void);
}
public override emit<K extends keyof Events & string>(eventName: K, ...args: Parameters<Events[K]>): boolean {
return super.emit(eventName, ...args);
}
}

export class HardenedHttpsValidationKit {
export class HardenedHttpsValidationKit extends TypedEventEmitter<ValidationKitEvents> {
private readonly options: HardenedHttpsValidationKitOptions;
private readonly logger: Logger | undefined;
private readonly validators: BaseValidator[];
private readonly validatedSockets: WeakSet<tls.TLSSocket> = new WeakSet();

constructor(options: HardenedHttpsValidationKitOptions, sink?: LogSink) {
super();
this.options = options;
if (options.enableLogging) this.logger = new Logger(this.constructor.name, sink);

Expand Down Expand Up @@ -47,12 +68,15 @@ export class HardenedHttpsValidationKit {
return finalOptions as T;
}

private runValidation(tlsSocket: tls.TLSSocket, callback?: (err: Error | null, stream: Duplex) => void): void {
private runValidation(tlsSocket: tls.TLSSocket): void {
if (this.validatedSockets.has(tlsSocket)) return;
this.validatedSockets.add(tlsSocket);

const active = this.getActiveValidators();
if (active.length === 0) return callback?.(null, tlsSocket);
if (active.length === 0) {
this.emit('validation:success', tlsSocket);
return;
}

let shouldResume = false;
try {
Expand All @@ -77,18 +101,18 @@ export class HardenedHttpsValidationKit {
this.logger?.warn('Failed to resume socket', err);
}
}
callback?.(null, tlsSocket);
this.emit('validation:success', tlsSocket);
})
.catch((err: Error) => {
this.logger?.error('An error occurred during validation', err);
callback?.(err, undefined as any);
// TODO: tlsSocket.destroy(err); ?
tlsSocket.destroy(err); // Destroy the socket to prevent further use (and force error propagation to eventual attached agent)
this.emit('validation:error', err);
});
}

public attachToSocket(tlsSocket: tls.TLSSocket, callback?: (err: Error | null, stream: Duplex) => void): void {
public attachToSocket(tlsSocket: tls.TLSSocket): void {
if (this.validatedSockets.has(tlsSocket)) return;
this.runValidation(tlsSocket, callback);
this.runValidation(tlsSocket);
}

/* istanbul ignore next */
Expand Down
4 changes: 2 additions & 2 deletions test/agent.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Duplex } from 'node:stream';
import tls, { TLSSocket } from 'node:tls';
import { HardenedHttpsAgent } from '../src/agent';
import { HardenedHttpsAgentOptions } from '../src/interfaces';
Expand Down Expand Up @@ -33,6 +32,7 @@ describe('HardenedHttpsAgent', () => {
const kit = {
applyBeforeConnect: jest.fn((opts) => opts),
attachToSocket: jest.fn(),
once: jest.fn(),
};
// Assign the mock instance to our variable so we can assert calls on it
mockValidationKit = kit as unknown as jest.Mocked<HardenedHttpsValidationKit>;
Expand Down Expand Up @@ -73,7 +73,7 @@ describe('HardenedHttpsAgent', () => {
const agent = new HardenedHttpsAgent(baseOptions);
agent.createConnection({}, jest.fn());

expect(mockValidationKit.attachToSocket).toHaveBeenCalledWith(mockSocket, expect.any(Function));
expect(mockValidationKit.attachToSocket).toHaveBeenCalledWith(mockSocket);
});

test('should use the connection options returned by applyBeforeConnect', () => {
Expand Down
5 changes: 1 addition & 4 deletions test/ct-validator.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import type { CertificateTransparencyPolicy } from '../src/interfaces';
import * as tls from 'node:tls';
import { loadTestCertsChain, getTestHardenedHttpsAgent, TEST_CT_POLICY } from './utils';
import { TEST_CERT_HOSTS } from '../scripts/constants';
import { loadTestCertsChain, getTestHardenedHttpsAgent, TEST_CT_POLICY, createMockSocket, createMockPeerCertificate } from './utils';
import { SCT_EXTENSION_OID_V1 } from '@gldywn/sct.js';
import { createMockSocket, createMockPeerCertificate, delay } from './utils';
import { CTValidator } from '../src/validators';
import { WrappedError } from '../src/validators/base';

jest.mock('node:tls');

Expand Down
3 changes: 1 addition & 2 deletions test/e2e/acceptance.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import axios, { type AxiosRequestConfig } from 'axios';
import { delay } from '../utils';
import { delay, spoofedAxios } from '../utils';
import {
basicCtPolicy,
basicDirectOcspPolicy,
basicStaplingOcspPolicy,
HardenedHttpsAgent,
type HardenedHttpsAgentOptions,
} from '../../src';
import { spoofedAxios } from '../utils/spoofedAxios';
import { basicMixedOcspPolicy, defaultAgentOptions, embeddedCfsslCaBundle } from '../../src/options';

// Note: This test file is not completely stable because it relies on network.
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/agent.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import https from 'node:https';
import axios from 'axios';
import { HardenedHttpsAgent } from '../../src/agent';
import { getCa, startTlsServer } from '../utils/server';
import { getCa, startTlsServer } from '../utils';

describe('End-to-end HardenedHttpsAgent integration', () => {
let server: ReturnType<typeof startTlsServer>;
Expand Down
3 changes: 1 addition & 2 deletions test/e2e/failure.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { type AxiosRequestConfig } from 'axios';
import { delay } from '../utils';
import { spoofedAxios } from '../utils/spoofedAxios';
import { delay, spoofedAxios } from '../utils';
import { HardenedHttpsAgent, type HardenedHttpsAgentOptions } from '../../src';
import {
basicCtPolicy,
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/validation-kit.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import https from 'node:https';
import tls from 'node:tls';
import { HardenedHttpsValidationKit } from '../../src/validation-kit';
import { getCa, startTlsServer } from '../utils/server';
import { getCa, startTlsServer } from '../utils';
import { basicCtPolicy } from '../../src/options';

jest.mock('../../src/validators/ct', () => ({
Expand Down
4 changes: 1 addition & 3 deletions test/ocsp-direct-validator.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import type { OCSPPolicy } from '../src/interfaces';
import * as tls from 'node:tls';
import { loadTestCertsChain, getTestHardenedHttpsAgent } from './utils';
import { createMockSocket, createMockPeerCertificate } from './utils/createMock';
import { loadTestCertsChain, getTestHardenedHttpsAgent, createMockSocket, createMockPeerCertificate } from './utils';
import * as easyOcsp from 'easy-ocsp';
import { OCSPDirectValidator } from '../src/validators';
import { WrappedError } from '../src/validators/base';

jest.mock('node:tls');
jest.mock('easy-ocsp');
Expand Down
3 changes: 1 addition & 2 deletions test/ocsp-mixed-validator.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { OCSPPolicy } from '../src/interfaces';
import * as tls from 'node:tls';
import { loadTestCertsChain, getTestHardenedHttpsAgent } from './utils';
import { createMockSocket, createMockPeerCertificate } from './utils/createMock';
import { loadTestCertsChain, getTestHardenedHttpsAgent, createMockSocket, createMockPeerCertificate } from './utils';
import * as easyOcsp from 'easy-ocsp';

jest.mock('node:tls');
Expand Down
3 changes: 1 addition & 2 deletions test/ocsp-stapling-validator.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { OCSPPolicy } from '../src/interfaces';
import * as tls from 'node:tls';
import { loadTestCertsChain, getTestHardenedHttpsAgent } from './utils';
import { createMockSocket, createMockPeerCertificate } from './utils/createMock';
import { loadTestCertsChain, getTestHardenedHttpsAgent, createMockSocket, createMockPeerCertificate } from './utils';
import * as easyOcsp from 'easy-ocsp';
import { OCSPStaplingValidator } from '../src/validators';

Expand Down
3 changes: 2 additions & 1 deletion test/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { Certificate } from 'pkijs';
import { fromBER } from 'asn1js';
import { getTestDataDir } from '../../scripts/utils';
import { HardenedHttpsAgent, CertificateTransparencyPolicy, OCSPPolicy, CRLSetPolicy } from '../../src';
import { CRLSet } from '@gldywn/crlset.js';

export { createMockSocket, createMockPeerCertificate } from './createMock';
export { startTlsServer, getCa } from './server';
export { spoofedAxios } from './spoofedAxios';

const TEST_DATA_DIR = getTestDataDir();

Expand Down
18 changes: 10 additions & 8 deletions test/validation-kit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,30 +120,32 @@ describe('HardenedHttpsValidationKit', () => {
mockOcspStaplingValidator.validate.mockResolvedValue(undefined);

const kit = new HardenedHttpsValidationKit(baseOptions);
kit.attachToSocket(mockSocket, (err) => {
expect(err).toBeNull();
kit.once('validation:success', (tlsSocket) => {
expect(tlsSocket).toBe(mockSocket);
expect(mockSocket.pause).toHaveBeenCalled();
expect(mockSocket.resume).toHaveBeenCalled();

done();
});

expect(mockSocket.pause).toHaveBeenCalled();
expect(mockSocket.resume).not.toHaveBeenCalled();
kit.attachToSocket(mockSocket);
});

test('should destroy the socket if any active validator fails', (done) => {
// Mock the socket.destroy method to avoid unhandled error propagation
mockSocket.destroy = jest.fn();

const validationError = new Error('Validation failed');
mockCtValidator.shouldRun.mockReturnValue(true);
mockCtValidator.validate.mockRejectedValue(validationError);

const kit = new HardenedHttpsValidationKit(baseOptions);
kit.attachToSocket(mockSocket, (err) => {
kit.once('validation:error', (err) => {
expect(err).toBe(validationError);
// TODO: Check this -> expect(mockSocket.destroy).toHaveBeenCalledWith(validationError);
expect(mockSocket.destroy).toHaveBeenCalledWith(validationError);
expect(mockSocket.resume).not.toHaveBeenCalled();

done();
});
kit.attachToSocket(mockSocket);
});

test('should pass modified options from one validator to the next during onBeforeConnect', () => {
Expand Down