Accidental DDoS through ActivityPub plugin
Published: – 1 Comment Last update:
ActivityPub is great to connect your WordPress blog to the Fediverse. However, depending on the activity, it is possible to create an accidental DDoS against the own server.
Due to the nature of the Fediverse, every instance or server is self-sufficient and thus there is no central point where data is stored. So, when you publish something in the Fediverse, in order to make this data available to other instances, they have to request these data from your server. As long as you have a small audience, this is usually no problem. However, not only your audience is what matters, but also the audience the users have, who interact with your content. While a like is no problem here, quoting or reposting means that every server from every follower of this account needs to request the information from your server.
That means, if someone like Matthias Pfefferle reposts content from you, you need to pray. 😄 This is because many of his 4,1k followers use different Fediverse instances, which all have to request the information from your server. Even on a server with many resources, this may slow down all requests or even brings the server to a complete shutdown if there are too many requests.
To overcome this, regular Fediverse instances use caching – and have a whole different software stack, which is explicitly designed to be able to handle such amount of requests. On the other hand, WordPress, by default, computes all request again from scratch every time. So, let’s just use caching for WordPress as well.
A neat plugin that supports all kinds of caching, is Surge:
https://wordpress.org/plugins/surge/
It’s a small but very efficient plugin, that caches nearly every content (with some manually collected exceptions) and thus allows to also handle large amount of requests. Another nice side-effect is that it speeds up the overall page load. Here at Epiphyt, I could reduce the page loading time of the start page from ~650 ms to ~135 ms. The small time-to-live of the cache of 10 minutes makes sure that the content is updated very fast after changes.
An alternative plugin, which I didn’t test here, is Cachify.
https://wordpress.org/plugins/cachify/
It has a different approach and could, if setup correctly, be even faster than Surge. However, since I use WooCommerce here and I knew that Surge is supporting that out of the box, I did stick with it (maybe Cachify does also support it out-of-the-box, but I couldn’t find any information about it when testing).
Additionally, if you want to take it to the extreme, you can also cache REST API responses, which are used for different things when other Fediverse instances talking to yours. This is where WP REST Cache comes in handy.
https://wordpress.org/plugins/wp-rest-cache/
However, the plugin does not support endpoints from ActivityPub or Webmention by default. Thus, I’ve created a small must-use plugin, which adds support for them, too:
<?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 language: PHP (php)
Just place it in wp-content/mu-plugins
(create the directory, if it does not exist) and it’s automatically active and working. You can verify it by going to Settings > WP REST Cache > Endpoint API Caches and check whether requests with Object Type ActivityPub start showing there. This doesn’t necessarily help you preventing DDoS, but usually caching speeds up the page loading time and reduces the load on the server. Both things you want to have.
Mentions
Reposts
Likes