diff --git a/README.md b/README.md index 4bc994939..901534768 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ -

Your self-hosted tool for effortlessly archiving, organizing, and sharing your favorite web links.

+

Your self-hosted app for effortlessly organizing, archiving, and sharing your favorite bookmarks.

Follow LinkAce on Mastodon @@ -68,11 +68,8 @@ LinkAce provides multiple ways of installing it on your server. The complete doc * [Setup without Docker](https://www.linkace.org/docs/v2/setup/setup-with-php/) * [One-Click Deployment to the Cloud](https://www.linkace.org/docs/v2/setup/one-click-deploy/) * [Setup with Kubernetes](https://www.linkace.org/docs/v2/setup/setup-to-k8s/) (Beta) -* [Official managed Hosting](https://hosting.linkace.org) (_Beta Waitlist_) +* [Official managed Hosting](https://hosting.linkace.org) (_Beta_) -  - -> **LinkAce 2.0 was just released!** This is a big upgrade to the application. Please read the [upgrade guide](https://www.linkace.org/docs/v2/upgrade/from-v1/) if you are still using LinkAce 1 and want to use version 2.   @@ -83,9 +80,8 @@ I built LinkAce to solve my own problem, and I now offer my solution and code wi :star: You can get personal and dedicated support by **becoming a supporter on [Open Collective](https://opencollective.com/linkace), [Patreon](https://www.patreon.com/Kovah) or [Github](https://github.com/sponsors/Kovah)**. -#### Our Supporters on Open Collective - +  ### Documentation and Community @@ -94,6 +90,11 @@ Details about all features and advanced configuration can be found in the [**pro Additionally, you may visit the [community discussions](https://github.com/Kovah/LinkAce/discussions) to share your ideas, talk with other users or find help for specific problems. +#### Our Supporters on Open Collective + + + +   @@ -103,16 +104,12 @@ Additionally, you may visit the [community discussions](https://github.com/Kovah Please consult the [**contribution guidelines**](CONTRIBUTING.md) to start working on LinkAce. -   - Thanks go to these wonderful people for their contributions: [![List of contributors](https://contrib.rocks/image?repo=kovah/linkace)](https://github.com/Kovah/LinkAce/graphs/contributors) -   - LinkAce is a project by [Kevin Woblick](https://woblick.dev) and [Contributors](https://github.com/Kovah/LinkAce/graphs/contributors) diff --git a/app/Console/Commands/DebugConfigCommand.php b/app/Console/Commands/DebugConfigCommand.php new file mode 100644 index 000000000..f2c461a3b --- /dev/null +++ b/app/Console/Commands/DebugConfigCommand.php @@ -0,0 +1,187 @@ +error('This command is only available when APP_DEBUG=true.'); + return self::FAILURE; + } + + $this->line(''); + $this->line('╔══════════════════════════════════════╗'); + $this->line('║ LinkAce Debug Configuration ║'); + $this->line('╚══════════════════════════════════════╝'); + $this->line(''); + + $this->printApplicationInfo(); + $this->printTrustedHostsInfo(); + $this->printTrustedProxiesInfo(); + $this->printDatabaseInfo(); + $this->printSystemRequirements(); + + return self::SUCCESS; + } + + private function resolveVersion(): string + { + try { + $package = json_decode(Storage::disk('root')->get('package.json'), false); + return isset($package->version) ? 'v' . $package->version : 'unknown'; + } catch (Exception) { + return 'unknown'; + } + } + + private function printApplicationInfo(): void + { + $this->line('Application'); + $this->table([], [ + ['LinkAce Version', $this->resolveVersion()], + ['Laravel Version', app()->version()], + ['PHP Version', PHP_VERSION], + ['Environment', config('app.env')], + ['Debug Mode', config('app.debug') ? 'true' : 'false'], + ]); + } + + private function printTrustedHostsInfo(): void + { + $this->line('Trusted Hosts'); + + $appUrl = config('app.url'); + $trustHosts = new TrustHosts(app()); + $patterns = $trustHosts->hosts(); + + $rows = [ + ['APP_URL', $appUrl], + ['Allowed host pattern', implode(', ', array_filter($patterns))], + ]; + + if ($appUrl === 'http://localhost') { + $rows[] = ['', '⚠ APP_URL is set to the default "http://localhost".']; + $rows[] = ['', ' Any request from a different host will be rejected with 400.']; + } elseif (!str_starts_with($appUrl, 'https://')) { + $rows[] = ['', '⚠ APP_URL uses http:// but users may be accessing via https://.']; + $rows[] = ['', ' Requests with a different scheme in the Host header may be blocked.']; + } + + $this->table([], $rows); + } + + private function printTrustedProxiesInfo(): void + { + $this->line('Trusted Proxies'); + + $proxies = config('app.trusted_proxies'); + + $headerMap = [ + Request::HEADER_X_FORWARDED_FOR => 'X-Forwarded-For', + Request::HEADER_X_FORWARDED_HOST => 'X-Forwarded-Host', + Request::HEADER_X_FORWARDED_PORT => 'X-Forwarded-Port', + Request::HEADER_X_FORWARDED_PROTO => 'X-Forwarded-Proto', + Request::HEADER_X_FORWARDED_AWS_ELB => 'X-Forwarded-* (AWS ELB)', + ]; + + $trustedHeaders = array_values(array_filter( + $headerMap, + fn($bit) => ( + (Request::HEADER_X_FORWARDED_FOR | + Request::HEADER_X_FORWARDED_HOST | + Request::HEADER_X_FORWARDED_PORT | + Request::HEADER_X_FORWARDED_PROTO | + Request::HEADER_X_FORWARDED_AWS_ELB) & $bit + ) === $bit, + ARRAY_FILTER_USE_KEY + )); + + $rows = [ + ['TRUSTED_PROXIES', $proxies ?? 'null (none trusted)'], + ['Trusted headers', implode(', ', $trustedHeaders)], + ]; + + if ($proxies === '*') { + $rows[] = ['', '⚠ All proxies are trusted (TRUSTED_PROXIES=*).']; + $rows[] = ['', ' X-Forwarded-Host sent by any upstream proxy is accepted and']; + $rows[] = ['', ' validated against the Trusted Hosts pattern above.']; + $rows[] = ['', ' If your proxy forwards an unexpected host, requests will fail.']; + } elseif ($proxies === null) { + $rows[] = ['', '⚠ No proxies are trusted. If you are behind a reverse proxy,']; + $rows[] = ['', ' set TRUSTED_PROXIES to your proxy IP or subnet.']; + } + + $this->table([], $rows); + } + + private function printDatabaseInfo(): void + { + $this->line('Database'); + + $connection = config('database.default'); + $dbConfig = config('database.connections.' . $connection); + + $rows = [ + ['DB_CONNECTION', $connection], + ]; + + if ($connection === 'sqlite') { + $rows[] = ['DB_DATABASE', $dbConfig['database'] ?? 'n/a']; + } else { + $rows[] = ['DB_HOST', $dbConfig['host'] ?? 'n/a']; + $rows[] = ['DB_PORT', $dbConfig['port'] ?? 'n/a']; + $rows[] = ['DB_DATABASE', $dbConfig['database'] ?? 'n/a']; + } + + $this->table([], $rows); + } + + private function printSystemRequirements(): void + { + $this->line('System Requirements'); + + $ok = '✔ OK'; + $fail = '✘ FAIL'; + + $phpOk = PHP_VERSION_ID >= 80110; + + $extensions = [ + 'bcmath', 'ctype', 'curl', 'dom', 'fileinfo', + 'filter', 'hash', 'json', 'mbstring', 'openssl', + 'pcre', 'session', 'tokenizer', 'xml', + ]; + + $dbExtensions = ['pdo_mysql', 'pdo_pgsql', 'pdo_sqlite']; + + $rows = [['PHP >= 8.1.10', $phpOk ? $ok : $fail . ' (found ' . PHP_VERSION . ')']]; + + foreach ($extensions as $ext) { + $rows[] = ['ext-' . $ext, extension_loaded($ext) ? $ok : $fail]; + } + + $rows[] = ['— Database drivers —', '']; + foreach ($dbExtensions as $ext) { + $rows[] = ['ext-' . $ext, extension_loaded($ext) ? $ok : 'not loaded']; + } + + $rows[] = ['— Filesystem —', '']; + $rows[] = ['.env writable', File::isWritable(base_path('.env')) ? $ok : $fail]; + $rows[] = ['storage/ writable', File::isWritable(storage_path()) ? $ok : $fail]; + $rows[] = ['storage/logs writable', File::isWritable(storage_path('logs')) ? $ok : $fail]; + + $this->table([], $rows); + } +} diff --git a/app/Http/Controllers/API/LinkController.php b/app/Http/Controllers/API/LinkController.php index 2c3c81416..9d3e5c4a0 100644 --- a/app/Http/Controllers/API/LinkController.php +++ b/app/Http/Controllers/API/LinkController.php @@ -60,6 +60,8 @@ public function show(Request $request, ApiLink $link): JsonResponse public function update(LinkUpdateRequest $request, ApiLink $link): JsonResponse { + $this->authorize('update', $link); + $updatedLink = LinkRepository::update($link, $request->all()); return response()->json($updatedLink); diff --git a/app/Http/Controllers/API/ListController.php b/app/Http/Controllers/API/ListController.php index 4f13212f0..2974ef756 100644 --- a/app/Http/Controllers/API/ListController.php +++ b/app/Http/Controllers/API/ListController.php @@ -53,6 +53,8 @@ public function show(ApiLinkList $list): JsonResponse public function update(ListUpdateRequest $request, ApiLinkList $list): JsonResponse { + $this->authorize('update', $list); + $updatedList = ListRepository::update($list, $request->all()); return response()->json($updatedList); diff --git a/app/Http/Controllers/API/NoteController.php b/app/Http/Controllers/API/NoteController.php index a2bc709eb..10742a4f8 100644 --- a/app/Http/Controllers/API/NoteController.php +++ b/app/Http/Controllers/API/NoteController.php @@ -26,6 +26,8 @@ public function store(NoteStoreRequest $request): JsonResponse public function update(NoteUpdateRequest $request, ApiNote $note): JsonResponse { + $this->authorize('update', $note); + $updatedNote = NoteRepository::update($note, $request->validated()); return response()->json($updatedNote); diff --git a/app/Http/Controllers/API/TagController.php b/app/Http/Controllers/API/TagController.php index aa67acfe8..9080b4b16 100644 --- a/app/Http/Controllers/API/TagController.php +++ b/app/Http/Controllers/API/TagController.php @@ -50,6 +50,8 @@ public function show(ApiTag $tag): JsonResponse public function update(TagUpdateRequest $request, ApiTag $tag): JsonResponse { + $this->authorize('update', $tag); + $updatedTag = TagRepository::update($tag, $request->all()); return response()->json($updatedTag); diff --git a/app/Policies/Api/AuthorizesUserApiActions.php b/app/Policies/Api/AuthorizesUserApiActions.php index 9811494b0..44af3094c 100644 --- a/app/Policies/Api/AuthorizesUserApiActions.php +++ b/app/Policies/Api/AuthorizesUserApiActions.php @@ -34,7 +34,7 @@ protected function userCanUpdateModel(User $user, Model $model): bool } return $user->tokenCan($this->updateAbility); } - return $model->visibility !== ModelAttribute::VISIBILITY_PRIVATE; + return false; } protected function userCanDeleteModel(User $user, Model $model): bool diff --git a/app/Policies/LinkListPolicy.php b/app/Policies/LinkListPolicy.php index 5df0c7d28..53faa46e0 100644 --- a/app/Policies/LinkListPolicy.php +++ b/app/Policies/LinkListPolicy.php @@ -28,7 +28,7 @@ public function create(User $user): bool public function update(User $user, LinkList $list): bool { - return $this->userCanAccessList($user, $list); + return $list->user->is($user); } public function delete(User $user, LinkList $list): bool diff --git a/app/Policies/LinkPolicy.php b/app/Policies/LinkPolicy.php index 5d6f3006e..a21df3eb3 100644 --- a/app/Policies/LinkPolicy.php +++ b/app/Policies/LinkPolicy.php @@ -28,7 +28,7 @@ public function create(User $user): bool public function update(User $user, Link $link): bool { - return $this->userCanAccessLink($user, $link); + return $link->user->is($user); } public function delete(User $user, Link $link): bool diff --git a/app/Policies/NotePolicy.php b/app/Policies/NotePolicy.php index c0a1cb559..7f504b35b 100644 --- a/app/Policies/NotePolicy.php +++ b/app/Policies/NotePolicy.php @@ -28,7 +28,7 @@ public function create(User $user): bool public function update(User $user, Note $note): bool { - return $this->userCanAccessNote($user, $note); + return $note->user->is($user); } public function delete(User $user, Note $note): bool diff --git a/app/Policies/TagPolicy.php b/app/Policies/TagPolicy.php index 7cde36798..49657fa1e 100644 --- a/app/Policies/TagPolicy.php +++ b/app/Policies/TagPolicy.php @@ -28,7 +28,7 @@ public function create(User $user): bool public function update(User $user, Tag $tag): bool { - return $this->userCanAccessTag($user, $tag); + return $tag->user->is($user); } public function delete(User $user, Tag $tag): bool diff --git a/app/View/Components/History/ActivityEntry.php b/app/View/Components/History/ActivityEntry.php index f787eafbf..5bf78bb33 100644 --- a/app/View/Components/History/ActivityEntry.php +++ b/app/View/Components/History/ActivityEntry.php @@ -33,7 +33,7 @@ protected function processActivity(): void if ($this->activity->causer() !== null) { $this->changes[] = trans('audit.activity_entry_with_causer', [ 'change' => $change, - 'causer' => $this->activity->causer?->name ?: trans('user.unknown_user'), + 'causer' => htmlspecialchars($this->activity->causer?->name ?: trans('user.unknown_user')), ]); return; } diff --git a/app/View/Components/History/UserEntry.php b/app/View/Components/History/UserEntry.php index dd617e9cb..b58a8ae60 100644 --- a/app/View/Components/History/UserEntry.php +++ b/app/View/Components/History/UserEntry.php @@ -22,15 +22,25 @@ public function render() $timestamp = formatDateTime($this->entry->created_at); if ($this->entry->event === 'deleted') { - $this->changes[] = trans('user.history_deleted', ['name' => $this->entry->getModified()['name']['old']]); + $this->changes[] = trans('user.history_deleted', [ + 'name' => htmlspecialchars($this->entry->getModified()['name']['old']), + ]); } elseif ($this->entry->event === 'restored') { - $this->changes[] = trans('user.history_restored', ['name' => $this->entry->getModified()['name']['new']]); + $this->changes[] = trans('user.history_restored', [ + 'name' => htmlspecialchars($this->entry->getModified()['name']['new']), + ]); } elseif ($this->entry->event === 'created') { - $this->changes[] = trans('user.history_created', ['name' => $this->entry->getModified()['name']['new']]); + $this->changes[] = trans('user.history_created', [ + 'name' => htmlspecialchars($this->entry->getModified()['name']['new']), + ]); } elseif ($this->entry->event === 'blocked') { - $this->changes[] = trans('user.history_blocked', ['name' => $this->entry->auditable->name]); + $this->changes[] = trans('user.history_blocked', [ + 'name' => htmlspecialchars($this->entry->auditable->name), + ]); } elseif ($this->entry->event === 'unblocked') { - $this->changes[] = trans('user.history_unblocked', ['name' => $this->entry->auditable->name]); + $this->changes[] = trans('user.history_unblocked', [ + 'name' => htmlspecialchars($this->entry->auditable->name), + ]); } else { foreach ($this->entry->getModified() as $field => $change) { $this->processChange($field, $change); diff --git a/package-lock.json b/package-lock.json index 07e37adc7..cc7905446 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkace", - "version": "2.5.5", + "version": "2.5.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkace", - "version": "2.5.5", + "version": "2.5.6", "license": "GPL-3.0-or-later", "dependencies": { "bootstrap": "^5.3.3", diff --git a/package.json b/package.json index f55814323..78752d364 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkace", - "version": "2.5.5", + "version": "2.5.6", "description": "A small, selfhosted bookmark manager with advanced features, built with Laravel and Docker", "homepage": "https://github.com/Kovah/LinkAce", "repository": { diff --git a/tests/Components/History/ActivityEntryTest.php b/tests/Components/History/ActivityEntryTest.php new file mode 100644 index 000000000..1e688beaf --- /dev/null +++ b/tests/Components/History/ActivityEntryTest.php @@ -0,0 +1,31 @@ +'; + $user = User::factory()->create(['name' => $payload]); + $activity = Activity::create([ + 'description' => ActivityLog::USER_API_TOKEN_GENERATED, + 'causer_type' => User::class, + 'causer_id' => $user->id, + ]); + + $output = (new ActivityEntry($activity))->render(); + + $this->assertStringNotContainsString($payload, $output); + $this->assertStringContainsString('<img src=x onerror=alert(1)>', $output); + } +} diff --git a/tests/Components/History/UserEntryTest.php b/tests/Components/History/UserEntryTest.php index ca3f219ff..5ca675ebf 100644 --- a/tests/Components/History/UserEntryTest.php +++ b/tests/Components/History/UserEntryTest.php @@ -72,4 +72,16 @@ public function test_model_blocking(): void $output = (new UserEntry($historyEntries[2]))->render(); $this->assertStringContainsString('User TestUser was created', $output); } + + public function test_user_names_are_escaped(): void + { + $payload = ''; + $user = User::factory()->create(['name' => $payload]); + + $historyEntry = $user->audits()->first(); + $output = (new UserEntry($historyEntry))->render(); + + $this->assertStringNotContainsString($payload, $output); + $this->assertStringContainsString('<img src=x onerror=alert(1)>', $output); + } } diff --git a/tests/Controller/API/BulkEditApiTest.php b/tests/Controller/API/BulkEditApiTest.php index a68ef952f..9f7981fca 100644 --- a/tests/Controller/API/BulkEditApiTest.php +++ b/tests/Controller/API/BulkEditApiTest.php @@ -107,6 +107,27 @@ public function test_alternative_links_edit(): void $this->assertEquals(ModelAttribute::VISIBILITY_PRIVATE, $otherLink->visibility); } + public function test_links_edit_skips_visible_links_owned_by_other_users(): void + { + $otherUser = User::factory()->create(); + $otherPublicLink = Link::factory()->for($otherUser)->create(); + $otherInternalLink = Link::factory()->for($otherUser)->create([ + 'visibility' => ModelAttribute::VISIBILITY_INTERNAL, + ]); + + $this->patchJson('api/v2/bulk/links', [ + 'models' => [$otherPublicLink->id, $otherInternalLink->id], + 'tags' => [], + 'tags_mode' => 'append', + 'lists' => [], + 'lists_mode' => 'append', + 'visibility' => ModelAttribute::VISIBILITY_PRIVATE, + ])->assertExactJson([null, null]); + + $this->assertEquals(ModelAttribute::VISIBILITY_PUBLIC, $otherPublicLink->refresh()->visibility); + $this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $otherInternalLink->refresh()->visibility); + } + public function test_lists_edit(): void { Log::shouldReceive('warning')->once(); @@ -128,6 +149,23 @@ public function test_lists_edit(): void $this->assertEquals(ModelAttribute::VISIBILITY_PRIVATE, $otherList->visibility); } + public function test_lists_edit_skips_visible_lists_owned_by_other_users(): void + { + $otherUser = User::factory()->create(); + $otherPublicList = LinkList::factory()->for($otherUser)->create(); + $otherInternalList = LinkList::factory()->for($otherUser)->create([ + 'visibility' => ModelAttribute::VISIBILITY_INTERNAL, + ]); + + $this->patchJson('api/v2/bulk/lists', [ + 'models' => [$otherPublicList->id, $otherInternalList->id], + 'visibility' => ModelAttribute::VISIBILITY_PRIVATE, + ])->assertExactJson([null, null]); + + $this->assertEquals(ModelAttribute::VISIBILITY_PUBLIC, $otherPublicList->refresh()->visibility); + $this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $otherInternalList->refresh()->visibility); + } + public function test_alternative_lists_edit(): void { Log::shouldReceive('warning')->once(); @@ -170,6 +208,23 @@ public function test_tags_edit(): void $this->assertEquals(ModelAttribute::VISIBILITY_PRIVATE, $otherTag->visibility); } + public function test_tags_edit_skips_visible_tags_owned_by_other_users(): void + { + $otherUser = User::factory()->create(); + $otherPublicTag = Tag::factory()->for($otherUser)->create(); + $otherInternalTag = Tag::factory()->for($otherUser)->create([ + 'visibility' => ModelAttribute::VISIBILITY_INTERNAL, + ]); + + $this->patchJson('api/v2/bulk/tags', [ + 'models' => [$otherPublicTag->id, $otherInternalTag->id], + 'visibility' => ModelAttribute::VISIBILITY_PRIVATE, + ])->assertExactJson([null, null]); + + $this->assertEquals(ModelAttribute::VISIBILITY_PUBLIC, $otherPublicTag->refresh()->visibility); + $this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $otherInternalTag->refresh()->visibility); + } + public function test_alternative_tags_edit(): void { Log::shouldReceive('warning')->once(); diff --git a/tests/Controller/API/LinkApiTest.php b/tests/Controller/API/LinkApiTest.php index b2508b602..9f0614bba 100644 --- a/tests/Controller/API/LinkApiTest.php +++ b/tests/Controller/API/LinkApiTest.php @@ -436,7 +436,7 @@ public function test_update_request(): void 'lists' => [$list->id], 'is_private' => false, 'check_disabled' => false, - ])->assertOk()->assertJson(['url' => 'https://new-internal-link.com']); + ])->assertForbidden(); $this->patchJsonAuthorized('api/v2/links/3', [ 'url' => 'https://new-internal-link.com', @@ -446,6 +446,9 @@ public function test_update_request(): void 'is_private' => false, 'check_disabled' => false, ])->assertForbidden(); + + $this->assertEquals('https://internal-link.com', Link::find(2)->url); + $this->assertEquals('https://private-link.com', Link::find(3)->url); } public function test_update_request_with_system_token(): void diff --git a/tests/Controller/API/ListApiTest.php b/tests/Controller/API/ListApiTest.php index f3e42ae49..27c08d53f 100644 --- a/tests/Controller/API/ListApiTest.php +++ b/tests/Controller/API/ListApiTest.php @@ -125,17 +125,16 @@ public function test_update_request(): void 'name' => 'Updated Internal List', 'description' => 'Custom Description', 'visibility' => 1, - ]) - ->assertOk() - ->assertJson([ - 'name' => 'Updated Internal List', - ]); + ])->assertForbidden(); $this->patchJsonAuthorized('api/v2/lists/3', [ 'name' => 'Updated Internal List', 'description' => 'Custom Description', 'visibility' => 1, ])->assertForbidden(); + + $this->assertEquals('Internal List', LinkList::find(2)->name); + $this->assertEquals('Private List', LinkList::find(3)->name); } public function test_invalid_update_request(): void diff --git a/tests/Controller/API/NoteApiTest.php b/tests/Controller/API/NoteApiTest.php index 7d0348212..aba51b73b 100644 --- a/tests/Controller/API/NoteApiTest.php +++ b/tests/Controller/API/NoteApiTest.php @@ -59,10 +59,17 @@ public function test_invalid_create_request(): void public function test_update_request(): void { - $this->createTestLinks(); + $testData = $this->createTestLinks(); + $otherUser = $testData[3]; Note::factory()->create(['link_id' => 1]); - Note::factory()->create(['link_id' => 2]); // Note for internal link of other user - Note::factory()->create(['link_id' => 3]); // Note for private link of other user + Note::factory()->for($otherUser)->create([ + 'link_id' => 2, + 'note' => 'Internal Note', + ]); // Note for internal link of other user + Note::factory()->for($otherUser)->create([ + 'link_id' => 3, + 'note' => 'Private Note', + ]); // Note for private link of other user $this->patchJsonAuthorized('api/v2/notes/1', [ 'note' => 'Gallia est omnis divisa in partes tres, quarum.', @@ -80,16 +87,15 @@ public function test_update_request(): void $this->patchJsonAuthorized('api/v2/notes/2', [ 'note' => 'Gallia est omnis divisa in partes tres, quarum.', 'visibility' => 1, - ]) - ->assertOk() - ->assertJson([ - 'note' => 'Gallia est omnis divisa in partes tres, quarum.', - ]); + ])->assertForbidden(); $this->patchJsonAuthorized('api/v2/notes/3', [ 'note' => 'Gallia est omnis divisa in partes tres, quarum.', 'visibility' => 1, ])->assertForbidden(); + + $this->assertEquals('Internal Note', Note::find(2)->note); + $this->assertEquals('Private Note', Note::find(3)->note); } public function test_invalid_update_request(): void diff --git a/tests/Controller/API/TagApiTest.php b/tests/Controller/API/TagApiTest.php index 0daa44c02..e7189a417 100644 --- a/tests/Controller/API/TagApiTest.php +++ b/tests/Controller/API/TagApiTest.php @@ -124,17 +124,16 @@ public function test_update_request(): void $this->patchJsonAuthorized('api/v2/tags/2', [ 'name' => 'Updated Internal Tag', 'visibility' => 1, - ]) - ->assertOk() - ->assertJson([ - 'name' => 'Updated Internal Tag', - ]); + ])->assertForbidden(); $this->patchJsonAuthorized('api/v2/tags/3', [ 'name' => 'Updated Private Tag', 'visibility' => 1, ]) ->assertForbidden(); + + $this->assertEquals('Internal Tag', Tag::find(2)->name); + $this->assertEquals('Private Tag', Tag::find(3)->name); } public function test_invalid_update_request(): void diff --git a/tests/Controller/App/AuditControllerTest.php b/tests/Controller/App/AuditControllerTest.php index 94eb51fa3..77a5ec5bc 100644 --- a/tests/Controller/App/AuditControllerTest.php +++ b/tests/Controller/App/AuditControllerTest.php @@ -2,9 +2,11 @@ namespace Tests\Controller\App; +use App\Enums\ActivityLog; use App\Enums\Role; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Spatie\Activitylog\Models\Activity; use Tests\TestCase; class AuditControllerTest extends TestCase @@ -39,4 +41,22 @@ public function test_audit_page_with_entries(): void $response = $this->get('system/audit'); $response->assertSee('System: Cron Token was re-generated'); } + + public function test_audit_page_escapes_activity_causer_name(): void + { + $this->user->assignRole(Role::ADMIN); + $payload = ''; + $attacker = User::factory()->create(['name' => $payload]); + Activity::create([ + 'description' => ActivityLog::USER_API_TOKEN_GENERATED, + 'causer_type' => User::class, + 'causer_id' => $attacker->id, + ]); + + $response = $this->get('system/audit'); + + $response->assertOk(); + $response->assertDontSee($payload, false); + $response->assertSee('<img src=x onerror=alert(1)>', false); + } } diff --git a/tests/Controller/Models/BulkEditControllerTest.php b/tests/Controller/Models/BulkEditControllerTest.php index 4a6fd2a31..f1bf7e949 100644 --- a/tests/Controller/Models/BulkEditControllerTest.php +++ b/tests/Controller/Models/BulkEditControllerTest.php @@ -119,6 +119,32 @@ public function test_links_edit_without_taxonomy(): void $this->assertEmpty($links[2]->tags()->pluck('id')->toArray()); } + public function test_links_edit_skips_visible_links_owned_by_other_users(): void + { + $otherUser = User::factory()->create(); + $otherPublicLink = Link::factory()->for($otherUser)->create([ + 'url' => 'https://other-public-link.com', + ]); + $otherInternalLink = Link::factory()->for($otherUser)->create([ + 'url' => 'https://other-internal-link.com', + 'visibility' => ModelAttribute::VISIBILITY_INTERNAL, + ]); + + $this->post('bulk-edit/update-links', [ + 'models' => $otherPublicLink->id . ',' . $otherInternalLink->id, + 'tags' => json_encode([]), + 'tags_mode' => 'append', + 'lists' => json_encode([]), + 'lists_mode' => 'append', + 'visibility' => ModelAttribute::VISIBILITY_PRIVATE, + ]) + ->assertRedirect('links') + ->assertSessionHas('flash_notification.0.message', 'Successfully updated 0 Links out of 2 selected ones.'); + + $this->assertEquals(ModelAttribute::VISIBILITY_PUBLIC, $otherPublicLink->refresh()->visibility); + $this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $otherInternalLink->refresh()->visibility); + } + public function test_alternative_links_edit(): void { Log::shouldReceive('warning')->once(); @@ -177,6 +203,25 @@ public function test_lists_edit(): void $this->assertEquals(ModelAttribute::VISIBILITY_PRIVATE, $otherList->visibility); } + public function test_lists_edit_skips_visible_lists_owned_by_other_users(): void + { + $otherUser = User::factory()->create(); + $otherPublicList = LinkList::factory()->for($otherUser)->create(); + $otherInternalList = LinkList::factory()->for($otherUser)->create([ + 'visibility' => ModelAttribute::VISIBILITY_INTERNAL, + ]); + + $this->post('bulk-edit/update-lists', [ + 'models' => $otherPublicList->id . ',' . $otherInternalList->id, + 'visibility' => ModelAttribute::VISIBILITY_PRIVATE, + ]) + ->assertRedirect('lists') + ->assertSessionHas('flash_notification.0.message', 'Successfully updated 0 Lists out of 2 selected ones.'); + + $this->assertEquals(ModelAttribute::VISIBILITY_PUBLIC, $otherPublicList->refresh()->visibility); + $this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $otherInternalList->refresh()->visibility); + } + public function test_alternative_lists_edit(): void { Log::shouldReceive('warning')->once(); @@ -223,6 +268,25 @@ public function test_tags_edit(): void $this->assertEquals(ModelAttribute::VISIBILITY_PRIVATE, $otherTag->visibility); } + public function test_tags_edit_skips_visible_tags_owned_by_other_users(): void + { + $otherUser = User::factory()->create(); + $otherPublicTag = Tag::factory()->for($otherUser)->create(); + $otherInternalTag = Tag::factory()->for($otherUser)->create([ + 'visibility' => ModelAttribute::VISIBILITY_INTERNAL, + ]); + + $this->post('bulk-edit/update-tags', [ + 'models' => $otherPublicTag->id . ',' . $otherInternalTag->id, + 'visibility' => ModelAttribute::VISIBILITY_PRIVATE, + ]) + ->assertRedirect('tags') + ->assertSessionHas('flash_notification.0.message', 'Successfully updated 0 Tags out of 2 selected ones.'); + + $this->assertEquals(ModelAttribute::VISIBILITY_PUBLIC, $otherPublicTag->refresh()->visibility); + $this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $otherInternalTag->refresh()->visibility); + } + public function test_alternative_tags_edit(): void { Log::shouldReceive('warning')->once(); diff --git a/tests/Controller/Models/LinkControllerTest.php b/tests/Controller/Models/LinkControllerTest.php index a833b3843..c459eeda6 100644 --- a/tests/Controller/Models/LinkControllerTest.php +++ b/tests/Controller/Models/LinkControllerTest.php @@ -383,7 +383,7 @@ public function test_edit_view(): void $this->createTestLinks(); $this->get('links/1/edit')->assertOk()->assertSee('https://public-link.com'); - $this->get('links/2/edit')->assertOk()->assertSee('https://internal-link.com'); + $this->get('links/2/edit')->assertForbidden(); $this->get('links/3/edit')->assertForbidden(); } @@ -427,7 +427,7 @@ public function test_update_response(): void 'tags' => null, 'visibility' => 1, 'check_disabled' => '0', - ])->assertRedirect('links/2'); + ])->assertForbidden(); $this->patch('links/3', [ 'url' => 'https://private-link.com', @@ -438,6 +438,9 @@ public function test_update_response(): void 'visibility' => 1, 'check_disabled' => '0', ])->assertForbidden(); + + $this->assertEquals('https://internal-link.com', Link::find(2)->url); + $this->assertEquals('https://private-link.com', Link::find(3)->url); } public function test_update_with_malicious_url(): void @@ -537,7 +540,7 @@ public function test_check_toggle_request(): void // Check other links $this->post('links/toggle-check/2', [ 'toggle' => '1', - ])->assertRedirect('links/2'); + ])->assertForbidden(); $this->post('links/toggle-check/3', ['toggle' => '1'])->assertForbidden(); } @@ -559,7 +562,7 @@ public function test_mark_working_request(): void $link = Link::first(); $this->post('links/mark-working/1')->assertRedirect('links/1'); - $this->post('links/mark-working/2')->assertRedirect('links/2'); + $this->post('links/mark-working/2')->assertForbidden(); $this->post('links/mark-working/3')->assertForbidden(); $this->assertEquals(Link::STATUS_OK, $link->refresh()->status); diff --git a/tests/Controller/Models/ListControllerTest.php b/tests/Controller/Models/ListControllerTest.php index 1d200a871..130f04494 100644 --- a/tests/Controller/Models/ListControllerTest.php +++ b/tests/Controller/Models/ListControllerTest.php @@ -206,7 +206,7 @@ public function test_edit_view(): void $this->createTestLists(); $this->get('lists/1/edit')->assertOk()->assertSee('Public List')->assertSee('Edit List'); - $this->get('lists/2/edit')->assertOk()->assertSee('Internal List')->assertSee('Edit List'); + $this->get('lists/2/edit')->assertForbidden(); $this->get('lists/3/edit')->assertForbidden(); } @@ -233,13 +233,16 @@ public function test_update_response(): void 'list_id' => 2, 'name' => 'New Internal List', 'visibility' => 1, - ])->assertRedirect('lists/2'); + ])->assertForbidden(); $this->patch('lists/3', [ 'list_id' => $list->id, 'name' => 'New Test List', 'visibility' => 1, ])->assertForbidden(); + + $this->assertEquals('Internal List', LinkList::find(2)->name); + $this->assertEquals('Private List', LinkList::find(3)->name); } public function test_missing_model_error_for_update(): void diff --git a/tests/Controller/Models/NoteControllerTest.php b/tests/Controller/Models/NoteControllerTest.php index a29bc5c75..40b9622f5 100644 --- a/tests/Controller/Models/NoteControllerTest.php +++ b/tests/Controller/Models/NoteControllerTest.php @@ -162,6 +162,36 @@ public function test_update_response(): void $this->assertEquals('Lorem ipsum dolor est updated', $note->refresh()->note); } + public function test_other_users_visible_notes_cannot_be_updated(): void + { + $link = Link::factory()->create(); + $otherUser = User::factory()->create(); + $publicNote = Note::factory()->for($otherUser)->create([ + 'link_id' => $link->id, + 'note' => 'Original public note', + ]); + $internalNote = Note::factory()->for($otherUser)->create([ + 'link_id' => $link->id, + 'note' => 'Original internal note', + 'visibility' => 2, + ]); + + $this->patch('notes/' . $publicNote->id, [ + 'link_id' => $link->id, + 'note' => 'Updated public note', + 'visibility' => 1, + ])->assertForbidden(); + + $this->patch('notes/' . $internalNote->id, [ + 'link_id' => $link->id, + 'note' => 'Updated internal note', + 'visibility' => 1, + ])->assertForbidden(); + + $this->assertEquals('Original public note', $publicNote->refresh()->note); + $this->assertEquals('Original internal note', $internalNote->refresh()->note); + } + public function test_missing_model_error_for_update(): void { $this->patch('notes/1', [ diff --git a/tests/Controller/Models/TagControllerTest.php b/tests/Controller/Models/TagControllerTest.php index ceb112ee6..77f1a9286 100644 --- a/tests/Controller/Models/TagControllerTest.php +++ b/tests/Controller/Models/TagControllerTest.php @@ -197,7 +197,7 @@ public function test_edit_view(): void $this->createTestTags(); $this->get('tags/1/edit')->assertOk()->assertSee('Public Tag'); - $this->get('tags/2/edit')->assertOk()->assertSee('Internal Tag'); + $this->get('tags/2/edit')->assertForbidden(); $this->get('tags/3/edit')->assertForbidden(); } @@ -224,13 +224,16 @@ public function test_update_response(): void 'tag_id' => 2, 'name' => 'New Internal Tag', 'visibility' => 1, - ])->assertRedirect('tags/2'); + ])->assertForbidden(); $this->patch('tags/3', [ 'tag_id' => 3, 'name' => 'New Private Tag', 'visibility' => 1, ])->assertForbidden(); + + $this->assertEquals('Internal Tag', Tag::find(2)->name); + $this->assertEquals('Private Tag', Tag::find(3)->name); } public function test_missing_model_error_for_update(): void