CVE-2026-96624: reported as a redirect, fixed as a destination
Fedify delivered activities to whatever inbox a remote actor advertised, without asking where that was. I reached it by following an earlier fix for redirects, filed it as a redirect bug, and proposed a patch that would have left the first case of my own proof of concept open. The maintainer retitled the report the next morning, moved the affected range back to the first release, and scored it higher than I had. Fixed in 2.0.28, 2.1.24, 2.2.13 and 2.3.8.
Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every request in it travelled between two listeners on my own machine’s loopback interface; no internal service, cloud metadata endpoint or third-party host was ever contacted. The issue was reported privately through the project’s own security advisory process, and is written up here after the advisory and the fixed releases were public. The CVE identifier had been assigned but its record not yet published at the time of writing.
Fedify is a TypeScript framework for building ActivityPub servers. When an account on a Fedify instance replies to someone, boosts them, or accepts their follow request, the framework reads the other actor’s inbox — a URL in the JSON document the other server publishes about itself — and POSTs the signed activity there. Whoever runs the other server chooses that URL.
Until 21 September, nothing checked what it pointed at. This is the first case of the proof of concept, run against the published @fedify/fedify@2.3.6:
=== A. entry hop: advertised inbox points straight at the internal service ===
REACHED POST /internal/admin-write host=127.0.0.1:18891 signed=true body=1960B
A remote actor advertises http://127.0.0.1:18891/internal/admin-write as its inbox, and Fedify sends it a signed POST carrying the whole activity. Put 169.254.169.254 or a private-range address there instead and the request goes there. The response never comes back to the attacker, so this is a blind, write-shaped request from inside the victim’s network rather than a read — but the attacker picks the address.
That line is the vulnerability. It is also the line I read past when I wrote the report.
Arriving by way of a redirect fix
I came in through the previous advisory. GHSA-cxc3-7q96-6cpx (CVE-2026-77632), fixed in August, was about Fedify’s authenticated document loader. It checked the first URL it fetched, then handed the request to doubleKnock() — the signed-fetch helper, which retries with a second signature scheme when a server refuses the first — and doubleKnock() followed Location headers wherever they led. The fix added a callback for the redirect step:
// packages/fedify/src/sig/http.ts
validateRedirect?: (url: string) => void | Promise<void>;
// in createRedirectRequest()
const url = new URL(location, request.url);
await validateRedirect?.(url.href);
Two question marks carry the whole defect. A caller that passes nothing gets a redirect step that validates nothing, and no type, test or log says so. So I listed the callers of doubleKnock(). The loader passed the callback. The internal retry recursion forwarded whatever it was given. Activity delivery did not:
// packages/fedify/src/federation/send.ts:315-316, at 2.3.6
response = rsaKey == null
? await fetch(request)
: await doubleKnock(request, rsaKey, { tracerProvider, specDeterminer });
The second case of the proof of concept makes the attacker’s inbox answer 302 toward the internal service. The POST follows it, with the body replayed and the request re-signed for the new host:
=== B. redirect hop: the inbox answers 302 toward the internal service ===
REACHED POST /internal/admin-write host=127.0.0.1:18891 signed=true body=1960B
Signature-Input of the redirected request: sig1=("@method" "@target-uri" "@authority" "host" "date" "content-digest");alg="rsa-v1_5-sha256"
=== C. positive control: the same redirect through the sink fixed in CVE-2026-77632 ===
REFUSED UrlError: Invalid or private address: 127.0.0.1
Case C is the control, and the script asserts it: the same process, the same release and the same redirect, sent through the loader the previous fix covered, must be refused, or the run is thrown away as a harness that cannot see anything. It was refused. The address classifier was complete; delivery simply never asked it.
The report I sent
I filed on 1 September. The title was “Server-Side Request Forgery via unvalidated redirects in outbound activity delivery to internal addresses”. The affected range started at 1.6.1, the first release of the commit that introduced double-knocking — which is to say, the first release in which delivery followed redirects through that helper. The score was 4.7. And the suggested fix was this:
: await doubleKnock(request, rsaKey, {
tracerProvider,
specDeterminer,
validateRedirect: allowPrivateAddress
? undefined
: (url: string) => validatePublicUrl(url),
});
Every part of that is organised around the redirect, because the redirect was the question I had carried over from the previous fix. The report did contain the right sentence, in its root-cause section: “the entry inbox URL is unvalidated as well as every redirect hop.” And case A was printed above case B in the output I pasted in. Neither of them reached the title, the range, or the patch.
The next morning, one word
dahlia, who maintains Fedify, accepted the report seven and a half hours after it arrived, and requested a CVE a minute later. Fifteen minutes before either, they changed the title:
Server-Side Request Forgery via unvalidated redirects in outbound activity delivery to internal addresses
Server-Side Request Forgery via unvalidated outbound activity delivery destinations
That word is the distance between case B and case A. A redirect is one way to reach an internal address. Advertising the internal address as the inbox is a shorter way, and it needs no doubleKnock(), no signing key and no 3xx at all. The published advisory follows the new title through to its consequences, and each of them corrects something I wrote:
- The range starts at the first release, not at 1.6.1. Delivery to an unvalidated inbox arrived with the commit that first implemented sending activities, released as JSR
0.1.0; the npm package begins at0.5.0and already contains it. My lower bound dated the redirect behaviour, not the bug. The advisory keeps both facts — the signed, body-replaying redirect still dates to1.6.1— but the range now covers everything. - There is a second branch my table could not have found. When no RSA key is available,
send.tscalls plainfetch(), whoseRequestis built with the defaultredirect: "follow". Those redirects never pass throughvalidateRedirectat all. I had enumerated the callers ofdoubleKnock(), and this branch does not call it.
My patch would have failed case A
Read the suggested fix again with case A in mind. It passes validateRedirect to doubleKnock(). Case A has no redirect, so the callback is never invoked; the first request goes to the advertised inbox, and the internal listener logs the same REACHED line it logged before the patch. The unsigned fetch() branch is not touched either.
The previous fix validated redirects and missed a caller. My fix validated redirects on that caller and missed the destination. It is the same mistake one level up — a patch that answers the question in the report instead of the question in the code — and the thing that would have caught it was cheap: apply the suggested fix locally and run cases A through C again. I tested the finding and not the remedy. The proof of concept that demonstrates a bug is also the right instrument for grading the fix proposed for it.
Five more edits, in one comment
On 21 September, dahlia wrote that they had reproduced the report, that send.ts:316 “is the only doubleKnock() caller that omits validateRedirect”, and that those two lines are the only outbound fetch in the federation/ directory. They said the asserted control in case C “saved us a round trip”. Then, instead of asking me to revise, they had edited the report in place and listed what they changed:
-
UI:RtoUI:N, so 4.7 to 5.8. I had scored user interaction as required, because my harness calledsendActivity()directly rather than driving an inboundFollowthrough an inbox listener, and I had put the stronger reading in a footnote. The listener in Fedify’s own manual settles it:federation .setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { // … const recipient = await follow.getActor(ctx); if (recipient == null) return; await ctx.sendActivity( { identifier: parsed.identifier }, recipient, new Accept({ actor: follow.objectId, object: follow.id }), ); });On an instance built from that example, anyone who follows any account causes an
Acceptto be delivered to the inbox they advertised. Nobody clicks anything. What my harness skipped was the inbound leg, not the vulnerable path, and the maintainer scored the path. - CWE-636, Not Failing Securely, alongside 918 and 441 — naming the optional callback itself as a weakness, rather than only its consequence.
- The affected versions moved to the current tags,
2.0.27,2.1.23,2.2.12and2.3.7. Releases had shipped while the report waited. - A note on what the victim sees. On any non-2xx reply, delivery reads up to 1 KiB of the internal service’s response body and raises a
SendActivityErrorcarrying it, with the status and headers, into the instance’s logs and delivery-failure handler. Blind to the attacker, not to the operator. They kept confidentiality atC:N, and wrote the reason down. - A caveat on the durable fix. I had suggested making redirect validation the default inside
doubleKnock(). It has been public API since 1.6.0, so changing the default is a breaking change, and they would rather not backport one.
I agreed with all five the same day. The first one is the edit I would most like to have made myself: I had written the argument down and then scored against it.
What shipped
The fix is 68ec7b4, by Jiwon Kwon (sij411), committed the same morning and released on 21 September as 2.0.28, 2.1.24, 2.2.13 and 2.3.8. It validates the destination, then the hops:
await validateUrl(inbox.href);
// …
const request = new Request(inbox, {
method: "POST",
headers,
body: JSON.stringify(activity),
redirect: "manual",
});
// …
response = rsaKey == null
? await fetchWithValidatedRedirects(request, validateUrl)
: await doubleKnock(request, rsaKey, {
tracerProvider,
specDeterminer,
validateRedirect: validateUrl,
});
The first line closes case A. The callback closes case B. The unsigned branch now goes through a new fetchWithValidatedRedirects(), which follows up to twenty redirects by hand, validates each Location, reproduces Fetch’s method rules (a POST answered with 301 or 302 becomes a GET, as does anything but GET and HEAD answered with 303) and drops credentials when the origin changes. The private-address opt-in is carried through immediate, queued and forwarded delivery, so two local instances used for end-to-end testing can still talk to each other.
One thing did not change, on purpose. validateRedirect is still optional on DoubleKnockOptions, on main as well as the maintenance lines. A future caller of doubleKnock() that forgets it will fail open the way delivery did; the CWE-636 on the advisory is the record of that choice, and the backwards-compatibility reason for it is stated in the thread. The 1.x lines are inside the affected range but outside security support, and stay unpatched.
If you run Fedify: 2.0.28, 2.1.24, 2.2.13, 2.3.8, or anything later.
The identifier
dahlia requested the CVE on 2 September. GitHub, the CNA for the repository, assigned CVE-2026-96624 on 23 September, twenty-one days later. Of the twenty-four advisories of mine on which a maintainer has asked GitHub for an identifier, it is the only one so far where GitHub has issued one. Its note on the advisory says the record is published to the CVE List once the advisory is. The advisory went public on 27 September at 08:10 UTC; when this was written the record was not yet there. The previous advisory’s own identifier, CVE-2026-77632, is still only reserved, five weeks after that advisory was published.
The credit will very likely stay where it is. The three earlier Fedify records GitHub has published — CVE-2026-34148, CVE-2026-50131 and CVE-2026-62857 — carry no credits field at all, so the reporter’s name lives on the advisory rather than on the record.
Advisory GHSA-f59r-8gcj-68f2 · CVE CVE-2026-96624 · Fix 68ec7b4 · Parent GHSA-cxc3-7q96-6cpx
Reported 1 September · retitled, accepted and CVE requested 2 September · rescored to 5.8 on 21 September · fixed and released 21 September · CVE assigned 23 September · advisory published 27 September, crediting me as reporter.
Eurico Nicacio — @h3llh0und