diff --git a/app/Console/Commands/ViewScheduledLogs.php b/app/Console/Commands/ViewScheduledLogs.php index 9ecf90716..b6e9a6121 100644 --- a/app/Console/Commands/ViewScheduledLogs.php +++ b/app/Console/Commands/ViewScheduledLogs.php @@ -28,6 +28,11 @@ class ViewScheduledLogs extends Command public function handle() { $date = $this->option('date') ?: now()->format('Y-m-d'); + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + $this->error('Invalid date format. Use Y-m-d (e.g. 2025-01-31).'); + + return self::INVALID; + } $logPaths = $this->getLogPaths($date); if (empty($logPaths)) { @@ -49,17 +54,19 @@ public function handle() $this->line(''); if (count($logPaths) === 1) { - $logPath = $logPaths[0]; + $logPath = escapeshellarg($logPaths[0]); if ($filters) { - passthru("tail -f {$logPath} | grep -E '{$filters}'"); + $escapedFilters = escapeshellarg($filters); + passthru("tail -f {$logPath} | grep -E {$escapedFilters}"); } else { passthru("tail -f {$logPath}"); } } else { // Multiple files - use multitail or tail with process substitution - $logPathsStr = implode(' ', $logPaths); + $logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths)); if ($filters) { - passthru("tail -f {$logPathsStr} | grep -E '{$filters}'"); + $escapedFilters = escapeshellarg($filters); + passthru("tail -f {$logPathsStr} | grep -E {$escapedFilters}"); } else { passthru("tail -f {$logPathsStr}"); } @@ -68,20 +75,23 @@ public function handle() $this->info("Showing last {$lines} lines of {$logTypeDescription} logs for {$date}{$filterDescription}:"); $this->line(''); + $escapedLines = escapeshellarg((string) $lines); if (count($logPaths) === 1) { - $logPath = $logPaths[0]; + $logPath = escapeshellarg($logPaths[0]); if ($filters) { - passthru("tail -n {$lines} {$logPath} | grep -E '{$filters}'"); + $escapedFilters = escapeshellarg($filters); + passthru("tail -n {$escapedLines} {$logPath} | grep -E {$escapedFilters}"); } else { - passthru("tail -n {$lines} {$logPath}"); + passthru("tail -n {$escapedLines} {$logPath}"); } } else { // Multiple files - concatenate and sort by timestamp - $logPathsStr = implode(' ', $logPaths); + $logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths)); if ($filters) { - passthru("tail -n {$lines} {$logPathsStr} | sort | grep -E '{$filters}'"); + $escapedFilters = escapeshellarg($filters); + passthru("tail -n {$escapedLines} {$logPathsStr} | sort | grep -E {$escapedFilters}"); } else { - passthru("tail -n {$lines} {$logPathsStr} | sort"); + passthru("tail -n {$escapedLines} {$logPathsStr} | sort"); } } } diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 17d14296b..6ce6b6d57 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -6,8 +6,8 @@ use App\Models\TeamInvitation; use App\Models\User; use App\Providers\RouteServiceProvider; +use Illuminate\Auth\Events\Verified; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Foundation\Auth\EmailVerificationRequest; use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; @@ -39,9 +39,29 @@ public function verify() return view('auth.verify-email'); } - public function email_verify(EmailVerificationRequest $request) + public function email_verify(Request $request) { - $request->fulfill(); + if (! $request->hasValidSignature()) { + abort(403); + } + + $user = auth()->user(); + if (! $user) { + abort(403); + } + + if (! hash_equals((string) $request->route('id'), (string) $user->getKey())) { + abort(403); + } + + if (! hash_equals((string) $request->route('hash'), hash('sha256', $user->getEmailForVerification()))) { + abort(403); + } + + if (! $user->hasVerifiedEmail()) { + $user->markEmailAsVerified(); + event(new Verified($user)); + } return redirect(RouteServiceProvider::HOME); } @@ -94,10 +114,6 @@ public function link() } else { $team = $user->teams()->first(); } - if (is_null(data_get($user, 'email_verified_at'))) { - $user->email_verified_at = now(); - $user->save(); - } Auth::login($user); session(['currentTeam' => $team]); diff --git a/app/Models/User.php b/app/Models/User.php index 3199d2024..237f3836f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -257,7 +257,7 @@ public function sendVerificationEmail() Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)), [ 'id' => $this->getKey(), - 'hash' => sha1($this->getEmailForVerification()), + 'hash' => hash('sha256', $this->getEmailForVerification()), ] ); $mail->view('emails.email-verification', [ diff --git a/tests/Feature/EmailVerificationHashTest.php b/tests/Feature/EmailVerificationHashTest.php new file mode 100644 index 000000000..5d42c4e44 --- /dev/null +++ b/tests/Feature/EmailVerificationHashTest.php @@ -0,0 +1,73 @@ +withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]); + Once::flush(); + if (! InstanceSettings::find(0)) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->saveQuietly(); + } +}); + +describe('email verification hash', function () { + test('sha256 hash is accepted and marks the user verified', function () { + $user = User::factory()->create([ + 'email' => 'verify-me@example.com', + 'email_verified_at' => null, + ]); + + $url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [ + 'id' => $user->getKey(), + 'hash' => hash('sha256', $user->getEmailForVerification()), + ]); + + $this->actingAs($user)->get($url)->assertRedirect(); + + $user->refresh(); + expect($user->email_verified_at)->not->toBeNull(); + }); + + test('legacy sha1 hash is rejected', function () { + $user = User::factory()->create([ + 'email' => 'legacy-sha1@example.com', + 'email_verified_at' => null, + ]); + + $url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [ + 'id' => $user->getKey(), + 'hash' => sha1($user->getEmailForVerification()), + ]); + + $this->actingAs($user)->get($url)->assertStatus(403); + + $user->refresh(); + expect($user->email_verified_at)->toBeNull(); + }); + + test('tampered signature is rejected', function () { + $user = User::factory()->create([ + 'email' => 'tampered@example.com', + 'email_verified_at' => null, + ]); + + $url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [ + 'id' => $user->getKey(), + 'hash' => hash('sha256', $user->getEmailForVerification()), + ]); + + $tampered = $url.'x'; + + $this->actingAs($user)->get($tampered)->assertStatus(403); + }); +}); diff --git a/tests/Feature/LinkLoginEmailVerificationTest.php b/tests/Feature/LinkLoginEmailVerificationTest.php new file mode 100644 index 000000000..036584e1e --- /dev/null +++ b/tests/Feature/LinkLoginEmailVerificationTest.php @@ -0,0 +1,60 @@ +withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]); + Once::flush(); + if (! InstanceSettings::find(0)) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->saveQuietly(); + } +}); + +describe('invitation link login', function () { + test('does not auto-verify the email address', function () { + $team = Team::factory()->create(); + $password = 'test-password-123'; + $user = User::factory()->create([ + 'email' => 'invitee@example.com', + 'password' => Hash::make($password), + 'email_verified_at' => null, + ]); + $user->teams()->attach($team->id, ['role' => 'member']); + + $token = Crypt::encryptString("{$user->email}@@@{$password}"); + + $this->get(route('auth.link', ['token' => $token])); + + $user->refresh(); + expect($user->email_verified_at)->toBeNull(); + }); + + test('still logs the user in', function () { + $team = Team::factory()->create(); + $password = 'test-password-123'; + $user = User::factory()->create([ + 'email' => 'invitee2@example.com', + 'password' => Hash::make($password), + 'email_verified_at' => null, + ]); + $user->teams()->attach($team->id, ['role' => 'member']); + + $token = Crypt::encryptString("{$user->email}@@@{$password}"); + + $this->get(route('auth.link', ['token' => $token])); + + expect(auth()->id())->toBe($user->id); + }); +}); diff --git a/tests/Feature/ScheduledLogsCommandInputTest.php b/tests/Feature/ScheduledLogsCommandInputTest.php new file mode 100644 index 000000000..83f313d80 --- /dev/null +++ b/tests/Feature/ScheduledLogsCommandInputTest.php @@ -0,0 +1,35 @@ +withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]); + Once::flush(); + if (! InstanceSettings::find(0)) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->saveQuietly(); + } +}); + +describe('logs:scheduled --date option', function () { + test('rejects a malformed date and exits before touching the shell', function () { + $this->artisan('logs:scheduled', ['--date' => '2025-01-01; touch /tmp/pwn']) + ->expectsOutputToContain('Invalid date format') + ->assertExitCode(ViewScheduledLogs::INVALID); + + expect(file_exists('/tmp/pwn'))->toBeFalse(); + }); + + test('accepts a well-formed date', function () { + $this->artisan('logs:scheduled', ['--date' => '2025-01-01']) + ->assertExitCode(0); + }); +});