From c4ce7457d8ba9b65746bc657f5b610ff71107f98 Mon Sep 17 00:00:00 2001 From: Khoi Date: Tue, 10 Mar 2026 23:17:31 +0700 Subject: [PATCH 1/5] Your commit message --- SECURITY_FIX_CWE285.md | 256 ++++++++++++++++++ .../Resources/Articles/ArticleResource.php | 13 + .../Resources/Media/MediaResource.php | 13 + app/Filament/Resources/Pages/PageResource.php | 13 + app/Models/Article.php | 11 + app/Models/Media.php | 43 +++ app/Models/Page.php | 11 + app/Models/User.php | 16 ++ app/Policies/ArticlePolicy.php | 67 +++++ app/Policies/MediaPolicy.php | 67 +++++ app/Policies/PagePolicy.php | 67 +++++ app/Providers/AppServiceProvider.php | 23 ++ config/media-library.php | 8 + ...user_id_to_articles_pages_media_tables.php | 56 ++++ database/seeders/ContentSeeder.php | 5 +- git | 0 16 files changed, 667 insertions(+), 2 deletions(-) create mode 100644 SECURITY_FIX_CWE285.md create mode 100644 app/Models/Media.php create mode 100644 app/Policies/ArticlePolicy.php create mode 100644 app/Policies/MediaPolicy.php create mode 100644 app/Policies/PagePolicy.php create mode 100644 config/media-library.php create mode 100644 database/migrations/2026_03_10_000000_add_user_id_to_articles_pages_media_tables.php create mode 100644 git diff --git a/SECURITY_FIX_CWE285.md b/SECURITY_FIX_CWE285.md new file mode 100644 index 000000000..4f781cc50 --- /dev/null +++ b/SECURITY_FIX_CWE285.md @@ -0,0 +1,256 @@ +# Security Fix: Broken Access Control (CWE-285) + +## Vulnerability Description + +**CVE-ID**: Pending assignment +**Severity**: HIGH (CVSS Score: 8.1) +**CWE**: CWE-285 - Improper Authorization +**Date Discovered**: March 10, 2026 +**Status**: FIXED + +### Summary + +A critical Broken Access Control vulnerability was discovered in the Simple CMS application. The vulnerability allowed authenticated users with Editor role to: + +1. **View** articles, pages, and media belonging to other users +2. **Edit** content created by other users +3. **Delete** resources owned by other users + +This violates the principle of least privilege and allows horizontal privilege escalation. + +### Attack Scenario + +``` +1. User A (Editor) creates Article #1 +2. User B (Editor) creates Article #2 +3. User B could access /admin/articles/1/edit +4. User B could modify or delete Article #1 (owned by User A) +5. No ownership validation was performed +``` + +### Impact + +- **Confidentiality**: HIGH - Users could access private content of others +- **Integrity**: HIGH - Users could modify content they don't own +- **Availability**: HIGH - Users could delete other users' content + +### Affected Components + +- `app/Models/Article.php` - No ownership tracking +- `app/Models/Page.php` - No ownership tracking +- `Spatie\MediaLibrary\MediaCollections\Models\Media` - No ownership tracking +- `app/Filament/Resources/Articles/ArticleResource.php` - No query scoping +- `app/Filament/Resources/Pages/PageResource.php` - No query scoping +- `app/Filament/Resources/Media/MediaResource.php` - No query scoping + +## Fix Implementation + +### 1. Database Schema Updates + +**Migration**: `2026_03_10_000000_add_user_id_to_articles_pages_media_tables.php` + +Added `user_id` foreign key to: +- `articles` table +- `pages` table +- `media` table + +### 2. Model Updates + +#### Article Model +- Added `user_id` to `$fillable` +- Added `user()` relationship +- Auto-assign `user_id` on creation via `booted()` method + +#### Page Model +- Added `user_id` to `$fillable` +- Added `user()` relationship +- Auto-assign `user_id` on creation via `booted()` method + +#### Media Model (Custom) +- Created custom `App\Models\Media` extending Spatie's Media +- Added `user_id` to `$fillable` +- Added `user()` relationship +- Auto-assign `user_id` on creation via `booted()` method + +#### User Model +- Added `articles()`, `pages()`, `media()` relationships + +### 3. Authorization Policies + +Created three Laravel Policies: + +#### ArticlePolicy +```php +public function update(User $user, Article $article): bool +{ + return $user->isAdmin() || $article->user_id === $user->id; +} + +public function delete(User $user, Article $article): bool +{ + return $user->isAdmin() || $article->user_id === $user->id; +} +``` + +#### PagePolicy +```php +public function update(User $user, Page $page): bool +{ + return $user->isAdmin() || $page->user_id === $user->id; +} + +public function delete(User $user, Page $page): bool +{ + return $user->isAdmin() || $page->user_id === $user->id; +} +``` + +#### MediaPolicy +```php +public function update(User $user, Media $media): bool +{ + return $user->isAdmin() || $media->user_id === $user->id; +} + +public function delete(User $user, Media $media): bool +{ + return $user->isAdmin() || $media->user_id === $user->id; +} +``` + +### 4. Filament Resource Scoping + +Updated all three Resources with `getEloquentQuery()` method: + +```php +public static function getEloquentQuery(): Builder +{ + $query = parent::getEloquentQuery(); + + // Non-admin users can only see their own records + if (auth()->check() && !auth()->user()->isAdmin()) { + $query->where('user_id', auth()->id()); + } + + return $query; +} +``` + +### 5. Policy Registration + +Updated `AppServiceProvider` to register policies: + +```php +protected $policies = [ + Article::class => ArticlePolicy::class, + Page::class => PagePolicy::class, + Media::class => MediaPolicy::class, +]; + +public function boot(): void +{ + foreach ($this->policies as $model => $policy) { + Gate::policy($model, $policy); + } +} +``` + +## Verification Steps + +### 1. Run Migration + +```bash +php artisan migrate +``` + +### 2. Test Authorization + +#### Test Case 1: Editor Cannot Edit Other's Article +```php +// As User B (Editor) +$articleByUserA = Article::where('user_id', 1)->first(); +$response = $this->actingAs($userB)->put("/admin/articles/{$articleByUserA->id}", [ + 'title' => 'Hacked!', +]); +$response->assertForbidden(); // Should return 403 +``` + +#### Test Case 2: Editor Cannot Delete Other's Page +```php +// As User B (Editor) +$pageByUserA = Page::where('user_id', 1)->first(); +$response = $this->actingAs($userB)->delete("/admin/pages/{$pageByUserA->id}"); +$response->assertForbidden(); // Should return 403 +``` + +#### Test Case 3: Editor Can Only See Own Records +```php +// As User B (Editor) - should only see their own articles +$this->actingAs($userB); +$articles = Article::all(); // Query is scoped +$this->assertTrue($articles->every(fn($a) => $a->user_id === $userB->id)); +``` + +#### Test Case 4: Admin Can Access Everything +```php +// As Admin +$this->actingAs($admin); +$allArticles = Article::all(); // No scoping +$this->assertTrue($allArticles->count() > 0); +``` + +## Security Best Practices Applied + +✅ **Principle of Least Privilege**: Users can only access their own resources +✅ **Defense in Depth**: Multiple layers (Query scoping + Policies) +✅ **Secure by Default**: `user_id` auto-assigned on creation +✅ **Clear Separation**: Admin vs Editor authorization +✅ **Database Constraints**: Foreign key enforces referential integrity + +## Remaining Considerations + +### 1. Existing Data Migration + +For existing records without `user_id`, you may need to: + +```php +// Assign all existing records to first admin +$admin = User::where('role', UserRole::Admin)->first(); +Article::whereNull('user_id')->update(['user_id' => $admin->id]); +Page::whereNull('user_id')->update(['user_id' => $admin->id]); +DB::table('media')->whereNull('user_id')->update(['user_id' => $admin->id]); +``` + +### 2. API Endpoints + +If the application exposes REST APIs, ensure: +- API authentication is enforced +- Same authorization policies apply +- Rate limiting is configured + +### 3. Testing + +Recommended tests to add: +- `tests/Feature/ArticleAuthorizationTest.php` +- `tests/Feature/PageAuthorizationTest.php` +- `tests/Feature/MediaAuthorizationTest.php` + +### 4. Audit Logging + +Consider adding audit logs to track: +- Who accessed what resources +- Failed authorization attempts +- Administrative overrides + +## References + +- **CWE-285**: Improper Authorization - https://cwe.mitre.org/data/definitions/285.html +- **OWASP Top 10 2021**: A01:2021 – Broken Access Control +- **Laravel Authorization**: https://laravel.com/docs/authorization +- **Filament Authorization**: https://filamentphp.com/docs/panels/resources/authorization + +## Credits + +- Discovered by: [Your Name] +- Fixed by: [Your Name] +- Date: March 10, 2026 diff --git a/app/Filament/Resources/Articles/ArticleResource.php b/app/Filament/Resources/Articles/ArticleResource.php index 500f5891d..a07389a37 100644 --- a/app/Filament/Resources/Articles/ArticleResource.php +++ b/app/Filament/Resources/Articles/ArticleResource.php @@ -13,6 +13,7 @@ use Filament\Schemas\Schema; use Filament\Support\Icons\Heroicon; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; class ArticleResource extends Resource { @@ -30,6 +31,18 @@ public static function table(Table $table): Table return ArticlesTable::configure($table); } + public static function getEloquentQuery(): Builder + { + $query = parent::getEloquentQuery(); + + // Non-admin users can only see their own articles + if (auth()->check() && !auth()->user()->isAdmin()) { + $query->where('user_id', auth()->id()); + } + + return $query; + } + public static function getRelations(): array { return [ diff --git a/app/Filament/Resources/Media/MediaResource.php b/app/Filament/Resources/Media/MediaResource.php index 89c391946..38a461af1 100644 --- a/app/Filament/Resources/Media/MediaResource.php +++ b/app/Filament/Resources/Media/MediaResource.php @@ -8,6 +8,7 @@ use Filament\Resources\Resource; use Filament\Support\Icons\Heroicon; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; use Spatie\MediaLibrary\MediaCollections\Models\Media; class MediaResource extends Resource @@ -29,6 +30,18 @@ public static function table(Table $table): Table return MediaTable::configure($table); } + public static function getEloquentQuery(): Builder + { + $query = parent::getEloquentQuery(); + + // Non-admin users can only see their own media + if (auth()->check() && !auth()->user()->isAdmin()) { + $query->where('user_id', auth()->id()); + } + + return $query; + } + public static function getPages(): array { return [ diff --git a/app/Filament/Resources/Pages/PageResource.php b/app/Filament/Resources/Pages/PageResource.php index 705c539cb..fa6780fc4 100644 --- a/app/Filament/Resources/Pages/PageResource.php +++ b/app/Filament/Resources/Pages/PageResource.php @@ -13,6 +13,7 @@ use Filament\Schemas\Schema; use Filament\Support\Icons\Heroicon; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; class PageResource extends Resource { @@ -30,6 +31,18 @@ public static function table(Table $table): Table return PagesTable::configure($table); } + public static function getEloquentQuery(): Builder + { + $query = parent::getEloquentQuery(); + + // Non-admin users can only see their own pages + if (auth()->check() && !auth()->user()->isAdmin()) { + $query->where('user_id', auth()->id()); + } + + return $query; + } + public static function getRelations(): array { return [ diff --git a/app/Models/Article.php b/app/Models/Article.php index 4e6411d48..51778595f 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -11,6 +11,7 @@ class Article extends Model { protected $fillable = [ + 'user_id', 'category_id', 'title', 'slug', @@ -29,6 +30,11 @@ class Article extends Model protected static function booted(): void { static::creating(function (Article $article) { + // Auto-assign current user ID when creating + if (empty($article->user_id) && auth()->check()) { + $article->user_id = auth()->id(); + } + if (empty($article->slug)) { $baseSlug = Str::slug($article->title); $slug = $baseSlug; @@ -52,6 +58,11 @@ public function category(): BelongsTo return $this->belongsTo(Category::class); } + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + public function views(): HasMany { return $this->hasMany(ArticleView::class); diff --git a/app/Models/Media.php b/app/Models/Media.php new file mode 100644 index 000000000..d9fb69a7b --- /dev/null +++ b/app/Models/Media.php @@ -0,0 +1,43 @@ +user_id) && auth()->check()) { + $media->user_id = auth()->id(); + } + }); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Page.php b/app/Models/Page.php index 4a986d495..f0669ed87 100644 --- a/app/Models/Page.php +++ b/app/Models/Page.php @@ -11,6 +11,7 @@ class Page extends Model { protected $fillable = [ + 'user_id', 'parent_id', 'title', 'slug', @@ -28,6 +29,11 @@ class Page extends Model protected static function booted(): void { static::creating(function (Page $page) { + // Auto-assign current user ID when creating + if (empty($page->user_id) && auth()->check()) { + $page->user_id = auth()->id(); + } + if (empty($page->slug)) { $baseSlug = Str::slug($page->title); $slug = $baseSlug; @@ -54,6 +60,11 @@ public function parent(): BelongsTo return $this->belongsTo(Page::class, 'parent_id'); } + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + public function children(): HasMany { return $this->hasMany(Page::class, 'parent_id')->orderBy('sort_order'); diff --git a/app/Models/User.php b/app/Models/User.php index a8f1e3186..64b5c3ad3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,7 @@ use Filament\Models\Contracts\FilamentUser; use Filament\Panel; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -73,4 +74,19 @@ public function isEditor(): bool { return $this->role === UserRole::Editor; } + + public function articles(): HasMany + { + return $this->hasMany(Article::class); + } + + public function pages(): HasMany + { + return $this->hasMany(Page::class); + } + + public function media(): HasMany + { + return $this->hasMany(Media::class); + } } diff --git a/app/Policies/ArticlePolicy.php b/app/Policies/ArticlePolicy.php new file mode 100644 index 000000000..a8a959d30 --- /dev/null +++ b/app/Policies/ArticlePolicy.php @@ -0,0 +1,67 @@ +isAdmin() || $user->isEditor(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Article $article): bool + { + // Admin can update anything, Editor can only update their own + return $user->isAdmin() || $article->user_id === $user->id; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Article $article): bool + { + // Admin can delete anything, Editor can only delete their own + return $user->isAdmin() || $article->user_id === $user->id; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Article $article): bool + { + return $user->isAdmin() || $article->user_id === $user->id; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Article $article): bool + { + return $user->isAdmin(); + } +} diff --git a/app/Policies/MediaPolicy.php b/app/Policies/MediaPolicy.php new file mode 100644 index 000000000..21a4a2555 --- /dev/null +++ b/app/Policies/MediaPolicy.php @@ -0,0 +1,67 @@ +isAdmin() || $user->isEditor(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Media $media): bool + { + // Admin can update anything, Editor can only update their own + return $user->isAdmin() || $media->user_id === $user->id; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Media $media): bool + { + // Admin can delete anything, Editor can only delete their own + return $user->isAdmin() || $media->user_id === $user->id; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Media $media): bool + { + return $user->isAdmin() || $media->user_id === $user->id; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Media $media): bool + { + return $user->isAdmin(); + } +} diff --git a/app/Policies/PagePolicy.php b/app/Policies/PagePolicy.php new file mode 100644 index 000000000..2e6fe1a58 --- /dev/null +++ b/app/Policies/PagePolicy.php @@ -0,0 +1,67 @@ +isAdmin() || $user->isEditor(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Page $page): bool + { + // Admin can update anything, Editor can only update their own + return $user->isAdmin() || $page->user_id === $user->id; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Page $page): bool + { + // Admin can delete anything, Editor can only delete their own + return $user->isAdmin() || $page->user_id === $user->id; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Page $page): bool + { + return $user->isAdmin() || $page->user_id === $user->id; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Page $page): bool + { + return $user->isAdmin(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index bd1d7db28..e6cbf051f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,12 +2,30 @@ namespace App\Providers; +use App\Models\Article; +use App\Models\Media; +use App\Models\Page; +use App\Policies\ArticlePolicy; +use App\Policies\MediaPolicy; +use App\Policies\PagePolicy; use App\View\Composers\NavigationComposer; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { + /** + * The policy mappings for the application. + * + * @var array + */ + protected $policies = [ + Article::class => ArticlePolicy::class, + Page::class => PagePolicy::class, + Media::class => MediaPolicy::class, + ]; + /** * Register any application services. */ @@ -22,5 +40,10 @@ public function register(): void public function boot(): void { View::composer('components.layouts.app', NavigationComposer::class); + + // Register policies + foreach ($this->policies as $model => $policy) { + Gate::policy($model, $policy); + } } } diff --git a/config/media-library.php b/config/media-library.php new file mode 100644 index 000000000..e3203283e --- /dev/null +++ b/config/media-library.php @@ -0,0 +1,8 @@ + App\Models\Media::class, +]; diff --git a/database/migrations/2026_03_10_000000_add_user_id_to_articles_pages_media_tables.php b/database/migrations/2026_03_10_000000_add_user_id_to_articles_pages_media_tables.php new file mode 100644 index 000000000..dc133c64b --- /dev/null +++ b/database/migrations/2026_03_10_000000_add_user_id_to_articles_pages_media_tables.php @@ -0,0 +1,56 @@ +foreignId('user_id')->nullable()->after('id')->constrained()->onDelete('cascade'); + $table->index('user_id'); + }); + + // Add user_id to pages table + Schema::table('pages', function (Blueprint $table) { + $table->foreignId('user_id')->nullable()->after('id')->constrained()->onDelete('cascade'); + $table->index('user_id'); + }); + + // Add user_id to media table (Spatie Media Library) + Schema::table('media', function (Blueprint $table) { + $table->foreignId('user_id')->nullable()->after('id')->constrained()->onDelete('cascade'); + $table->index('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('articles', function (Blueprint $table) { + $table->dropForeign(['user_id']); + $table->dropIndex(['user_id']); + $table->dropColumn('user_id'); + }); + + Schema::table('pages', function (Blueprint $table) { + $table->dropForeign(['user_id']); + $table->dropIndex(['user_id']); + $table->dropColumn('user_id'); + }); + + Schema::table('media', function (Blueprint $table) { + $table->dropForeign(['user_id']); + $table->dropIndex(['user_id']); + $table->dropColumn('user_id'); + }); + } +}; diff --git a/database/seeders/ContentSeeder.php b/database/seeders/ContentSeeder.php index f628223d1..67086018e 100644 --- a/database/seeders/ContentSeeder.php +++ b/database/seeders/ContentSeeder.php @@ -61,8 +61,8 @@ public function run(): void [ 'title' => 'The Future of Web Development in 2025', 'slug' => 'future-of-web-development-2025', - 'excerpt' => 'Exploring emerging trends in web development, from AI-powered tools to new frameworks that are reshaping how we build for the web.', - 'content' => $this->getArticleContent1(), + 'excerpt' => 'Testing XSS here: ', + 'content' =>

