VYPR
High severityNVD Advisory· Published May 28, 2026

CVE-2026-45343

CVE-2026-45343

Description

LinkAce is a self-hosted archive to collect website links. Prior to 2.5.6, LinkAce contains a stored cross-site scripting vulnerability that allows a low-privilege user to execute arbitrary JavaScript in an administrator's browser session. This affects instances configured with SSO/OAuth authentication, which is one of the supported authentication methods in LinkAce. An attacker who sets their OAuth display name to a malicious script and then creates an API token will plant a persistent XSS payload in the audit log. When any admin navigates to /system/audit, the payload executes in the admin's browser context. This enables session cookie theft, CSRF token exfiltration (exposed in the la-app-data meta tag), or any other action the admin can perform. This vulnerability is fixed in 2.5.6.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Stored XSS in LinkAce ≤2.5.5 allows a low-privilege SSO user to execute arbitrary JavaScript in the admin audit log, leading to session theft.

Vulnerability

LinkAce versions prior to 2.5.5 contain a stored cross-site scripting vulnerability in the admin audit log. When SSO/OAuth authentication is used, the SocialiteController.php stores the OAuth provider's display name directly into the users.name column via $authUser->getNickname() or Str::studly($authUser->getName()) without sanitization [1]. The SSO callback bypasses the normal alpha_dash validation applied by UpdateUserRequest [1]. When the user performs any activity-logged action (e.g., creating or revoking an API token), the ActivityEntry component inserts the causer's name into a translation string without calling htmlspecialchars(), and the result is rendered in history-entry.blade.php using unescaped {!! $change !!} [1]. The vulnerability is fixed in version 2.5.6.

Exploitation

An attacker must have a valid account on a LinkAce instance configured with SSO/OAuth authentication. The attacker sets their OAuth display name to a malicious script (e.g., `) via their OAuth provider, then creates an API token to trigger an activity log entry [1]. When any administrator navigates to /system/audit`, the unescaped payload executes in the administrator's browser context [1]. No further user interaction is required beyond the admin viewing the audit log.

Impact

Successful exploitation allows the attacker to execute arbitrary JavaScript in the administrator's session. This enables session cookie theft, exfiltration of CSRF tokens (exposed in the la-app-data meta tag), or any action the administrator can perform, including full account compromise and data exfiltration [1].

Mitigation

Upgrade to LinkAce version 2.5.6, which fixes the vulnerability [1]. There is no official workaround published for versions prior to 2.5.6. The vulnerability is not listed in CISA's KEV catalog as of this writing.

AI Insight generated on May 28, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

3
8ec9d0abf6e9

Properly escape user names in activity logs

https://github.com/kovah/linkaceKevin WoblickMay 9, 2026Fixed in 2.5.6via llm-release-walk
5 files changed · +79 6
  • app/View/Components/History/ActivityEntry.php+1 1 modified
    @@ -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;
             }
    
  • app/View/Components/History/UserEntry.php+15 5 modified
    @@ -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);
    
  • tests/Components/History/ActivityEntryTest.php+31 0 added
    @@ -0,0 +1,31 @@
    +<?php
    +
    +namespace Tests\Components\History;
    +
    +use App\Enums\ActivityLog;
    +use App\Models\User;
    +use App\View\Components\History\ActivityEntry;
    +use Illuminate\Foundation\Testing\RefreshDatabase;
    +use Spatie\Activitylog\Models\Activity;
    +use Tests\TestCase;
    +
    +class ActivityEntryTest extends TestCase
    +{
    +    use RefreshDatabase;
    +
    +    public function test_causer_name_is_escaped(): void
    +    {
    +        $payload = '<img src=x onerror=alert(1)>';
    +        $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('&lt;img src=x onerror=alert(1)&gt;', $output);
    +    }
    +}
    
  • tests/Components/History/UserEntryTest.php+12 0 modified
    @@ -72,4 +72,16 @@ public function test_model_blocking(): void
             $output = (new UserEntry($historyEntries[2]))->render();
             $this->assertStringContainsString('User <code>TestUser</code> was created', $output);
         }
    +
    +    public function test_user_names_are_escaped(): void
    +    {
    +        $payload = '<img src=x onerror=alert(1)>';
    +        $user = User::factory()->create(['name' => $payload]);
    +
    +        $historyEntry = $user->audits()->first();
    +        $output = (new UserEntry($historyEntry))->render();
    +
    +        $this->assertStringNotContainsString($payload, $output);
    +        $this->assertStringContainsString('&lt;img src=x onerror=alert(1)&gt;', $output);
    +    }
     }
    
  • tests/Controller/App/AuditControllerTest.php+20 0 modified
    @@ -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 = '<img src=x onerror=alert(1)>';
    +        $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('&lt;img src=x onerror=alert(1)&gt;', false);
    +    }
     }
    
2c121b4b179a

Prohibit users from editing entities that do not belong to them

