Summary
A remote actor can force a Fedify server to accumulate unbounded, permanent per-host circuit-breaker records in its KV store, gradually exhausting storage/memory until KV writes degrade — an availability (denial-of-service) issue. It is remotely triggerable (following a local actor is enough to start delivery to an attacker-chosen inbox) and cheaply amplified: because the record key includes the port, one attacker domain with port-varied inbox URLs yields one permanent record per port. Released 2.3.0/2.3.1 are affected for every configuration; after PR #917 the default policy is bounded, but a custom failure policy without stateTtl remains unbounded.
Details
Component: the outbox-delivery circuit breaker. Files: packages/fedify/src/federation/{circuit-breaker.ts, middleware.ts, metrics.ts}.
1. The key includes the port. getRemoteHost(url) returns url.host (port-inclusive):
// metrics.ts:1741
export function getRemoteHost(url: URL): string {
return url.host;
}
and the KV key is built from it, so each distinct host:port is a distinct record:
// circuit-breaker.ts:459
#key(remoteHost: string): KvKey {
return [...this.#prefix, remoteHost] as KvKey;
}
2. A failed delivery writes a per-host record. In the outbox delivery error path, a 5xx response or a transport failure (connection refused, timeout, DNS/TLS — isTransportDeliveryError) routes to recordFailure, which keeps/writes a record. Only permanent/4xx/429 routes to recordReachableFailure (→ recordSuccess, which deletes):
// middleware.ts:1482-1494
if (error instanceof SendActivityError) {
const { statusCode } = error;
const stateChange = isPermanentFailure || statusCode === 429 ||
(statusCode >= 400 && statusCode < 500)
? await this.circuitBreaker.recordReachableFailure(remoteHost) // deletes
: statusCode >= 500
? await this.circuitBreaker.recordFailure(remoteHost) // keeps
: undefined;
...
} else if (isTransportDeliveryError(error)) {
const stateChange = await this.circuitBreaker.recordFailure(remoteHost); // keeps
}
3. One failure suffices — no need to trip the circuit. recordFailure writes a record even below the failure threshold (the closed branch still calls #replace):
// circuit-breaker.ts:404-410
} else {
newState = {
state: "closed",
failures: failures.map((t) => t.toString()),
};
}
if (await this.#replace(remoteHost, oldState, newState)) { ... }
So one failed delivery ⇒ one record.
4. No TTL for the vulnerable config. #setOptions attaches a ttl only when stateTtl is set:
// circuit-breaker.ts:635-638
#setOptions(): { ttl: Temporal.Duration } | undefined {
return this.#options.stateTtl == null
? undefined
: { ttl: this.#options.stateTtl };
}
and stateTtl is null for a custom failure policy with no explicit stateTtl, because the custom branch has no derived default (unlike the numeric branch):
// circuit-breaker.ts:733-742
stateTtl = configuredStateTtl ?? // numeric branch: derived default
maxDuration(
recoveryDelay.add(failureWindow),
recoveryDelay.add(heldActivityTtl),
);
} else {
failure = options.failure;
pruneFailures = (timestamps) =>
timestamps.slice(-MAX_CUSTOM_FAILURE_HISTORY);
stateTtl = configuredStateTtl; // custom branch: undefined if unset
⇒ records for this config never expire. pruneFailures caps the failure array inside one record (MAX_CUSTOM_FAILURE_HISTORY = 100, circuit-breaker.ts:135), so per-record size is bounded — but the record count (one per host:port) is not.
5. The recent legacy-sweep does not close this. The sweep added on 2.3-maintenance is guarded to run only when a TTL is configured, and it re-writes with the same (absent) TTL — so the custom-no-stateTtl config is skipped entirely, and would not gain a ttl even if swept:
// circuit-breaker.ts:467-469
#sweepLegacyStates(): void {
if (this.#kv.cas == null) return;
if (this.#options.stateTtl == null) return; // custom-no-stateTtl: skipped
...
}
// circuit-breaker.ts:523-527 (#migrateLegacyState)
await this.#kv.cas!(key, value, markCircuitBreakerState(state), this.#setOptions());
// #setOptions() is undefined when stateTtl == null → migrated record still has no ttl
When it was introduced. The circuit breaker landed in 3f9a49d4 "Add circuit breaker state tracking" (2026-05-26, first released in 2.3.0); that commit wrote every record with no ttl, so the no-TTL DoS shipped with the feature. The residual custom-policy hole was introduced by the fix itself — c1ac7e8b "Expire circuit breaker state" (the #917 work) gave the numeric branch a derived default but left the custom branch at stateTtl = configuredStateTtl with no fallback.
Suggested remediation.
- Release the #917 default derived TTL.
- Close the custom-policy hole: warn now (non-breaking) when a custom
failure policy is configured without stateTtl, then require stateTtl for custom policies on next (breaking) — or fold custom policies into the derived-TTL model.
- Optional hardening for the amplification root: normalize/collapse a single host's keys, or cap distinct circuit keys, so one host cannot fan out into unbounded distinct records via port.
PoC
A. Minimal local reproduction (shows the unbounded record growth directly). Vulnerable config = a custom failure policy with no stateTtl. Each distinct host:port produces one permanent record.
// CircuitBreaker's constructor options are @internal; import from source
// (or drive it from a *.test.ts) rather than the public package index.
import { CircuitBreaker } from "./packages/fedify/src/federation/circuit-breaker.ts";
import { MemoryKvStore } from "@fedify/fedify";
const kv = new MemoryKvStore();
const cb = new CircuitBreaker({
kv,
prefix: ["_fedify", "circuit"],
options: { failure: () => false }, // custom policy, NO stateTtl → no ttl
});
// One failed delivery per distinct host:port. `failure: () => false` keeps the
// circuit closed, so every call takes the closed branch and writes a record.
for (let port = 1; port <= 10_000; port++) {
await cb.recordFailure(`attacker.example:${port}`);
}
// Result: 10,000 records under the ["_fedify","circuit"] prefix, each written
// with #setOptions() === undefined (no expiry). Nothing ever removes them.
let count = 0;
for await (const _ of kv.list(["_fedify", "circuit"])) count++;
console.log(count); // 10000 — grows without bound as `port` (or host) varies
Compare with the default numeric policy (options: {}) or an explicit stateTtl: those records carry a ttl and self-drain, so the count stays bounded.
B. Remote end-to-end trigger (how a real deployment is hit).
- Stand up static ActivityPub actor documents on one attacker domain whose
inbox URLs differ only by port: https://a.evil:1/inbox, https://a.evil:2/inbox, … Each actor has its own keypair to sign a Follow.
- From each actor, send a signed
Follow to a local actor on the victim Fedify server (a custom-failure-policy deployment without stateTtl).
- The victim tries to deliver the resulting
Accept (and any later posts) to that inbox host:port. Nothing listens there ⇒ connection refused ⇒ isTransportDeliveryError ⇒ recordFailure ⇒ one permanent record keyed by the distinct host:port.
- Repeat across ports. Cost ≈ one signed Follow per record; no new DNS or domain per record. Records accumulate permanently ⇒ KV/storage exhaustion.
(For released 2.3.0/2.3.1 the same holds for the default policy too — no custom config needed — since no records carry a ttl there.)
Impact
- Type: availability / denial-of-service via uncontrolled resource consumption (unbounded KV record growth). No confidentiality, integrity, or code-execution impact.
- Who is impacted:
- Released 2.3.0 / 2.3.1 (and current
main): every deployment — all configs write circuit records without a ttl.
- Post-#917 (
2.3-maintenance, unreleased): deployments running a custom failure policy without stateTtl. The default policy is mitigated (derived ~7-day TTL, self-draining).
- Preconditions: the victim federates (accepts follows / delivers activity) — the normal operating state. Post-#917 it additionally requires the non-default custom-policy config, which an attacker cannot detect from outside, plus sustained delivery of many signed Follows.
- Manifestation: gradual, silent storage/memory growth until KV writes slow or fail; easy to miss without monitoring circuit-record counts.
Drafted by Shiro (Claude Opus 4.8), an AI assistant working with @nyanrus, who reviewed it before filing. Pinned lines verified against 2.3-maintenance tip 3e14f2d7; introduction traced to 3f9a49d4 (2.3.0). Note: #922 (the public issue that first surfaced the custom-policy variant) was deleted by its author, so the residual hole currently has no public tracker — only #916 (default) and #917 (fix) remain public. If I've misread the custom-branch TTL path or the sweep guard, please tell me — I'd rather be corrected than send you chasing a wrong lead.
Summary
A remote actor can force a Fedify server to accumulate unbounded, permanent per-host circuit-breaker records in its KV store, gradually exhausting storage/memory until KV writes degrade — an availability (denial-of-service) issue. It is remotely triggerable (following a local actor is enough to start delivery to an attacker-chosen inbox) and cheaply amplified: because the record key includes the port, one attacker domain with port-varied inbox URLs yields one permanent record per port. Released 2.3.0/2.3.1 are affected for every configuration; after PR #917 the default policy is bounded, but a custom
failurepolicy withoutstateTtlremains unbounded.Details
Component: the outbox-delivery circuit breaker. Files:
packages/fedify/src/federation/{circuit-breaker.ts, middleware.ts, metrics.ts}.1. The key includes the port.
getRemoteHost(url)returnsurl.host(port-inclusive):and the KV key is built from it, so each distinct
host:portis a distinct record:2. A failed delivery writes a per-host record. In the outbox delivery error path, a 5xx response or a transport failure (connection refused, timeout, DNS/TLS —
isTransportDeliveryError) routes torecordFailure, which keeps/writes a record. Only permanent/4xx/429 routes torecordReachableFailure(→recordSuccess, which deletes):3. One failure suffices — no need to trip the circuit.
recordFailurewrites a record even below the failure threshold (theclosedbranch still calls#replace):So one failed delivery ⇒ one record.
4. No TTL for the vulnerable config.
#setOptionsattaches a ttl only whenstateTtlis set:and
stateTtlis null for a customfailurepolicy with no explicitstateTtl, because the custom branch has no derived default (unlike the numeric branch):⇒ records for this config never expire.
pruneFailurescaps the failure array inside one record (MAX_CUSTOM_FAILURE_HISTORY = 100,circuit-breaker.ts:135), so per-record size is bounded — but the record count (one perhost:port) is not.5. The recent legacy-sweep does not close this. The sweep added on
2.3-maintenanceis guarded to run only when a TTL is configured, and it re-writes with the same (absent) TTL — so the custom-no-stateTtlconfig is skipped entirely, and would not gain a ttl even if swept:When it was introduced. The circuit breaker landed in
3f9a49d4"Add circuit breaker state tracking" (2026-05-26, first released in 2.3.0); that commit wrote every record with no ttl, so the no-TTL DoS shipped with the feature. The residual custom-policy hole was introduced by the fix itself —c1ac7e8b"Expire circuit breaker state" (the #917 work) gave the numeric branch a derived default but left the custom branch atstateTtl = configuredStateTtlwith no fallback.Suggested remediation.
failurepolicy is configured withoutstateTtl, then requirestateTtlfor custom policies onnext(breaking) — or fold custom policies into the derived-TTL model.PoC
A. Minimal local reproduction (shows the unbounded record growth directly). Vulnerable config = a custom
failurepolicy with nostateTtl. Each distincthost:portproduces one permanent record.Compare with the default numeric policy (
options: {}) or an explicitstateTtl: those records carry a ttl and self-drain, so the count stays bounded.B. Remote end-to-end trigger (how a real deployment is hit).
inboxURLs differ only by port:https://a.evil:1/inbox,https://a.evil:2/inbox, … Each actor has its own keypair to sign a Follow.Followto a local actor on the victim Fedify server (a custom-failure-policy deployment withoutstateTtl).Accept(and any later posts) to that inboxhost:port. Nothing listens there ⇒ connection refused ⇒isTransportDeliveryError⇒recordFailure⇒ one permanent record keyed by the distincthost:port.(For released 2.3.0/2.3.1 the same holds for the default policy too — no custom config needed — since no records carry a ttl there.)
Impact
main): every deployment — all configs write circuit records without a ttl.2.3-maintenance, unreleased): deployments running a customfailurepolicy withoutstateTtl. The default policy is mitigated (derived ~7-day TTL, self-draining).Drafted by Shiro (Claude Opus 4.8), an AI assistant working with @nyanrus, who reviewed it before filing. Pinned lines verified against
2.3-maintenancetip3e14f2d7; introduction traced to3f9a49d4(2.3.0). Note: #922 (the public issue that first surfaced the custom-policy variant) was deleted by its author, so the residual hole currently has no public tracker — only #916 (default) and #917 (fix) remain public. If I've misread the custom-branch TTL path or the sweep guard, please tell me — I'd rather be corrected than send you chasing a wrong lead.