Summary
A background-image download helper fetched a configured URL with manual redirect handling, then re-issued the request to whatever Location header the server returned — no scheme check, no host check, no private-address check. The fix validates the redirect target against the original imgDownloadUrl before following it, and hardens a nearby PDF-assembly loop that built file paths from unvalidated filenames.
Affected Versions
| Affected | not applicable (first-party code) — the updateCardBg() background-image downloader and imagesToPDF() |
| Fixed in | not applicable (first-party code) — corrected in the linked security pull request |
| Ecosystem | N/A (first-party Node.js / ESM module) |
| CVE / GHSA | not assigned |
| CWE | CWE-918 (Server-Side Request Forgery) |
Introduction
This SSRF lived in the least suspicious kind of code: a cosmetic asset updater. updateCardBg() downloads a card background image from a URL that comes out of configuration (imgDownloadUrl), and because it wants to log each hop, it asks for manual redirect handling rather than letting the HTTP client follow redirects itself. When the response status is 301, 302, 307, or 308, it reads the Location header and calls fetch() again.
That second fetch() was the problem. The value of location is attacker-influenceable data — it is chosen by whoever answers the first request — and it was handed to fetch() verbatim, together with the same fetchOptions used for the original request. There was no check that the redirect stayed on the configured host, no check that it stayed on HTTPS, and no check against loopback, link-local, or RFC 1918 destinations. One 302 Location: http://169.254.169.254/latest/meta-data/ and the server obligingly makes that request on the attacker's behalf.
The broader finding covers other outbound calls in the same codebase — URLs from imgDownloadUrl, updateSources, and API base URLs are all fetched without private-range or scheme validation — but the redirect hop is the sharp edge, because it is the one place where a remote party, not a local operator, picks the destination.
The Vulnerability Explained
Reduced to its essentials, the vulnerable path looked like this:
if (rsp.status === 301 || rsp.status === 302 ||
rsp.status === 307 || rsp.status === 308) {
let location = rsp.headers.get('location')
tjLogger.debug('更新卡片背景图片重定向:', rsp.status, 'url:', location)
return fetch(location, fetchOptions)
}
return rsp
Three separate weaknesses sit in that last fetch():
1. The destination is remote-controlled. location is a response header. The code trusts imgDownloadUrl as a configuration value — reasonable — but then transitively trusts every host that URL can point at. A compromised CDN, an expired domain, a hijacked DNS answer, or an operator-supplied imgDownloadUrl pasted from a forum post all give an attacker control over the second request's target.
2. No scheme restriction. Because the original URL may be https:, nothing forced the redirect to stay there. A downgrade to http: reopens the request to network interception, and in Node's fetch any supported scheme is fair game.
3. fetchOptions are reattached. Whatever headers, proxy agent, or credentials were configured for the trusted image host get sent to the attacker-chosen host as well. That turns a plain SSRF into potential credential leakage.
Attack scenario against this code path
- The deployment sets
imgDownloadUrlto an image host — say a community-run background gallery. - That host (or someone who has taken it over) answers the background download with
302 Location: http://169.254.169.254/latest/meta-data/iam/security-credentials/default. updateCardBg()matches the 302 branch, logs the hop, and callsfetch('http://169.254.169.254/...', fetchOptions)from inside the VM.- The response body is treated as the new card background and written to disk as an image asset — where the application may well serve it back over HTTP.
Step 4 is what makes this a high severity finding rather than a blind SSRF. The attacker does not need to read the response out of a timing side channel; the bytes are persisted as a static asset. The same trick against http://127.0.0.1:<port>/ reaches admin endpoints, internal management APIs, and anything else the service can talk to but the internet cannot.
The Fix
The redirect target is now parsed and checked before it is used, and the checked URL object — not the raw header string — is what gets fetched:
let redirectUrl
try {
redirectUrl = new URL(location, imgDownloadUrl)
} catch {
redirectUrl = null
}
if (
!redirectUrl ||
redirectUrl.protocol !== 'https:' ||
redirectUrl.hostname !== new URL(imgDownloadUrl).hostname
) {
tjLogger.warn('更新卡片背景图片重定向地址不受信任, 已阻止:', location)
return rsp
}
return fetch(redirectUrl, fetchOptions)
Each piece earns its place:
new URL(location, imgDownloadUrl)resolves the header against the original request URL. This handles relativeLocationvalues correctly (which the old code could not — a bare/images/x.pngwould have thrown an invalid-URL error) and, more importantly, produces a parsed object whoseprotocolandhostnamecan be inspected instead of string-matched.- The
try/catchsettingredirectUrl = nullmeans a malformed or missingLocationheader fails closed rather than throwing out of the promise chain. redirectUrl.protocol !== 'https:'blocks scheme downgrades and any non-HTTPS scheme outright.redirectUrl.hostname !== new URL(imgDownloadUrl).hostnameis the control that actually stops the SSRF. Rather than enumerating bad destinations —169.254.169.254,127.0.0.1,10.0.0.0/8,metadata.google.internal, decimal-encoded IPs, DNS names that resolve privately — it allows exactly one destination: the host the operator already configured. Denylists of private ranges are notoriously leaky; a same-host constraint has no bypass surface.- Returning
rspon rejection keeps the failure non-fatal. The caller gets the original redirect response, the background simply is not updated, andtjLogger.warnrecords the blocked URL so an operator can see a hostile or misconfigured host.
The second change: filenames in the PDF assembler
The same commit tightened the image loop in imagesToPDF():
// before
const imgPath = path.join(inputDir, file)
// after
const safeFileName = path.basename(file)
const imgPath = `${inputDir}${path.sep}${safeFileName}`
path.join() normalizes .. segments, but normalizing is not confining — path.join('/var/cards', '../../etc/passwd') resolves cleanly to /etc/passwd. Passing each entry through path.basename() first discards every directory component, so the constructed path is always a direct child of inputDir. It is a small change, but it closes the natural follow-on to an SSRF that writes attacker-chosen bytes into a directory that is later enumerated and read.
Note that this pull request is an unverified suggestion: no automated test suite could be run against the repository, so the behavioural change (cross-host redirects are now refused) should be confirmed against your own imgDownloadUrl before merging.
Key Takeaways
- Manual redirect handling makes you the HTTP client's security policy. The moment you read a
Locationheader and callfetch()yourself, the destination is remote-controlled input and needs the same validation you would apply to a user-submitted URL. - Trusting a configured URL like
imgDownloadUrldoes not extend to trusting every host it can redirect to. Validate the hop, not just the seed. - A hostname equality check against the original URL beats a private-IP denylist for this shape of problem — there is no
0x7f.1,[::ffff:127.0.0.1], or rebinding trick that makes a foreign hostname equal the configured one. - Reattaching the original
fetchOptionsto a redirect hop ships your headers and proxy configuration to whatever host answered. Treat redirect requests as a fresh trust decision. path.join(inputDir, file)is not a containment primitive. Iffileis not known-safe, run it throughpath.basename()first, as the PDF image loop now does.
How Orbis AppSec Detected This
- Source: the
locationvalue read fromrsp.headers.get('location')on a manually-handled 301/302/307/308 response to the configuredimgDownloadUrldownload, plus URLs drawn fromupdateSourcesand API base-URL configuration. - Sink:
fetch(location, fetchOptions)— an outbound server-side HTTP request whose destination came entirely from the response header, with the original request options reattached. - Missing control: no parsing of the redirect target, no scheme allowlist (HTTPS only), no comparison against the configured host, and no rejection of loopback, link-local (
169.254.169.254), or RFC 1918 destinations. - CWE: CWE-918 — Server-Side Request Forgery (SSRF).
- Fix: the redirect target is resolved with
new URL(location, imgDownloadUrl)and refused unless it ishttps:on the same hostname asimgDownloadUrl, with the rejection logged viatjLogger.warn.
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
The dangerous line here was four tokens long: fetch(location, fetchOptions). Everything around it looked careful — a status-code check, a debug log of the hop, a configured rather than user-supplied base URL — and none of that mattered, because the one value that decided where the server sent its next request came off the wire. Once the redirect target is resolved against imgDownloadUrl and constrained to HTTPS on the same hostname, the second hop can no longer reach the metadata service, loopback ports, or any other internal address, and a hostile redirect degrades to a warning in the log instead of a credential dump saved as a background image.