VYPR
Low severity3.3NVD Advisory· Published Mar 3, 2026· Updated Apr 29, 2026

CVE-2026-3449

CVE-2026-3449

Description

Versions of the package @tootallnate/once before 3.0.1 are vulnerable to Incorrect Control Flow Scoping in promise resolving when AbortSignal option is used. The Promise remains in a permanently pending state after the signal is aborted, causing any await or .then() usage to hang indefinitely. This can cause a control-flow leak that can lead to stalled requests, blocked workers, or degraded application availability.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
@tootallnate/oncenpm
< 3.0.13.0.1

Affected products

1

Patches

1
b9f43cc5259b

Fix promise hang when `AbortSignal` is aborted

https://github.com/TooTallNate/onceNathan RajlichFeb 9, 2026via ghsa
3 files changed · +80 8
  • src/index.ts+12 2 modified
    @@ -15,7 +15,7 @@ export default function once<
     ): Promise<EventListenerParameters<Emitter, Event>> {
     	return new Promise((resolve, reject) => {
     		function cleanup() {
    -			signal?.removeEventListener('abort', cleanup);
    +			signal?.removeEventListener('abort', onAbort);
     			emitter.removeListener(name, onEvent);
     			emitter.removeListener('error', onError);
     		}
    @@ -27,7 +27,17 @@ export default function once<
     			cleanup();
     			reject(err);
     		}
    -		signal?.addEventListener('abort', cleanup);
    +		function onAbort() {
    +			cleanup();
    +			const err = new Error('The operation was aborted');
    +			err.name = 'AbortError';
    +			reject(err);
    +		}
    +		if (signal?.aborted) {
    +			onAbort();
    +			return;
    +		}
    +		signal?.addEventListener('abort', onAbort);
     		emitter.on(name, onEvent);
     		emitter.on('error', onError);
     	});
    
  • src/types.ts+1 0 modified
    @@ -30,6 +30,7 @@ export type EventListenerParameters<
     export type WithDefault<T, D> = [T] extends [never] ? D : T;
     
     export interface AbortSignal {
    +	aborted: boolean;
     	addEventListener: (name: string, listener: (...args: any[]) => any) => void;
     	removeEventListener: (
     		name: string,
    
  • test/once.test.ts+67 6 modified
    @@ -99,19 +99,80 @@ describe('once()', () => {
     		await new Promise((r) => process.nextTick(r));
     
     		expect(wasResolved).toEqual(true);
    +	});
    +
    +	it('should reject with AbortError when signal is aborted', async () => {
    +		const emitter = new EventEmitter();
    +		const controller = new AbortController();
    +		const { signal } = controller;
     
    -		// Reset
    -		wasResolved = false;
    +		const promise = once(emitter, 'foo', { signal });
     
    -		once(emitter, 'foo', { signal }).then(onResolve, onResolve);
    +		controller.abort();
    +
    +		try {
    +			await promise;
    +			throw new Error('Should not happen');
    +		} catch (err: any) {
    +			expect(err.name).toEqual('AbortError');
    +			expect(err.message).toEqual('The operation was aborted');
    +		}
    +	});
     
    -		// This time abort
    +	it('should reject immediately if signal is already aborted', async () => {
    +		const emitter = new EventEmitter();
    +		const controller = new AbortController();
    +		controller.abort();
    +
    +		const { signal } = controller;
    +
    +		try {
    +			await once(emitter, 'foo', { signal });
    +			throw new Error('Should not happen');
    +		} catch (err: any) {
    +			expect(err.name).toEqual('AbortError');
    +			expect(err.message).toEqual('The operation was aborted');
    +		}
    +	});
    +
    +	it('should not resolve with event after being aborted', async () => {
    +		const emitter = new EventEmitter();
    +		const controller = new AbortController();
    +		const { signal } = controller;
    +
    +		let wasResolved = false;
    +		let wasRejected = false;
    +
    +		const promise = once(emitter, 'foo', { signal }).then(
    +			() => { wasResolved = true; },
    +			() => { wasRejected = true; }
    +		);
    +
    +		// Abort first, then emit
     		controller.abort();
     		emitter.emit('foo');
     
    -		// Promise is fulfilled on next tick, so wait a bit
    -		await new Promise((r) => process.nextTick(r));
    +		await promise;
     
     		expect(wasResolved).toEqual(false);
    +		expect(wasRejected).toEqual(true);
    +	});
    +
    +	it('should clean up listeners on the emitter when aborted', async () => {
    +		const emitter = new EventEmitter();
    +		const controller = new AbortController();
    +		const { signal } = controller;
    +
    +		once(emitter, 'foo', { signal }).catch(() => {});
    +
    +		expect(emitter.listenerCount('foo')).toEqual(1);
    +		expect(emitter.listenerCount('error')).toEqual(1);
    +
    +		controller.abort();
    +
    +		await new Promise((r) => process.nextTick(r));
    +
    +		expect(emitter.listenerCount('foo')).toEqual(0);
    +		expect(emitter.listenerCount('error')).toEqual(0);
     	});
     });
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.