litespeedtech / litespeedtech/lscache_wp
Purge ignores post type visibility: non-public CPTs purge homepage, front page and archives
Nobody has claimed this yet.
- Dominant language
- PHP
- Stars
- 257
- Forks
- 123
- PR merge metrics
- No merged PRs in 30d
Description
## Summary
`Purge::purge_publish()` fires on **any** post transitioning to `publish`, and `Purge::_get_purge_tags_by_post()` builds the full site-wide purge tag set without ever checking whether the post's type is public or viewable. Any plugin that stores internal bookkeeping rows as a `public => false` custom post type with `post_status => 'publish'` therefore purges the front page, home, pages, feed and date archives on every write.
Separately, the URL tag built at `purge.cls.php:1334` collapses to `URL./` — the homepage — for any post type without rewrite rules, because `Tag::get_uri_tag()` strips the query string from a query-string permalink. **That path fires regardless of every `O_PURGE_POST_*` setting**, so disabling the purge options in Settings does not stop the homepage being purged.
## Environment
- LiteSpeed Cache 7.9.1
- WordPress 7.1, PHP 8.5.9
- Shared cPanel hosting, LiteSpeed web server + LSCache module
## Minimal reproduction (no third-party plugin required)
Drop this in an mu-plugin, request the URL below, and inspect the `X-LiteSpeed-Purge` response header:
```php
add_action( 'init', function () {
register_post_type( 'lsc_test_private', array(
'public' => false,
'rewrite' => false,
'query_var' => false,
) );
} );
add_action( 'wp_loaded', function () {
if ( ! isset( $_GET['lsc_repro'] ) ) {
return;
}
wp_insert_post( array(
'post_type' => 'lsc_test_private',
'post_status' => 'publish',
'post_title' => 'repro ' . time(),
) );
} );
```
Request `/?lsc_repro=1`.
**Expected:** no purge, or at most the post's own `Po.` tag. The post type is explicitly `public => false` and has no public URL, so nothing user-visible has changed.
**Actual:** `X-LiteSpeed-Purge` contains the full set — `URL./`, `F`, `H`, `PGS`, `PGSRP`, `FD`, `D.`, `W.recent-posts-1`, `REST`.
## Code path
**1. The hook is registered unconditionally and is not filterable.**
`src/purge.cls.php:79-95`:
```php
$purge_post_events = apply_filters(
'litespeed_purge_post_events',
array( 'delete_post', 'wp_trash_post', 'wp_update_comment_count' )
);
foreach ( $purge_post_events as $event ) {
add_action( $event, array( $this, 'purge_post' ) );
}
// Purge post only when status is/was publish.
add_action( 'transition_post_status', array( $this, 'purge_publish' ), 10, 3 );
```
`transition_post_status` sits *outside* the `litespeed_purge_post_events` array, so a plugin cannot opt its own post types out through the documented filter. The only workaround available to third parties is `remove_action()` against the singleton, which is fragile across releases.
**2. No post-type check anywhere in the chain.**
`purge_publish()` (`purge.cls.php:112`) checks status only:
```php
public function purge_publish( $new_status, $old_status, $post ) {
if ( 'publish' !== $new_status && 'publish' !== $old_status ) {
return;
}
$this->purge_post( $post->ID );
}
```
`purge_post()` (`purge.cls.php:1048`) checks status only. `_get_purge_tags_by_post()` (`purge.cls.php:1320`) reads `$the_post->post_type` for taxonomy, adjacent-post and post-type-archive purposes, but never consults `is_post_type_viewable()` or the post type object's `public` flag before adding `TYPE_FRONTPAGE`, `TYPE_HOME`, `TYPE_PAGES`, `TYPE_PAGES_WITH_RECENT_POSTS`, `TYPE_FEED` or the date-archive tags.
**3. The `URL./` tag — likely a one-word mistake.**
`purge.cls.php:1332-1335`:
```php
$post_status = get_post_status( $post_id );
if ( function_exists( 'is_post_status_viewable' ) && is_post_status_viewable( $post_status ) ) {
$purge_tags[] = Tag::get_uri_tag( wp_make_link_relative( get_permalink( $post_id ) ) );
}
```
`is_post_status_viewable()` takes a **status**, not a post. `'publish'` is viewable irrespective of which post type carries it, so this guard passes for every non-public CPT. Then:
- `get_permalink()` on a post type with `rewrite => false` returns the query-string form, e.g. `https://example.com/?post_type=lsc_test_private&p=38667`
- `wp_make_link_relative()` → `/?post_type=lsc_test_private&p=38667`
- `Tag::get_uri_tag()` (`tag.cls.php:191-192`) does `strtok( $uri, '?' )` → `/`, then `trailingslashit()` → `/`
Result: the tag `URL./`, which is the homepage's own URL tag. The homepage cache entry is dropped because a private post's permalink degrades to the site root once the query string is stripped.
This is why the problem cannot be configured away: `O_PURGE_POST_FRONTPAGE`, `O_PURGE_POST_HOMEPAGE`, `O_PURGE_POST_PAGES` and `O_PURGE_POST_ALL` all gate their own tags, but the `URL.` tag at line 1334 is added unconditionally.
## Suggested fix
Two independent changes:
1. **Guard the purge on post type visibility.** Early-return in `purge_publish()` (or at the top of `_get_purge_tags_by_post()`) when `! is_post_type_viewable( $post->post_type )`. If an unconditional skip is too aggressive for back-compat, gate it behind a filter defaulting to skip.
2. **Fix the URL tag guard at line 1333** to check the post *type*, not the status — `is_post_type_viewable( get_post_type( $post_id ) )` — or reject the tag when the relative permalink has no path component beyond `/`.
A supporting change worth considering: move `transition_post_status` inside the `litespeed_purge_post_events` filter, or add a `litespeed_skip_purge_post` filter, so plugins have a supported opt-out rather than needing `remove_action()`.
## Real-world impact
Found via the WordPress ActivityPub plugin (9.3.1), which stores every inbound federation activity as an `ap_inbox` post — correctly registered `public => false`, `rewrite => false`, `query_var => false`, `exclude_from_search => true` — with `post_status => 'publish'` (`includes/collection/class-inbox.php:129`).
Measured on a live shared-hosting install over an 11-hour instrumented window, using LiteSpeed's own purge debug log (`wp-content/litespeed/debug/purge.log`):
| Scenario | Purge rate |
|---|---|
| Following 11 accounts | burst of ~13 purges in 3.5 min, settling to ~1.5/hour overnight (11 h measured) |
| Following 58 accounts | burst of ~83 purges in ~25 min, then **~18/hour sustained for 5+ hours with no decay** |
18 purges/hour is a full-site cache wipe roughly every 3–4 minutes, continuously, from ordinary background federation traffic — no posts published, no admin activity. 100% of sampled inbox deliveries produced an identical full purge tag set, from at least 6 distinct remote hosts. Deactivating the ActivityPub plugin produced zero purge events in the following 10 minutes, versus a genuine inbound-triggered purge seconds before deactivation.
This is not a resource-consumption problem: CPU, memory, DB connections and I/O stayed near zero. It is purely cache invalidation — real visitors intermittently hit a cold, fully-rendered page instead of the LSCache/edge cache, in proportion to background write volume.
ActivityPub is only the case that surfaced this. The same pattern — a `public => false` CPT written with `post_status => 'publish'` — is common in job queues, log and audit tables, sync state and several form and CRM plugins, so the blast radius is wider than one integration.
Cross-reference: Automattic/wordpress-activitypub#3735
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/purge.cls.php at purge_publish(), purge_post(), and _get_purge_tags_by_post(), then inspect Tag::get_uri_tag() in tag.cls.php. Reproduce with the supplied mu-plugin and inspect the X-LiteSpeed-Purge header. Done means non-public post types no longer trigger site-wide or URL./ purges, with the documented opt-out behavior addressed if included.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- php, wordpress
- Domain
- backend, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100