diff --git a/app/Http/Controllers/LnurlAuthController.php b/app/Http/Controllers/LnurlAuthController.php index b381bad..f0e324d 100644 --- a/app/Http/Controllers/LnurlAuthController.php +++ b/app/Http/Controllers/LnurlAuthController.php @@ -154,14 +154,10 @@ final class LnurlAuthController extends Controller */ private function ensureLoginKeyExists(string $k1, int $userId): void { - $loginKey = LoginKey::where('k1', $k1)->first(); - - if (! $loginKey) { - LoginKey::create([ - 'k1' => $k1, - 'user_id' => $userId, - ]); - } + LoginKey::query()->updateOrCreate( + ['k1' => $k1], + ['user_id' => $userId], + ); } /** diff --git a/app/Jobs/FetchNostrProfileJob.php b/app/Jobs/FetchNostrProfileJob.php index 050f5a9..a2f9fda 100644 --- a/app/Jobs/FetchNostrProfileJob.php +++ b/app/Jobs/FetchNostrProfileJob.php @@ -133,6 +133,15 @@ class FetchNostrProfileJob implements ShouldQueue private function downloadAndSaveProfilePhoto(User $user, string $photoUrl): void { + if (!$this->isPublicHttpUrl($photoUrl)) { + \Log::warning('Refused to download Nostr profile photo from disallowed URL', [ + 'user_id' => $user->id, + 'url' => $photoUrl, + ]); + + return; + } + try { // Download the image from the URL $response = Http::timeout(10)->get($photoUrl); @@ -178,6 +187,41 @@ class FetchNostrProfileJob implements ShouldQueue } } + /** + * Reject URLs that are not http(s) or that resolve to a private/loopback + * address, to prevent SSRF when fetching arbitrary profile photo URLs. + */ + private function isPublicHttpUrl(string $url): bool + { + $parts = parse_url($url); + if ($parts === false || empty($parts['scheme']) || empty($parts['host'])) { + return false; + } + + if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) { + return false; + } + + $host = $parts['host']; + $ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : (gethostbynamel($host) ?: []); + + if (empty($ips)) { + return false; + } + + foreach ($ips as $ip) { + if (!filter_var( + $ip, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE, + )) { + return false; + } + } + + return true; + } + private function getImageExtension(?string $contentType, string $url): string { // Try to get extension from content type diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 7fbef57..0289854 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Route; +use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; use Laravel\Nightwatch\Facades\Nightwatch; use Laravel\Nightwatch\Http\Middleware\Sample; @@ -36,6 +37,10 @@ class AppServiceProvider extends ServiceProvider { $this->configureRateLimiting(); + if ($this->app->environment('production')) { + URL::forceScheme('https'); + } + Livewire::setUpdateRoute(function ($handle) { return Route::post('/livewire/update', $handle) ->middleware(['web', Sample::rate(0)]); diff --git a/bootstrap/app.php b/bootstrap/app.php index dc34ac6..c7d97bf 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -16,6 +16,8 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware) { + $middleware->trustProxies(at: '*'); + $middleware->web(append: [ DomainMiddleware::class, LangCountrySession::class, diff --git a/resources/views/livewire/courses/create.blade.php b/resources/views/livewire/courses/create.blade.php index 1f8a439..1fbe07d 100644 --- a/resources/views/livewire/courses/create.blade.php +++ b/resources/views/livewire/courses/create.blade.php @@ -14,7 +14,7 @@ class extends Component { use WithFileUploads; use SeoTrait; - #[Validate('image|max:10240')] // 10MB Max + #[Validate('image|mimes:jpeg,png,webp|max:5120|dimensions:max_width=4000,max_height=4000')] public $logo; public string $name = ''; diff --git a/resources/views/livewire/courses/edit.blade.php b/resources/views/livewire/courses/edit.blade.php index 6e88904..c3817c8 100644 --- a/resources/views/livewire/courses/edit.blade.php +++ b/resources/views/livewire/courses/edit.blade.php @@ -16,7 +16,7 @@ class extends Component { use WithFileUploads; use SeoTrait; - #[Validate('image|max:10240')] // 10MB Max + #[Validate('image|mimes:jpeg,png,webp|max:5120|dimensions:max_width=4000,max_height=4000')] public $logo; public Course $course; diff --git a/resources/views/livewire/lecturers/create.blade.php b/resources/views/livewire/lecturers/create.blade.php index 085a2df..baf2dd4 100644 --- a/resources/views/livewire/lecturers/create.blade.php +++ b/resources/views/livewire/lecturers/create.blade.php @@ -13,7 +13,7 @@ class extends Component { use WithFileUploads; use SeoTrait; - #[Validate('image|max:10240')] // 10MB Max + #[Validate('image|mimes:jpeg,png,webp|max:5120|dimensions:max_width=4000,max_height=4000')] public $avatar; public string $name = ''; diff --git a/resources/views/livewire/lecturers/edit.blade.php b/resources/views/livewire/lecturers/edit.blade.php index c86e98f..ce31d46 100644 --- a/resources/views/livewire/lecturers/edit.blade.php +++ b/resources/views/livewire/lecturers/edit.blade.php @@ -15,7 +15,7 @@ class extends Component { use WithFileUploads; use SeoTrait; - #[Validate('image|max:10240')] // 10MB Max + #[Validate('image|mimes:jpeg,png,webp|max:5120|dimensions:max_width=4000,max_height=4000')] public $avatar; public Lecturer $lecturer; diff --git a/resources/views/livewire/meetups/create.blade.php b/resources/views/livewire/meetups/create.blade.php index b8bb054..3822016 100644 --- a/resources/views/livewire/meetups/create.blade.php +++ b/resources/views/livewire/meetups/create.blade.php @@ -15,7 +15,7 @@ class extends Component { use WithFileUploads; use SeoTrait; - #[Validate('image|max:10240')] // 10MB Max + #[Validate('image|mimes:jpeg,png,webp|max:5120|dimensions:max_width=4000,max_height=4000')] public $logo; // Basic Information diff --git a/resources/views/livewire/meetups/edit.blade.php b/resources/views/livewire/meetups/edit.blade.php index effdf91..e093c36 100644 --- a/resources/views/livewire/meetups/edit.blade.php +++ b/resources/views/livewire/meetups/edit.blade.php @@ -17,7 +17,7 @@ class extends Component { use WithFileUploads; use SeoTrait; - #[Validate('image|max:10240')] // 10MB Max + #[Validate('image|mimes:jpeg,png,webp|max:5120|dimensions:max_width=4000,max_height=4000')] public $logo; public Meetup $meetup; @@ -90,6 +90,35 @@ class extends Component { } } + /** + * Whitelist the keys allowed inside github_data and coerce types so a + * tampered payload cannot smuggle arbitrary keys into the stored JSON. + */ + protected function sanitizeGithubData(?string $raw): ?array + { + if (empty($raw)) { + return null; + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return null; + } + + $clean = []; + if (array_key_exists('top', $decoded) && (is_string($decoded['top']) || is_numeric($decoded['top']))) { + $clean['top'] = (string) $decoded['top']; + } + if (array_key_exists('left', $decoded) && (is_string($decoded['left']) || is_numeric($decoded['left']))) { + $clean['left'] = (string) $decoded['left']; + } + if (array_key_exists('state', $decoded) && is_string($decoded['state'])) { + $clean['state'] = mb_substr($decoded['state'], 0, 64); + } + + return $clean === [] ? null : $clean; + } + public function mount(): void { $this->authorizeAccess(); @@ -140,19 +169,10 @@ class extends Component { 'simplex' => ['nullable', 'string', 'max:255'], 'signal' => ['nullable', 'string', 'max:255'], 'community' => ['required', 'string', 'max:255'], + 'github_data' => ['nullable', 'json'], ]); - // Convert github_data string back to array if provided - if (!empty($validated['github_data'])) { - $decoded = json_decode($validated['github_data'], true); - if (json_last_error() === JSON_ERROR_NONE) { - $validated['github_data'] = $decoded; - } else { - $validated['github_data'] = null; - } - } else { - $validated['github_data'] = null; - } + $validated['github_data'] = $this->sanitizeGithubData($validated['github_data'] ?? null); $this->meetup->update($validated);