fix: empty status filter, hidden-field probing, own private posts

Review fix round 2: content-query no longer lets get_posts() fall back
to 'publish' when every requested status is capability-gated out (now
short-circuits to an empty page). Its field filter shares PostPresenter's
hidden-field rule (Pods password type, protected meta) so a hidden key
is rejected like an unknown one instead of reaching the query. Password
fields are now type-checked as strings on write. A user without
edit_private_posts can still query their own private posts when the
query is already author-restricted, matching WordPress core.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-25 22:53:58 +02:00
co-authored by Claude Opus 5.5
parent 0890e6f165
commit b9d46f3cec
5 changed files with 123 additions and 15 deletions
@@ -9,6 +9,7 @@ use LIT\MCP\Abilities\Cursor;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use WP_Error;
use WP_Post;
@@ -137,8 +138,16 @@ final class ContentQueryAbility implements Ability
}
}
$args = $this->queryArgs($input);
if ($args['post_status'] === []) {
// Every requested status was gated out by the post type's caps – get_posts()
// would otherwise treat an empty post_status as its own default ('publish'),
// returning results the caller has no right to see instead of none at all.
return ['items' => [], 'next_cursor' => null];
}
$limit = Cursor::limit($input['limit'] ?? null);
$posts = get_posts(array_merge($this->queryArgs($input), [
$posts = get_posts(array_merge($args, [
'posts_per_page' => $limit + 1,
'offset' => $offset,
]));
@@ -164,9 +173,18 @@ final class ContentQueryAbility implements Ability
$typeObject = get_post_type_object($postType);
$caps = is_object($typeObject) && isset($typeObject->cap) ? $typeObject->cap : null;
// Without edit_others_posts, even a status the user may edit in principle must
// stay limited to their own posts – otherwise the query would leak drafts etc.
// of other authors that happen to pass the per-status gate below.
$restrictedToOwnPosts = ! current_user_can($this->capability($caps, 'edit_others_posts'));
$args = [
'post_type' => $postType,
'post_status' => $this->allowedStatuses((array) ($input['status'] ?? self::STATUSES), $caps),
'post_status' => $this->allowedStatuses(
(array) ($input['status'] ?? self::STATUSES),
$caps,
$restrictedToOwnPosts
),
'orderby' => (string) ($input['orderby'] ?? 'date'),
'order' => strtoupper((string) ($input['order'] ?? 'desc')),
'no_found_rows' => true,
@@ -174,10 +192,7 @@ final class ContentQueryAbility implements Ability
'suppress_filters' => false,
];
// Without edit_others_posts, even a status the user may edit in principle must
// stay limited to their own posts – otherwise the query would leak drafts etc.
// of other authors that happen to pass the per-status gate below.
if (! current_user_can($this->capability($caps, 'edit_others_posts'))) {
if ($restrictedToOwnPosts) {
$args['author'] = get_current_user_id();
}
@@ -243,10 +258,15 @@ final class ContentQueryAbility implements Ability
* even fetches rows that would only be discarded by the per-post check afterwards
* (which would otherwise waste a page slot and reveal the post existed).
*
* "private" is the one exception: WordPress lets an author edit their own private
* post with just edit_posts, so when the query is already author-restricted to the
* current user (no edit_others_posts) their own private posts stay in scope even
* without edit_private_posts.
*
* @param list<mixed> $statuses
* @return list<string>
*/
private function allowedStatuses(array $statuses, ?object $caps): array
private function allowedStatuses(array $statuses, ?object $caps, bool $restrictedToOwnPosts): array
{
$gates = [
'private' => $this->capability($caps, 'edit_private_posts'),
@@ -256,7 +276,17 @@ final class ContentQueryAbility implements Ability
return array_values(array_filter(
array_map('strval', $statuses),
static fn (string $status): bool => ! isset($gates[$status]) || current_user_can($gates[$status])
static function (string $status) use ($gates, $restrictedToOwnPosts): bool {
if (! isset($gates[$status])) {
return true;
}
if ($status === 'private' && $restrictedToOwnPosts) {
return true;
}
return current_user_can($gates[$status]);
}
));
}
@@ -267,7 +297,11 @@ final class ContentQueryAbility implements Ability
private function rejectUnlessKnownField(string $postType, string $key): ?WP_Error
{
$allowed = array_keys($this->schema->fields($postType));
$visible = array_filter(
$this->schema->fields($postType),
static fn (FieldDefinition $field): bool => ! PostPresenter::isHiddenField($field)
);
$allowed = array_keys($visible);
if (in_array($key, $allowed, true)) {
return null;
}
@@ -144,7 +144,7 @@ class PostPresenter
{
$result = [];
foreach ($this->schema->fields((string) $post->post_type) as $key => $field) {
if ($this->isHidden($field)) {
if (self::isHiddenField($field)) {
continue;
}
@@ -158,9 +158,11 @@ class PostPresenter
* Pods password fields and readonly protected meta (WordPress' own "_"-prefixed
* internal keys) are never read out via lit-content-get/-query: neither belongs in
* front of the model, and both are one plugin registration away from leaking secrets
* or internal engine state.
* or internal engine state. Public and static so ContentQueryAbility can apply the
* same rule to its `field` filter – a hidden field must be as unreachable through the
* query filter as it is through the fields map, or the filter becomes a probe.
*/
private function isHidden(FieldDefinition $field): bool
public static function isHiddenField(FieldDefinition $field): bool
{
if ($field->type === 'password') {
return true;