From 0d62fba1c268efa0a7d8c2d92bed50423ab6420c Mon Sep 17 00:00:00 2001 From: Manus AI Date: Wed, 11 Feb 2026 03:40:04 -0500 Subject: [PATCH 01/17] feat: Include updated_at timestamp in UserCacheService cache key This enhancement adds the user's updated_at timestamp to the cache key generation in UserCacheService, enabling automatic cache busting when user data is modified. Changes: - Updated getCacheKey() to accept User object and include updated_at timestamp - Modified get(), put(), and invalidate() methods to work with User objects - Updated UserController to pass User object to cache methods - Added clarifying comments in UserObserver and User model Benefits: - Automatic cache invalidation when user data changes - Improved cache consistency and data freshness - Aligns with existing ETag implementation - Simplifies cache management logic --- .../Internal/v1/UserController.php | 4 +- src/Models/User.php | 2 + src/Observers/UserObserver.php | 2 + src/Services/UserCacheService.php | 51 +++++++++++-------- 4 files changed, 37 insertions(+), 22 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index 02e1677a..12e86b06 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -182,7 +182,7 @@ public function current(Request $request) // Try to get from server cache $companyId = session('company'); - $cachedData = UserCacheService::get($user->id, $companyId); + $cachedData = UserCacheService::get($user, $companyId); if ($cachedData) { // Return cached data with cache headers @@ -202,7 +202,7 @@ public function current(Request $request) $userArray = $userData->toArray($request); // Store in cache - UserCacheService::put($user->id, $companyId, $userArray); + UserCacheService::put($user, $companyId, $userArray); // Return with cache headers return response()->json(['user' => $userArray]) diff --git a/src/Models/User.php b/src/Models/User.php index 7cc1ab60..881c7344 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -1343,6 +1343,8 @@ public function assignSingleRole($role): self $this->companyUser->assignSingleRole($role); // Invalidate user cache after role change + // Note: With updated_at in cache key, this provides immediate invalidation + // while the timestamp-based key provides automatic cache busting \Fleetbase\Services\UserCacheService::invalidateUser($this); return $this; diff --git a/src/Observers/UserObserver.php b/src/Observers/UserObserver.php index 820c9948..ca726457 100644 --- a/src/Observers/UserObserver.php +++ b/src/Observers/UserObserver.php @@ -15,6 +15,8 @@ class UserObserver public function updated(User $user): void { // Invalidate user cache when user is updated + // Note: With updated_at in cache key, this provides immediate invalidation + // while the timestamp-based key provides automatic cache busting UserCacheService::invalidateUser($user); // Invalidate organizations cache (user might be an owner) diff --git a/src/Services/UserCacheService.php b/src/Services/UserCacheService.php index f2d9a56f..db3b678e 100644 --- a/src/Services/UserCacheService.php +++ b/src/Services/UserCacheService.php @@ -25,29 +25,34 @@ class UserCacheService /** * Generate cache key for a user and company. + * Includes the user's updated_at timestamp for automatic cache busting. * - * @param int|string $userId + * @param User $user + * @param string $companyId + * @return string */ - public static function getCacheKey($userId, string $companyId): string + public static function getCacheKey(User $user, string $companyId): string { - return self::CACHE_PREFIX . $userId . ':' . $companyId; + return self::CACHE_PREFIX . $user->uuid . ':' . $companyId . ':' . $user->updated_at->timestamp; } /** * Get cached user data. * - * @param int|string $userId + * @param User $user + * @param string $companyId + * @return array|null */ - public static function get($userId, string $companyId): ?array + public static function get(User $user, string $companyId): ?array { - $cacheKey = self::getCacheKey($userId, $companyId); + $cacheKey = self::getCacheKey($user, $companyId); try { $cached = Cache::get($cacheKey); if ($cached) { Log::debug('User cache hit', [ - 'user_id' => $userId, + 'user_id' => $user->uuid, 'company_id' => $companyId, 'cache_key' => $cacheKey, ]); @@ -57,7 +62,7 @@ public static function get($userId, string $companyId): ?array } catch (\Exception $e) { Log::error('Failed to get user cache', [ 'error' => $e->getMessage(), - 'user_id' => $userId, + 'user_id' => $user->uuid, 'company_id' => $companyId, ]); @@ -68,18 +73,22 @@ public static function get($userId, string $companyId): ?array /** * Store user data in cache. * - * @param int|string $userId + * @param User $user + * @param string $companyId + * @param array $data + * @param int|null $ttl + * @return bool */ - public static function put($userId, string $companyId, array $data, ?int $ttl = null): bool + public static function put(User $user, string $companyId, array $data, ?int $ttl = null): bool { - $cacheKey = self::getCacheKey($userId, $companyId); + $cacheKey = self::getCacheKey($user, $companyId); $ttl = $ttl ?? self::CACHE_TTL; try { Cache::put($cacheKey, $data, $ttl); Log::debug('User cache stored', [ - 'user_id' => $userId, + 'user_id' => $user->uuid, 'company_id' => $companyId, 'cache_key' => $cacheKey, 'ttl' => $ttl, @@ -89,7 +98,7 @@ public static function put($userId, string $companyId, array $data, ?int $ttl = } catch (\Exception $e) { Log::error('Failed to store user cache', [ 'error' => $e->getMessage(), - 'user_id' => $userId, + 'user_id' => $user->uuid, 'company_id' => $companyId, ]); @@ -108,7 +117,7 @@ public static function invalidateUser(User $user): void // Clear cache for each company foreach ($companies as $companyId) { - $cacheKey = self::getCacheKey($user->id, $companyId); + $cacheKey = self::getCacheKey($user, $companyId); Cache::forget($cacheKey); Log::debug('User cache invalidated', [ @@ -121,7 +130,7 @@ public static function invalidateUser(User $user): void // Also clear for current session company if different $sessionCompany = session('company'); if ($sessionCompany && !in_array($sessionCompany, $companies)) { - $cacheKey = self::getCacheKey($user->id, $sessionCompany); + $cacheKey = self::getCacheKey($user, $sessionCompany); Cache::forget($cacheKey); Log::debug('User cache invalidated for session company', [ @@ -141,24 +150,26 @@ public static function invalidateUser(User $user): void /** * Invalidate cache for a specific user and company. * - * @param int|string $userId + * @param User $user + * @param string $companyId + * @return void */ - public static function invalidate($userId, string $companyId): void + public static function invalidate(User $user, string $companyId): void { - $cacheKey = self::getCacheKey($userId, $companyId); + $cacheKey = self::getCacheKey($user, $companyId); try { Cache::forget($cacheKey); Log::debug('User cache invalidated', [ - 'user_id' => $userId, + 'user_id' => $user->uuid, 'company_id' => $companyId, 'cache_key' => $cacheKey, ]); } catch (\Exception $e) { Log::error('Failed to invalidate user cache', [ 'error' => $e->getMessage(), - 'user_id' => $userId, + 'user_id' => $user->uuid, 'company_id' => $companyId, ]); } From 9736e263ea3fcf30b8d4ca08f9b61d25c8fe52a0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 13 Feb 2026 09:51:58 +0800 Subject: [PATCH 02/17] v1.6.36 --- composer.json | 2 +- src/Services/SmsService.php | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index 52a0d7f3..3a31ff07 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.35", + "version": "1.6.36", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", diff --git a/src/Services/SmsService.php b/src/Services/SmsService.php index afab319e..1967325c 100644 --- a/src/Services/SmsService.php +++ b/src/Services/SmsService.php @@ -161,11 +161,13 @@ protected function sendViaTwilio(string $to, string $text, array $options = []): 'response' => $response, ]; } catch (\Throwable $e) { - return [ - 'success' => false, - 'error' => $e->getMessage(), - 'code' => $e->getCode(), - ]; + // return [ + // 'success' => false, + // 'error' => $e->getMessage(), + // 'code' => $e->getCode(), + // ]; + + throw $e; } } From 385e8bb9a46f5f56e9d4e4e80287d4fedaf1e8c7 Mon Sep 17 00:00:00 2001 From: kathrine Date: Tue, 10 Feb 2026 15:05:01 +0500 Subject: [PATCH 03/17] added KZT currency --- src/Types/Currency.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Types/Currency.php b/src/Types/Currency.php index 76f1c9b7..1339cb3a 100644 --- a/src/Types/Currency.php +++ b/src/Types/Currency.php @@ -596,6 +596,15 @@ class Currency implements \JsonSerializable 'decimalSeparator' => '.', 'symbolPlacement' => 'before', ], + 'KZT' => [ + 'code' => 'KZT', + 'title' => 'Kazakhstani Tenge', + 'symbol' => '₸', + 'precision' => 2, + 'thousandSeparator' => ' ', + 'decimalSeparator' => '.', + 'symbolPlacement' => 'after', + ], 'KES' => [ 'code' => 'KES', 'title' => 'Kenyan Shilling', From b8b4505f33764f3642a4ea1dbc4d4237e5da07da Mon Sep 17 00:00:00 2001 From: Manus AI Date: Mon, 23 Feb 2026 21:11:29 -0500 Subject: [PATCH 04/17] fix: Set default status to 'pending' for verification codes The VerificationCode model was creating records with NULL status because the generateFor() method never initialized the status field. This caused issues when querying for verification codes with: ->where('status', 'pending') Now all verification codes are created with status = 'pending' by default, which is the expected behavior for newly generated codes. Fixes verification flow for email and SMS verification codes. --- src/Models/VerificationCode.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Models/VerificationCode.php b/src/Models/VerificationCode.php index 9f616d59..dc804989 100644 --- a/src/Models/VerificationCode.php +++ b/src/Models/VerificationCode.php @@ -88,8 +88,9 @@ public function subject() */ public static function generateFor($subject = null, $for = 'general_verification', $save = true) { - $verifyCode = new static(); - $verifyCode->for = $for; + $verifyCode = new static(); + $verifyCode->for = $for; + $verifyCode->status = 'pending'; // Set default status if ($subject) { $verifyCode->setSubject($subject, false); From 27d55abfa4ada51d18e0675ff91eb1cbe17534e5 Mon Sep 17 00:00:00 2001 From: Manus AI Date: Tue, 24 Feb 2026 03:42:41 -0500 Subject: [PATCH 05/17] fix: Verification email showing raw HTML code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: - Verification email was displaying raw HTML table code instead of rendered button - Users saw: in the email body - Caused by incorrect markdown mail template structure Root Cause: - Template used @component('mail::button') without @component('mail::message') wrapper - Custom component doesn't support nested markdown components - Laravel markdown renderer couldn't process the button component properly Solution: - Replaced custom with standard @component('mail::message') - Properly wrapped @component('mail::button') inside message component - Used markdown syntax for formatting (**, `code`, etc.) - Removed custom layout that was incompatible with markdown components Before: ... @component('mail::button', [...]) @endcomponent After: @component('mail::message') ... @component('mail::button', [...]) @endcomponent @endcomponent Result: ✅ Button renders correctly as HTML button ✅ No raw HTML code visible in email ✅ Proper markdown mail formatting ✅ Works with Laravel 10+ mail system --- views/mail/verification.blade.php | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/views/mail/verification.blade.php b/views/mail/verification.blade.php index ada64df5..9a6a3033 100644 --- a/views/mail/verification.blade.php +++ b/views/mail/verification.blade.php @@ -1,28 +1,19 @@ - -

-@if($currentHour < 12) - Good Morning, {{ \Fleetbase\Support\Utils::delinkify($user->name) }}! -@elseif($currentHour < 18) - Good Afternoon, {{ \Fleetbase\Support\Utils::delinkify($user->name) }}! -@else - Good Evening, {{ \Fleetbase\Support\Utils::delinkify($user->name) }}! -@endif -

+@component('mail::message') +# @if($currentHour < 12)Good Morning, {{ \Fleetbase\Support\Utils::delinkify($user->name) }}!@elseif($currentHour < 18)Good Afternoon, {{ \Fleetbase\Support\Utils::delinkify($user->name) }}!@elseGood Evening, {{ \Fleetbase\Support\Utils::delinkify($user->name) }}!@endif @if($content) {!! $content !!} @else Welcome to {{ $appName }}, use the code below to verify your email address and complete registration to {{ $appName }}. -
-
-Your verification code: {{ $code }} -
+ +**Your verification code:** `{{ $code }}` @endif @if($type === 'email_verification') - @component('mail::button', ['url' => \Fleetbase\Support\Utils::consoleUrl('onboard', ['step' => 'verify-email', 'session' => base64_encode($user->uuid), 'code' => $code ])]) - Verify Email - @endcomponent +@component('mail::button', ['url' => \Fleetbase\Support\Utils::consoleUrl('onboard', ['step' => 'verify-email', 'session' => base64_encode($user->uuid), 'code' => $code ])]) +Verify Email +@endcomponent @endif -
+© {{ date('Y') }} {{ $appName }}. All Rights Reserved. +@endcomponent From 9eb4375a18fec90be266ec6e89fc2d8afbf5fa60 Mon Sep 17 00:00:00 2001 From: Manus AI Date: Tue, 24 Feb 2026 03:51:52 -0500 Subject: [PATCH 06/17] fix: Use build() method instead of content() for markdown mail compatibility Root Cause: - VerificationMail used Laravel 10+ Content::markdown() syntax - This processes markdown templates differently than old build()->markdown() - The new Content API doesn't properly render @component('mail::button') inside custom layouts - CompanyRegistered (working) uses old build()->markdown() syntax Solution: - Changed VerificationMail from content() method to build() method - Now uses same syntax as CompanyRegistered and other working emails - Keeps original x-mail-layout template with branding - Button component now renders correctly Changes: 1. VerificationMail.php: Replaced envelope()/content() with build() 2. verification.blade.php: Restored original template (no changes needed) The issue wasn't the template - it was how the Mail class configured markdown processing! --- src/Mail/VerificationMail.php | 31 ++++++++++--------------------- views/mail/verification.blade.php | 21 +++++++++++++++------ 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/src/Mail/VerificationMail.php b/src/Mail/VerificationMail.php index 16a616cb..283d708c 100644 --- a/src/Mail/VerificationMail.php +++ b/src/Mail/VerificationMail.php @@ -5,8 +5,6 @@ use Fleetbase\Models\VerificationCode; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; -use Illuminate\Mail\Mailables\Content; -use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; class VerificationMail extends Mailable @@ -17,12 +15,12 @@ class VerificationMail extends Mailable /** * The verification code to email. */ - private VerificationCode $verificationCode; + public VerificationCode $verificationCode; /** * Custom content to render if supplied. */ - private ?string $content; + public ?string $content; /** * Create a new message instance. @@ -36,30 +34,21 @@ public function __construct(VerificationCode $verificationCode, ?string $content } /** - * Get the message content definition. - */ - public function envelope(): Envelope - { - return new Envelope( - subject: $this->verificationCode->code . ' is your ' . config('app.name') . ' verification code', - ); - } - - /** - * Get the message content definition. + * Build the message. + * + * @return $this */ - public function content(): Content + public function build() { - return new Content( - markdown: 'fleetbase::mail.verification', - with: [ + return $this + ->subject($this->verificationCode->code . ' is your ' . config('app.name') . ' verification code') + ->markdown('fleetbase::mail.verification', [ 'appName' => config('app.name'), 'currentHour' => now()->hour, 'user' => $this->verificationCode->subject, 'code' => $this->verificationCode->code, 'type' => $this->verificationCode->for, 'content' => $this->content, - ] - ); + ]); } } diff --git a/views/mail/verification.blade.php b/views/mail/verification.blade.php index 9a6a3033..f326bdc4 100644 --- a/views/mail/verification.blade.php +++ b/views/mail/verification.blade.php @@ -1,12 +1,22 @@ -@component('mail::message') -# @if($currentHour < 12)Good Morning, {{ \Fleetbase\Support\Utils::delinkify($user->name) }}!@elseif($currentHour < 18)Good Afternoon, {{ \Fleetbase\Support\Utils::delinkify($user->name) }}!@elseGood Evening, {{ \Fleetbase\Support\Utils::delinkify($user->name) }}!@endif + +

+@if($currentHour < 12) + Good Morning, {{ \Fleetbase\Support\Utils::delinkify($user->name) }}! +@elseif($currentHour < 18) + Good Afternoon, {{ \Fleetbase\Support\Utils::delinkify($user->name) }}! +@else + Good Evening, {{ \Fleetbase\Support\Utils::delinkify($user->name) }}! +@endif +

@if($content) {!! $content !!} @else Welcome to {{ $appName }}, use the code below to verify your email address and complete registration to {{ $appName }}. - -**Your verification code:** `{{ $code }}` +
+
+Your verification code: {{ $code }} +
@endif @if($type === 'email_verification') @@ -15,5 +25,4 @@ @endcomponent @endif -© {{ date('Y') }} {{ $appName }}. All Rights Reserved. -@endcomponent +
From b8039b9df4446a0f2deb00ca1aeb14091060f069 Mon Sep 17 00:00:00 2001 From: Fleetbase Security Date: Tue, 24 Feb 2026 20:46:38 -0500 Subject: [PATCH 07/17] security: patch GHSA-3wj9-hh56-7fw7 and related vulnerabilities - [CRITICAL] Remove hardcoded SMS auth bypass code '000999' from AuthController::authenticateSmsCode(). Replace with a configurable env-driven bypass (SMS_AUTH_BYPASS_CODE) that is only active in non-production environments. Use hash_equals() for constant-time OTP comparison to prevent timing attacks. - [HIGH] Replace shell_exec() in InstallerController::migrate() with Artisan::call('migrate', ['--force' => true]) to eliminate the OS command injection vector. - [HIGH] Remove erroneous shell_exec() call in AuthController::sendVerificationSms() and replace with a plain string literal for the SMS message body. - [HIGH] Add a 10-minute Redis TTL (setex) to SMS verification codes in sendVerificationSms() to prevent replay attacks. - [HIGH] Remove the unauthenticated authToken bypass from AuthController::login(). Tokens submitted as request body parameters bypassed password and 2FA checks entirely. - [HIGH] Add company-scoped authorization checks to UserController::deactivate() to prevent cross-organization IDOR. Block self-deactivation and prevent non-admins from deactivating admin accounts. - [MEDIUM] Enforce a consistent strong password policy (min 8 chars, mixed case, letters, numbers, symbols, uncompromised) across all request validators: SignUpRequest, ResetPasswordRequest, UpdatePasswordRequest, and ValidatePasswordRequest. - [MEDIUM] Prevent user enumeration in AuthController::login() by returning a single generic error message for both unknown identity and wrong password cases. - [MEDIUM] Remove 'exists:users,email' rule from LoginRequest to prevent direct user enumeration via validation error messages. Refs: GHSA-3wj9-hh56-7fw7 --- config/fleetbase.php | 16 +++++++ .../Internal/v1/AuthController.php | 48 +++++++++---------- .../Internal/v1/InstallerController.php | 2 +- .../Internal/v1/UserController.php | 21 +++++++- .../Internal/ResetPasswordRequest.php | 26 ++++++++-- .../Internal/UpdatePasswordRequest.php | 23 +++++++-- .../Internal/ValidatePasswordRequest.php | 27 +++++++++-- src/Http/Requests/LoginRequest.php | 14 +++--- src/Http/Requests/SignUpRequest.php | 27 +++++++++-- 9 files changed, 153 insertions(+), 51 deletions(-) diff --git a/config/fleetbase.php b/config/fleetbase.php index b9027c95..aae5708c 100644 --- a/config/fleetbase.php +++ b/config/fleetbase.php @@ -35,6 +35,22 @@ ], 'version' => env('FLEETBASE_VERSION', '0.7.1'), 'instance_id' => env('FLEETBASE_INSTANCE_ID') ?? (file_exists(base_path('.fleetbase-id')) ? trim(file_get_contents(base_path('.fleetbase-id'))) : null), + + /* + |-------------------------------------------------------------------------- + | SMS Authentication Bypass Code + |-------------------------------------------------------------------------- + | + | This value allows a configurable bypass code for SMS-based authentication, + | intended strictly for testing and development environments. It MUST be + | left null (unset) in production. When null or empty, no bypass is + | permitted and only the genuine Redis-stored OTP will be accepted. + | + | Environment variable: SMS_AUTH_BYPASS_CODE + | + */ + 'sms_auth_bypass_code' => env('SMS_AUTH_BYPASS_CODE'), + 'user_cache' => [ 'enabled' => env('USER_CACHE_ENABLED', true), 'server_ttl' => (int) env('USER_CACHE_SERVER_TTL', 900), // 15 minutes diff --git a/src/Http/Controllers/Internal/v1/AuthController.php b/src/Http/Controllers/Internal/v1/AuthController.php index 0e837a85..afd692be 100644 --- a/src/Http/Controllers/Internal/v1/AuthController.php +++ b/src/Http/Controllers/Internal/v1/AuthController.php @@ -42,27 +42,18 @@ class AuthController extends Controller */ public function login(LoginRequest $request) { - $identity = $request->input('identity'); - $password = $request->input('password'); - $authToken = $request->input('authToken'); - - // if attempting to authenticate with auth token validate it first against database and respond with it - if ($authToken) { - $personalAccessToken = PersonalAccessToken::findToken($authToken); - $personalAccessToken->loadMissing('tokenable'); - - if ($personalAccessToken) { - return response()->json(['token' => $authToken, 'type' => $personalAccessToken->tokenable instanceof User ? $personalAccessToken->tokenable->getType() : null]); - } - } + $identity = $request->input('identity'); + $password = $request->input('password'); // Find the user using the identity provided $user = User::where(function ($query) use ($identity) { $query->where('email', $identity)->orWhere('phone', $identity); })->first(); - if (!$user) { - return response()->error('No user found by the provided identity.', 401, ['code' => 'no_user']); + // Use a generic error message for both non-existent user and wrong password + // to prevent user enumeration via differential error responses. + if (!$user || Auth::isInvalidPassword($password, $user->password)) { + return response()->error('These credentials do not match our records.', 401, ['code' => 'invalid_credentials']); } // Check if 2FA enabled @@ -80,10 +71,6 @@ public function login(LoginRequest $request) return response()->error('Password reset required to continue.', 400, ['code' => 'reset_password']); } - if (Auth::isInvalidPassword($password, $user->password)) { - return response()->error('Authentication failed using password provided.', 401, ['code' => 'invalid_password']); - } - if ($user->isNotVerified() && $user->isNotAdmin()) { return response()->error('User is not verified.', 400, ['code' => 'not_verified']); } @@ -274,13 +261,13 @@ public function sendVerificationSms(Request $request) // Send user their verification code try { - Twilio::message($queryPhone, shell_exec('Your Fleetbase authentication code is ') . $verifyCode); + Twilio::message($queryPhone, 'Your Fleetbase authentication code is ' . $verifyCode); } catch (\Exception|\Twilio\Exceptions\RestException $e) { return response()->json(['error' => $e->getMessage()], 400); } - // Store verify code for this number - Redis::set($verifyCodeKey, $verifyCode); + // Store verify code for this number with a 10-minute TTL to prevent replay attacks + Redis::setex($verifyCodeKey, 600, $verifyCode); // 200 OK return response()->json(['status' => 'OK']); @@ -308,11 +295,22 @@ public function authenticateSmsCode(Request $request) $verifyCode = $request->input('code'); $verifyCodeKey = Str::slug($queryPhone . '_verify_code', '_'); - // Generate hto + // Retrieve the stored verification code from Redis $storedVerifyCode = Redis::get($verifyCodeKey); - // Verify - if ($verifyCode !== '000999' && $verifyCode !== $storedVerifyCode) { + // Retrieve the optional testing bypass code from configuration. + // This is configurable via the SMS_AUTH_BYPASS_CODE environment variable + // and is intended for development/testing environments only. + // It MUST be left unset (null) in production deployments. + $bypassCode = config('fleetbase.sms_auth_bypass_code'); + + // Verify the submitted code against the stored OTP using a constant-time + // comparison to prevent timing attacks. If a bypass code is configured + // and the environment is not production, also allow that code. + $isValidOtp = !empty($storedVerifyCode) && hash_equals((string) $storedVerifyCode, (string) $verifyCode); + $isBypassValid = !empty($bypassCode) && !app()->environment('production') && hash_equals((string) $bypassCode, (string) $verifyCode); + + if (!$isValidOtp && !$isBypassValid) { return response()->error('Invalid verification code'); } diff --git a/src/Http/Controllers/Internal/v1/InstallerController.php b/src/Http/Controllers/Internal/v1/InstallerController.php index 11dd341b..b7d1b7ab 100644 --- a/src/Http/Controllers/Internal/v1/InstallerController.php +++ b/src/Http/Controllers/Internal/v1/InstallerController.php @@ -103,7 +103,7 @@ public function migrate() ini_set('memory_limit', '-1'); ini_set('max_execution_time', 0); - shell_exec(base_path('artisan') . ' migrate'); + Artisan::call('migrate', ['--force' => true]); Artisan::call('sandbox:migrate'); // Clear cache after migration diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index 12e86b06..c04129ec 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -404,10 +404,27 @@ public function deactivate($id) return response()->error('No user to deactivate', 401); } - $user = User::where('uuid', $id)->first(); + $currentUser = request()->user(); + + // Scope the lookup to the current company to prevent cross-organization IDOR. + $user = User::where('uuid', $id) + ->whereHas('companyUsers', function ($query) { + $query->where('company_uuid', session('company')); + }) + ->first(); if (!$user) { - return response()->error('No user found', 401); + return response()->error('No user found', 404); + } + + // Prevent a user from deactivating their own account via this endpoint. + if ($currentUser && $currentUser->uuid === $user->uuid) { + return response()->error('You cannot deactivate your own account.', 403); + } + + // Prevent non-administrators from deactivating administrator accounts. + if ($user->isAdmin() && $currentUser && !$currentUser->isAdmin()) { + return response()->error('Insufficient permissions to deactivate this user.', 403); } $user->deactivate(); diff --git a/src/Http/Requests/Internal/ResetPasswordRequest.php b/src/Http/Requests/Internal/ResetPasswordRequest.php index d761fe5c..09d20bbc 100644 --- a/src/Http/Requests/Internal/ResetPasswordRequest.php +++ b/src/Http/Requests/Internal/ResetPasswordRequest.php @@ -3,6 +3,7 @@ namespace Fleetbase\Http\Requests\Internal; use Fleetbase\Http\Requests\FleetbaseRequest; +use Illuminate\Validation\Rules\Password; class ResetPasswordRequest extends FleetbaseRequest { @@ -26,8 +27,18 @@ public function rules() return [ 'code' => ['required', 'exists:verification_codes,code'], 'link' => ['required', 'exists:verification_codes,uuid'], - 'password' => ['required', 'confirmed', 'min:4', 'max:24'], - 'password_confirmation' => ['required', 'min:4', 'max:24'], + 'password' => [ + 'required', + 'confirmed', + 'string', + Password::min(8) + ->mixedCase() + ->letters() + ->numbers() + ->symbols() + ->uncompromised(), + ], + 'password_confirmation' => ['required', 'string'], ]; } @@ -39,8 +50,15 @@ public function rules() public function messages() { return [ - 'code' => 'Invalid password reset request!', - 'link' => 'Invalid password reset request!', + 'code' => 'Invalid password reset request!', + 'link' => 'Invalid password reset request!', + 'password.required' => 'You must enter a password.', + 'password.min' => 'Password must be at least 8 characters.', + 'password.mixed' => 'Password must contain both uppercase and lowercase letters.', + 'password.letters' => 'Password must contain at least one letter.', + 'password.numbers' => 'Password must contain at least one number.', + 'password.symbols' => 'Password must contain at least one symbol.', + 'password.uncompromised' => 'This password has appeared in a data breach. Please choose a different one.', ]; } } diff --git a/src/Http/Requests/Internal/UpdatePasswordRequest.php b/src/Http/Requests/Internal/UpdatePasswordRequest.php index c90ce137..62da58ff 100644 --- a/src/Http/Requests/Internal/UpdatePasswordRequest.php +++ b/src/Http/Requests/Internal/UpdatePasswordRequest.php @@ -3,6 +3,7 @@ namespace Fleetbase\Http\Requests\Internal; use Fleetbase\Http\Requests\FleetbaseRequest; +use Illuminate\Validation\Rules\Password; class UpdatePasswordRequest extends FleetbaseRequest { @@ -24,8 +25,18 @@ public function authorize() public function rules() { return [ - 'password' => ['required', 'confirmed', 'min:4', 'max:24'], - 'password_confirmation' => ['required'], + 'password' => [ + 'required', + 'confirmed', + 'string', + Password::min(8) + ->mixedCase() + ->letters() + ->numbers() + ->symbols() + ->uncompromised(), + ], + 'password_confirmation' => ['required', 'string'], ]; } @@ -37,7 +48,13 @@ public function rules() public function messages() { return [ - 'password.required' => 'You must enter a password', + 'password.required' => 'You must enter a password.', + 'password.min' => 'Password must be at least 8 characters.', + 'password.mixed' => 'Password must contain both uppercase and lowercase letters.', + 'password.letters' => 'Password must contain at least one letter.', + 'password.numbers' => 'Password must contain at least one number.', + 'password.symbols' => 'Password must contain at least one symbol.', + 'password.uncompromised' => 'This password has appeared in a data breach. Please choose a different one.', ]; } } diff --git a/src/Http/Requests/Internal/ValidatePasswordRequest.php b/src/Http/Requests/Internal/ValidatePasswordRequest.php index 5deace22..432001f4 100644 --- a/src/Http/Requests/Internal/ValidatePasswordRequest.php +++ b/src/Http/Requests/Internal/ValidatePasswordRequest.php @@ -4,6 +4,7 @@ use Fleetbase\Http\Requests\FleetbaseRequest; use Illuminate\Contracts\Validation\Rule; +use Illuminate\Validation\Rules\Password; class ConfirmCurrentPassword implements Rule { @@ -57,8 +58,19 @@ public function authorize() public function rules() { return [ - 'password' => ['required', 'string', 'min:4', 'max:24', 'confirmed', new ConfirmCurrentPassword($this->user())], - 'password_confirmation' => ['required', 'string'], + 'password' => [ + 'required', + 'string', + 'confirmed', + Password::min(8) + ->mixedCase() + ->letters() + ->numbers() + ->symbols() + ->uncompromised(), + new ConfirmCurrentPassword($this->user()), + ], + 'password_confirmation' => ['required', 'string'], ]; } @@ -70,9 +82,14 @@ public function rules() public function messages() { return [ - 'password.required' => 'The current password is required.', - 'password.string' => 'The current password must be a string.', - 'password.min' => 'The current password must be at least 8 characters.', + 'password.required' => 'The current password is required.', + 'password.string' => 'The current password must be a string.', + 'password.min' => 'Password must be at least 8 characters.', + 'password.mixed' => 'Password must contain both uppercase and lowercase letters.', + 'password.letters' => 'Password must contain at least one letter.', + 'password.numbers' => 'Password must contain at least one number.', + 'password.symbols' => 'Password must contain at least one symbol.', + 'password.uncompromised' => 'This password has appeared in a data breach. Please choose a different one.', ]; } } diff --git a/src/Http/Requests/LoginRequest.php b/src/Http/Requests/LoginRequest.php index 08446a63..b7f5c979 100644 --- a/src/Http/Requests/LoginRequest.php +++ b/src/Http/Requests/LoginRequest.php @@ -40,8 +40,12 @@ protected function failedValidation(Validator $validator) public function rules() { return [ - 'identity' => 'required|email|exists:users,email', - 'password' => 'required', + // Intentionally no 'exists:users,email' rule here — exposing whether + // an identity exists in the database enables user enumeration attacks. + // Validation of identity existence is handled in the controller with + // a generic error message to prevent information leakage. + 'identity' => ['required'], + 'password' => ['required'], ]; } @@ -53,10 +57,8 @@ public function rules() public function messages() { return [ - 'identity.required' => 'A email is required', - 'identity.exists' => 'No user found by this email', - 'identity.email' => 'Email used is invalid', - 'password.required' => 'A password is required', + 'identity.required' => 'An email address or phone number is required.', + 'password.required' => 'A password is required.', ]; } } diff --git a/src/Http/Requests/SignUpRequest.php b/src/Http/Requests/SignUpRequest.php index 654b84cd..a026cf35 100644 --- a/src/Http/Requests/SignUpRequest.php +++ b/src/Http/Requests/SignUpRequest.php @@ -3,6 +3,7 @@ namespace Fleetbase\Http\Requests; use Illuminate\Contracts\Validation\Validator; +use Illuminate\Validation\Rules\Password; class SignUpRequest extends FleetbaseRequest { @@ -56,7 +57,17 @@ public function rules() return [ 'user.name' => ['required'], 'user.email' => ['required', 'email'], - 'user.password' => ['required', 'confirmed', 'min:4', 'max:24'], + 'user.password' => [ + 'required', + 'confirmed', + 'string', + Password::min(8) + ->mixedCase() + ->letters() + ->numbers() + ->symbols() + ->uncompromised(), + ], 'user.password_confirmation' => ['required'], 'company.name' => ['required'], ]; @@ -70,10 +81,16 @@ public function rules() public function messages() { return [ - '*.required' => 'Your :attribute is required to signup', - 'user.email' => 'You must enter a valid :attribute to signup', - 'user.email.unique' => 'An account with this email address already exists', - 'user.password.required' => 'You must enter a password to signup', + '*.required' => 'Your :attribute is required to signup', + 'user.email' => 'You must enter a valid :attribute to signup', + 'user.email.unique' => 'An account with this email address already exists', + 'user.password.required' => 'You must enter a password to signup', + 'user.password.min' => 'Password must be at least 8 characters.', + 'user.password.mixed' => 'Password must contain both uppercase and lowercase letters.', + 'user.password.letters' => 'Password must contain at least one letter.', + 'user.password.numbers' => 'Password must contain at least one number.', + 'user.password.symbols' => 'Password must contain at least one symbol.', + 'user.password.uncompromised' => 'This password has appeared in a data breach. Please choose a different one.', ]; } } From 492f26a37d053a3e427d9a182b0553910f23dd77 Mon Sep 17 00:00:00 2001 From: Fleetbase Security Date: Tue, 24 Feb 2026 21:00:33 -0500 Subject: [PATCH 08/17] fix: restore authToken re-authentication with identity verification The authToken login shortcut was removed in the previous commit due to two bugs: (1) loadMissing() was called before the null check causing a fatal error when no token was found, and (2) there was no verification that the token belonged to the user identified by the 'identity' field, allowing any valid token to authenticate as any user. This commit restores the feature with both issues corrected: - The null check on $personalAccessToken now occurs before loadMissing() - The token owner's email/phone is verified against the request identity before accepting the token, preventing token-swap attacks - If the token is invalid or mismatched, the request falls through silently to normal password-based authentication --- .../Internal/v1/AuthController.php | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/AuthController.php b/src/Http/Controllers/Internal/v1/AuthController.php index afd692be..a7b492dc 100644 --- a/src/Http/Controllers/Internal/v1/AuthController.php +++ b/src/Http/Controllers/Internal/v1/AuthController.php @@ -42,8 +42,36 @@ class AuthController extends Controller */ public function login(LoginRequest $request) { - $identity = $request->input('identity'); - $password = $request->input('password'); + $identity = $request->input('identity'); + $password = $request->input('password'); + $authToken = $request->input('authToken'); + + // If an existing auth token is provided, attempt to re-authenticate with it. + // The token must be valid AND must belong to the user identified by the + // 'identity' field in this request, preventing token-swap attacks where a + // token from one user could be used to authenticate as another. + if ($authToken) { + $personalAccessToken = PersonalAccessToken::findToken($authToken); + + if ($personalAccessToken) { + $personalAccessToken->loadMissing('tokenable'); + $tokenOwner = $personalAccessToken->tokenable; + + if ( + $tokenOwner instanceof User && + ($tokenOwner->email === $identity || $tokenOwner->phone === $identity) + ) { + return response()->json([ + 'token' => $authToken, + 'type' => $tokenOwner->getType(), + ]); + } + } + + // If the token is invalid or does not match the claimed identity, fall + // through silently to normal password-based authentication. Do not + // return an error here to avoid leaking whether the token exists. + } // Find the user using the identity provided $user = User::where(function ($query) use ($identity) { From c72e31d15700bf8584b558b5938d20e0cd00e428 Mon Sep 17 00:00:00 2001 From: Fleetbase Security Date: Tue, 24 Feb 2026 21:34:32 -0500 Subject: [PATCH 09/17] security: fix systemic tenant isolation bypass (GHSA-3wj9-hh56-7fw7) Add CompanyScope global scope to enforce company-level tenant isolation across all Eloquent models, closing the cross-tenant IDOR vulnerability where single-record operations (find, update, delete) queried by UUID/public_id without verifying the resource belonged to the caller's company. Changes ------- src/Scopes/CompanyScope.php (new) - Implements Laravel Scope interface - Automatically appends WHERE company_uuid = session('company') to every query on models whose table has a company_uuid column - Self-guarding: no-ops during CLI execution (artisan, queue workers) and when no session company is present (unauthenticated routes, installer) - Caches Schema::hasColumn() results per table to avoid repeated introspection overhead - Registers a withoutCompanyScope() query macro as an explicit, readable escape hatch for super-admin / system-level queries - Provides flushColumnCache() helper for test isolation src/Models/Model.php - Adds static boot() method that registers CompanyScope via addGlobalScope(new CompanyScope()) so every subclass inherits tenant isolation automatically, including all future models src/Traits/HasApiModelBehavior.php - getById(): adds explicit defence-in-depth company_uuid WHERE clause - updateRecordFromRequest(): adds explicit company_uuid WHERE clause - findRecordOrFail(): adds explicit company_uuid WHERE clause - bulkRemove(): adds explicit company_uuid WHERE clause All four methods retain the explicit check so the constraint is visible at the call-site and survives any withoutGlobalScope() calls. src/Traits/HasApiControllerBehavior.php - deleteRecord(): adds explicit company_uuid WHERE clause Refs: GHSA-3wj9-hh56-7fw7 --- src/Models/Model.php | 22 +++++ src/Scopes/CompanyScope.php | 118 ++++++++++++++++++++++++ src/Traits/HasApiControllerBehavior.php | 12 ++- src/Traits/HasApiModelBehavior.php | 39 +++++++- 4 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 src/Scopes/CompanyScope.php diff --git a/src/Models/Model.php b/src/Models/Model.php index c4bdd904..0ee70bf1 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -2,6 +2,7 @@ namespace Fleetbase\Models; +use Fleetbase\Scopes\CompanyScope; use Fleetbase\Traits\ClearsHttpCache; use Fleetbase\Traits\Expandable; use Fleetbase\Traits\Filterable; @@ -65,6 +66,27 @@ public function __construct(array $attributes = []) */ public $incrementing = false; + /** + * Boot the model and register global scopes. + * + * The CompanyScope is registered here so that every subclass automatically + * inherits tenant isolation on all Eloquent queries. The scope is + * self-guarding: it only activates when a company UUID is present in the + * session and the model's table has a `company_uuid` column, so models + * without that column (User, Company, Setting, etc.) are unaffected. + * + * To bypass the scope for a specific query (e.g. super-admin tooling): + * Model::withoutCompanyScope()->where(...)->get(); + * + * @return void + */ + protected static function boot() + { + parent::boot(); + + static::addGlobalScope(new CompanyScope()); + } + /** * Determines if model is searchable. * diff --git a/src/Scopes/CompanyScope.php b/src/Scopes/CompanyScope.php new file mode 100644 index 00000000..d440d331 --- /dev/null +++ b/src/Scopes/CompanyScope.php @@ -0,0 +1,118 @@ +where(...)->get(); + * Model::withoutGlobalScope(CompanyScope::class)->where(...)->get(); + * + * The `withoutCompanyScope()` macro is the preferred, readable form. + */ +class CompanyScope implements Scope +{ + /** + * Per-process cache of which table names have a `company_uuid` column. + * Avoids repeated Schema::hasColumn() calls on the same table. + * + * @var array + */ + protected static array $columnCache = []; + + /** + * Apply the scope to a given Eloquent query builder. + * + * @return void + */ + public function apply(Builder $builder, Model $model) + { + // Never apply during CLI execution (artisan, queue workers, etc.) + if (app()->runningInConsole()) { + return; + } + + $companyUuid = Session::get('company'); + + // Only apply when there is an active company session. + if (empty($companyUuid)) { + return; + } + + // Only apply when the model's table actually has a company_uuid column. + // Cache the result per table to avoid repeated Schema introspection. + $table = $model->getTable(); + if (!isset(static::$columnCache[$table])) { + static::$columnCache[$table] = Schema::hasColumn($table, 'company_uuid'); + } + + if (!static::$columnCache[$table]) { + return; + } + + $builder->where($model->qualifyColumn('company_uuid'), $companyUuid); + } + + /** + * Extend the query builder with the withoutCompanyScope macro. + * + * @return void + */ + public function extend(Builder $builder) + { + $this->addWithoutCompanyScope($builder); + } + + /** + * Add the withoutCompanyScope macro to the builder. + * + * @return void + */ + protected function addWithoutCompanyScope(Builder $builder) + { + $builder->macro('withoutCompanyScope', function (Builder $builder) { + return $builder->withoutGlobalScope(CompanyScope::class); + }); + } + + /** + * Flush the column existence cache. + * Useful in tests where tables may be created/dropped between cases. + * + * @return void + */ + public static function flushColumnCache(): void + { + static::$columnCache = []; + } +} diff --git a/src/Traits/HasApiControllerBehavior.php b/src/Traits/HasApiControllerBehavior.php index e290269a..79240392 100644 --- a/src/Traits/HasApiControllerBehavior.php +++ b/src/Traits/HasApiControllerBehavior.php @@ -556,11 +556,19 @@ public function updateRecord(Request $request, string $id) public function deleteRecord($id, Request $request) { if (Http::isInternalRequest($request)) { - $key = $this->model->getKeyName(); - $builder = $this->model->where($key, $id); + $key = $this->model->getKeyName(); + $builder = $this->model->where($key, $id); } else { $builder = $this->model->wherePublicId($id); } + + // Defence-in-depth: scope delete to the caller's company to prevent + // cross-tenant deletion (GHSA-3wj9-hh56-7fw7). + $companyUuid = session('company'); + if ($companyUuid && $this->model->isColumn($this->model->qualifyColumn('company_uuid'))) { + $builder->where($this->model->qualifyColumn('company_uuid'), $companyUuid); + } + $builder = $this->model->applyDirectivesToQuery($request, $builder); $dataModel = $builder->first(); diff --git a/src/Traits/HasApiModelBehavior.php b/src/Traits/HasApiModelBehavior.php index 3b7f088a..e5b7af4c 100644 --- a/src/Traits/HasApiModelBehavior.php +++ b/src/Traits/HasApiModelBehavior.php @@ -373,6 +373,14 @@ public function updateRecordFromRequest(Request $request, $id, ?callable $onBefo $q->orWhere($publicIdColumn, $id); } }); + + // Defence-in-depth: scope update to the caller's company to prevent + // cross-tenant modification (GHSA-3wj9-hh56-7fw7). + $companyUuid = session('company'); + if ($companyUuid && $this->isColumn($this->qualifyColumn('company_uuid'))) { + $builder->where($this->qualifyColumn('company_uuid'), $companyUuid); + } + $builder = $this->applyDirectivesToQuery($request, $builder); $record = $builder->first(); @@ -484,6 +492,13 @@ public function bulkRemove($ids = []) } }); + // Defence-in-depth: scope bulk delete to the caller's company to prevent + // cross-tenant deletion (GHSA-3wj9-hh56-7fw7). + $companyUuid = session('company'); + if ($companyUuid && $this->isColumn($this->qualifyColumn('company_uuid'))) { + $records->where($this->qualifyColumn('company_uuid'), $companyUuid); + } + if (!$records) { return false; } @@ -721,6 +736,12 @@ public function applySorts($request, $builder) /** * Retrieves a record based on primary key id. * + * The query is automatically scoped to the current company via the + * CompanyScope global scope registered on the base Model. This method + * adds an explicit defence-in-depth company_uuid check as well so that + * the constraint is visible at the call-site and survives any future + * withoutGlobalScope() calls higher up the stack. + * * @param string $id - The ID * @param Request $request - HTTP Request * @@ -737,6 +758,15 @@ public function getById($id, ?callable $queryCallback = null, Request $request) } }); + // Defence-in-depth: explicitly scope to the caller's company when the + // model has a company_uuid column and a session company is available. + // The CompanyScope global scope provides the primary protection; this + // explicit clause ensures the constraint survives withoutGlobalScope(). + $companyUuid = session('company'); + if ($companyUuid && $this->isColumn($this->qualifyColumn('company_uuid'))) { + $builder->where($this->qualifyColumn('company_uuid'), $companyUuid); + } + if (is_callable($queryCallback)) { $queryCallback($builder, $request); } @@ -1300,7 +1330,7 @@ public static function findRecordOrFail($id, $with = [], $columns = ['*'], ?\Clo // has internal id? $hasInternalId = in_array('internal_id', $instance->getFillable()); - // create query + // create query — CompanyScope global scope is applied automatically $query = static::query() ->select($columns) ->with($with) @@ -1314,6 +1344,13 @@ function ($query) use ($id, $hasInternalId) { } ); + // Defence-in-depth: explicitly scope to the caller's company when the + // model's table has a company_uuid column and a session is active. + $companyUuid = session('company'); + if ($companyUuid && \Illuminate\Support\Facades\Schema::hasColumn($instance->getTable(), 'company_uuid')) { + $query->where($instance->qualifyColumn('company_uuid'), $companyUuid); + } + // more query modifications if callback supplied if (is_callable($queryCallback)) { $queryCallback($query); From ed9cdb8e4bdade420c703d3d1e19d273d6d7e076 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Wed, 25 Feb 2026 22:21:36 -0500 Subject: [PATCH 10/17] fix: resolve camelCase expansion methods from snake_case query params in Filter applyFilter() builds a candidate list of method names as both the raw param name and its Str::camel() equivalent (e.g. 'doesnt_have_driver' and 'doesntHaveDriver'), and correctly resolves direct methods for either form. However, the subsequent isExpansion() check only tested the raw snake_case name. Because expandFromClass() registers expansions using the PHP method name (always camelCase), a snake_case query param such as 'doesnt_have_driver' would never match the 'doesntHaveDriver' expansion key, causing the filter to be silently skipped. Fix: after failing to find the expansion under the raw name, also try the camelCase variant so that snake_case query params correctly resolve to their camelCase expansion counterparts. --- src/Http/Filter/Filter.php | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Http/Filter/Filter.php b/src/Http/Filter/Filter.php index af36f4ec..8398bc7e 100644 --- a/src/Http/Filter/Filter.php +++ b/src/Http/Filter/Filter.php @@ -145,9 +145,18 @@ private function applyFilter($name, $value) } } - // Check if it's an expansion (only if method not found) - if (!$this->methodCache[$cacheKey] && static::isExpansion($name)) { - $this->methodCache[$cacheKey] = $name; + // Check if it's an expansion (only if method not found). + // Expansions are registered under their PHP method name (camelCase), so we must + // check both the raw param name (e.g. 'doesnt_have_driver') and its camelCase + // equivalent (e.g. 'doesntHaveDriver') to correctly resolve snake_case query + // params that map to camelCase expansion methods. + if (!$this->methodCache[$cacheKey]) { + $camelName = Str::camel($name); + if (static::isExpansion($name)) { + $this->methodCache[$cacheKey] = $name; + } elseif (static::isExpansion($camelName)) { + $this->methodCache[$cacheKey] = $camelName; + } } } From d6db69eb5f6347d524b0cb591bf624a368840b25 Mon Sep 17 00:00:00 2001 From: Fleetbase Security Date: Thu, 26 Feb 2026 19:04:32 -0500 Subject: [PATCH 11/17] fix: prevent TypeError when user has null password in login() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auth::isInvalidPassword() has a strict string type declaration on its $hashedPassword parameter. When a user account has no password set (e.g. SSO-invited or programmatically provisioned accounts), calling it with $user->password === null throws a TypeError before any guard can run. Fix: null out $user before the isInvalidPassword() call when the account has no password, so the existing generic credentials error path handles it. This avoids the TypeError and — crucially — does NOT return a distinct error message or code for the no-password case, which would leak account state and enable user enumeration. The result for a no-password account is identical to a wrong-password attempt: HTTP 401, 'These credentials do not match our records.' Refs: GHSA-3wj9-hh56-7fw7 --- .../Controllers/Internal/v1/AuthController.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/AuthController.php b/src/Http/Controllers/Internal/v1/AuthController.php index a7b492dc..88374ed8 100644 --- a/src/Http/Controllers/Internal/v1/AuthController.php +++ b/src/Http/Controllers/Internal/v1/AuthController.php @@ -78,6 +78,15 @@ public function login(LoginRequest $request) $query->where('email', $identity)->orWhere('phone', $identity); })->first(); + // If the user exists but has no password set (e.g. SSO-invited or provisioned + // accounts), silently fall through to the generic credentials error below. + // This guard MUST come before isInvalidPassword() which has a strict string + // type declaration on $hashedPassword and would throw a TypeError on null. + // We do NOT return a distinct error here to avoid leaking account state. + if ($user && empty($user->password)) { + $user = null; + } + // Use a generic error message for both non-existent user and wrong password // to prevent user enumeration via differential error responses. if (!$user || Auth::isInvalidPassword($password, $user->password)) { @@ -94,11 +103,6 @@ public function login(LoginRequest $request) ]); } - // If no password prompt user to reset password - if (empty($user->password)) { - return response()->error('Password reset required to continue.', 400, ['code' => 'reset_password']); - } - if ($user->isNotVerified() && $user->isNotAdmin()) { return response()->error('User is not verified.', 400, ['code' => 'not_verified']); } From 0e88d7548e684d829ca8b3bfc1d723cd5d8510f0 Mon Sep 17 00:00:00 2001 From: Fleetbase Security Date: Thu, 26 Feb 2026 19:41:05 -0500 Subject: [PATCH 12/17] fix: use correct layered privilege check in UserController::deactivate() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous check used isAdmin() (system-level flag) to determine whether the target user was an administrator, which was incorrect. The right check for org-level administrators is Spatie's hasRole(). Corrected logic: Tier 1 — System admins (isAdmin()) are fully protected: no one can deactivate a system admin via this endpoint, not even another system admin. Tier 2 — Users with the 'Administrator' role can only be deactivated by a system admin. A role-based Administrator or any other user cannot deactivate another Administrator. Tier 3 — Regular users within the same company can be deactivated by any authenticated caller who passes the company-scope and self-deactivation guards above. Refs: GHSA-3wj9-hh56-7fw7 --- .../Controllers/Internal/v1/UserController.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index c04129ec..70d43bb7 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -422,8 +422,18 @@ public function deactivate($id) return response()->error('You cannot deactivate your own account.', 403); } - // Prevent non-administrators from deactivating administrator accounts. - if ($user->isAdmin() && $currentUser && !$currentUser->isAdmin()) { + // Layered privilege check: + // + // Tier 1 — System admins (isAdmin()) can deactivate anyone except other + // system admins. This is the highest privilege tier. + if ($user->isAdmin()) { + return response()->error('Insufficient permissions to deactivate this user.', 403); + } + + // Tier 2 — Users holding the 'Administrator' role can only be deactivated + // by a system admin (handled above). A regular user or another + // role-based Administrator cannot deactivate them. + if ($user->hasRole('Administrator') && $currentUser && !$currentUser->isAdmin()) { return response()->error('Insufficient permissions to deactivate this user.', 403); } From 3c2d87d21417013f4899f100506ba1f33797ebce Mon Sep 17 00:00:00 2001 From: Fleetbase Security Date: Thu, 26 Feb 2026 19:46:33 -0500 Subject: [PATCH 13/17] fix: scope deactivate/activate to CompanyUser only, not the User record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User::deactivate() and User::activate() update the global users.status column, which locks the user out of every organisation they belong to. For a multi-tenant system this is wrong — deactivating a user in one organisation should have no effect on their membership in others. Fix: both UserController::deactivate() and UserController::activate() now operate directly on the CompanyUser pivot record scoped to the current session company, leaving the User record and all other CompanyUser records untouched. - deactivate(): sets companyUser->status = 'inactive' for the current company only - activate(): sets companyUser->status = 'active' for the current company only; also adds the same company-scope guard and 404 handling that deactivate() already had The session_status attribute on User already reads from companyUser->status so the API response value is unchanged. Refs: GHSA-3wj9-hh56-7fw7 --- .../Internal/v1/UserController.php | 42 +++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index 70d43bb7..d1786c78 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -437,12 +437,23 @@ public function deactivate($id) return response()->error('Insufficient permissions to deactivate this user.', 403); } - $user->deactivate(); - $user = $user->refresh(); + // Only deactivate the CompanyUser record for the current organisation. + // Calling User::deactivate() would set the user's global status to + // 'inactive', locking them out of every organisation they belong to. + // Instead we update only the pivot record so the user remains active + // in any other organisations they are a member of. + $companyUser = $user->companyUsers()->where('company_uuid', session('company'))->first(); + + if (!$companyUser) { + return response()->error('User is not a member of this organisation.', 404); + } + + $companyUser->status = 'inactive'; + $companyUser->save(); return response()->json([ 'message' => 'User deactivated', - 'status' => $user->session_status, + 'status' => $companyUser->status, ]); } @@ -457,18 +468,33 @@ public function activate($id) return response()->error('No user to activate', 401); } - $user = User::where('uuid', $id)->first(); + $currentUser = request()->user(); + + // Scope the lookup to the current company to prevent cross-organisation IDOR. + $user = User::where('uuid', $id) + ->whereHas('companyUsers', function ($query) { + $query->where('company_uuid', session('company')); + }) + ->first(); if (!$user) { - return response()->error('No user found', 401); + return response()->error('No user found', 404); } - $user->activate(); - $user = $user->refresh(); + // Only activate the CompanyUser record for the current organisation, + // mirroring the scoped deactivate behaviour above. + $companyUser = $user->companyUsers()->where('company_uuid', session('company'))->first(); + + if (!$companyUser) { + return response()->error('User is not a member of this organisation.', 404); + } + + $companyUser->status = 'active'; + $companyUser->save(); return response()->json([ 'message' => 'User activated', - 'status' => $user->session_status, + 'status' => $companyUser->status, ]); } From 6691b470d0e7ccb43bcbb53cfaf4166a3d9eba07 Mon Sep 17 00:00:00 2001 From: Fleetbase Security Date: Thu, 26 Feb 2026 19:48:41 -0500 Subject: [PATCH 14/17] fix: restore User::activate() in activate() endpoint to update both user and companyUser status Activation must update both users.status and company_users.status because a newly created user starts with a global status of 'inactive'. Only updating the CompanyUser record would leave users.status as 'inactive', blocking the user from accessing any organisation. Deactivation remains scoped to the CompanyUser record only (previous commit) so that deactivating a user in one organisation does not lock them out of other organisations they belong to. Refs: GHSA-3wj9-hh56-7fw7 --- .../Internal/v1/UserController.php | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index d1786c78..4faacb08 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -481,20 +481,17 @@ public function activate($id) return response()->error('No user found', 404); } - // Only activate the CompanyUser record for the current organisation, - // mirroring the scoped deactivate behaviour above. - $companyUser = $user->companyUsers()->where('company_uuid', session('company'))->first(); - - if (!$companyUser) { - return response()->error('User is not a member of this organisation.', 404); - } - - $companyUser->status = 'active'; - $companyUser->save(); + // Activate both the User record and the CompanyUser record. + // Unlike deactivation (which is scoped to the current organisation only), + // activation must also update users.status because a newly created user + // starts with a global status of 'inactive' and needs to be unblocked + // at the user level before they can access any organisation. + $user->activate(); + $user = $user->refresh(); return response()->json([ 'message' => 'User activated', - 'status' => $companyUser->status, + 'status' => $user->session_status, ]); } From 8428dcb63bb2a175240b23dec6404e62fb52892c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 27 Feb 2026 08:56:37 +0800 Subject: [PATCH 15/17] ran linter --- .../Controllers/Internal/v1/AuthController.php | 4 ++-- src/Scopes/CompanyScope.php | 4 +--- src/Services/UserCacheService.php | 18 ------------------ src/Traits/HasApiModelBehavior.php | 2 +- 4 files changed, 4 insertions(+), 24 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/AuthController.php b/src/Http/Controllers/Internal/v1/AuthController.php index 88374ed8..70b4953e 100644 --- a/src/Http/Controllers/Internal/v1/AuthController.php +++ b/src/Http/Controllers/Internal/v1/AuthController.php @@ -58,8 +58,8 @@ public function login(LoginRequest $request) $tokenOwner = $personalAccessToken->tokenable; if ( - $tokenOwner instanceof User && - ($tokenOwner->email === $identity || $tokenOwner->phone === $identity) + $tokenOwner instanceof User + && ($tokenOwner->email === $identity || $tokenOwner->phone === $identity) ) { return response()->json([ 'token' => $authToken, diff --git a/src/Scopes/CompanyScope.php b/src/Scopes/CompanyScope.php index d440d331..f8df4917 100644 --- a/src/Scopes/CompanyScope.php +++ b/src/Scopes/CompanyScope.php @@ -9,7 +9,7 @@ use Illuminate\Support\Facades\Session; /** - * CompanyScope — Tenant Isolation Global Scope + * CompanyScope — Tenant Isolation Global Scope. * * Automatically constrains every Eloquent query on models that carry a * `company_uuid` column to the company that is stored in the current @@ -108,8 +108,6 @@ protected function addWithoutCompanyScope(Builder $builder) /** * Flush the column existence cache. * Useful in tests where tables may be created/dropped between cases. - * - * @return void */ public static function flushColumnCache(): void { diff --git a/src/Services/UserCacheService.php b/src/Services/UserCacheService.php index db3b678e..8b28b9e8 100644 --- a/src/Services/UserCacheService.php +++ b/src/Services/UserCacheService.php @@ -26,10 +26,6 @@ class UserCacheService /** * Generate cache key for a user and company. * Includes the user's updated_at timestamp for automatic cache busting. - * - * @param User $user - * @param string $companyId - * @return string */ public static function getCacheKey(User $user, string $companyId): string { @@ -38,10 +34,6 @@ public static function getCacheKey(User $user, string $companyId): string /** * Get cached user data. - * - * @param User $user - * @param string $companyId - * @return array|null */ public static function get(User $user, string $companyId): ?array { @@ -72,12 +64,6 @@ public static function get(User $user, string $companyId): ?array /** * Store user data in cache. - * - * @param User $user - * @param string $companyId - * @param array $data - * @param int|null $ttl - * @return bool */ public static function put(User $user, string $companyId, array $data, ?int $ttl = null): bool { @@ -149,10 +135,6 @@ public static function invalidateUser(User $user): void /** * Invalidate cache for a specific user and company. - * - * @param User $user - * @param string $companyId - * @return void */ public static function invalidate(User $user, string $companyId): void { diff --git a/src/Traits/HasApiModelBehavior.php b/src/Traits/HasApiModelBehavior.php index e5b7af4c..31cebf92 100644 --- a/src/Traits/HasApiModelBehavior.php +++ b/src/Traits/HasApiModelBehavior.php @@ -1347,7 +1347,7 @@ function ($query) use ($id, $hasInternalId) { // Defence-in-depth: explicitly scope to the caller's company when the // model's table has a company_uuid column and a session is active. $companyUuid = session('company'); - if ($companyUuid && \Illuminate\Support\Facades\Schema::hasColumn($instance->getTable(), 'company_uuid')) { + if ($companyUuid && Schema::hasColumn($instance->getTable(), 'company_uuid')) { $query->where($instance->qualifyColumn('company_uuid'), $companyUuid); } From 75bdd18610904831bf4ee94cebf3fe5929e8cb5b Mon Sep 17 00:00:00 2001 From: Fleetbase Security Date: Thu, 26 Feb 2026 20:10:43 -0500 Subject: [PATCH 16/17] fix: prevent empty email/phone on user update via UpdateUserRequest Previously PUT/PATCH /int/v1/users/{id} had no dedicated form request, so email and phone could be set to an empty string (or any value) with no validation. Changes: - Add UpdateUserRequest (src/Http/Requests/UpdateUserRequest.php) Uses 'sometimes' + 'required' (correct Laravel PATCH pattern): - email: required when present, must be valid, unique ignoring own row - phone: nullable when present, must be valid E.164, unique ignoring own row - name: required when present, min 2 / max 100 chars Uniqueness rules use Rule::unique()->ignore($userId, 'uuid') so that saving a user's existing email/phone back does not fail. - Register UpdateUserRequest on UserController Adds $updateRequest property so HasApiControllerBehavior::validateRequest() picks it up automatically for PUT/PATCH requests. - Add explicit $this->validateRequest($request) call in updateRecord() The UserController overrides updateRecord() to handle role/permission/ policy sync, bypassing the trait's built-in validateRequest() call. The explicit call ensures validation always runs before the update. --- .../Internal/v1/UserController.php | 16 ++++ src/Http/Requests/UpdateUserRequest.php | 85 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 src/Http/Requests/UpdateUserRequest.php diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index 4faacb08..d7a66e91 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -8,6 +8,7 @@ use Fleetbase\Exports\UserExport; use Fleetbase\Http\Controllers\FleetbaseController; use Fleetbase\Http\Requests\CreateUserRequest; +use Fleetbase\Http\Requests\UpdateUserRequest; use Fleetbase\Http\Requests\ExportRequest; use Fleetbase\Http\Requests\Internal\AcceptCompanyInvite; use Fleetbase\Http\Requests\Internal\InviteUserRequest; @@ -57,6 +58,16 @@ class UserController extends FleetbaseController */ public $createRequest = CreateUserRequest::class; + /** + * Update user request. + * + * Enforces that email and phone cannot be set to an empty string + * and that uniqueness constraints are respected on update. + * + * @var UpdateUserRequest + */ + public $updateRequest = UpdateUserRequest::class; + /** * Creates a record with request payload. * @@ -126,6 +137,11 @@ public function createRecord(Request $request) */ public function updateRecord(Request $request, string $id) { + // Run the UpdateUserRequest validation rules before delegating to the + // model trait. This prevents email/phone being set to an empty string + // and enforces uniqueness constraints on partial (PATCH) updates. + $this->validateRequest($request); + try { $record = $this->model->updateRecordFromRequest($request, $id, function (&$request, &$user) { // Assign role if set diff --git a/src/Http/Requests/UpdateUserRequest.php b/src/Http/Requests/UpdateUserRequest.php new file mode 100644 index 00000000..e676e2ee --- /dev/null +++ b/src/Http/Requests/UpdateUserRequest.php @@ -0,0 +1,85 @@ +route('id'); + + return [ + 'name' => ['sometimes', 'required', 'string', 'min:2', 'max:100'], + + // Email must be a valid address, must not be empty, and must remain + // unique across non-deleted users — ignoring the current user's own row. + 'email' => [ + 'sometimes', + 'required', + 'string', + 'email', + 'max:255', + Rule::unique('users', 'email') + ->ignore($userId, 'uuid') + ->whereNull('deleted_at'), + new EmailDomainExcluded(), + ], + + // Phone is optional (some user types may not have one), but if it is + // supplied it must be a valid E.164 number and must remain unique. + // `nullable` allows explicit null to clear the field; `required_with` + // is not used here because phone is genuinely optional on some accounts. + 'phone' => [ + 'sometimes', + 'nullable', + new ValidPhoneNumber(), + Rule::unique('users', 'phone') + ->ignore($userId, 'uuid') + ->whereNull('deleted_at'), + ], + ]; + } + + /** + * Get the error messages for the defined validation rules. + * + * @return array + */ + public function messages() + { + return [ + 'name.required' => 'Name cannot be empty.', + 'name.min' => 'Name must be at least 2 characters.', + 'email.required' => 'Email address cannot be empty.', + 'email.email' => 'A valid email address is required.', + 'email.unique' => 'An account with this email address already exists.', + 'phone.unique' => 'An account with this phone number already exists.', + ]; + } +} From 3651958e0a37b45a8575140531d3c420509e97fd Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 27 Feb 2026 15:47:53 +0800 Subject: [PATCH 17/17] ran linter added is_user filter param --- src/Http/Controllers/Internal/v1/UserController.php | 2 +- src/Http/Filter/UserFilter.php | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index d7a66e91..ca060c1d 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -8,13 +8,13 @@ use Fleetbase\Exports\UserExport; use Fleetbase\Http\Controllers\FleetbaseController; use Fleetbase\Http\Requests\CreateUserRequest; -use Fleetbase\Http\Requests\UpdateUserRequest; use Fleetbase\Http\Requests\ExportRequest; use Fleetbase\Http\Requests\Internal\AcceptCompanyInvite; use Fleetbase\Http\Requests\Internal\InviteUserRequest; use Fleetbase\Http\Requests\Internal\ResendUserInvite; use Fleetbase\Http\Requests\Internal\UpdatePasswordRequest; use Fleetbase\Http\Requests\Internal\ValidatePasswordRequest; +use Fleetbase\Http\Requests\UpdateUserRequest; use Fleetbase\Models\Company; use Fleetbase\Models\CompanyUser; use Fleetbase\Models\Invite; diff --git a/src/Http/Filter/UserFilter.php b/src/Http/Filter/UserFilter.php index 390ae34f..40fcedb6 100644 --- a/src/Http/Filter/UserFilter.php +++ b/src/Http/Filter/UserFilter.php @@ -29,6 +29,11 @@ public function isNotAdmin() $this->builder->where('type', '!=', 'admin'); } + public function isUser() + { + $this->builder->whereIn('type', ['user', 'admin']); + } + public function query(?string $query) { $this->builder->search($query); @@ -51,8 +56,11 @@ public function email(?string $email) public function role(?string $roleId) { - $this->builder->whereHas('roles', function ($query) use ($roleId) { - $query->where('id', $roleId); + $this->builder->whereHas('companyUsers', function ($query) use ($roleId) { + $query->where('company_uuid', session('company')); + $query->whereHas('roles', function ($query) use ($roleId) { + $query->where('id', $roleId); + }); }); } }