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.
| Package | Affected versions | Patched versions |
|---|---|---|
@tootallnate/oncenpm | < 3.0.1 | 3.0.1 |
Affected products
1Patches
1b9f43cc5259bFix promise hang when `AbortSignal` is aborted
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
5News mentions
0No linked articles in our index yet.