Issue #3623602: Rate limit the interaction bearer doorway, the one route Orchestra answers without asking who is calling

Bounds how much work one caller can ask of the interaction doorway: the one address Orchestra answers without asking who is calling, because the capability link is the whole of the authorization and the person holding it has no account on the site.

What this adds

Drupal\orchestra\FloodGuard (@api) counts requests against an address in two tiers inside one period, over core's flood service. It holds no limits of its own: each address passes the ones it read from its own module's settings, so one mechanism serves every public address a module might add.

DoorwayFloodSubscriber applies it to /interaction/*, matched on the route's own path so the module's settings forms are not counted against the visitors' budget. It runs after the router and before the controller, so a refusal costs none of the token check, the capability resolution, the interaction plugin or the render.

  • Per link — one capability link or continuation handle, counted on its own, so a caller wearing out one link is refused while everybody else is still answered.
  • In total — requests to the doorway counted together. A caller working through links it is guessing presents a fresh one each time and never reaches the per-link limit; this is the tier that sees it.

The tiers are not independent. A request the per-link limit has already refused is not charged to the total, because otherwise one link being hammered would use up the number everybody shares and shut the doorway for every other visitor — the exact thing the per-link tier exists to prevent. The total still sees a flood spread across many links, because each of those is inside its own limit.

Every request is counted, not only the failures: the work being bounded is the work done before a request is refused, so a counter that recorded only the requests it liked would leave the whole problem in place.

Off as shipped, and off means untouched

Both limits ship at 0 and the site-wide switch ships off. While it is off nothing is counted and core's flood storage is not touched at all — not even the write a limit set impossibly high would still cost on every request. A site already fronted by a proxy or a firewall pays nothing for a mechanism it is not using. The status report says it is off, because a protection nobody can see is one nobody turns on, and it asks FloodGuard::isCounting() rather than working the answer out for itself, so the report and the counting cannot drift.

Turning it on takes the limits on the new Interaction settings page and the switch in the Request limits section of the Orchestra settings, which is also where the period lives.

What a refused caller sees

A 429 with one plain sentence and nothing else: not whether the link was any good, not whether the run exists, not which limit was reached. A plain response rather than the themed 4xx page, which core serves by making a full subrequest — a refused caller would otherwise cost a page render, more than answering it would have. It carries no Retry-After (the count is a rolling window), is marked not to be stored, and carries Referrer-Policy: no-referrer like every other refusal at this doorway, because the URL it turns away has the link in it.

The first refusal of a caller in each period is logged, with a hash of the link rather than the link itself. That throttle records one row per caller per period rather than one per refused request, so being refused costs one write rather than a write for every request a flood makes.

What it does not cover

The run named in the path is loaded while the route is matched, before any listener can know which route it is, so one entity load per request is still spent on a caller this refuses. Everything after it is not.

A request naming a run that does not exist is not counted at all: routing answers it 404 while matching the route, so it never reaches the limits and never appears in the total. Size the total for the traffic the doorway actually answers rather than for everything aimed at its path — and be deliberate about it, since when a flood spread across many links reaches that one shared number, the doorway refuses every visitor until the period rolls.

The documentation says all of this, and says that a site reachable from the internet wants a proxy in front of this as well.

Coverage

41 tests across five classes: FloodGuardTest (22), DoorwayFloodTest (9), DoorwayLimitsRequirementTest (4), DoorwayLimitsFormTest (3) and SettingsFormRequestLimitsTest (3).

Every behavioural claim was proved in both directions by removing its mechanism and watching the named test name itself in the failure. The two claims worth reading the tests for: that a caller the per-link tier has caught stops spending the shared ceiling, and that the off state never touches the flood service at all — asserted with a recording double rather than by asking the flood service questions, so a drift in how a caller is named cannot make those assertions pass vacuously.

Documentation in docs/interaction.md and the module README; French for every new string.

Edited by Frank Mably

Merge request reports

Loading