Skip to content

Remote denial-of-service via unbounded circuit-breaker state growth (per-host KV records without TTL)

Moderate
dahlia published GHSA-fx98-wc5v-jrg5 Aug 26, 2026

Package

npm @fedify/fedify (npm)

Affected versions

>= 2.3.0, <= 2.3.4

Patched versions

2.3.5
@fedify/fedify (JSR)
>= 2.3.0, <= 2.3.4
2.3.5

Description

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.

  1. Release the #917 default derived TTL.
  2. 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.
  3. 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).

  1. 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.
  2. From each actor, send a signed Follow to a local actor on the victim Fedify server (a custom-failure-policy deployment without stateTtl).
  3. The victim tries to deliver the resulting Accept (and any later posts) to that inbox host:port. Nothing listens there ⇒ connection refused ⇒ isTransportDeliveryErrorrecordFailure ⇒ one permanent record keyed by the distinct host:port.
  4. 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.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
None
Availability
Low

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L

CVE ID

CVE-2026-69132

Weaknesses

Uncontrolled Resource Consumption

The product does not properly control the allocation and maintenance of a limited resource. Learn more on MITRE.

Allocation of Resources Without Limits or Throttling

The product allocates a reusable resource or group of resources on behalf of an actor without imposing any intended restrictions on the size or number of resources that can be allocated. Learn more on MITRE.