Back to Blog
high SEVERITY7 min read

updateCardBg() Follows Unvalidated 302 Location Headers

A background-image updater fetched a configured image URL with manual redirect handling and then re-issued the request to whatever `Location` header came back, with no scheme or host checks. A redirect to `http://169.254.169.254/` or `http://127.0.0.1:<port>/` would have been followed with the original fetch options attached, and the response body written to disk as an image asset. The fix resolves the redirect target against `imgDownloadUrl` and rejects anything that is not HTTPS on the same ho

O
By Orbis AppSec
Published September 22, 2026Reviewed September 22, 2026

Answer Summary

The affected code is first-party: the `updateCardBg()` helper that downloads a card background image from the configured `imgDownloadUrl`, plus the `imagesToPDF()` routine in the same module. Anyone able to influence the redirect response from that configured host — a compromised or hostile CDN, a DNS/proxy position, or a writable config value — could make the server re-issue the fetch against internal addresses such as the cloud metadata endpoint or `127.0.0.1` services, with the original fetch options (headers, cookies) reattached and the response body stored as an image file. The fix parses the `Location` header with `new URL(location, imgDownloadUrl)`, blocks the request unless the resolved URL uses `https:` and matches the `imgDownloadUrl` hostname, logs the rejection through `tjLogger.warn`, and returns the original response; `imagesToPDF()` also now runs each entry through `path.basename()` before building the read path. There is no released package version for this fix, and no CVE or GHSA is assigned. The class is CWE-918 (Server-Side Request Forgery).

Vulnerability at a Glance

cweCWE-918
fixResolve the redirect target with `new URL(location, imgDownloadUrl)` and require `https:` plus a hostname match before re-fetching
riskServer-initiated requests to cloud metadata and loopback services, with the response body persisted as an image asset
languageJavaScript (Node.js, ESM)
root causeThe `Location` header from a manually-handled 301/302/307/308 response was passed straight into `fetch()` with no scheme or host validation
vulnerabilityServer-Side Request Forgery via unvalidated redirect following

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

  1. The deployment sets imgDownloadUrl to an image host — say a community-run background gallery.
  2. 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.
  3. updateCardBg() matches the 302 branch, logs the hop, and calls fetch('http://169.254.169.254/...', fetchOptions) from inside the VM.
  4. 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 relative Location values correctly (which the old code could not — a bare /images/x.png would have thrown an invalid-URL error) and, more importantly, produces a parsed object whose protocol and hostname can be inspected instead of string-matched.
  • The try/catch setting redirectUrl = null means a malformed or missing Location header 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).hostname is 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 rsp on rejection keeps the failure non-fatal. The caller gets the original redirect response, the background simply is not updated, and tjLogger.warn records 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 Location header and call fetch() 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 imgDownloadUrl does 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 fetchOptions to 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. If file is not known-safe, run it through path.basename() first, as the PDF image loop now does.

How Orbis AppSec Detected This

  • Source: the location value read from rsp.headers.get('location') on a manually-handled 301/302/307/308 response to the configured imgDownloadUrl download, plus URLs drawn from updateSources and 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 is https: on the same hostname as imgDownloadUrl, with the rejection logged via tjLogger.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.

Prevention and further reading

Frequently Asked Questions

Why does `updateCardBg()` handle 301/302/307/308 itself instead of letting fetch follow redirects?

The download is issued with manual redirect handling so the code can inspect and log the hop through `tjLogger.debug`. That design choice is exactly what created the SSRF sink: the code, not the HTTP client, decided where the second request went.

Does the same-hostname check in the fix break CDNs that redirect to a different download host?

Yes, deliberately. The check compares `redirectUrl.hostname` against `new URL(imgDownloadUrl).hostname`, so a cross-host redirect is logged and the original response is returned instead. If your `imgDownloadUrl` legitimately redirects to a separate asset host, add that hostname to an explicit allowlist rather than dropping the comparison.

What did the `path.basename()` change in `imagesToPDF()` fix, given `path.join()` already normalizes paths?

`path.join()` normalizes `..` segments but happily resolves them upward out of `inputDir`, so a filename like `../../etc/passwd` produced a read outside the intended directory. Running each entry through `path.basename()` first strips every directory component, so the constructed path always stays a direct child of `inputDir`.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #152

Related Articles

high

stream_media_file SSRF: src Parameter Reaches requests.get()

A media-download helper accepted a fully attacker-controlled URL from the `src` query parameter and passed it straight to `requests.get()`, turning the service into an open HTTP proxy for internal networks and cloud metadata endpoints. The fix introduces an `assert_safe_url()` guard that resolves the hostname with `getaddrinfo()` and rejects private, loopback, link-local, reserved, and multicast addresses before any request is issued. The guard is now called at the top of both `download_media_fi

high

ip-address 10.2.0 SSRF: Inconsistent Parsing Bypasses IP Checks

The `ip-address` npm package version 10.2.0 contains an inconsistent parsing vulnerability that allows attackers to bypass IP-based access controls. By representing IPv4 addresses in IPv4-mapped IPv6 notation, attackers can trick applications into allowing requests to blocked internal addresses. Upgrading to 10.3.1 resolves this through stricter address normalization.

medium

How gitlab.bandit.B501 happens in Python and how to fix it

The `proverbia-scraper.py` script disabled TLS certificate verification on its `requests.get()` call and silenced the resulting security warnings, exposing the scraper to man-in-the-middle attacks. The fix removes the `verify=False` flag and the warning suppression, restoring proper certificate validation while keeping the existing 30-second timeout intact.

high

How Server-Side Request Forgery (SSRF) happens in Go HTTP handlers and how to fix it

A Server-Side Request Forgery (SSRF) vulnerability was discovered in `internal/web/controller/server.go` where the `applySubTemplate` endpoint accepted arbitrary URLs from user input and passed them directly to `serverService.ApplySubTemplateFromGithub()` without any host validation. An attacker could exploit this to make the server issue HTTP requests to internal network resources, cloud metadata endpoints, or redirect-controlled destinations. The fix introduces a strict allowlist that restrict

critical

How SSRF via Vulnerable Dependency Versions Happens in Node.js and How to Fix It

A permissive semver range in `package.json` allowed npm to install axios versions vulnerable to SSRF (CVE-2024-39338). By bumping the minimum version from `^1.6.0` to `^1.7.4`, all downstream consumers of this SDK are now protected from server-side request forgery attacks. This critical fix required changing just one line in the dependency manifest.

high

runStreaming() Command Injection: Defense-in-Depth for Electron Child

An Electron application's `runStreaming()` utility accepted a command string and argument array without validating either, creating a latent command injection vector. The fix adds strict type checking and a whitelist regex that rejects shell metacharacters, bounding the failure mode even if caller input becomes attacker-influenced.