| 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. |
|
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:
http://<public-host>/attachment.txt on a Trello cardvalidateAttachmentUrl() resolves it, sees a public IP, allows it302 Location: http://127.0.0.1:18080/secretfetch() follows, and the loopback body is stored as the attachmentThe 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.