Rate-Limiting und Absicherung eigener Endpunkte
Rate-Limiting und Absicherung eigener Endpunkte
~7 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026
Kapitel 80-83 haben sechs Zugriffspunkte auf dieselbe Geschäftslogik geöffnet: zwei REST-Routen für den Punktestand, eine schreibende REST-Route zum Einlösen, zwei GraphQL-Felder für Punktestand und Prämienkatalog sowie eine GraphQL-Mutation fürs Einlösen. Jeder davon ist bereits per ACL oder self/force gegen den falschen Aufrufer abgesichert. Offen bleibt eine andere Frage: Was hindert einen berechtigten Kunden - oder ein geleaktes Token, oder eine fehlerhafte mobile App, die in einer Schleife hängt - daran, POST /V1/loyalty/rewards/:rewardId/redeem hundertmal pro Sekunde aufzurufen?
Was ACL nicht abdeckt: wer vs. wie oft
Mironsoft_Loyalty::points_view (Kapitel 80) und force="true" (ebenfalls Kapitel 80) beantworten ausschließlich die Frage "darf dieser Aufrufer überhaupt zugreifen, und wenn ja, nur auf seine eigenen Daten?". Ein vollständig autorisierter, eingeloggter Kunde besteht diese Prüfung bei jedem einzelnen Aufruf erneut - ACL kennt keinen Begriff von "zu oft". Genau diese Lücke schließt Rate-Limiting, und sie betrifft in dieser Serie vor allem die einzige schreibende Route: RewardRedemptionManagementInterface::redeem() verändert bei jedem Aufruf tatsächlich den Punktestand und schreibt einen Ledger-Eintrag (Kapitel 6) - anders als die rein lesenden Felder aus Kapitel 80/82.
Was Magento Open Source von Haus aus mitbringt - und was nicht
Anders als man vielleicht erwarten würde, bringt Magento Open Source keinen generischen Mechanismus mit, der einen selbst registrierten webapi.xml- oder schema.graphqls-Endpunkt automatisch drosselt. Magentos eigene Kunden-Login-Sperre (Magento\Customer\Model\Authentication) throttelt zwar fehlgeschlagene Login-Versuche - aber über failures_num/first_failure-Spalten direkt auf der Kunden-Entity, nicht über einen wiederverwendbaren Cache-Dienst, und ausschließlich für den Login-Vorgang selbst. Adobe Commerce Cloud bietet über Fastly eine echte API-Rate-Limiting-Schicht auf Infrastrukturebene an - für dieses Open-Source-Projekt mit Mark-Shust-Setup nicht verfügbar.
Ein limit_req auf Nginx-Ebene wäre die naheliegende Alternative, scheitert hier aber an der Granularität: Nginx sieht nur die URL /rest/de/V1/loyalty/rewards/123/redeem bzw. die eine gemeinsame URL /graphql für alle GraphQL-Felder dieses Shops - es kennt weder den authentifizierten Kunden hinter dem Token noch den Unterschied zwischen loyaltyRewards (lesend, unkritisch) und redeemLoyaltyReward (schreibend, sensibel) innerhalb derselben GraphQL-Anfrage. Diese Feinheit kennt ausschließlich die Anwendung selbst - genau deshalb landet die Lösung in PHP, nicht in der Webserver-Konfiguration.
Der gemeinsame Ansatzpunkt: RewardRedemptionManagementInterface::redeem()
Kapitel 81 hat die Einlöse-Logik bewusst in eine eigene Service-Klasse ausgelagert, und Kapitel 83 hat gezeigt, dass sowohl die REST-Route als auch die GraphQL-Mutation exakt dieselbe Methode aufrufen. Dieselbe Wiederverwendung trägt jetzt ein zweites Mal: Statt zweier separater Prüfungen - eine im Webapi-Layer, eine im GraphQL-Resolver - reicht ein einziges Plugin auf der Interface-Methode selbst:
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model\RateLimiter;
use Magento\Framework\App\CacheInterface;
use Magento\Framework\Serialize\Serializer\Json;
/**
* Cache-backed fixed-window rate limiter for reward redemption attempts.
* Deliberately the generic application cache instead of chapter 8's
* LoyaltyCatalog cache type - that type exists for tag-based catalog
* invalidation, and reusing it here would mean an admin flushing the
* catalog cache accidentally resets every customer's attempt counter, and
* vice versa. A rate-limit counter has no invalidation tag of its own; it
* only needs a short, self-expiring lifetime, which a plain cache entry
* already provides.
*/
class RedemptionRateLimiter
{
private const CACHE_ID_PREFIX = 'mironsoft_loyalty_redeem_attempts_';
private const MAX_ATTEMPTS = 5;
private const WINDOW_SECONDS = 60;
/**
* @param CacheInterface $cache Generic application cache frontend.
* @param Json $json Serializes the attempt counter into the cache entry.
*/
public function __construct(
private readonly CacheInterface $cache,
private readonly Json $json,
) {
}
/**
* Whether the given customer has already exhausted the redemption quota
* for the currently active time window.
*
* @param int $customerId Customer entity ID attempting a redemption.
* @return bool
*/
public function isExceeded(int $customerId): bool
{
return $this->getAttemptCount($customerId) >= self::MAX_ATTEMPTS;
}
/**
* Registers one more redemption attempt for the given customer. A cache
* miss starts a fresh window with a full WINDOW_SECONDS lifetime; an
* existing entry keeps counting within its already-shrinking expiry.
*
* @param int $customerId Customer entity ID attempting a redemption.
* @return void
*/
public function registerAttempt(int $customerId): void
{
$count = $this->getAttemptCount($customerId) + 1;
$this->cache->save(
$this->json->serialize(['count' => $count]),
self::CACHE_ID_PREFIX . $customerId,
[],
self::WINDOW_SECONDS
);
}
/**
* Reads the current attempt count for the given customer's active
* window, or zero if no window is active (cache miss or expired entry).
*
* @param int $customerId Customer entity ID.
* @return int
*/
private function getAttemptCount(int $customerId): int
{
$cached = $this->cache->load(self::CACHE_ID_PREFIX . $customerId);
if ($cached === false) {
return 0;
}
/** @var array{count: int} $data */
$data = $this->json->unserialize($cached);
return (int) $data['count'];
}
}
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Plugin\Api;
use Magento\Framework\Webapi\Exception as WebapiException;
use Mironsoft\Loyalty\Api\RewardRedemptionManagementInterface;
use Mironsoft\Loyalty\Model\RateLimiter\RedemptionRateLimiter;
/**
* Throttles reward redemption attempts before RewardRedemptionManagement::redeem()
* runs. Placed on the shared service contract (chapter 79) rather than on
* either transport controller, so both the REST route (chapter 81) and the
* GraphQL mutation (chapter 83) are covered by a single check instead of two
* duplicated ones - a client cannot dodge the limit by simply switching
* transports.
*/
class ThrottleRewardRedemptionPlugin
{
/**
* @param RedemptionRateLimiter $rateLimiter Cache-backed attempt counter.
*/
public function __construct(
private readonly RedemptionRateLimiter $rateLimiter,
) {
}
/**
* @param RewardRedemptionManagementInterface $subject Intercepted service contract.
* @param int $rewardId Reward entity ID to redeem.
* @param int $customerId Customer entity ID redeeming the reward.
* @return array{0: int, 1: int}
* @throws WebapiException If the customer has exceeded the redemption quota.
*/
public function beforeRedeem(
RewardRedemptionManagementInterface $subject,
int $rewardId,
int $customerId
): array {
if ($this->rateLimiter->isExceeded($customerId)) {
throw new WebapiException(
__('Too many redemption attempts. Please wait a moment before trying again.'),
0,
WebapiException::HTTP_TOO_MANY_REQUESTS
);
}
$this->rateLimiter->registerAttempt($customerId);
return [$rewardId, $customerId];
}
}
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Mironsoft\Loyalty\Api\RewardRedemptionManagementInterface">
<plugin name="mironsoft_loyalty_throttle_reward_redemption"
type="Mironsoft\Loyalty\Plugin\Api\ThrottleRewardRedemptionPlugin"/>
</type>
</config>
Achtung: Dasselbe bekannte, bewusst nicht behobene Muster wie schon bei RewardRedemptionManagement::redeem() selbst (Kapitel 81): load() gefolgt von save() ist nicht atomar. Zwei nahezu gleichzeitige Anfragen desselben Kunden könnten beide denselben, noch nicht aktualisierten Zählerstand lesen und beide durchkommen - der Zähler unterzählt dann leicht. Für eine Missbrauchsbremse ist das tolerierbar; wer eine strikte Garantie braucht, ersetzt CacheInterface::save()/load() durch ein atomares INCR/EXPIRE direkt gegen Redis.
HTTP 429 über REST - und was davon bei GraphQL ankommt
\Magento\Framework\Webapi\Exception ist bewusst gewählt statt einer einfachen LocalizedException: Webapi\ErrorProcessor::maskException() lässt eine bereits vom Typ Webapi\Exception übergebene Ausnahme unverändert durch, statt sie - wie jede andere LocalizedException - pauschal auf HTTP 400 abzubilden. Über REST kommt beim Client also tatsächlich HTTP 429 Too Many Requests an, nicht das generische 400 aus Kapitel 81.
Über GraphQL passiert etwas Interessantes: Webapi\Exception erbt von LocalizedException - genau die Klasse, die der Resolver aus Kapitel 83 bereits im catch (LocalizedException $exception)-Block abfängt und zu GraphQlInputException ummappt. Ohne jede weitere Anpassung an RedeemLoyaltyReward landet die Rate-Limit-Meldung deshalb auch über GraphQL beim Client - allerdings ohne die 429-Semantik, denn GraphQL kennt pro Feld ohnehin keinen eigenen HTTP-Status. Die Nachricht bleibt identisch, die extensions.category lautet aber schlicht graphql-input, nicht "zu viele Anfragen".
Den Rate-Limiter testen
# Sechs Einlöse-Versuche in Folge - der sechste muss HTTP 429 liefern:
for i in {1..6}; do
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
https://mironsoft.test/rest/V1/loyalty/rewards/42/redeem \
-H 'Authorization: Bearer <token>'
doneDie öffentliche loyaltyRewards-Query braucht einen anderen Hebel
RedemptionRateLimiter schlüsselt bewusst über die Kunden-ID - für redeemLoyaltyReward//redeem passend, da beide Wege einen authentifizierten Kunden voraussetzen. loyaltyRewards (Kapitel 82) ist dagegen absichtlich gästefähig und kennt gar keine Kunden-ID, an der ein Zähler ansetzen könnte. Für diesen Fall bleibt der eingangs verworfene Infrastruktur-Hebel tatsächlich der richtige: ein IP-basiertes limit_req auf Nginx-Ebene, grob genug für einen öffentlichen, lesenden Katalog-Endpunkt, aber ohne die Kunden-Granularität, die der Einlöse-Endpunkt braucht. Zwei verschiedene Bedrohungsmodelle, zwei verschiedene, bewusst getrennte Lösungen - kein einzelner Mechanismus deckt beide gleich gut ab.
Mit Missbrauch abgewehrt, schließt Kapitel 87 Block 10 ab: Wie findet ein berechtigter Client - eine echte mobile App, ein Partnersystem - diese sechs Endpunkte überhaupt, ohne den Quellcode dieses Moduls zu lesen?