How Resource Exhaustion via Missing Fetch Timeouts Happens in Node.js and How to Fix It
Introduction
The lib/index.js file in the dsh-plugin-marketplace package serves as the core GitHub client, handling API requests for plugin registry operations, repository content fetching, and release downloads. But a dangerous inconsistency lurked in how the code handled network timeouts: one fetch() call at line 1161 correctly implemented AbortSignal.timeout(), while other critical fetch calls at lines 101 and 145—both hitting the GitHub API—had no timeout mechanism whatsoever.
This inconsistency meant that any downstream consumer of this Node.js library was vulnerable to resource exhaustion. An attacker who could influence which GitHub endpoints were queried—or simply wait for GitHub API slowdowns—could cause indefinite connection hangs that would drain the application's connection pool and eventually bring it to its knees.
The vulnerability is particularly impactful because this is a library, not an application. Every project that depends on dsh-plugin-marketplace inherits this flaw, amplifying the blast radius significantly.
The Vulnerability Explained
What Happens When Fetch Has No Timeout?
In Node.js, the fetch() API will wait indefinitely by default for a response. There is no built-in timeout. If the remote server is slow, overloaded, or deliberately stalling (a "slowloris"-style scenario), the connection remains open, consuming a socket and associated memory.
Here's what the vulnerable code pattern looked like in lib/index.js:
// Line ~101 — GitHub API call WITHOUT timeout
const response = await fetch(`${RAW_BASE}/${owner}/${repo}/...`, {
headers: {
"User-Agent": USER_AGENT,
// ... other headers
}
});
// Line ~145 — Another GitHub API call WITHOUT timeout
const releaseResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/...`, {
headers: {
"User-Agent": USER_AGENT,
// ... other headers
}
});
Meanwhile, a different fetch call in the same file did have proper timeout handling:
// Line ~1161 — Registry fetch WITH timeout (the correct pattern)
const registryResponse = await fetch(registryUrl, {
signal: AbortSignal.timeout(registryRequestTimeoutMs),
headers: { /* ... */ }
});
This inconsistency is the crux of the vulnerability. A developer reviewing line 1161 would reasonably assume all fetch calls were protected. But the GitHub API calls at lines 101 and 145 were completely unguarded.
The Attack Scenario
Consider this concrete exploitation path:
-
Trigger: An attacker identifies that the
dsh-plugin-marketplacelibrary fetches raw content fromraw.githubusercontent.comand release metadata fromapi.github.comduring plugin installation. -
Manipulation: The attacker either:
- Configures a customregistryUrlpointing to a malicious registry that references GitHub repositories with deliberately slow or non-existent release assets, or
- Exploits a network condition (DNS poisoning, MITM on non-pinned connections) to route GitHub API requests to a slow-responding server. -
Exhaustion: Each triggered plugin install opens fetch connections to the GitHub API without any timeout. The attacker triggers multiple concurrent installs. Each connection hangs indefinitely, consuming:
- A socket from the Node.js connection pool
- Associated memory buffers
- An event loop slot waiting on the promise -
Denial of Service: After enough concurrent hanging requests, the connection pool is exhausted. The host application can no longer make any outbound HTTP requests—not just to GitHub, but to any service. The application becomes completely unresponsive.
The USER_AGENT constant "dsh-plugin-marketplace" and the RAW_BASE constant "https://raw.githubusercontent.com" confirm these are production GitHub API calls, not test stubs.
Why This Is Critical for a Library
Unlike an application vulnerability that affects a single deployment, this flaw exists in a shared library. Every downstream consumer—whether a CLI tool, desktop app (the Tauri/Rust dependencies in src-tauri/Cargo.lock confirm this is also used in a desktop context), or server-side process—inherits the vulnerability. The MAX_PATCH_CHARS = 65536 constant and the GitHubError class definition visible in the diff show this is a substantial, actively-used client with real production consumers.
The Fix
The fix applies the existing registryRequestTimeoutMs configuration parameter to all fetch operations, not just the registry fetch. This is both elegant and backwards-compatible: the timeout value was already configurable by consumers, it just wasn't being applied everywhere.
Before (Vulnerable)
// GitHub raw content fetch — no timeout
const response = await fetch(`${RAW_BASE}/${owner}/${repo}/...`, {
headers: {
"User-Agent": USER_AGENT,
}
});
// GitHub API release fetch — no timeout
const releaseResponse = await fetch(`https://api.github.com/repos/...`, {
headers: {
"User-Agent": USER_AGENT,
}
});
After (Fixed)
// GitHub raw content fetch — timeout enforced
const response = await fetch(`${RAW_BASE}/${owner}/${repo}/...`, {
signal: AbortSignal.timeout(registryRequestTimeoutMs),
headers: {
"User-Agent": USER_AGENT,
}
});
// GitHub API release fetch — timeout enforced
const releaseResponse = await fetch(`https://api.github.com/repos/...`, {
signal: AbortSignal.timeout(registryRequestTimeoutMs),
headers: {
"User-Agent": USER_AGENT,
}
});
Documentation Updates
The fix also updated both README.md and README.en.md to reflect the expanded scope of registryRequestTimeoutMs:
Before:
Configure the cache and timeout with
registryCacheMinutesandregistryRequestTimeoutMs.
After:
Configure the cache with
registryCacheMinutes;registryRequestTimeoutMsapplies to both Registry and install-time GitHub requests.
This documentation change is critical—it informs downstream consumers that the timeout parameter now governs all outbound requests, not just registry fetches. Users who had set a custom registryRequestTimeoutMs value will now automatically benefit from consistent timeout enforcement.
Why the GitHubError Class Changes Matter
The diff also shows structural improvements to the GitHubError class:
// Before: properties declared implicitly in constructor
var GitHubError = class extends Error {
constructor(code, message, details = {}) {
super(message);
// After: explicit class field declarations
var GitHubError = class extends Error {
code;
details;
constructor(code, message, details = {}) {
super(message);
Adding explicit code and details class field declarations improves the error handling path. When a timeout triggers an AbortError, the error propagation chain through GitHubError is now more predictable, ensuring that timeout failures are properly surfaced to consumers rather than silently swallowed.
Prevention & Best Practices
1. Create a Centralized Fetch Wrapper
The root cause of this vulnerability was having multiple independent fetch() calls with inconsistent configurations. A centralized wrapper prevents this:
async function safeFetch(url, options = {}) {
const timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS;
const { timeoutMs: _, ...fetchOptions } = options;
return fetch(url, {
...fetchOptions,
signal: AbortSignal.timeout(timeoutMs),
});
}
2. Lint for Unprotected Fetch Calls
Use a Semgrep rule or ESLint custom rule to flag any fetch() call that doesn't include a signal option:
# Semgrep rule
rules:
- id: fetch-missing-timeout
pattern: fetch($URL, { ... })
pattern-not: fetch($URL, { ..., signal: $SIGNAL, ... })
message: "fetch() call missing AbortSignal timeout"
severity: WARNING
3. Set Connection Pool Limits
Even with timeouts, configure your HTTP agent's maximum socket pool:
import { Agent } from 'undici';
const agent = new Agent({
connections: 10, // max concurrent connections
pipelining: 1,
connectTimeout: 10000, // 10s connect timeout
});
4. Test Timeout Behavior
Write integration tests that simulate slow responses (using tools like nock or msw) to verify that all fetch paths properly abort after the configured timeout.
5. Reference Standards
- CWE-400: Uncontrolled Resource Consumption
- OWASP: Denial of Service guidelines recommend enforcing timeouts on all external service calls
- Node.js Best Practices: The
AbortSignal.timeout()API (available since Node.js 17.3) is the recommended approach for fetch timeouts
Key Takeaways
- Inconsistent timeout enforcement is as dangerous as no timeouts at all: The fetch at line 1161 had
AbortSignal.timeout(), but the GitHub API calls at lines 101 and 145 did not—creating a false sense of security. - Library vulnerabilities have amplified impact: Because
dsh-plugin-marketplaceis consumed by downstream projects (including a Tauri desktop app), every consumer inherited this DoS vulnerability. - Reuse existing configuration parameters: The fix elegantly extended
registryRequestTimeoutMsto cover all fetch calls rather than introducing a new config option, maintaining backwards compatibility. - Documentation must reflect security boundaries: Updating both
README.mdandREADME.en.mdto clarify thatregistryRequestTimeoutMsnow applies to GitHub requests ensures consumers understand the timeout scope. - Audit all call sites, not just the one that triggered the alert: When one
fetch()is missing a timeout, check every otherfetch()in the codebase—the pattern tends to repeat.
How Orbis AppSec Detected This
- Source: External network responses from
raw.githubusercontent.comandapi.github.comendpoints, triggered by plugin installation and registry operations. - Sink: Unprotected
fetch()calls atlib/index.js:101andlib/index.js:145that could hang indefinitely withoutAbortSignal.timeout(). - Missing control: No timeout mechanism on GitHub API fetch calls, despite the
registryRequestTimeoutMsconfiguration being available and used elsewhere in the same file (line 1161). - CWE: CWE-400 — Uncontrolled Resource Consumption
- Fix: Applied
AbortSignal.timeout(registryRequestTimeoutMs)to all fetch operations in the GitHub client and updated documentation to reflect the unified timeout scope.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
This vulnerability is a textbook example of how inconsistent security controls can be just as dangerous as absent ones. A single fetch() call with proper timeout handling gave the appearance of safety, while two other critical fetch paths to the GitHub API remained completely unprotected. For a library consumed by multiple downstream projects, this created a systemic denial-of-service risk.
The fix was surgical and elegant: extending the existing registryRequestTimeoutMs parameter to all fetch operations, updating documentation to communicate the change, and improving the GitHubError class for better error propagation. It's a reminder that security isn't just about fixing individual bugs—it's about ensuring consistent enforcement of security controls across every code path.
When writing code that makes outbound HTTP requests, always ask: "Does every single fetch call in this file have a timeout?" If the answer isn't a confident "yes," you have a vulnerability waiting to happen.