fix: restrict content-query by cap before pagination, filter hidden fields
Review fix round 1: content-query now excludes posts/statuses the user cannot edit before querying (author restriction without edit_others_posts, status filtering by edit_private_posts/edit_published_posts) instead of only dropping them after fetching, and rejects unknown field/taxonomy filters. content-types' not-found hint only lists editable types. PostPresenter skips Pods password fields and readonly protected meta. Query input gains search/terms length limits. 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:
+1
-1
@@ -18,7 +18,7 @@ final class PodsFieldSource implements FieldSource
|
||||
{
|
||||
private const TYPES = [
|
||||
'text' => 'string', 'website' => 'string', 'phone' => 'string', 'email' => 'string',
|
||||
'password' => 'string', 'slug' => 'string', 'color' => 'string', 'oembed' => 'string',
|
||||
'password' => 'password', 'slug' => 'string', 'color' => 'string', 'oembed' => 'string',
|
||||
'paragraph' => 'text', 'wysiwyg' => 'text', 'code' => 'text',
|
||||
'number' => 'number', 'currency' => 'number',
|
||||
'boolean' => 'boolean',
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\WordPress\Content;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Cursor;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use WP_Error;
|
||||
@@ -48,12 +49,17 @@ final class ContentQueryAbility implements Ability
|
||||
'items' => ['type' => 'string', 'enum' => self::STATUSES],
|
||||
'description' => 'Standard: alle außer Papierkorb.',
|
||||
],
|
||||
'search' => ['type' => 'string'],
|
||||
'search' => ['type' => 'string', 'maxLength' => 200],
|
||||
'taxonomy' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'taxonomy' => ['type' => 'string'],
|
||||
'terms' => ['type' => 'array', 'minItems' => 1, 'items' => ['type' => ['integer', 'string']]],
|
||||
'terms' => [
|
||||
'type' => 'array',
|
||||
'minItems' => 1,
|
||||
'maxItems' => 50,
|
||||
'items' => ['type' => ['integer', 'string']],
|
||||
],
|
||||
],
|
||||
'required' => ['taxonomy', 'terms'],
|
||||
'additionalProperties' => false,
|
||||
@@ -115,6 +121,22 @@ final class ContentQueryAbility implements Ability
|
||||
return Cursor::invalid();
|
||||
}
|
||||
|
||||
$postType = (string) $input['post_type'];
|
||||
|
||||
if (isset($input['field']) && is_array($input['field'])) {
|
||||
$error = $this->rejectUnlessKnownField($postType, (string) $input['field']['key']);
|
||||
if ($error !== null) {
|
||||
return $error;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($input['taxonomy']) && is_array($input['taxonomy'])) {
|
||||
$error = $this->rejectUnlessKnownTaxonomy($postType, (string) $input['taxonomy']['taxonomy']);
|
||||
if ($error !== null) {
|
||||
return $error;
|
||||
}
|
||||
}
|
||||
|
||||
$limit = Cursor::limit($input['limit'] ?? null);
|
||||
$posts = get_posts(array_merge($this->queryArgs($input), [
|
||||
'posts_per_page' => $limit + 1,
|
||||
@@ -138,9 +160,13 @@ final class ContentQueryAbility implements Ability
|
||||
*/
|
||||
private function queryArgs(array $input): array
|
||||
{
|
||||
$postType = (string) $input['post_type'];
|
||||
$typeObject = get_post_type_object($postType);
|
||||
$caps = is_object($typeObject) && isset($typeObject->cap) ? $typeObject->cap : null;
|
||||
|
||||
$args = [
|
||||
'post_type' => (string) $input['post_type'],
|
||||
'post_status' => $input['status'] ?? self::STATUSES,
|
||||
'post_type' => $postType,
|
||||
'post_status' => $this->allowedStatuses((array) ($input['status'] ?? self::STATUSES), $caps),
|
||||
'orderby' => (string) ($input['orderby'] ?? 'date'),
|
||||
'order' => strtoupper((string) ($input['order'] ?? 'desc')),
|
||||
'no_found_rows' => true,
|
||||
@@ -148,6 +174,13 @@ 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'))) {
|
||||
$args['author'] = get_current_user_id();
|
||||
}
|
||||
|
||||
if (isset($input['search']) && $input['search'] !== '') {
|
||||
$args['s'] = (string) $input['search'];
|
||||
}
|
||||
@@ -204,4 +237,63 @@ final class ContentQueryAbility implements Ability
|
||||
|
||||
return count($clauses) > 1 ? array_merge(['relation' => 'OR'], $clauses) : $clauses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops statuses the current user has no right to edit at all, so the query never
|
||||
* 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).
|
||||
*
|
||||
* @param list<mixed> $statuses
|
||||
* @return list<string>
|
||||
*/
|
||||
private function allowedStatuses(array $statuses, ?object $caps): array
|
||||
{
|
||||
$gates = [
|
||||
'private' => $this->capability($caps, 'edit_private_posts'),
|
||||
'publish' => $this->capability($caps, 'edit_published_posts'),
|
||||
'future' => $this->capability($caps, 'edit_published_posts'),
|
||||
];
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map('strval', $statuses),
|
||||
static fn (string $status): bool => ! isset($gates[$status]) || current_user_can($gates[$status])
|
||||
));
|
||||
}
|
||||
|
||||
private function capability(?object $caps, string $name): string
|
||||
{
|
||||
return $caps !== null && isset($caps->{$name}) ? (string) $caps->{$name} : $name;
|
||||
}
|
||||
|
||||
private function rejectUnlessKnownField(string $postType, string $key): ?WP_Error
|
||||
{
|
||||
$allowed = array_keys($this->schema->fields($postType));
|
||||
if (in_array($key, $allowed, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Errors::invalidInput(
|
||||
sprintf('Feld "%s" ist für Inhaltstyp "%s" nicht verfügbar.', $key, $postType),
|
||||
['field.key' => 'unbekanntes Feld'],
|
||||
$allowed === []
|
||||
? 'Für diesen Inhaltstyp sind keine Felder verfügbar.'
|
||||
: 'Verfügbare Felder: ' . implode(', ', $allowed) . '.'
|
||||
);
|
||||
}
|
||||
|
||||
private function rejectUnlessKnownTaxonomy(string $postType, string $taxonomy): ?WP_Error
|
||||
{
|
||||
$allowed = array_column($this->schema->taxonomies($postType), 'name');
|
||||
if (in_array($taxonomy, $allowed, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Errors::invalidInput(
|
||||
sprintf('Taxonomie "%s" ist für Inhaltstyp "%s" nicht verfügbar.', $taxonomy, $postType),
|
||||
['taxonomy.taxonomy' => 'unbekannte Taxonomie'],
|
||||
$allowed === []
|
||||
? 'Für diesen Inhaltstyp sind keine Taxonomien verfügbar.'
|
||||
: 'Verfügbare Taxonomien: ' . implode(', ', $allowed) . '.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,13 +99,22 @@ final class ContentTypesAbility implements Ability
|
||||
if ($description === null) {
|
||||
return Errors::notFound(
|
||||
sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $postType),
|
||||
'Verfügbare Inhaltstypen: ' . implode(', ', $this->schema->postTypeNames()) . '.'
|
||||
'Verfügbare Inhaltstypen: ' . implode(', ', $this->editableTypeNames()) . '.'
|
||||
);
|
||||
}
|
||||
|
||||
return ['post_types' => [$description]];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function editableTypeNames(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->schema->postTypeNames(),
|
||||
fn (string $name): bool => $this->canEdit($name)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Only post types the user can edit are listed or described (spec §4.5): requesting
|
||||
* a managed but non-editable type is a forbidden error, not a silent omission.
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\WordPress\Content;
|
||||
use LIT\MCP\Abilities\JsonMap;
|
||||
use LIT\MCP\Abilities\Rev;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||||
use WP_Post;
|
||||
|
||||
/**
|
||||
@@ -143,12 +144,31 @@ class PostPresenter
|
||||
{
|
||||
$result = [];
|
||||
foreach ($this->schema->fields((string) $post->post_type) as $key => $field) {
|
||||
if ($this->isHidden($field)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[$key] = $this->schema->sourceOf($field)->read((int) $post->ID, $field);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private function isHidden(FieldDefinition $field): bool
|
||||
{
|
||||
if ($field->type === 'password') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $field->status !== FieldDefinition::WRITABLE && str_starts_with($field->key, '_');
|
||||
}
|
||||
|
||||
/** @return array{id: int, url: string, alt: string}|null */
|
||||
private function featuredImage(WP_Post $post): ?array
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user