Limits¶
Every endpoint kessai exposes refuses what it should refuse. None of them refuses for free. A webhook whose signature does not hold has already had every account loaded, its key read and an HMAC computed by the time it is turned away; a notice that names no remote has already been looked up. Refusing is not the same as being cheap to refuse, and the gap between the two is what a stranger can spend on your behalf.
Two things close it, and they are not alternatives. Request limits bound how often an endpoint answers at all. An address list then refuses a caller before the expensive part, and can only be written where you know who should be calling.
They run in that order, and the order matters: an address a list refuses is refused cheaply, but not for free, and a flood of requests from off the list is still a flood. Counting only what the list let through would hide exactly the traffic the across-the-endpoint tier exists to see.
Both ship off. A limit guessed for a site nobody has seen refuses that site's own payment provider rather than an attacker, and it does it in silence: a dropped notification is a payment settled late by the expiry sweep instead of on time by the notification. The status report says the limits are off, so that shipping them off does not mean shipping them unnoticed.
This is not a substitute for a proxy
A reverse proxy or a firewall refuses a flood before it reaches PHP, and a site on the internet should have one. What is here bounds the work reachable inside Drupal, which is the half kessai can be held responsible for and the half that is still missing on a site that has both.
Switching it on¶
Two settings govern all of it, site-wide, and they sit in the Request limits section of whichever settings page belongs to the half of kessai this site runs: Payments › Settings on a site that settles its own payments, Web services › Kessai client › Notice limits on one that settles them through a kessai elsewhere. They are whether the limits apply at all, and the time period every limit counts over. One period for every endpoint: the limits differ by endpoint, how far back you want to look does not.
A site part-way through moving from one half to the other has both pages, and the two settings appear on both. They are one setting seen twice, not two: whichever page you save it from, it is the same value that every endpoint reads.
Nothing is counted until this is on, and off means untouched. A limit set impossibly high is not the same thing: it still writes a row for every request, which is the per-request cost the limits exist to avoid, paid for an answer that is always yes.
Each endpoint then carries its own two limits, in the Request limits section of the page it belongs to:
| Endpoint | Where | Counted per |
|---|---|---|
| Worldline webhook | Payments › Worldline | Address |
| Worldline return | Payments › Worldline | Payment |
| Payment handoff | Payments › Settings | Payment |
/kessai-api/* |
Payments › API limits | Consumer |
| Notice | Web services › Kessai client › Notice limits | Address |
All of them take the same permission as the rest of that section, so whoever administers payments here can both set the limits and switch them on.
0 means no limit, on either tier, which is how you take just a ceiling or just fairness between callers. The per-caller tier stops one consumer or one source starving the rest; the across-the-endpoint tier catches a flood spread thinly enough that none of them reaches its own limit.
A refused request is counted anyway. What is being bounded is the work done before a request is turned away, and every request in a real flood is refused, which is what makes it a flood rather than traffic. A counter that only recorded the requests it liked would leave the endpoint tier untouched by the thing it exists to catch.
A caller over its own limit counts only against itself. It is turned away before any of that work, so it costs the endpoint nothing, and counting it there too would let one consumer far over its limit lock every other consumer out.
What the API limits do not cover. A request that fails authentication or
the permission never reaches the counting at all: access is checked as the
route is matched, and it throws there. So an unsigned flood at /kessai-api
is refused before any consumer is known, having already cost a token lookup,
and bounding that is a proxy's job. What is bounded here is one consumer
exhausting the engine for the others, which no permission expresses.
What a caller is told differs by what refusing costs them. The webhook and the notice are told to come back, because a provider that is merely busy should retry. A payer past the handoff limit is sent back with a message asking them to try again later; a payer past the return limit is sent onward without asking the provider, because they have paid or not, and the webhook and the expiry sweep both still settle the payment.
Refusals are logged once per period per caller, for each thing there is to say. The log is the one thing a stranger can still fill after being turned away, at whatever rate they can send. Once for each reason rather than once for the caller, because an endpoint refuses for more than one, and a caller whose first notice failed its signature and whose second carried another account's payment has done two different things: one slot between them would have meant the second was refused and written down nowhere. The reasons are a closed set in the code, so what a stranger meets is still a fixed number of lines and not a rate.
A caller that has not stopped is told of again each period, rather than once at the moment it started. An operator who missed the first line, or who wants to know whether it is still going on, has something to read.
Switching the limits off returns all of it to a line per refused request.
Address lists¶
Two endpoints have a caller you can name, and for those a list refuses a stranger for the cost of one comparison: no account loaded, no key read, no HMAC. It still spends that caller's budget first, for the reason above.
- The Worldline webhook is called by the provider. The list is site-wide, under Payments › Worldline. Not per account, which is where it looks like it belongs: a notification names the provider's own key id, and the verifier builds one key store across every account, enabled or not, so which account it is can only be worked out by verifying it, which is after the work the list exists to avoid.
-
A notice is sent by the kessai that took the payment, and its account is in the route, so the host that should be calling is known before anything is verified. The list is on the remote itself, because two remotes are two different hosts.
It is not the host in that remote's base URL, and kessai does not check it against one. The base URL is where this site calls out to; the list is where that host calls from. They differ whenever it answers behind a load balancer or a CDN, or sends from more than one machine, so ask that site's operator what it sends from rather than assuming.
The handoff and the Worldline return cannot have one: they are opened by the payer's browser, from wherever the payer is.
Empty admits everything, which is what a site that has made no promise about somebody else's infrastructure should have. Getting a list wrong drops real traffic in silence, so check it against what the provider publishes.
A list behind an undeclared proxy is worse than no list
Drupal reads a forwarded address only from a proxy you have named in
$settings['reverse_proxy_addresses']. Behind a proxy it has not been told
about, every request appears to come from the proxy, so your list either
refuses the caller it was written for or admits the whole internet, and
looks the same either way. A list that reads a header anyone can set is
worse than none, because it is believed.
The status report raises this as a warning whenever a list is configured and no trusted proxy is declared. It is a warning, not an error, because a site served directly needs no proxy and its lists are right as they are. If the site sits behind a proxy, declare it, or clear the lists so nothing depends on them.
A module with limits of its own¶
A gateway module that counts requests on its own endpoints puts its limits on
its settings page, with FloodLimitsFormTrait::buildFloodSection(). The status
report lists every page that carries limits when the limits are on and none is
set. Kessai does not know which modules a site installed, so each one declares
its page:
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
final class MyGatewayHooks {
use StringTranslationTrait;
#[Hook('kessai_limits_page_info')]
public function kessaiLimitsPageInfo(): array {
return [
// The route name of the settings page.
'my_gateway.settings' => [
// The words the page is known by in the list.
'title' => $this->t('My gateway'),
],
];
}
}
A page built with buildFloodSectionWithSwitch() also carries the site-wide
switch. It adds 'switch' => TRUE, and the status report sends an operator
there to turn the limits on. The hook is described in kessai.api.php.