Das ActivityPub-Plugin und der versehentliche DDoS
Veröffentlicht: – 9 Kommentare Letzte Aktualisierung:
ActivityPub ist super, um dein WordPress-Blog zum Fediverse zu verbinden. Je nach Aktivität kann es jedoch sein, dass du einen versehentlichen DDoS gegen deinen eigenen Server verursachst.
Aufgrund der Natur des Fediverse ist jede Instanz bzw. jeder Server autark und es gibt keinen zentralen Punkt, an dem Daten gespeichert sind. Wenn du demnach etwas im Fediverse veröffentlichst, müssen alle Instanzen die Daten von deinem Server abfragen, um sie zu bekommen. Solange du eine kleine Audienz hast, ist das in der Regel kein Problem. Allerdings ist nicht nur deine Audienz, was zählt, sondern auch die Audienz der Benutzer, die mit deinem Inhalt interagieren. Während ein Like dabei kein Problem darstellt, heißt ein Zitieren oder Teilen deines Inhalts, dass jeder Server jedes Followers dieses Kontos die Informationen von deinem Server abfragen muss.
Das heißt, wenn jemand wie Matthias Pfefferle Inhalte von dir neu veröffentlicht, musst du beten. 😄 Das kommt daher, dass viele seiner 4,1 Tausend Follower unterschiedliche Fediverse-Instanzen verwenden, die alle diese Informationen von deinem Server abfragen müssen. Selbst ein Server mit vielen Ressourcen kann hier schnell mal überlastet werden, wenn es zu viele Anfragen sind.
Um das zu lösen, verwenden normale Fediverse-Instanzen Caching – und haben einen ganz anderen Aufbau ihrer Software, die explizit dafür konzipiert wurde, um solche Datenmengen verarbeiten zu können. Auf der anderen Seite berechnet WordPress standardmäßig jede Anfrage wieder von neuem. Lass uns daher auch für WordPress Caching verwenden.
Ein praktisches Plugin, das alle möglichen Arten von Caching unterstützt, ist Surge.
https://de.wordpress.org/plugins/surge/
Es ist ein kleines, aber sehr effizientes Plugin, das nahezu jeden Inhalt zwischenspeichert (mit einigen manuell gesammelten Ausnahmen) und so auch viele gleichzeitige Anfragen erlaubt. Ein anderer positiver Seiteneffekt ist, dass des die Seitenladezeit insgesamte verbessert. Hier bei Epiphyt konnte ich damit die Seitenladezeit der Startseite von ~650 ms auf ~135 ms verringern. Die geringe Zeit von 10 Minuten, bis der Cache als veraltet gilt, führt zudem dazu, dass der Inhalt bei einer Änderung schnell aktualisiert wird.
Ein alternatives Plugin, das ich nicht hier getestet habe, ist Cachify.
https://de.wordpress.org/plugins/cachify/
Es hat einen anderen Mechanismus, aber kann, wenn richtig eingerichtet, sogar noch schneller als Surge sein. Da ich hier jedoch WooCommerce verwende und wusste, dass Surge das direkt unterstützt, habe ich Cachify hier nicht im Einsatz (vielleicht unterstützt Cachify es ebenfalls direkt, aber ich konnte darüber keine Informationen finden).
Zusätzlich wenn du es auf die Spitze treiben willst, kannst du auch REST-API-Antworten zwischenspeichern, die für verschiedene Dinge verwendet werden, wenn andere Fediverse-Instanzen sich mit deiner verbinden. Hierfür eignet sich WP REST Cache sehr gut.
https://de.wordpress.org/plugins/wp-rest-cache/
Allerdings unterstützt dieses Plugin standardmäßig keine Endpunkte von ActivityPub oder Webmention. Deshalb habe ich ein kleines „Must-Use“-Plugin entwickelt, das auch die Unterstützung dafür hinzufügt:
<?php
declare(strict_types = 1);
namespace epiphyt\Fediverse;
use WP_Rest_Cache_Plugin\Includes\Caching\Caching;
/*
Plugin Name: Caching ActivityPub/Webmention requests
Description: Optimize caching for ActivityPub/Webmention requests.
Author: Epiphyt
Author URI: https://epiph.yt/en/
License: GPL2
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Plugin URI: https://epiph.yt/en/blog/2025/activitypub-plugin-and-accidental-ddos/
*/
/**
* Caching settings for REST endpoints with WP REST Cache plugin.
*
* @author Epiphyt
* @license GPL2
* @package epiphyt\Fediverse
*/
final class REST_Cache_Settings {
private const ENDPOINT_ACTIVITYPUB = 'activitypub/1.0';
/**
* @var string[][] Cacheable endpoints
*/
private static $endpoints = [
self::ENDPOINT_ACTIVITYPUB => [
'',
'actors',
'application',
'collections',
'comments',
'inbox',
'interactions',
'nodeinfo',
'posts',
'users',
'webfinger',
],
];
/**
* Initialize functionality.
*/
public static function init(): void {
\add_action( 'transition_post_status', [ self::class, 'reset_cache_by_transition_post_status' ], 10, 3 );
\add_filter( 'wp_rest_cache/allowed_endpoints', [ self::class, 'cache_endpoints' ], 10, 3 );
\add_filter( 'wp_rest_cache/determine_object_type', [ self::class, 'set_object_type' ], 10, 4 );
\add_filter( 'wp_rest_cache/is_single_item', [ self::class, 'set_is_single_item' ], 10, 3 );
}
/**
* Register ActivityPub endpoints for caching.
*
* @param string[] $allowed_endpoints Current cacheable endpoints
* @return string[] Updated cacheable endpoints
*/
public static function cache_endpoints( array $allowed_endpoints ): array {
if ( ! isset( $allowed_endpoints[ self::ENDPOINT_ACTIVITYPUB ] ) ) {
$allowed_endpoints[ self::ENDPOINT_ACTIVITYPUB ] = self::$endpoints[ self::ENDPOINT_ACTIVITYPUB ];
}
return $allowed_endpoints;
}
/**
* Test, whether the current endpoint is an ActivityPub endpoint.
*
* @param string $uri URI to test
* @return bool Whether the current endpoint is an ActivityPub endpoint
*/
private static function is_activitypub_endpoint( string $uri ): bool {
$search = '/' . self::ENDPOINT_ACTIVITYPUB . '/';
return \str_contains( $uri, $search ) || \str_contains( $uri, 'rest_route=' . \rawurlencode( $search ) );
}
/**
* Reset cache by transition post status.
*
* @param string $new_status New post status
* @param string $old_status Old post status
* @param \WP_Post $post Post object
*/
public static function reset_cache_by_transition_post_status( string $new_status, string $old_status, \WP_Post $post ): void {
// if the post is neither now published nor it was published, we don't care
if ( $new_status !== 'publish' && $old_status !== 'publish' ) {
return;
}
$supported_post_types = (array) \get_option( 'activitypub_support_post_types', [] );
if ( ! \in_array( $post->post_type, $supported_post_types, true ) ) {
return;
}
if ( ! \class_exists( 'WP_Rest_Cache_Plugin\Includes\Caching\Caching' ) ) {
return;
}
Caching::get_instance()->delete_object_type_caches( 'ActivityPub' );
}
/**
* Set whether the cache represents a single item.
*
* Always return false for ActivityPub endpoints, since cache entries cannot be flushed otherwise.
*
* @param bool $is_single Whether the current cache represents a single item
* @param mixed $data Data to cache
* @param string $uri Request URI
* @return bool Whether the cache represents a single item
*/
public static function set_is_single_item( bool $is_single, mixed $data, string $uri ): bool {
if ( self::is_activitypub_endpoint( $uri ) ) {
return false;
}
return $is_single;
}
/**
* Set object type for ActivityPub.
*
* @param string $object_type Object type
* @param string $cache_key Object key
* @param mixed $data Data to cache
* @param string $uri Request URI
* @return string Updated object type
*/
public static function set_object_type( string $object_type, string $cache_key, mixed $data, string $uri ): string {
if ( self::is_activitypub_endpoint( $uri ) ) {
return 'ActivityPub';
}
return $object_type;
}
}
REST_Cache_Settings::init();
Code-Sprache: PHP (php)
Lege es einfach in wp-content/mu-plugins
(erstelle das Verzeichnis, wenn es nicht existiert) und es ist automatisch aktiv und arbeitet. Du kannst es nachvollziehen, indem du zu Einstellungen > WP REST Cache > API-Cache-Endpunkte gehst und prüfst, ob Anfragen mit dem Objekt-Typ ActivityPub dort auftauchen. Das hilft dir zwar nicht unbedingt dabei, DDoS zu vermeiden, aber Caching verbessert eigentlich immer die Seitenladezeit und verringert die Last auf dem Server. Beides Dinge, die man haben will.
Hallo.
Danke für diesen Beitrag und das Sichtbarmachen dieser Problematik. Gibt es hier schon praktische Erfahrungen oder ist diese Befürchtung bislang eher theoretisch?
Wie funktioniert das eigentlich im Fediverse genau? Holt jede Instanz neue Beiträge „einmal“ über die API ab und stellt diesen Beitrag allen Followern auf der Instanz bereit oder wird für jeden Follower eine einzelne API-Abfrage gemacht?
Wenn ich deinen Code oben richtig verstehe, wird der Cache nur geleert, wenn ein neuer Post veröffentlicht wird, richtig? Aber wenn ein publizierter Post geändert wird, wird der Cache nicht geleert, richtig?
Schöne Grüße, Marco
Der Beitrag ist auf Basis der praktischen Erfahrung entstanden.
Die Instanz holt die Daten einmalig ab und speichert sie dann bei sich zwischen.
Der Code selbst oben leert den Cache nicht beim Aktualisieren eines Inhalts. Das ist aber für diese API-Endpunkte meines Wissens nach nicht relevant, da sie nicht den eigentlichen Inhalt speichern. Für die normalen API-Endpunkte für Beiträge etc. kann das Plugin das bereits von allein. 🙂
Danke für deine schnelle Antwort.
Also wenn die Posts nur einmalig pro Instanz abgeholt werden, dann müßte sich die Problematik doch eigentlich in Grenzen halten. Bei einem großen Account mit mehreren tausend Followern würde ich jetzt mal schätzen, dass die sich vermutlich über vielleicht 100 verschiedene Instanzen verteilen. Und 100 Requests sollten auch kleine Environments schaffen. Oder überseh ich was?
Bzgl. Update. Das habe ich nicht ganz verstanden. Hintergrund ist, ich habe recht häufig den Fall, dass ich nach dem Veröffentlichen noch Rechtschreibfehler oder ungünstige Formulieren ändere. In solchen Fällen muss doch eine „Update“ Nachricht an die Instanzen geschickt und der aktualisierte Inhalt bereitgestellt werden. Passiert das nicht über die ActivityPub Endpoints?
Nehmen wir mal an, ein Seitenaufruf braucht 250 ms Verarbeitungszeit am Server, um eine Anfrage zu beliefern. Die 100 Anfragen kommen aber alle nahezu gleichzeitig rein. In der Regel hat man einen PHP-Prozess pro CPU-Kern. Selbst bei einem großen Server mit 32 oder gar 64 Kernen bzw. Threads können nicht alle Anfragen gleichzeitig verarbeitet werden. Nehmen wir eine realistischere Zahl für ein Webhosting, sind wir vielleicht bei 5 gleichzeitig möglichen PHP-Verbindungen. Bis alle 100 Anfragen dann verarbeitet wurden, dauert es insgesamt 5 Sekunden. In der Zeit ist deine Seite tot.
Und die 250 ms sind schon sehr freundlich berechnet, ein normales Webhosting dürfte da langsamer sein. Durch den Shared-Charakter hast du dann auch noch häufig das Problem, dass die Anfragen langsamer verarbeitet werden, je mehr vorhanden sind.
Nochmals: das ist kein theoretisches Problem. Man findet auch online bereits einige Beschwerden darüber, weshalb ich mit diesem Artikel eine Abhilfe bieten möchte.
Soweit ich weiß, werden die eigentlichen Inhalte nicht über die API von den anderen Instanzen abgefragt, sondern über die jeweilige URL des Inhalts und einem entsprechenden Header, dass es sich um eine ActivityPub-Anfrage handelt, wodurch dann ein JSON zurückgegeben wird.
Danke für die ausführliche Erläuterung. Meine Fragen zielen auch nicht darauf ab, den beschriebenen Sachverhalt anzuzweifeln. Es geht mir eher darum, die Problematik sowie das Zusammenspiel der Komponenten im Fediverse zu verstehen. Mir sind hier die Systematiken noch nicht ganz transparent. Ich werde auf jeden Fall das Thema aufgreifen.
Allerdings weiß noch nicht genau wie ich das Thema angehe. Für das Caching nutze ich bislang wpRocket. wpRockt cached im Default jedoch nicht die REST API (Standard) Endpoints. Es gibt aber die Möglichkeit, dies per Snippet zu aktivieren. Ob man hier Custom Endpoints ergänzen kann, muss ich noch herausfinden. Falls das nicht geht, versuche ich deinen beschriebenen Ansatz oben.
Jedoch ist mir auch noch unklar, auf welcher Ebene das JSON (mit dem Beitrag), welches an das Fediverse zurückgegeben wird, gecached wird. Soweit ich das verstehe, sollte das nicht im Scope von WP REST CACHE liegen. Aber ob hier die Caching-Mechanismen von wpRocket oder anderen Caching-Plugins greifen?
Genau dafür habe ich in meinem Beitrag Surge empfohlen.
Okay. Abschließend Danke, deine Antworten haben wir sehr geholfen, das Thema etwas besser zu durchdringen.
That’s not too bad! 🙂
This whole post is a good outline for a WP Meetup discussion I want to have.