fix: check WordPress meta capabilities per field key

Fields from the meta/sample sources need edit_post_meta (update, checked in
PostWriter::prepare) or add_post_meta (create, checked right after the insert;
a refusal deletes the new draft again) for their key; a refusal is a
fields.<key> validation error.

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-26 01:24:20 +02:00
co-authored by Claude Opus 5.5
parent 563d9e8d15
commit 106a099483
4 changed files with 160 additions and 0 deletions
@@ -118,6 +118,11 @@ final class ContentCreateAbility implements Ability
}
$postId = (int) $postId;
$denied = $this->writer->deniedMeta($postId, $postType, $prepared['fields'], 'add_post_meta');
if ($denied !== []) {
return $this->refuse($postId, $postType, $denied);
}
try {
$this->writer->writeExtras($postId, $postType, $prepared, $input);
} catch (Throwable $e) {
@@ -187,6 +192,37 @@ final class ContentCreateAbility implements Ability
);
}
/**
* Meta rights can only be checked once the draft exists (add_post_meta needs its ID): a
* refused field removes the brand-new draft again and is reported like any invalid input.
*
* @param array<string, string> $denied fields.<key> => error
* @throws WriteFailed When the draft cannot be deleted.
*/
private function refuse(int $postId, string $postType, array $denied): WP_Error
{
try {
$deleted = (bool) wp_delete_post($postId, true);
} catch (Throwable $e) {
$deleted = false;
}
if (! $deleted) {
$summary = sprintf('Entwurf %d: Anlegen nicht zurückgenommen', $postId);
$this->logPartial($postType, (string) $postId, $summary);
throw new WriteFailed(
'Keine Berechtigung für ' . implode(', ', array_keys($denied)) . '.',
false,
$postId,
null,
null,
sprintf('Meta rights denied for new post %d; draft could not be deleted', $postId)
);
}
return Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $denied);
}
private function logPartial(string $postType, string $postId, string $summary): void
{
$this->log->recordPartial($this->name(), $postType, $postId, $summary);
@@ -33,6 +33,11 @@ class PostWriter
'template' => 'page_template',
];
/** Field sources that store plain post meta, guarded per key by WordPress' meta capabilities. */
private const META_SOURCES = ['meta', 'sample'];
private const META_DENIED = 'Keine Berechtigung, dieses Feld zu ändern.';
/** wp_insert_post() ignores an empty page_template; this value selects the default template. */
public const DEFAULT_TEMPLATE = 'default';
@@ -91,6 +96,12 @@ class PostWriter
$errors['fields.' . $key] = $message;
}
// On create the entry does not exist yet; lit/content-create checks add_post_meta after the insert.
$postId = (int) ($input['post_id'] ?? 0);
if (! $isCreate && $postId > 0) {
$errors = array_merge($errors, $this->deniedMeta($postId, $postType, $fields['values'], 'edit_post_meta'));
}
$terms = $this->terms->resolve($postType, is_array($input['terms'] ?? null) ? $input['terms'] : []);
foreach ($terms['errors'] as $taxonomy => $message) {
$errors['terms.' . $taxonomy] = $message;
@@ -110,6 +121,33 @@ class PostWriter
return ['fields' => $fields['values'], 'terms' => $terms['terms'], 'warnings' => $fields['warnings']];
}
/**
* Meta fields (sources meta/sample) the current user may not write: WordPress decides per
* key (protected keys, register_meta auth callbacks) via edit_post_meta / add_post_meta.
*
* @param array<string, mixed> $values Validated field values.
* @param string $cap edit_post_meta (update) or add_post_meta (create).
* @return array<string, string> fields.<key> => error
*/
public function deniedMeta(int $postId, string $postType, array $values, string $cap): array
{
$definitions = $this->schema->fields($postType);
$denied = [];
foreach (array_keys($values) as $key) {
$key = (string) $key;
$definition = $definitions[$key] ?? null;
if (
$definition !== null
&& in_array($definition->source, self::META_SOURCES, true)
&& ! current_user_can($cap, $postId, $key)
) {
$denied['fields.' . $key] = self::META_DENIED;
}
}
return $denied;
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>