From 75b3117f52210e47be335c72882b3a58b8c9dddb Mon Sep 17 00:00:00 2001 From: Oliver <20591763+Cryptoom@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:39:10 +0300 Subject: [PATCH] fix(posts): reject inline media that does not resolve to a real, workspace-owned Media row TPX-02/A0 found that CreatePost and UpdatePost write posts.media straight from client input with no check that media.*.id/path resolves to a real medias row: tests/Feature/Api/PostApiTest.php created a post with media[0].id = "media-1" (no matching medias row) and it was accepted. Root cause confirmed as a bug, not a feature: every legitimate write path (the 3 dedicated MCP/API attach tools, the web asset gallery, Unsplash/ Giphy save-from-url, AI regeneration) always echoes back a real, server- issued Media id, scoped to the workspace via FindWorkspaceAsset or MediaAttacher's download-and-host flow. The web StorePostRequest client (createPostFromAsset in GalleryBrowser.vue) sends only asset.id from a prior server response, never a client-generated id. App\Support\PostMediaRules::assertHostedMediaExists mirrors the existing FindWorkspaceAsset lookup used by attach-existing-asset: any media item that claims to already be hosted (id and/or path set) must resolve to a medias row with mediable_type=workspace, mediable_id=, collection=assets, or the request is rejected with a 422 on media.N.id. A bare external url with no id/path (API-only fresh-download case) is left alone, HostInlineMedia/MediaAttacher still download and host it before anything is persisted. Wired into all four request classes: - App\Http\Requests\App\Post\StorePostRequest previously had NO item-level media validation at all ('media' => ['nullable','array']); now uses PostMediaRules::rules(hosted: true) plus the new check. - App\Http\Requests\App\Post\UpdatePostRequest, Api StorePostRequest and Api UpdatePostRequest already used PostMediaRules::rules() for shape but never checked existence; added a withValidator() closure. Also closes a related crash: a non-UUID id (e.g. "media-1") reached Media::whereKey() and threw an unhandled Postgres "invalid input syntax for type uuid" 500 instead of a 422. assertHostedMediaExists now checks Str::isUuid() first. Security: this closes a cross-tenant IDOR. Nothing previously verified that a referenced media id belonged to the requesting workspace, so a crafted id/path could reference (or claim to reference) another workspace's asset. Verified against production (web02, trypost-pgsql, read-only): 0 of 32 posts with non-empty media have an orphaned or cross-tenant media id today, so this closes the gap going forward without any known bad data to migrate. Updated 6 existing tests (PostApiTest, PostMediaApiTest x5, PostMediaAltTextValidationTest x3, UpdatePostRequestTest) that used fabricated ids like "media-1"/"hosted-1"/"m1" as a fixture shortcut for the "already-hosted" case; they now create a real Media::factory() ->assets() row first, which is what those code paths were always meant to receive. Added tests/Feature/Api/PostMediaExistsValidationTest.php and tests/Feature/PostMediaExistsValidationWebTest.php (11 new tests) that prove the fix red-before/green-after: fabricated id rejected, cross- tenant id rejected, real workspace id accepted, bare url still downloads, path without id rejected. NOT FOR MERGE. Reference implementation for A1 (media_post_platform pivot with a hard FK to medias.id) from the plan at ~/.claude/plans/proud-bubbling-dewdrop.md. See TPX-03 completion report in the Vault for the full design writeup, verification results, and IDOR scope. --- .../Requests/Api/Post/StorePostRequest.php | 12 ++ .../Requests/Api/Post/UpdatePostRequest.php | 8 + .../Requests/App/Post/StorePostRequest.php | 15 +- .../Requests/App/Post/UpdatePostRequest.php | 8 + app/Support/PostMediaRules.php | 68 ++++++++ tests/Feature/Api/PostApiTest.php | 7 +- tests/Feature/Api/PostMediaApiTest.php | 47 +++++- .../Api/PostMediaExistsValidationTest.php | 158 ++++++++++++++++++ .../PostMediaAltTextValidationTest.php | 25 ++- .../PostMediaExistsValidationWebTest.php | 110 ++++++++++++ tests/Feature/UpdatePostRequestTest.php | 9 +- 11 files changed, 453 insertions(+), 14 deletions(-) create mode 100644 tests/Feature/Api/PostMediaExistsValidationTest.php create mode 100644 tests/Feature/PostMediaExistsValidationWebTest.php diff --git a/app/Http/Requests/Api/Post/StorePostRequest.php b/app/Http/Requests/Api/Post/StorePostRequest.php index 8f19cb3ba..dda1c680b 100644 --- a/app/Http/Requests/Api/Post/StorePostRequest.php +++ b/app/Http/Requests/Api/Post/StorePostRequest.php @@ -14,6 +14,7 @@ use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class StorePostRequest extends FormRequest { @@ -61,6 +62,17 @@ public function rules(): array ]; } + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + PostMediaRules::assertHostedMediaExists( + $validator, + $this->user()->currentWorkspace, + (array) $this->input('media', []), + ); + }); + } + /** * @return array */ diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index 2796b80f4..6e80f6cf0 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -82,6 +82,14 @@ public function attributes(): array public function withValidator(Validator $validator): void { + $validator->after(function (Validator $validator): void { + PostMediaRules::assertHostedMediaExists( + $validator, + $this->user()->currentWorkspace, + (array) $this->input('media', []), + ); + }); + $validator->after(function (Validator $validator): void { if (! in_array($this->input('status'), [Status::Scheduled->value, Status::Publishing->value], true)) { return; diff --git a/app/Http/Requests/App/Post/StorePostRequest.php b/app/Http/Requests/App/Post/StorePostRequest.php index c564ec583..eec7189de 100644 --- a/app/Http/Requests/App/Post/StorePostRequest.php +++ b/app/Http/Requests/App/Post/StorePostRequest.php @@ -4,7 +4,9 @@ namespace App\Http\Requests\App\Post; +use App\Support\PostMediaRules; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Validator; class StorePostRequest extends FormRequest { @@ -20,7 +22,18 @@ public function rules(): array { return [ 'date' => ['nullable', 'date_format:Y-m-d'], - 'media' => ['nullable', 'array'], + ...PostMediaRules::rules(hosted: true), ]; } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + PostMediaRules::assertHostedMediaExists( + $validator, + $this->user()->currentWorkspace, + (array) $this->input('media', []), + ); + }); + } } diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index 83647284d..64a40b2a7 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -79,6 +79,14 @@ public function attributes(): array public function withValidator(Validator $validator): void { + $validator->after(function (Validator $validator): void { + PostMediaRules::assertHostedMediaExists( + $validator, + $this->user()->currentWorkspace, + (array) $this->input('media', []), + ); + }); + $validator->after(function (Validator $validator): void { if (! $this->isPublishingOrScheduling()) { return; diff --git a/app/Support/PostMediaRules.php b/app/Support/PostMediaRules.php index 4d192778f..001aa1699 100644 --- a/app/Support/PostMediaRules.php +++ b/app/Support/PostMediaRules.php @@ -5,8 +5,13 @@ namespace App\Support; use App\Enums\Media\Source; +use App\Models\Media; +use App\Models\Workspace; use Closure; +use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; /** * Single source of truth for inline post `media` validation, shared by the post @@ -62,4 +67,67 @@ public static function rules(bool $hosted): array 'media.*.source_meta' => ['sometimes', 'nullable', 'array'], ]; } + + /** + * Reject inline media items that claim to already be hosted (`id` and/or + * `path` set) but don't resolve to a real `medias` row owned by this + * workspace. `rules()` above only checks shape (id is a non-empty string). + * It never confirms the id exists, so a client could otherwise write an + * arbitrary id/path pair straight into `posts.media`, including another + * workspace's real asset (IDOR) or a path nothing backs at all. Mirrors + * the lookup `FindWorkspaceAsset` already uses for `attach-existing-asset`. + * + * A bare `url` with no `id`/`path` is left alone: that's the API-only + * "please download this external URL" case (`PostMediaRules::rules` + * with `hosted: false`), and `HostInlineMedia`/`MediaAttacher` handle it + * by fetching the URL and creating a fresh, workspace-owned `Media` row + * before anything is persisted. There's no pre-existing id to check yet. + * + * @param array> $media + */ + public static function assertHostedMediaExists(Validator $validator, Workspace $workspace, array $media): void + { + foreach ($media as $index => $item) { + $id = data_get($item, 'id'); + $path = data_get($item, 'path'); + + if (blank($id)) { + if (filled($path)) { + $validator->errors()->add( + "media.{$index}.id", + 'The media id field is required when path is present.', + ); + } + + // Blank id, blank path: a bare external url for HostInlineMedia to fetch. Nothing to verify yet. + continue; + } + + if ($validator->errors()->has("media.{$index}.id")) { + // A shape rule (e.g. "must be a string") already failed for this item. + continue; + } + + // A non-UUID id can never match a real medias row (id is a UUID + // primary key), and Postgres rejects it as an invalid uuid literal + // before the query even runs, an unhandled 500 instead of a + // graceful 422. Fail the same way a real, absent id would. + if (! Str::isUuid((string) $id)) { + $validator->errors()->add("media.{$index}.id", 'Media not found.'); + + continue; + } + + $exists = Media::query() + ->where('mediable_type', Relation::getMorphAlias(Workspace::class)) + ->where('mediable_id', $workspace->id) + ->where('collection', 'assets') + ->whereKey($id) + ->exists(); + + if (! $exists) { + $validator->errors()->add("media.{$index}.id", 'Media not found.'); + } + } + } } diff --git a/tests/Feature/Api/PostApiTest.php b/tests/Feature/Api/PostApiTest.php index a52ee1069..b30c15361 100644 --- a/tests/Feature/Api/PostApiTest.php +++ b/tests/Feature/Api/PostApiTest.php @@ -7,6 +7,7 @@ use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Jobs\PublishPost; +use App\Models\Media; use App\Models\Post; use App\Models\PostPlatform; use App\Models\SocialAccount; @@ -101,10 +102,14 @@ it('creates a post with content, media, and labels', function () { $label = WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id]); + $asset = Media::factory()->assets()->create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $this->workspace->id, + ]); $payload = [ 'content' => 'Hello from the API', - 'media' => [['id' => 'media-1', 'path' => 'media/foo.jpg', 'url' => 'https://example.com/foo.jpg', 'type' => 'image']], + 'media' => [['id' => $asset->id, 'path' => $asset->path, 'url' => 'https://example.com/'.$asset->path, 'type' => 'image']], 'platforms' => [ ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], ], diff --git a/tests/Feature/Api/PostMediaApiTest.php b/tests/Feature/Api/PostMediaApiTest.php index caa4cdb6a..7edf64720 100644 --- a/tests/Feature/Api/PostMediaApiTest.php +++ b/tests/Feature/Api/PostMediaApiTest.php @@ -490,6 +490,12 @@ it('keeps an already-hosted item and a freshly-hosted url in order', function () { $this->socialAccount->update(['is_active' => true]); + $asset = Media::factory()->assets()->create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $this->workspace->id, + 'path' => 'assets/already.jpg', + ]); + Http::fake([ '93.184.216.34/external.jpg' => Http::response( file_get_contents(__DIR__.'/../../fixtures/1x1.png'), @@ -502,7 +508,7 @@ ->postJson(route('api.posts.store'), [ 'content' => 'Mixed media post', 'media' => [ - ['id' => 'hosted-1', 'path' => 'assets/already.jpg', 'url' => 'https://cdn.trypost.test/assets/already.jpg', 'type' => 'image'], + ['id' => $asset->id, 'path' => $asset->path, 'url' => 'https://cdn.trypost.test/assets/already.jpg', 'type' => 'image'], ['url' => 'https://93.184.216.34/external.jpg'], ], 'platforms' => [ @@ -517,19 +523,25 @@ ->and(data_get($media, '0.path'))->toBe('assets/already.jpg') ->and(data_get($media, '1.url'))->not->toContain('93.184.216.34') ->and(data_get($media, '1.path'))->not->toBeNull(); - // Only the external URL is hosted; the passed-through item creates no new row. - expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(1); + // The pre-existing asset is reused (no duplicate row); only the external url is newly hosted. + expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(2); }); it('passes already-hosted media through on create without downloading', function () { $this->socialAccount->update(['is_active' => true]); Http::preventStrayRequests(); + $asset = Media::factory()->assets()->create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $this->workspace->id, + 'path' => 'assets/foo.jpg', + ]); + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) ->postJson(route('api.posts.store'), [ 'content' => 'Hosted media post', 'media' => [[ - 'id' => 'media-1', + 'id' => $asset->id, 'path' => 'assets/foo.jpg', 'url' => 'https://cdn.trypost.test/assets/foo.jpg', 'type' => 'image', @@ -541,7 +553,8 @@ ->assertCreated(); expect(data_get(Post::where('content', 'Hosted media post')->firstOrFail()->media, '0.path'))->toBe('assets/foo.jpg'); - expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0); + // The pre-existing asset is reused; nothing new is downloaded or hosted. + expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(1); }); it('downloads and hosts an external media url when updating a post', function () { @@ -587,11 +600,17 @@ $this->socialAccount->update(['is_active' => true]); Http::preventStrayRequests(); + $asset = Media::factory()->assets()->create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $this->workspace->id, + 'path' => 'assets/foo.jpg', + ]); + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) ->postJson(route('api.posts.store'), [ 'content' => 'Alt text post', 'media' => [[ - 'id' => 'media-1', + 'id' => $asset->id, 'path' => 'assets/foo.jpg', 'url' => 'https://cdn.trypost.test/assets/foo.jpg', 'type' => 'image', @@ -609,11 +628,17 @@ }); it('accepts and persists media alt text on update', function () { + $asset = Media::factory()->assets()->create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $this->workspace->id, + 'path' => 'assets/foo.jpg', + ]); + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) ->putJson(route('api.posts.update', $this->post), [ 'status' => 'draft', 'media' => [[ - 'id' => 'media-1', + 'id' => $asset->id, 'path' => 'assets/foo.jpg', 'url' => 'https://cdn.trypost.test/assets/foo.jpg', 'type' => 'image', @@ -626,11 +651,17 @@ }); it('preserves every media meta key on update, not just alt_text', function () { + $asset = Media::factory()->assets()->create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $this->workspace->id, + 'path' => 'assets/foo.jpg', + ]); + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) ->putJson(route('api.posts.update', $this->post), [ 'status' => 'draft', 'media' => [[ - 'id' => 'media-1', + 'id' => $asset->id, 'path' => 'assets/foo.jpg', 'url' => 'https://cdn.trypost.test/assets/foo.jpg', 'type' => 'image', diff --git a/tests/Feature/Api/PostMediaExistsValidationTest.php b/tests/Feature/Api/PostMediaExistsValidationTest.php new file mode 100644 index 000000000..a3020bf9e --- /dev/null +++ b/tests/Feature/Api/PostMediaExistsValidationTest.php @@ -0,0 +1,158 @@ +user = $result['user']; + $this->workspace = $result['workspace']; + $this->plainToken = $result['plain_token']; + + $this->socialAccount = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::LinkedIn, + ]); +}); + +it('rejects creating a post with a fabricated media id/path (no matching medias row)', function () { + $payload = [ + 'content' => 'Fabricated media id', + 'media' => [['id' => 'media-1', 'path' => 'media/foo.jpg', 'url' => 'https://example.com/foo.jpg', 'type' => 'image']], + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + ]; + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), $payload) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) + ->assertJsonValidationErrors(['media.0.id']); + + expect(Post::where('workspace_id', $this->workspace->id)->count())->toBe(0); +}); + +it('creates a post when media id references a real asset owned by the workspace', function () { + $asset = Media::factory()->assets()->create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $this->workspace->id, + ]); + + $payload = [ + 'content' => 'Real media id', + 'media' => [['id' => $asset->id, 'path' => $asset->path, 'url' => 'https://example.com/'.$asset->path, 'type' => 'image']], + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + ]; + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), $payload) + ->assertCreated(); + + $post = Post::where('workspace_id', $this->workspace->id)->first(); + expect($post->media)->toHaveCount(1) + ->and(data_get($post->media, '0.id'))->toBe($asset->id); +}); + +it('rejects creating a post with another workspace\'s real media id (cross-tenant IDOR)', function () { + $other = Workspace::factory()->create(); + $foreignAsset = Media::factory()->assets()->create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $other->id, + ]); + + $payload = [ + 'content' => 'Cross-tenant media id', + 'media' => [['id' => $foreignAsset->id, 'path' => $foreignAsset->path, 'url' => $foreignAsset->url, 'type' => 'image']], + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + ]; + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), $payload) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) + ->assertJsonValidationErrors(['media.0.id']); + + expect(Post::where('workspace_id', $this->workspace->id)->count())->toBe(0); +}); + +it('still allows a bare external url with no id/path (API download-and-host path)', function () { + Http::fake([ + 'example.com/photo.png' => Http::response( + file_get_contents(__DIR__.'/../../fixtures/1x1.png'), + 200, + ['Content-Type' => 'image/png'], + ), + ]); + Storage::fake(); + + $payload = [ + 'content' => 'Fresh external url', + 'media' => [['url' => 'https://example.com/photo.png']], + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + ]; + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), $payload) + ->assertCreated(); + + $post = Post::where('workspace_id', $this->workspace->id)->first(); + expect($post->media)->toHaveCount(1); + + $storedId = data_get($post->media, '0.id'); + expect(Media::query()->whereKey($storedId)->exists())->toBeTrue(); +}); + +it('rejects updating a post with a fabricated media id/path', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + $payload = [ + 'status' => 'draft', + 'media' => [['id' => 'forged-id', 'path' => 'media/forged.jpg', 'url' => 'https://example.com/forged.jpg', 'type' => 'image']], + ]; + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->putJson(route('api.posts.update', $post), $payload) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) + ->assertJsonValidationErrors(['media.0.id']); + + expect($post->fresh()->media)->toBe([]); +}); + +it('rejects a media item that sends a path without a resolvable id', function () { + $payload = [ + 'content' => 'Path without id', + 'media' => [['path' => 'media/orphan.jpg', 'url' => 'https://example.com/orphan.jpg', 'type' => 'image']], + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + ]; + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), $payload) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) + ->assertJsonValidationErrors(['media.0.id']); +}); diff --git a/tests/Feature/PostMediaAltTextValidationTest.php b/tests/Feature/PostMediaAltTextValidationTest.php index 9fcb6b8b2..1c3be8572 100644 --- a/tests/Feature/PostMediaAltTextValidationTest.php +++ b/tests/Feature/PostMediaAltTextValidationTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Enums\UserWorkspace\Role; +use App\Models\Media; use App\Models\Post; use App\Models\User; use App\Models\Workspace; @@ -18,11 +19,17 @@ 'user_id' => $user->id, ]); + $asset = Media::factory()->assets()->create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $workspace->id, + 'path' => 'uploads/x.jpg', + ]); + $response = $this->actingAs($user)->put(route('app.posts.update', $post), [ 'status' => 'draft', 'content' => 'hi', 'media' => [[ - 'id' => 'm1', 'path' => 'uploads/x.jpg', 'url' => 'https://cdn.test/x.jpg', + 'id' => $asset->id, 'path' => 'uploads/x.jpg', 'url' => 'https://cdn.test/x.jpg', 'meta' => ['alt_text' => 'a golden retriever on a beach'], ]], ]); @@ -42,11 +49,17 @@ 'user_id' => $user->id, ]); + $asset = Media::factory()->assets()->create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $workspace->id, + 'path' => 'uploads/x.jpg', + ]); + $response = $this->actingAs($user)->put(route('app.posts.update', $post), [ 'status' => 'draft', 'content' => 'hi', 'media' => [[ - 'id' => 'm1', 'path' => 'uploads/x.jpg', 'url' => 'https://cdn.test/x.jpg', 'type' => 'image', + 'id' => $asset->id, 'path' => 'uploads/x.jpg', 'url' => 'https://cdn.test/x.jpg', 'type' => 'image', 'meta' => [ 'width' => 1080, 'height' => 1350, @@ -102,13 +115,19 @@ 'user_id' => $user->id, ]); + $asset = Media::factory()->assets()->create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $workspace->id, + 'path' => 'uploads/x.jpg', + ]); + $altText = str_repeat('a', 2000); $response = $this->actingAs($user)->put(route('app.posts.update', $post), [ 'status' => 'draft', 'content' => 'hi', 'media' => [[ - 'id' => 'm1', 'path' => 'uploads/x.jpg', 'url' => 'https://cdn.test/x.jpg', + 'id' => $asset->id, 'path' => 'uploads/x.jpg', 'url' => 'https://cdn.test/x.jpg', 'meta' => ['alt_text' => $altText], ]], ]); diff --git a/tests/Feature/PostMediaExistsValidationWebTest.php b/tests/Feature/PostMediaExistsValidationWebTest.php new file mode 100644 index 000000000..d82ab5ab8 --- /dev/null +++ b/tests/Feature/PostMediaExistsValidationWebTest.php @@ -0,0 +1,110 @@ + ['nullable', 'array']), + * and App\Http\Requests\App\Post\UpdatePostRequest validated shape only (id/path required + * strings) with no check that the id actually resolves to a medias row owned by the + * workspace. Both now run App\Support\PostMediaRules::assertHostedMediaExists. + */ +beforeEach(function () { + $this->user = User::factory()->create([]); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); + + $this->socialAccount = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::LinkedIn, + ]); +}); + +test('store post rejects a fabricated media id/path (no matching medias row)', function () { + $response = $this->actingAs($this->user)->post(route('app.posts.store'), [ + 'media' => [['id' => 'media-1', 'path' => 'media/foo.jpg', 'url' => 'https://example.com/foo.jpg', 'type' => 'image']], + ]); + + $response->assertSessionHasErrors(['media.0.id']); + expect(Post::where('workspace_id', $this->workspace->id)->count())->toBe(0); +}); + +test('store post accepts a media id that resolves to a real workspace asset', function () { + $asset = Media::factory()->assets()->create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $this->workspace->id, + ]); + + $response = $this->actingAs($this->user)->post(route('app.posts.store'), [ + 'media' => [['id' => $asset->id, 'path' => $asset->path, 'url' => 'https://example.com/'.$asset->path, 'type' => 'image']], + ]); + + $response->assertSessionDoesntHaveErrors(); + $response->assertRedirect(); + + $post = Post::where('workspace_id', $this->workspace->id)->first(); + expect($post)->not->toBeNull(); + expect($post->media)->toHaveCount(1) + ->and(data_get($post->media, '0.id'))->toBe($asset->id); +}); + +test('store post rejects another workspace\'s real media id (cross-tenant IDOR)', function () { + $other = Workspace::factory()->create(); + $foreignAsset = Media::factory()->assets()->create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $other->id, + ]); + + $response = $this->actingAs($this->user)->post(route('app.posts.store'), [ + 'media' => [['id' => $foreignAsset->id, 'path' => $foreignAsset->path, 'url' => 'https://example.com/'.$foreignAsset->path, 'type' => 'image']], + ]); + + $response->assertSessionHasErrors(['media.0.id']); + expect(Post::where('workspace_id', $this->workspace->id)->count())->toBe(0); +}); + +test('update post rejects a fabricated media id/path', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + $response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [ + 'status' => 'draft', + 'media' => [['id' => 'forged-id', 'path' => 'media/forged.jpg', 'url' => 'https://example.com/forged.jpg', 'type' => 'image']], + ]); + + $response->assertSessionHasErrors(['media.0.id']); + expect($post->fresh()->media)->toBe([]); +}); + +test('update post accepts a media id that resolves to a real workspace asset', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + $asset = Media::factory()->assets()->create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $this->workspace->id, + ]); + + $response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [ + 'status' => 'draft', + 'media' => [['id' => $asset->id, 'path' => $asset->path, 'url' => 'https://example.com/'.$asset->path, 'type' => 'image']], + ]); + + $response->assertSessionDoesntHaveErrors(); + expect($post->fresh()->media)->toHaveCount(1) + ->and(data_get($post->fresh()->media, '0.id'))->toBe($asset->id); +}); diff --git a/tests/Feature/UpdatePostRequestTest.php b/tests/Feature/UpdatePostRequestTest.php index 113af6dde..8c96b7b37 100644 --- a/tests/Feature/UpdatePostRequestTest.php +++ b/tests/Feature/UpdatePostRequestTest.php @@ -6,6 +6,7 @@ use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Enums\UserWorkspace\Role; +use App\Models\Media; use App\Models\Post; use App\Models\PostPlatform; use App\Models\SocialAccount; @@ -625,9 +626,15 @@ }); test('draft save accepts media source metadata for ai regeneration', function () { + $asset = Media::factory()->assets()->create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $this->workspace->id, + 'path' => 'ai-images/generated.webp', + ]); + $payload = [ [ - 'id' => 'media-ai-keep-meta', + 'id' => $asset->id, 'path' => 'ai-images/generated.webp', 'url' => 'https://example.com/ai-images/generated.webp', 'type' => 'image',