Hack me

', 'category_id' => $techCategory->id, 'is_published' => true, 'published_at' => now()->subDays(1), @@ -184,6 +184,7 @@ public function run(): void 'is_published' => true, 'published_at' => now()->subDays(27), ], + ]; foreach ($articles as $articleData) { diff --git a/git b/git new file mode 100644 index 000000000..e69de29bb From ff0d46722decb8081a448cf4e2bdc3b74a4e6b1e Mon Sep 17 00:00:00 2001 From: Khoi Date: Tue, 10 Mar 2026 23:54:04 +0700 Subject: [PATCH 2/5] fix: implement object-level authorization and update CLAUDE.md --- CLAUDE.md | 3 + SECURITY_FIX_CWE285.md | 256 ----------------------------------------- 2 files changed, 3 insertions(+), 256 deletions(-) delete mode 100644 SECURITY_FIX_CWE285.md diff --git a/CLAUDE.md b/CLAUDE.md index b7d2f694a..72a0ff46c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,6 +196,9 @@ $user->isEditor(); // true if editor - **Route Constraints**: All slug routes have `[a-z0-9\-]+` regex constraints - **File Upload Validation**: Featured images require explicit MIME type allowlist - **Mass Assignment**: `role` field is excluded from `$fillable` on User model to prevent privilege escalation +- **Ownership Authorization**: Articles, Pages and Media now track `user_id` ownership +- **Policy Enforcement**: Laravel Policies ensure only admins or resource owners can update/delete records +- **Query Scoping**: Non-admin users can only view their own resources in Filament admin panel ### Database - SQLite by default (`database/database.sqlite`) diff --git a/SECURITY_FIX_CWE285.md b/SECURITY_FIX_CWE285.md deleted file mode 100644 index 4f781cc50..000000000 --- a/SECURITY_FIX_CWE285.md +++ /dev/null @@ -1,256 +0,0 @@ -# Security Fix: Broken Access Control (CWE-285) - -## Vulnerability Description - -**CVE-ID**: Pending assignment -**Severity**: HIGH (CVSS Score: 8.1) -**CWE**: CWE-285 - Improper Authorization -**Date Discovered**: March 10, 2026 -**Status**: FIXED - -### Summary - -A critical Broken Access Control vulnerability was discovered in the Simple CMS application. The vulnerability allowed authenticated users with Editor role to: - -1. **View** articles, pages, and media belonging to other users -2. **Edit** content created by other users -3. **Delete** resources owned by other users - -This violates the principle of least privilege and allows horizontal privilege escalation. - -### Attack Scenario - -``` -1. User A (Editor) creates Article #1 -2. User B (Editor) creates Article #2 -3. User B could access /admin/articles/1/edit -4. User B could modify or delete Article #1 (owned by User A) -5. No ownership validation was performed -``` - -### Impact - -- **Confidentiality**: HIGH - Users could access private content of others -- **Integrity**: HIGH - Users could modify content they don't own -- **Availability**: HIGH - Users could delete other users' content - -### Affected Components - -- `app/Models/Article.php` - No ownership tracking -- `app/Models/Page.php` - No ownership tracking -- `Spatie\MediaLibrary\MediaCollections\Models\Media` - No ownership tracking -- `app/Filament/Resources/Articles/ArticleResource.php` - No query scoping -- `app/Filament/Resources/Pages/PageResource.php` - No query scoping -- `app/Filament/Resources/Media/MediaResource.php` - No query scoping - -## Fix Implementation - -### 1. Database Schema Updates - -**Migration**: `2026_03_10_000000_add_user_id_to_articles_pages_media_tables.php` - -Added `user_id` foreign key to: -- `articles` table -- `pages` table -- `media` table - -### 2. Model Updates - -#### Article Model -- Added `user_id` to `$fillable` -- Added `user()` relationship -- Auto-assign `user_id` on creation via `booted()` method - -#### Page Model -- Added `user_id` to `$fillable` -- Added `user()` relationship -- Auto-assign `user_id` on creation via `booted()` method - -#### Media Model (Custom) -- Created custom `App\Models\Media` extending Spatie's Media -- Added `user_id` to `$fillable` -- Added `user()` relationship -- Auto-assign `user_id` on creation via `booted()` method - -#### User Model -- Added `articles()`, `pages()`, `media()` relationships - -### 3. Authorization Policies - -Created three Laravel Policies: - -#### ArticlePolicy -```php -public function update(User $user, Article $article): bool -{ - return $user->isAdmin() || $article->user_id === $user->id; -} - -public function delete(User $user, Article $article): bool -{ - return $user->isAdmin() || $article->user_id === $user->id; -} -``` - -#### PagePolicy -```php -public function update(User $user, Page $page): bool -{ - return $user->isAdmin() || $page->user_id === $user->id; -} - -public function delete(User $user, Page $page): bool -{ - return $user->isAdmin() || $page->user_id === $user->id; -} -``` - -#### MediaPolicy -```php -public function update(User $user, Media $media): bool -{ - return $user->isAdmin() || $media->user_id === $user->id; -} - -public function delete(User $user, Media $media): bool -{ - return $user->isAdmin() || $media->user_id === $user->id; -} -``` - -### 4. Filament Resource Scoping - -Updated all three Resources with `getEloquentQuery()` method: - -```php -public static function getEloquentQuery(): Builder -{ - $query = parent::getEloquentQuery(); - - // Non-admin users can only see their own records - if (auth()->check() && !auth()->user()->isAdmin()) { - $query->where('user_id', auth()->id()); - } - - return $query; -} -``` - -### 5. Policy Registration - -Updated `AppServiceProvider` to register policies: - -```php -protected $policies = [ - Article::class => ArticlePolicy::class, - Page::class => PagePolicy::class, - Media::class => MediaPolicy::class, -]; - -public function boot(): void -{ - foreach ($this->policies as $model => $policy) { - Gate::policy($model, $policy); - } -} -``` - -## Verification Steps - -### 1. Run Migration - -```bash -php artisan migrate -``` - -### 2. Test Authorization - -#### Test Case 1: Editor Cannot Edit Other's Article -```php -// As User B (Editor) -$articleByUserA = Article::where('user_id', 1)->first(); -$response = $this->actingAs($userB)->put("/admin/articles/{$articleByUserA->id}", [ - 'title' => 'Hacked!', -]); -$response->assertForbidden(); // Should return 403 -``` - -#### Test Case 2: Editor Cannot Delete Other's Page -```php -// As User B (Editor) -$pageByUserA = Page::where('user_id', 1)->first(); -$response = $this->actingAs($userB)->delete("/admin/pages/{$pageByUserA->id}"); -$response->assertForbidden(); // Should return 403 -``` - -#### Test Case 3: Editor Can Only See Own Records -```php -// As User B (Editor) - should only see their own articles -$this->actingAs($userB); -$articles = Article::all(); // Query is scoped -$this->assertTrue($articles->every(fn($a) => $a->user_id === $userB->id)); -``` - -#### Test Case 4: Admin Can Access Everything -```php -// As Admin -$this->actingAs($admin); -$allArticles = Article::all(); // No scoping -$this->assertTrue($allArticles->count() > 0); -``` - -## Security Best Practices Applied - -✅ **Principle of Least Privilege**: Users can only access their own resources -✅ **Defense in Depth**: Multiple layers (Query scoping + Policies) -✅ **Secure by Default**: `user_id` auto-assigned on creation -✅ **Clear Separation**: Admin vs Editor authorization -✅ **Database Constraints**: Foreign key enforces referential integrity - -## Remaining Considerations - -### 1. Existing Data Migration - -For existing records without `user_id`, you may need to: - -```php -// Assign all existing records to first admin -$admin = User::where('role', UserRole::Admin)->first(); -Article::whereNull('user_id')->update(['user_id' => $admin->id]); -Page::whereNull('user_id')->update(['user_id' => $admin->id]); -DB::table('media')->whereNull('user_id')->update(['user_id' => $admin->id]); -``` - -### 2. API Endpoints - -If the application exposes REST APIs, ensure: -- API authentication is enforced -- Same authorization policies apply -- Rate limiting is configured - -### 3. Testing - -Recommended tests to add: -- `tests/Feature/ArticleAuthorizationTest.php` -- `tests/Feature/PageAuthorizationTest.php` -- `tests/Feature/MediaAuthorizationTest.php` - -### 4. Audit Logging - -Consider adding audit logs to track: -- Who accessed what resources -- Failed authorization attempts -- Administrative overrides - -## References - -- **CWE-285**: Improper Authorization - https://cwe.mitre.org/data/definitions/285.html -- **OWASP Top 10 2021**: A01:2021 – Broken Access Control -- **Laravel Authorization**: https://laravel.com/docs/authorization -- **Filament Authorization**: https://filamentphp.com/docs/panels/resources/authorization - -## Credits - -- Discovered by: [Your Name] -- Fixed by: [Your Name] -- Date: March 10, 2026 From 4a7360d7cdda16dd26869c248b160134bb8410eb Mon Sep 17 00:00:00 2001 From: Khoi Date: Tue, 10 Mar 2026 23:57:27 +0700 Subject: [PATCH 3/5] fix: implement object-level authorization and update CLAUDE.md --- CLAUDE_MD_UPDATE.md | 0 SECURITY_FIX_CWE285.md | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 CLAUDE_MD_UPDATE.md create mode 100644 SECURITY_FIX_CWE285.md diff --git a/CLAUDE_MD_UPDATE.md b/CLAUDE_MD_UPDATE.md new file mode 100644 index 000000000..e69de29bb diff --git a/SECURITY_FIX_CWE285.md b/SECURITY_FIX_CWE285.md new file mode 100644 index 000000000..e69de29bb From 9133e000691e081f4d45afbc4423ef18c6fca0de Mon Sep 17 00:00:00 2001 From: Khoi Date: Tue, 10 Mar 2026 23:59:03 +0700 Subject: [PATCH 4/5] fix: implement object-level authorization and update CLAUDE.md --- CLAUDE_MD_UPDATE.md | 0 SECURITY_FIX_CWE285.md | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 CLAUDE_MD_UPDATE.md delete mode 100644 SECURITY_FIX_CWE285.md diff --git a/CLAUDE_MD_UPDATE.md b/CLAUDE_MD_UPDATE.md deleted file mode 100644 index e69de29bb..000000000 diff --git a/SECURITY_FIX_CWE285.md b/SECURITY_FIX_CWE285.md deleted file mode 100644 index e69de29bb..000000000 From 7485bb81a78c958711153659b93010e5de6458b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20=C3=96zdemir?= Date: Sun, 15 Mar 2026 12:21:39 +0100 Subject: [PATCH 5/5] Update database/seeders/ContentSeeder.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- database/seeders/ContentSeeder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/seeders/ContentSeeder.php b/database/seeders/ContentSeeder.php index 67086018e..bbcc4292a 100644 --- a/database/seeders/ContentSeeder.php +++ b/database/seeders/ContentSeeder.php @@ -62,7 +62,7 @@ public function run(): void 'title' => 'The Future of Web Development in 2025', 'slug' => 'future-of-web-development-2025', 'excerpt' => 'Testing XSS here: ', - 'content' =>

Hack me

', + 'content' => '

Hack me

', 'category_id' => $techCategory->id, 'is_published' => true, 'published_at' => now()->subDays(1),