https://github.com/kovah/linkaceKevin WoblickMay 9, 2026Fixed in 2.5.6via llm-release-walk
19 files changed · +205 32
  • app/Http/Controllers/API/LinkController.php+2 0 modified
    @@ -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);
    
  • app/Http/Controllers/API/ListController.php+2 0 modified
    @@ -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);
    
  • app/Http/Controllers/API/NoteController.php+2 0 modified
    @@ -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);
    
  • app/Http/Controllers/API/TagController.php+2 0 modified
    @@ -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);
    
  • app/Policies/Api/AuthorizesUserApiActions.php+1 1 modified
    @@ -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
    
  • app/Policies/LinkListPolicy.php+1 1 modified
    @@ -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
    
  • app/Policies/LinkPolicy.php+1 1 modified
    @@ -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
    
  • app/Policies/NotePolicy.php+1 1 modified
    @@ -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
    
  • app/Policies/TagPolicy.php+1 1 modified
    @@ -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
    
  • tests/Controller/API/BulkEditApiTest.php+55 0 modified
    @@ -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();
    
  • tests/Controller/API/LinkApiTest.php+4 1 modified
    @@ -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
    
  • tests/Controller/API/ListApiTest.php+4 5 modified
    @@ -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
    
  • tests/Controller/API/NoteApiTest.php+14 8 modified
    @@ -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
    
  • tests/Controller/API/TagApiTest.php+4 5 modified
    @@ -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
    
  • tests/Controller/Models/BulkEditControllerTest.php+64 0 modified
    @@ -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();
    
  • tests/Controller/Models/LinkControllerTest.php+7 4 modified
    @@ -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);
    
  • tests/Controller/Models/ListControllerTest.php+5 2 modified
    @@ -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
    
  • tests/Controller/Models/NoteControllerTest.php+30 0 modified
    @@ -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', [
    
  • tests/Controller/Models/TagControllerTest.php+5 2 modified
    @@ -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
    
8ec9d0abf6e9
https://github.com/kovah/linkaceFixed in 2.5.6via llm-release-walk

Vulnerability mechanics

Root cause

"Missing HTML escaping of the OAuth display name in the ActivityEntry component combined with lack of input sanitization in the SSO callback allows stored XSS."

Attack vector

An attacker authenticates via SSO/OAuth into a LinkAce instance that has SSO enabled, setting their OAuth display name to a malicious script such as `<img src=x onerror=alert(document.cookie)>` [ref_id=1]. The name is stored unsanitized in the `users.name` column. The attacker then performs any activity-logged action (e.g., creating an API token), which writes an entry to the `activity_log` table with the attacker as the causer [ref_id=1]. When an administrator navigates to `/system/audit`, the `ActivityEntry` component renders the causer's name without HTML escaping, causing the payload to execute in the admin's browser context [ref_id=1]. This enables session cookie theft, CSRF token exfiltration from the `la-app-data` meta tag, or any other action the admin can perform [ref_id=1].

Affected code

The vulnerability spans two code paths. In `app/Http/Controllers/SocialiteController.php` (lines 36 and 61), the SSO callback stores the OAuth provider's display name directly via `$user->update(['name' => $authUser->getNickname() ...])` without sanitization, bypassing the `alpha_dash` validation that `UpdateUserRequest` normally enforces [ref_id=1]. In `app/View/Components/History/ActivityEntry.php` (line 36), the causer's name is interpolated into a translation string without escaping: `'causer' => $this->activity->causer?->name` [ref_id=1]. The result is rendered in `resources/views/components/history-entry.blade.php` (line 2) via `{!! $change !!}`, which outputs raw HTML without escaping [ref_id=1].

What the fix does

The advisory recommends two fixes [ref_id=1]. First, in `ActivityEntry.php` line 36, the causer name should be passed through `htmlspecialchars()` before interpolation into the translation string, matching the escaping pattern already used in `ProcessesHistory::processChange()`. Second, the SSO callback in `SocialiteController.php` should validate or sanitize the OAuth display name before storing it, applying the same `alpha_dash` constraint used by `UpdateUserRequest` to close the vector at the input layer. The advisory notes that `ProcessesHistory` correctly applies `htmlspecialchars()` to old and new values, confirming the missing escaping in `ActivityEntry` was an oversight [ref_id=1].

Preconditions

  • configLinkAce instance must be configured with SSO/OAuth authentication enabled
  • authAttacker must be able to authenticate via SSO and set their OAuth display name to a malicious value
  • inputAn administrator must navigate to /system/audit to trigger the stored payload

Reproduction

1. Deploy LinkAce v2.5.5 with SSO/OAuth enabled (e.g., GitHub). Create an admin account via the setup wizard and ensure at least one SSO provider is configured [ref_id=1]. 2. As the attacker, set your OAuth provider display name (e.g., GitHub nickname, Google name) to `<img src=x onerror=alert(document.cookie)>` before authenticating [ref_id=1]. 3. Authenticate via SSO into the LinkAce instance. The malicious name is stored in the `users.name` column without sanitization [ref_id=1]. 4. Navigate to Settings > API Tokens and create a new API token. This writes an entry to the `activity_log` table with the attacker as the causer [ref_id=1]. 5. As the admin, navigate to `/system/audit`. The audit page renders the activity entry with the attacker's unsanitized name, and the JavaScript payload executes in the admin's browser [ref_id=1].

Generated on May 28, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

1

News mentions

0

No linked articles in our index yet.