litespeedtech / litespeedtech/lscache_wp

Purge ignores post type visibility: non-public CPTs purge homepage, front page and archives

Open
#1,044 2 comments 0 reactions 0 assignees View on GitHub

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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.