Hall of Fame image from https://openclipart.org/detail/120343/trophy
Back to Hall of Fame Contents Back to Wekan Website

Contents / FollowBleed

CVE Vulnerability name Date Responsible Security Disclosure by Vulnerabilities
GHSA-j9p2-jm73-p549

FollowBleed

2026-08-03 RandomGenerator and xet7

Coordinated disclosure via GitHub Security Advisory GHSA-j9p2-jm73-p549.
  • FollowBleed — the import downloads validated the URL and then fetched it with something that follows redirects, so a public URL could answer 302 Location: http://127.0.0.1:… and the loopback body became the imported attachment (CWE-918 Server-Side Request Forgery)
  • Affected the live Trello import (server/trelloApiImport.js) and, unreported, the offline Trello and WeKan importers
  • A bypass of the fix that closed LiveBleed / CVE-2026-30844
  • Fixed at upcoming WeKan release


Details

FollowBleed — a redirect walked the import downloads past the SSRF guard (CWE-918)

WeKan can import a board from Trello, and an attachment on a Trello card carries a URL. Whoever owns that board chooses it, WeKan fetches it server-side, and the response is stored as an imported attachment that can be read back through WeKan. That is why an unguarded fetch here is a non-blind SSRF: the attacker does not merely cause the request, they get to read what came back.

This was found once already and fixed as LiveBleed / CVE-2026-30844: every live-import download was gated on validateAttachmentUrl(), which parses the URL, resolves the hostname and refuses loopback, private, link-local and cloud-metadata addresses. The URL really was validated. The download was not:

// vulnerable — the URL is checked, the request is not
const validation = await validateAttachmentUrl(url);
if (!validation.valid) return null;

const res = await fetch(url, {                 // <-- follows redirects
  headers: { Authorization: authHeader(key, token) },
});
const buffer = Buffer.from(await res.arrayBuffer());
    

The platform fetch() follows redirects by default. So the guard only ever saw the request, and the target gets to answer:

  1. the attacker puts http://<public-host>/attachment.txt on a Trello card
  2. validateAttachmentUrl() resolves it, sees a public IP, allows it
  3. that host answers 302 Location: http://127.0.0.1:18080/secret
  4. fetch() follows, and the loopback body is stored as the attachment

The reporter demonstrated exactly this against WeKan v10.53 in a Docker lab, with a redirector on a Docker-only network and a loopback-only service inside WeKan's own network namespace; the imported attachment came back holding the loopback service's response. The same path reaches internal HTTP admin panels, cloud metadata endpoints, internal APIs and anything else only exposed inside the container or network namespace.

An SSRF-hardened fetch already existed in server/lib/ssrfGuard.js — written for DnsBleed, resolving DNS once, pinning the connection to the validated IP and refusing redirects — but the live-import sinks did not use it.

Fix: a guard on the URL alone cannot hold, because the target gets to answer and an answer can name a new URL. So fetchSafe() validates every hop. maxRedirects is how many redirects a caller is willing to follow, and it defaults to 0 — any 3xx refused outright, exactly as before, which is right for outgoing webhooks and avatar downloads because a legitimate one never redirects. A caller that must follow one passes a small number, and each hop goes through the same protocol allowlist, blocked-range check and DNS pinning as the original URL before a packet is sent to it.

// fixed — server/lib/ssrfGuard.js
for (let hop = 0; ; hop += 1) {
  const { parsed, resolvedIp } = await validateAndResolve(currentUrl);
  const res = await requestOnce(parsed, resolvedIp, currentOptions);

  if (!(res.statusCode >= 300 && res.statusCode < 400)) return readResponse(res);

  res.destroy();                                  // never followed silently
  if (maxRedirects === 0) throw new Error('SSRF_GUARD: Redirects are not allowed');
  if (hop >= maxRedirects) throw new Error('SSRF_GUARD: Too many redirects');
  currentUrl = new URL(res.headers.location, parsed.href).href;   // re-validated above
}
    

Credentials are dropped when a redirect crosses to another origin, so following Trello's 302 to a signed S3 URL cannot hand the Trello API key and token to whoever the redirect names.

Refusing every redirect was not an option here. Trello's own attachment endpoint answers with a 302 to a signed S3 URL, so a blanket refusal would have meant importing no attachments at all — and a guard that breaks the feature is a guard somebody switches off.

The offline importers had the same hole and were not part of the report. They handed the validated URL to Meteor-Files' Attachments.loadAsync(), which downloads with the platform fetch() too, so a pasted Trello or WeKan board export reached 127.0.0.1 by exactly the same 302. models/lib/importAttachmentDownload.js validates, downloads through fetchSafe with every hop checked, and hands back the bytes, which are stored with the same call both importers already used for an attachment that arrived inline.

tests/followbleed.test.cjs replays the reported attack against a stubbed transport — a public host that 302s to 127.0.0.1 — and asserts that the second hop is never sent, so the test states the danger and not only the remedy. It also covers a redirect to a hostname that resolves to a private IP, to the metadata address, to a non-http scheme, a relative Location, the chain limit, credential stripping across origins, 303/307 method handling, and that a legitimate public-to-public redirect is still followed with each hop pinned.

Reported privately by RandomGenerator via GitHub Security Advisory GHSA-j9p2-jm73-p549, reproduced against tag v10.53. Fixed at the upcoming WeKan release.