From 106a0994833b154d1c1bb135d1416f2a7ddd9cce Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 01:24:20 +0200 Subject: [PATCH] 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. validation error. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../Content/ContentCreateAbility.php | 36 ++++++++++++++++ .../WordPress/Content/PostWriter.php | 38 ++++++++++++++++ .../Content/ContentCreateAbilityTest.php | 43 +++++++++++++++++++ .../WordPress/Content/PostWriterTest.php | 43 +++++++++++++++++++ 4 files changed, 160 insertions(+) diff --git a/src/Integrations/WordPress/Content/ContentCreateAbility.php b/src/Integrations/WordPress/Content/ContentCreateAbility.php index e4a14e7..8bb622c 100644 --- a/src/Integrations/WordPress/Content/ContentCreateAbility.php +++ b/src/Integrations/WordPress/Content/ContentCreateAbility.php @@ -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 $denied fields. => 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); diff --git a/src/Integrations/WordPress/Content/PostWriter.php b/src/Integrations/WordPress/Content/PostWriter.php index 0a401dd..a2d1161 100644 --- a/src/Integrations/WordPress/Content/PostWriter.php +++ b/src/Integrations/WordPress/Content/PostWriter.php @@ -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 $values Validated field values. + * @param string $cap edit_post_meta (update) or add_post_meta (create). + * @return array fields. => 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 $input * @return array diff --git a/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php index e036260..f43a175 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php @@ -39,6 +39,7 @@ final class ContentCreateAbilityTest extends TestCase $writer = Mockery::mock(PostWriter::class); $writer->allows('prepare')->andReturn(['fields' => ['role' => 'Leitung'], 'terms' => [], 'warnings' => $warnings]); $writer->allows('postData')->andReturn(['post_title' => 'Anna']); + $writer->allows('deniedMeta')->andReturn([]); Functions\when('get_current_user_id')->justReturn(3); return $writer; @@ -85,6 +86,7 @@ final class ContentCreateAbilityTest extends TestCase $writer = Mockery::mock(PostWriter::class); $writer->allows('prepare')->with('lit_person', $input, true)->andReturn($prepared); $writer->allows('postData')->andReturn(['post_title' => 'Anna']); + $writer->expects('deniedMeta')->with(42, 'lit_person', ['role' => 'Leitung'], 'add_post_meta')->andReturn([]); $writer->expects('writeExtras')->with(42, 'lit_person', $prepared, $input); Functions\when('get_current_user_id')->justReturn(3); Functions\expect('wp_insert_post')->once()->with([ @@ -126,6 +128,47 @@ final class ContentCreateAbilityTest extends TestCase self::assertSame('lit_write_failed', $failure->toError()->get_error_code()); } + public function testMetaFieldsTheUserMayNotAddAreRefusedAndTheDraftIsRemoved(): void + { + $writer = Mockery::mock(PostWriter::class); + $writer->allows('prepare')->andReturn(['fields' => ['city' => 'Trier'], 'terms' => [], 'warnings' => []]); + $writer->allows('postData')->andReturn(['post_title' => 'Anna']); + $writer->expects('deniedMeta')->with(42, 'lit_person', ['city' => 'Trier'], 'add_post_meta') + ->andReturn(['fields.city' => 'Keine Berechtigung, dieses Feld zu ändern.']); + $writer->expects('writeExtras')->never(); + Functions\when('get_current_user_id')->justReturn(3); + Functions\when('wp_insert_post')->justReturn(42); + Functions\expect('wp_delete_post')->once()->with(42, true)->andReturn(new WP_Post(['ID' => 42])); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->never(); + + $result = (new ContentCreateAbility($this->schema(), $writer, $log))->execute(self::INPUT); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertArrayHasKey('fields.city', $result->get_error_data()['fields']); + } + + public function testARefusedDraftThatCannotBeDeletedIsAPartialWrite(): void + { + $writer = Mockery::mock(PostWriter::class); + $writer->allows('prepare')->andReturn(['fields' => ['city' => 'Trier'], 'terms' => [], 'warnings' => []]); + $writer->allows('postData')->andReturn(['post_title' => 'Anna']); + $writer->allows('deniedMeta')->andReturn(['fields.city' => 'Keine Berechtigung, dieses Feld zu ändern.']); + Functions\when('get_current_user_id')->justReturn(3); + Functions\when('wp_insert_post')->justReturn(42); + Functions\when('wp_delete_post')->justReturn(false); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial'); + + try { + (new ContentCreateAbility($this->schema(), $writer, $log))->execute(self::INPUT); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(42, $e->postId); + } + } + public function testADraftThatCannotBeDeletedIsAPartialWriteAndLogged(): void { $writer = $this->writer(); diff --git a/tests/Unit/Integrations/WordPress/Content/PostWriterTest.php b/tests/Unit/Integrations/WordPress/Content/PostWriterTest.php index 2ef9e04..8bd7146 100644 --- a/tests/Unit/Integrations/WordPress/Content/PostWriterTest.php +++ b/tests/Unit/Integrations/WordPress/Content/PostWriterTest.php @@ -131,6 +131,49 @@ final class PostWriterTest extends TestCase self::assertCount(1, $result['warnings']); } + public function testMetaFieldsNeedTheRightToEditThatMetaKeyOnUpdate(): void + { + Functions\when('get_object_taxonomies')->justReturn([]); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('fields')->andReturn([ + 'city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'), + 'secret' => new FieldDefinition('secret', 'secret', 'string', 'meta', 'writable'), + 'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'), + ]); + $checked = []; + Functions\when('current_user_can')->alias(static function (string $cap, ...$args) use (&$checked): bool { + $checked[] = array_merge([$cap], $args); + return $args !== [5, 'secret']; + }); + + $result = (new PostWriter($schema, new FieldValidator(), new TermAssigner()))->prepare( + 'page', + ['post_id' => 5, 'fields' => ['city' => 'Trier', 'secret' => 'x', 'role' => 'Leitung']], + false + ); + + self::assertSame(['fields.secret' => 'Keine Berechtigung, dieses Feld zu ändern.'], $result->get_error_data()['fields']); + self::assertSame([['edit_post_meta', 5, 'city'], ['edit_post_meta', 5, 'secret']], $checked); + } + + public function testCreateChecksMetaRightsOnlyOnceTheDraftExists(): void + { + Functions\when('get_object_taxonomies')->justReturn([]); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('fields')->andReturn([ + 'city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'), + 'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'), + ]); + Functions\expect('current_user_can')->once()->with('add_post_meta', 42, 'city')->andReturn(false); + $writer = new PostWriter($schema, new FieldValidator(), new TermAssigner()); + + self::assertIsArray($writer->prepare('page', ['fields' => ['city' => 'Trier', 'role' => 'x']], true)); + self::assertSame( + ['fields.city' => 'Keine Berechtigung, dieses Feld zu ändern.'], + $writer->deniedMeta(42, 'page', ['city' => 'Trier', 'role' => 'x'], 'add_post_meta') + ); + } + public function testPostDataMapsOnlyGivenKeys(): void { $writer = new PostWriter(Mockery::mock(ContentSchema::class), new FieldValidator(), new TermAssigner());