Package Information
- Package Name:
@tootallnate/once
- Affected Versions: All versions up to and including
3.0.0 (latest)
- Vulnerability Type: Promise Hang / Denial of Service (DoS)
- Severity: Low / Medium
- Status: Unpatched (project appears to be in maintenance / legacy mode)
Summary
The @tootallnate/once package supports an optional AbortSignal to allow cancellation before the Promise resolves. However, when the signal is aborted, the implementation removes event listeners but does not resolve or reject the Promise.
As a result, the returned Promise remains in a permanently pending state, causing any await or .then() usage to hang indefinitely.
This creates a control-flow leak that can lead to stalled requests, blocked workers, or degraded application availability.
Technical Details
Root Cause
When an AbortSignal is provided and abort() is called:
- Event listeners are removed correctly (good for memory safety)
- The Promise is never settled (neither resolved nor rejected)
Relevant code (from src/index.ts):
export default function once(emitter, name, { signal } = {}) {
return new Promise((resolve, reject) => {
function cleanup() {
signal?.removeEventListener('abort', cleanup);
emitter.removeListener(name, onEvent);
emitter.removeListener('error', onError);
}
// onEvent() and onError() call cleanup() and resolve/reject
signal?.addEventListener('abort', cleanup); // <-- Promise never settles
emitter.on(name, onEvent);
emitter.on('error', onError);
});
}
The cleanup() function removes listeners but does not call resolve() or reject(), leaving the Promise permanently pending.
Proof of Concept (PoC)
Tested with: Node.js v20.18.2
const EventEmitter = require('events');
const once = require('@tootallnate/once');
async function test() {
const emitter = new EventEmitter();
const controller = new AbortController();
console.log('Starting once()...');
const promise = once(emitter, 'foo', { signal: controller.signal });
let settled = false;
promise.then(() => settled = true).catch(() => settled = true);
console.log('Aborting...');
controller.abort();
await new Promise(r => setTimeout(r, 1000));
if (!settled) {
console.log('[!] VULNERABILITY VERIFIED: Promise remains permanently pending');
}
}
test();
Output:
Starting once()...
Aborting...
[!] VULNERABILITY VERIFIED: Promise remains permanently pending
Impact
Applications using AbortSignal for cancellation or timeouts may experience:
- Hung
await once(...) calls
- Stalled HTTP request handlers
- Blocked worker threads or job queues
- Resource exhaustion over time
This is particularly risky in concurrency-limited environments, where permanently pending Promises can accumulate and exhaust available execution capacity.
Severity Justification
- Low → Medium severity
- No direct data compromise
- Can lead to application-level Denial of Service (DoS) via hung control flow
- Impact depends on how widely the function is awaited in critical execution paths
Recommendation / Fix
Expected Behavior
When the AbortSignal is aborted, the Promise returned by once() should be rejected, ideally with an AbortError.
Suggested Fix
Introduce a dedicated abort handler that:
- Calls
cleanup()
- Rejects the Promise
function onAbort() {
cleanup();
const err = new Error('The operation was aborted');
err.name = 'AbortError';
reject(err);
}
signal?.addEventListener('abort', onAbort, { once: true });
Additionally, if signal.aborted === true at invocation time, the Promise should reject immediately.
Workaround for Consumers
Until patched, consumers should avoid awaiting once() directly with an AbortSignal, or wrap it using Promise.race() with a Promise that explicitly rejects on abort.
Notes
- This issue is not a memory leak (event listeners are correctly removed).
- It is a control-flow leak, where a Promise becomes permanently pending, breaking expected cancellation semantics.
Disclosure
This issue was identified through manual code review and runtime testing.
No exploit code beyond the PoC above is required to trigger the issue.
Package Information
@tootallnate/once3.0.0(latest)Summary
The
@tootallnate/oncepackage supports an optionalAbortSignalto allow cancellation before the Promise resolves. However, when the signal is aborted, the implementation removes event listeners but does not resolve or reject the Promise.As a result, the returned Promise remains in a permanently pending state, causing any
awaitor.then()usage to hang indefinitely.This creates a control-flow leak that can lead to stalled requests, blocked workers, or degraded application availability.
Technical Details
Root Cause
When an
AbortSignalis provided andabort()is called:Relevant code (from
src/index.ts):The
cleanup()function removes listeners but does not callresolve()orreject(), leaving the Promise permanently pending.Proof of Concept (PoC)
Tested with: Node.js v20.18.2
Output:
Impact
Applications using
AbortSignalfor cancellation or timeouts may experience:await once(...)callsThis is particularly risky in concurrency-limited environments, where permanently pending Promises can accumulate and exhaust available execution capacity.
Severity Justification
Recommendation / Fix
Expected Behavior
When the
AbortSignalis aborted, the Promise returned byonce()should be rejected, ideally with anAbortError.Suggested Fix
Introduce a dedicated abort handler that:
cleanup()Additionally, if
signal.aborted === trueat invocation time, the Promise should reject immediately.Workaround for Consumers
Until patched, consumers should avoid awaiting
once()directly with anAbortSignal, or wrap it usingPromise.race()with a Promise that explicitly rejects on abort.Notes
Disclosure
This issue was identified through manual code review and runtime testing.
No exploit code beyond the PoC above is required to trigger the issue.