From d970bbffc6b580e9d19202a693fb21f0eff03552 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Fri, 12 Jun 2026 10:11:15 +0200 Subject: [PATCH] Improve security --- app/Console/Commands/MailPreview.php | 2 - app/Http/Controllers/Api/ApiController.php | 5 +- app/Http/Controllers/Api/ApiV2Controller.php | 8 +- .../Auth/ForgotPasswordController.php | 42 +- .../Auth/ResetPasswordController.php | 55 ++- .../Controllers/FailedReleasesController.php | 42 +- app/Http/Controllers/GetNzbController.php | 47 ++- app/Jobs/ReindexReleaseJob.php | 26 ++ app/Jobs/SendPasswordResetEmail.php | 46 --- app/Mail/PasswordReset.php | 50 --- app/Models/Category.php | 5 +- app/Models/Release.php | 9 +- app/Models/User.php | 11 +- .../Search/SearchIndexOutboxDispatcher.php | 388 ++++++++++++++++++ bootstrap/app.php | 4 - ...10_000000_create_trusted_devices_table.php | 10 +- ...000_create_password_reset_tokens_table.php | 28 ++ ...11_000001_add_resetguid_to_users_table.php | 28 ++ phpstan-baseline.neon | 42 -- .../emails/markdown/passwordReset.blade.php | 20 - routes/api.php | 2 +- routes/web.php | 18 +- .../Auth/PasswordResetSecurityTest.php | 285 +++++++++++++ .../FailedReleasesControllerSecurityTest.php | 132 ++++++ tests/Feature/Mail/MailRenderingTest.php | 3 - 25 files changed, 1077 insertions(+), 231 deletions(-) create mode 100644 app/Jobs/ReindexReleaseJob.php delete mode 100644 app/Jobs/SendPasswordResetEmail.php delete mode 100644 app/Mail/PasswordReset.php create mode 100644 app/Services/Search/SearchIndexOutboxDispatcher.php create mode 100644 database/migrations/2026_06_11_000000_create_password_reset_tokens_table.php create mode 100644 database/migrations/2026_06_11_000001_add_resetguid_to_users_table.php delete mode 100644 resources/views/emails/markdown/passwordReset.blade.php create mode 100644 tests/Feature/Auth/PasswordResetSecurityTest.php create mode 100644 tests/Feature/FailedReleasesControllerSecurityTest.php diff --git a/app/Console/Commands/MailPreview.php b/app/Console/Commands/MailPreview.php index b8bebb422..c72d1a897 100644 --- a/app/Console/Commands/MailPreview.php +++ b/app/Console/Commands/MailPreview.php @@ -15,7 +15,6 @@ use App\Mail\ForgottenPassword; use App\Mail\IncidentDetected; use App\Mail\InvitationMail; use App\Mail\NewAccountCreatedEmail; -use App\Mail\PasswordReset; use App\Mail\WelcomeEmail; use App\Models\Invitation; use App\Models\ServiceIncident; @@ -56,7 +55,6 @@ class MailPreview extends Command $mailables = [ 'welcome' => fn (): Mailable => new WelcomeEmail($user), 'forgotten_password' => fn (): Mailable => new ForgottenPassword('https://example.test/reset/abc123'), - 'password_reset' => fn (): Mailable => new PasswordReset($user, 'TempPass!2026'), 'new_account_created' => fn (): Mailable => new NewAccountCreatedEmail($user), 'account_change' => fn (): Mailable => new AccountChange($user), 'account_will_expire' => fn (): Mailable => new AccountWillExpire($user, 5), diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php index d8e7adf00..1d8b4ff6a 100644 --- a/app/Http/Controllers/Api/ApiController.php +++ b/app/Http/Controllers/Api/ApiController.php @@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api; use App\Events\UserAccessedApi; use App\Http\Controllers\BasePageController; +use App\Http\Controllers\GetNzbController; use App\Models\Category; use App\Models\Genre; use App\Models\Release; @@ -510,7 +511,9 @@ class ApiController extends BasePageController UserRequest::addApiRequest($uid, $request->getRequestUri()); $relData = Release::checkGuidForApi($request->input('id')); if ($relData) { - return redirect(url('/getnzb?r='.rawurlencode((string) $apiKey).'&id='.rawurlencode((string) $request->input('id')).(($request->has('del') && $request->input('del') === '1') ? '&del=1' : ''))); + $request->attributes->set(GetNzbController::REQUEST_USER_ATTRIBUTE, User::findOrFail($uid)); + + return app(GetNzbController::class)->getNzb($request); } return showApiError(300, 'No such item (the guid you provided has no release in our database)'); diff --git a/app/Http/Controllers/Api/ApiV2Controller.php b/app/Http/Controllers/Api/ApiV2Controller.php index acefcd3b0..e00db7ee2 100644 --- a/app/Http/Controllers/Api/ApiV2Controller.php +++ b/app/Http/Controllers/Api/ApiV2Controller.php @@ -9,9 +9,11 @@ use App\Data\Api\DetailsData; use App\Data\Api\ReleaseData; use App\Events\UserAccessedApi; use App\Http\Controllers\BasePageController; +use App\Http\Controllers\GetNzbController; use App\Models\Category; use App\Models\Genre; use App\Models\Release; +use App\Models\RootCategory; use App\Models\Settings; use App\Models\UsenetGroup; use App\Models\User; @@ -194,7 +196,7 @@ class ApiV2Controller extends BasePageController 'book-search' => ['available' => 'yes', 'supportedParams' => 'id,cat,minsize,maxsize,maxage,group,limit,offset,sort'], 'anime-search' => ['available' => 'yes', 'supportedParams' => 'id,anidbid,anilistid,cat,minsize,maxsize,maxage,limit,offset,sort'], ], - 'categories' => $category->map(static fn ($c) => CategoryData::fromCategory($c))->values(), + 'categories' => $category->map(static fn (RootCategory $rootCategory): CategoryData => CategoryData::fromCategory($rootCategory))->values(), 'groups' => Schema::hasTable('usenet_groups') ? UsenetGroup::query() ->where('active', 1) @@ -613,7 +615,9 @@ class ApiV2Controller extends BasePageController UserRequest::addApiRequest($user->id, $request->getRequestUri()); $relData = Release::checkGuidForApi($request->input('id')); if ($relData) { - return redirect('/getnzb?r='.rawurlencode((string) $request->input('api_token')).'&id='.rawurlencode((string) $request->input('id')).(($request->has('del') && $request->input('del') === '1') ? '&del=1' : '')); + $request->attributes->set(GetNzbController::REQUEST_USER_ATTRIBUTE, $user); + + return app(GetNzbController::class)->getNzb($request); } return response()->json(['data' => 'No such item (the guid you provided has no release in our database)'], 404); diff --git a/app/Http/Controllers/Auth/ForgotPasswordController.php b/app/Http/Controllers/Auth/ForgotPasswordController.php index d222c8b3f..e6245e5aa 100644 --- a/app/Http/Controllers/Auth/ForgotPasswordController.php +++ b/app/Http/Controllers/Auth/ForgotPasswordController.php @@ -9,8 +9,8 @@ use App\Jobs\SendPasswordForgottenEmail; use App\Models\User; use App\Support\CaptchaHelper; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Validator; -use Illuminate\Support\Str; class ForgotPasswordController extends Controller { @@ -45,15 +45,12 @@ class ForgotPasswordController extends Controller return view('auth.passwords.email'); } - // Handle POST request - $sent = ''; - $email = $request->input('email') ?? ''; - $rssToken = $request->input('apikey') ?? ''; + $email = (string) $request->input('email', ''); - if (empty($email) && empty($rssToken)) { + if ($email === '') { return redirect() ->route('forgottenpassword') - ->withErrors(['error' => 'Missing parameter (email and/or apikey) to send password reset']) + ->withErrors(['email' => 'Please enter your email address to send a password reset link.']) ->withInput($request->except(CaptchaHelper::getResponseFieldName())); } @@ -68,26 +65,35 @@ class ForgotPasswordController extends Controller } // Check whether the user exists, but always return the same success message - // to avoid account/API-key enumeration. - $ret = ! empty($rssToken) ? User::findByRssToken($rssToken) : User::findByEmail($email); - if ($ret === null) { + // to avoid account enumeration. + $user = User::findByEmail($email); + if ($user === null) { return redirect()->route('forgottenpassword')->with('success', 'Password reset email has been sent!'); } // Check if user is soft deleted - $user = User::withTrashed()->find($ret['id']); - if ($user && $user->trashed()) { + if (User::withTrashed()->find($user->id)?->trashed()) { return redirect()->route('forgottenpassword')->with('success', 'Password reset email has been sent!'); } - // Generate a forgottenpassword guid, store it in the user table - $guid = Str::random(32); - User::updatePassResetGuid($ret['id'], $guid); + $this->sendResetLink($user); - // Send the email - $resetLink = url('/').'/resetpassword?guid='.$guid; - SendPasswordForgottenEmail::dispatch($ret, $resetLink); + // Clear any legacy GUID once a broker token has been issued. + User::updatePassResetGuid($user->id, null); return redirect()->route('forgottenpassword')->with('success', 'Password reset email has been sent!'); } + + private function sendResetLink(User $user): void + { + $token = Password::broker()->createToken($user); + + SendPasswordForgottenEmail::dispatch( + $user, + route('password.reset', [ + 'token' => $token, + 'email' => $user->email, + ]), + ); + } } diff --git a/app/Http/Controllers/Auth/ResetPasswordController.php b/app/Http/Controllers/Auth/ResetPasswordController.php index 0653f6f2d..5c102c2c0 100644 --- a/app/Http/Controllers/Auth/ResetPasswordController.php +++ b/app/Http/Controllers/Auth/ResetPasswordController.php @@ -5,10 +5,14 @@ declare(strict_types=1); namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; -use App\Jobs\SendPasswordResetEmail; use App\Models\User; use App\Support\Auth\RedirectsUsers; +use Illuminate\Auth\Events\PasswordReset; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Password; +use Illuminate\Support\Str; +use Illuminate\Validation\Rules\Password as PasswordRule; class ResetPasswordController extends Controller { @@ -29,16 +33,13 @@ class ResetPasswordController extends Controller $this->middleware('guest'); } - /** - * @throws \Exception - */ - public function reset(Request $request): mixed + public function showLegacyResetForm(Request $request): mixed { if ($request->missing('guid')) { return redirect()->route('password.request')->with('error', 'No reset code provided.'); } - $user = User::findByResetGuid($request->input('guid')); + $user = User::findByResetGuid((string) $request->input('guid')); if ($user === null) { return redirect()->route('password.request')->with('error', 'Bad reset code provided.'); } @@ -48,15 +49,43 @@ class ResetPasswordController extends Controller return redirect()->route('password.request')->with('error', 'This account has been deactivated.'); } - // Reset the password, inform the user, send out the email - User::updatePassResetGuid($user->id, ''); - $newpass = User::generatePassword(); - User::updatePassword($user->id, $newpass); + $token = Password::broker()->createToken($user); + User::updatePassResetGuid($user->id, null); - SendPasswordResetEmail::dispatch($user, $newpass); + return redirect()->route('password.reset', [ + 'token' => $token, + 'email' => $user->email, + ]); + } - return redirect()->route('login') - ->with('success', 'Your password has been reset to '.$newpass.' and sent to your e-mail address.'); + public function reset(Request $request): mixed + { + $request->validate([ + 'token' => ['required', 'string'], + 'email' => ['required', 'email'], + 'password' => ['required', 'confirmed', PasswordRule::defaults()], + ]); + + $status = Password::broker()->reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function (User $user, string $password): void { + $user->forceFill([ + 'password' => Hash::make($password), + 'remember_token' => Str::random(60), + 'resetguid' => null, + ])->save(); + + event(new PasswordReset($user)); + }, + ); + + if ($status === Password::PASSWORD_RESET) { + return redirect()->route('login')->with('success', __($status)); + } + + return back() + ->withInput($request->only('email')) + ->withErrors(['email' => __($status)]); } public function showResetForm(Request $request, mixed $token = null): mixed diff --git a/app/Http/Controllers/FailedReleasesController.php b/app/Http/Controllers/FailedReleasesController.php index 8a1cce120..ec32d1392 100644 --- a/app/Http/Controllers/FailedReleasesController.php +++ b/app/Http/Controllers/FailedReleasesController.php @@ -15,28 +15,42 @@ class FailedReleasesController extends BasePageController { public function failed(Request $request): Application|Response|\Illuminate\Contracts\Foundation\Application|ResponseFactory { - if ($request->missing('api_token')) { + if ($request->missing('guid')) { return response('Bad request, please supply all parameters!', 400)->withHeaders(['X-DNZB-RCode' => 400, 'X-DNZB-RText' => 'Bad request, please supply all parameters!']); } - $res = User::findVerifiedByApiToken((string) $request->input('api_token')); - if ($res === null) { + $user = $this->resolveUser($request); + if ($user === null) { return response('Unauthorised, wrong rss key!', 401)->withHeaders(['X-DNZB-RCode' => 401, 'X-DNZB-RText' => 'Unauthorised, wrong rss key!']); } - $uid = $res['id']; - $rssToken = $res['api_token']; + $alt = Release::getAlternate($request->input('guid'), $user->id); - if (isset($uid, $rssToken) && $request->has('guid')) { - $alt = Release::getAlternate($request->input('guid'), $uid); - - if (empty($alt)) { - return response('No NZB found for alternate match!', 404)->withHeaders(['X-DNZB-RCode' => 404, 'X-DNZB-RText' => 'No NZB found for alternate match.']); - } - - return response('Success')->withHeaders(['Location' => url('/').'/getnzb?id='.$alt['guid'].'&r='.$rssToken]); + if (empty($alt)) { + return response('No NZB found for alternate match!', 404)->withHeaders(['X-DNZB-RCode' => 404, 'X-DNZB-RText' => 'No NZB found for alternate match.']); } - return response('Bad request, please supply all parameters!', 400)->withHeaders(['X-DNZB-RCode' => 400, 'X-DNZB-RText' => 'Bad request, please supply all parameters!']); + $locationParams = ['id' => $alt['guid']]; + if ($request->filled('api_token')) { + $locationParams['r'] = $user->api_token; + } + + return response('Success')->withHeaders([ + 'Location' => url('/getnzb').'?'.http_build_query($locationParams, '', '&', PHP_QUERY_RFC3986), + ]); + } + + private function resolveUser(Request $request): ?User + { + $sessionUser = $request->user(); + if ($sessionUser instanceof User) { + return $sessionUser; + } + + if ($request->filled('api_token')) { + return User::findVerifiedByApiToken((string) $request->input('api_token')); + } + + return null; } } diff --git a/app/Http/Controllers/GetNzbController.php b/app/Http/Controllers/GetNzbController.php index 951a2cd33..fed98bc91 100644 --- a/app/Http/Controllers/GetNzbController.php +++ b/app/Http/Controllers/GetNzbController.php @@ -22,6 +22,8 @@ use Symfony\Component\HttpFoundation\StreamedResponse; class GetNzbController extends BasePageController { + public const string REQUEST_USER_ATTRIBUTE = 'nntmux.getnzb.user'; + private const int BUFFER_SIZE = 1000000; private const string NZB_SUFFIX = '.nzb'; @@ -82,6 +84,11 @@ class GetNzbController extends BasePageController */ private function authenticateUser(Request $request): array|Response { + $resolvedUser = $request->attributes->get(self::REQUEST_USER_ATTRIBUTE); + if ($resolvedUser instanceof User) { + return $this->getUserDataFromResolvedUser($resolvedUser); + } + // Try session authentication first if ($request->user()) { return $this->getUserDataFromSession(); @@ -91,6 +98,31 @@ class GetNzbController extends BasePageController return $this->getUserDataFromRssToken($request); } + /** + * Get user data from a user resolved by an upstream API controller. + * + * @return array|Response + */ + private function getUserDataFromResolvedUser(User $user): array|Response + { + if (! $user->hasVerifiedEmail()) { + return showApiError(100); + } + + if ($user->is_disabled || $user->hasRole('Disabled')) { + return showApiError(101); + } + + $user->loadMissing('role'); + + return [ + 'uid' => $user->id, + 'userName' => $user->username, + 'maxDownloads' => $user->role->downloadrequests, + 'rssToken' => $user->api_token, + ]; + } + /** * Get user data from authenticated session * @@ -269,7 +301,7 @@ class GetNzbController extends BasePageController $this->updateDownloadStatistics($request, $uid, $releaseId, $releaseData->id); // Build response headers - $headers = $this->buildNzbHeaders($releaseId, $uid, $rssToken, $releaseData); + $headers = $this->buildNzbHeaders($request, $releaseId, $uid, $rssToken, $releaseData); // Stream modified NZB content $cleanName = FilenameSanitizer::sanitize($releaseData->searchname, "release-{$releaseId}"); @@ -312,12 +344,21 @@ class GetNzbController extends BasePageController * * @return array */ - private function buildNzbHeaders(string $releaseId, int $uid, string $rssToken, Release $releaseData): array + private function buildNzbHeaders(Request $request, string $releaseId, int $uid, string $rssToken, Release $releaseData): array { + $failureParams = [ + 'guid' => $releaseId, + 'userid' => $uid, + ]; + + if (! $request->user() && $rssToken !== '') { + $failureParams['api_token'] = $rssToken; + } + $headers = [ 'Content-Type' => 'application/x-nzb', 'Expires' => now()->addYear()->toRfc7231String(), - 'X-DNZB-Failure' => url('/failed')."?guid={$releaseId}&userid={$uid}&api_token={$rssToken}", + 'X-DNZB-Failure' => url('/failed').'?'.http_build_query($failureParams, '', '&', PHP_QUERY_RFC3986), 'X-DNZB-Category' => e($releaseData->category_name), 'X-DNZB-Details' => url("/details/{$releaseId}"), ]; diff --git a/app/Jobs/ReindexReleaseJob.php b/app/Jobs/ReindexReleaseJob.php new file mode 100644 index 000000000..b030f95d9 --- /dev/null +++ b/app/Jobs/ReindexReleaseJob.php @@ -0,0 +1,26 @@ +releaseId); + } +} diff --git a/app/Jobs/SendPasswordResetEmail.php b/app/Jobs/SendPasswordResetEmail.php deleted file mode 100644 index 265debaaa..000000000 --- a/app/Jobs/SendPasswordResetEmail.php +++ /dev/null @@ -1,46 +0,0 @@ -user = $user; - $this->newPass = $newPass; - } - - /** - * Execute the job. - */ - public function handle(): void - { - Mail::to($this->user->email)->send(new PasswordReset($this->user, $this->newPass)); - } -} diff --git a/app/Mail/PasswordReset.php b/app/Mail/PasswordReset.php deleted file mode 100644 index 321b1cbe2..000000000 --- a/app/Mail/PasswordReset.php +++ /dev/null @@ -1,50 +0,0 @@ -username = (string) $user->username; - $this->newPass = $newPass; - $this->siteEmail = (string) config('mail.from.address'); - $this->site = (string) config('app.name'); - $this->preheader = 'Your password has been reset — temporary credentials inside.'; - } - - /** - * @throws \Exception - */ - public function build(): static - { - return $this->from($this->siteEmail) - ->brandedSubject('Your password has been reset') - ->markdown('emails.markdown.passwordReset', [ - 'username' => $this->username, - 'newPass' => $this->newPass, - 'site' => $this->site, - 'preheader' => $this->preheader, - ]); - } -} diff --git a/app/Models/Category.php b/app/Models/Category.php index 44c3bfa06..36baf5e56 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -553,11 +553,12 @@ class Category extends Model } /** - * @return list + * @return Collection */ - public static function getForApi(): mixed + public static function getForApi(): Collection { $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_long')); + /** @var Collection|null $result */ $result = Cache::get(md5('ForApi')); if ($result !== null) { return $result; diff --git a/app/Models/Release.php b/app/Models/Release.php index bb5360c78..0b67a4511 100644 --- a/app/Models/Release.php +++ b/app/Models/Release.php @@ -616,7 +616,14 @@ class Release extends Model return false; } - return self::query()->leftJoin('dnzb_failures as df', 'df.release_id', '=', 'releases.id')->whereIn('releases.id', $searchResult)->where('df.release_id', '=', null)->where('releases.categories_id', $rel['categories_id'])->where('id', '<>', $rel['id'])->orderByDesc('releases.postdate')->first(['guid']); + return self::query() + ->leftJoin('dnzb_failures as df', 'df.release_id', '=', 'releases.id') + ->whereIn('releases.id', $searchResult) + ->whereNull('df.release_id') + ->where('releases.categories_id', $rel['categories_id']) + ->where('releases.id', '<>', $rel['id']) + ->orderByDesc('releases.postdate') + ->first(['releases.guid']); } return false; diff --git a/app/Models/User.php b/app/Models/User.php index bc1b1535a..8d94935b2 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -13,6 +13,7 @@ use App\Rules\ValidEmailDomain; use App\Services\InvitationService; use Carbon\CarbonImmutable; use Illuminate\Auth\MustVerifyEmail as MustVerifyEmailTrait; +use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; use Illuminate\Contracts\Auth\MustVerifyEmail as MustVerifyEmailContract; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -93,6 +94,7 @@ use Spatie\Permission\Traits\HasRoles; * @property string|null $role_name Computed from join with roles table * @property int|null $num Computed count value * @property string|null $mth Computed month value + * @property-read bool $is_disabled Computed via Attribute accessor * @property bool|null $is_role_expired Computed via Attribute accessor * @property int|null $days_until_expiry Computed via Attribute accessor * @property int|null $count Computed count from aggregate queries @@ -122,7 +124,7 @@ use Spatie\Permission\Traits\HasRoles; * @method static Builder|User expiringSoon(int $days = 7) * @method static Builder|User expired() */ -final class User extends Authenticatable implements HasPasskeys, MustVerifyEmailContract +final class User extends Authenticatable implements CanResetPasswordContract, HasPasskeys, MustVerifyEmailContract { use HasFactory; // @phpstan-ignore missingType.generics use HasRoles; @@ -811,7 +813,12 @@ final class User extends Authenticatable implements HasPasskeys, MustVerifyEmail */ public static function findByResetGuid(string $guid): ?static { - return static::whereResetguid($guid)->first(); + if (static::whereApiToken($guid)->exists()) { + return null; + } + + return static::whereResetguid($guid) + ->first(); } // ===== User Management Methods ===== diff --git a/app/Services/Search/SearchIndexOutboxDispatcher.php b/app/Services/Search/SearchIndexOutboxDispatcher.php new file mode 100644 index 000000000..05a827335 --- /dev/null +++ b/app/Services/Search/SearchIndexOutboxDispatcher.php @@ -0,0 +1,388 @@ + $rows + * @return array + */ + public static function compactRows(array $rows): array + { + usort( + $rows, + static fn (object $a, object $b): int => self::rowId($a) <=> self::rowId($b) + ); + + /** @var array $compacted */ + $compacted = []; + + foreach ($rows as $row) { + $compacted[self::documentKey($row)] = $row; + } + + $latestRows = array_values($compacted); + + usort( + $latestRows, + static fn (object $a, object $b): int => self::rowId($a) <=> self::rowId($b) + ); + + return $latestRows; + } + + /** + * Dispatch one compacted outbox row. + */ + public function dispatchRow(object $row): void + { + $payload = self::payload($row); + $entityId = self::entityId($row, $payload); + $entityType = self::normalizeEntityType($row->entity_type ?? ''); + $action = self::normalizeAction($row->action ?? ''); + + if ($entityId <= 0) { + Log::warning('Search outbox row skipped because it has no entity id', [ + 'row_id' => self::rowId($row), + 'entity_type' => $row->entity_type ?? null, + 'action' => $row->action ?? null, + ]); + + return; + } + + match ($entityType) { + 'release' => $this->dispatchRelease($action, $entityId, $payload), + 'predb' => $this->dispatchPredb($action, $entityId, $payload), + 'movie' => $this->dispatchMovie($action, $entityId, $payload), + 'tvshow' => $this->dispatchTvShow($action, $entityId, $payload), + 'secondary' => $this->dispatchSecondaryRow($action, $entityId, $payload, $row), + default => $this->dispatchSecondaryEntity($action, $entityType, $entityId, $payload, $row), + }; + } + + /** + * @param array $payload + */ + private function dispatchRelease(string $action, int $entityId, array $payload): void + { + match ($action) { + 'delete' => Search::deleteRelease($entityId), + 'insert', 'upsert' => empty($payload) + ? Search::updateRelease($entityId) + : Search::insertRelease(self::withId($payload, $entityId)), + 'update' => Search::updateRelease($entityId), + default => self::logUnknownAction($action, 'release', $entityId), + }; + } + + /** + * @param array $payload + */ + private function dispatchPredb(string $action, int $entityId, array $payload): void + { + if ($action === 'delete') { + Search::deletePreDb($entityId); + + return; + } + + if (empty($payload)) { + Log::warning('Search outbox predb row skipped because it has no payload', [ + 'action' => $action, + 'entity_id' => $entityId, + ]); + + return; + } + + match ($action) { + 'insert', 'upsert' => Search::insertPredb(self::withId($payload, $entityId)), + 'update' => Search::updatePreDb(self::withId($payload, $entityId)), + default => self::logUnknownAction($action, 'predb', $entityId), + }; + } + + /** + * @param array $payload + */ + private function dispatchMovie(string $action, int $entityId, array $payload): void + { + match ($action) { + 'delete' => Search::deleteMovie($entityId), + 'insert', 'upsert' => empty($payload) + ? Search::updateMovie($entityId) + : Search::insertMovie(self::withId($payload, $entityId)), + 'update' => Search::updateMovie($entityId), + default => self::logUnknownAction($action, 'movie', $entityId), + }; + } + + /** + * @param array $payload + */ + private function dispatchTvShow(string $action, int $entityId, array $payload): void + { + match ($action) { + 'delete' => Search::deleteTvShow($entityId), + 'insert', 'upsert' => empty($payload) + ? Search::updateTvShow($entityId) + : Search::insertTvShow(self::withId($payload, $entityId)), + 'update' => Search::updateTvShow($entityId), + default => self::logUnknownAction($action, 'tvshow', $entityId), + }; + } + + /** + * @param array $payload + */ + private function dispatchSecondaryRow(string $action, int $entityId, array $payload, object $row): void + { + $index = self::secondaryIndexFromPayload($payload); + + if ($index === null) { + Log::warning('Search outbox secondary row skipped because it has no index', [ + 'row_id' => self::rowId($row), + 'entity_id' => $entityId, + 'action' => $action, + ]); + + return; + } + + $this->dispatchSecondary($action, $index, $entityId, $payload); + } + + /** + * @param array $payload + */ + private function dispatchSecondaryEntity(string $action, string $entityType, int $entityId, array $payload, object $row): void + { + $index = self::secondaryIndexFromEntityType($entityType); + + if ($index === null) { + Log::warning('Search outbox row skipped because it has an unknown entity type', [ + 'row_id' => self::rowId($row), + 'entity_type' => $row->entity_type ?? null, + 'normalized_entity_type' => $entityType, + 'entity_id' => $entityId, + 'action' => $action, + ]); + + return; + } + + $this->dispatchSecondary($action, $index, $entityId, $payload); + } + + /** + * @param array $payload + */ + private function dispatchSecondary(string $action, SecondarySearchIndex $index, int $entityId, array $payload): void + { + if ($action === 'delete') { + Search::deleteSecondary($index, $entityId); + + return; + } + + $document = self::secondaryDocument($payload); + + match ($action) { + 'insert', 'upsert' => empty($document) + ? Search::updateSecondary($index, $entityId) + : Search::insertSecondary($index, $entityId, $document), + 'update' => empty($document) + ? Search::updateSecondary($index, $entityId) + : Search::insertSecondary($index, $entityId, $document), + default => self::logUnknownAction($action, $index->value, $entityId), + }; + } + + /** + * @return array + */ + private static function payload(object $row): array + { + $payload = $row->payload ?? null; + + if ($payload === null || $payload === '') { + return []; + } + + if (is_array($payload)) { + return $payload; + } + + if ($payload instanceof stdClass) { + return (array) $payload; + } + + if (! is_string($payload)) { + return []; + } + + try { + $decoded = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + Log::warning('Search outbox row has invalid JSON payload', [ + 'row_id' => self::rowId($row), + 'error' => $e->getMessage(), + ]); + + return []; + } + + return is_array($decoded) ? $decoded : []; + } + + /** + * @param array $payload + */ + private static function entityId(object $row, array $payload): int + { + $entityId = $row->entity_id ?? ($payload['id'] ?? null); + + return is_numeric($entityId) ? (int) $entityId : 0; + } + + private static function rowId(object $row): int + { + $id = $row->id ?? 0; + + return is_numeric($id) ? (int) $id : 0; + } + + private static function documentKey(object $row): string + { + $payload = self::payload($row); + $entityType = self::normalizeEntityType($row->entity_type ?? ''); + $entityId = self::entityId($row, $payload); + $secondaryIndex = $entityType === 'secondary' + ? self::secondaryIndexFromPayload($payload) + : self::secondaryIndexFromEntityType($entityType); + + if ($secondaryIndex !== null) { + return 'secondary:'.$secondaryIndex->value.':'.$entityId; + } + + return $entityType.':'.$entityId; + } + + private static function normalizeEntityType(mixed $entityType): string + { + $type = strtolower((string) $entityType); + $type = str_replace('\\', '/', $type); + $type = basename($type); + $type = str_replace(['-', ' '], '_', $type); + + return match ($type) { + 'release', 'releases' => 'release', + 'pre', 'pres', 'predb', 'predbs' => 'predb', + 'movie', 'movies', 'movieinfo', 'movie_info' => 'movie', + 'tv', 'video', 'videos', 'tvshow', 'tvshows', 'tv_show', 'tv_shows' => 'tvshow', + 'secondary', 'secondary_search', 'secondary_search_index' => 'secondary', + default => $type, + }; + } + + private static function normalizeAction(mixed $action): string + { + $value = strtolower((string) $action); + $value = str_replace(['-', ' '], '_', $value); + + return match ($value) { + 'insert', 'inserted', 'create', 'created' => 'insert', + 'upsert', 'upserted', 'replace', 'replaced', 'save', 'saved', 'index', 'indexed' => 'upsert', + 'update', 'updated' => 'update', + 'delete', 'deleted', 'destroy', 'destroyed', 'remove', 'removed' => 'delete', + default => $value, + }; + } + + /** + * @param array $payload + * @return array + */ + private static function withId(array $payload, int $id): array + { + $payload['id'] ??= $id; + + return $payload; + } + + /** + * @param array $payload + */ + private static function secondaryIndexFromPayload(array $payload): ?SecondarySearchIndex + { + foreach (['index', 'secondary', 'secondary_index', 'secondarySearchIndex', 'type', 'entity', 'entity_type'] as $key) { + if (isset($payload[$key]) && is_scalar($payload[$key])) { + $index = self::secondaryIndexFromEntityType((string) $payload[$key]); + + if ($index !== null) { + return $index; + } + } + } + + return null; + } + + private static function secondaryIndexFromEntityType(string $entityType): ?SecondarySearchIndex + { + return match (self::normalizeEntityType($entityType)) { + 'music', 'musicinfo', 'music_info' => SecondarySearchIndex::Music, + 'book', 'books', 'bookinfo', 'book_info' => SecondarySearchIndex::Books, + 'game', 'games', 'gamesinfo', 'games_info' => SecondarySearchIndex::Games, + 'console', 'consoleinfo', 'console_info' => SecondarySearchIndex::Console, + 'steam', 'steamapp', 'steamapps', 'steam_app', 'steam_apps' => SecondarySearchIndex::Steam, + 'anime', 'anidb', 'anidbtitle', 'anidbtitles', 'anidb_title', 'anidb_titles', 'anidbinfo', 'anidb_info' => SecondarySearchIndex::Anime, + default => null, + }; + } + + /** + * @param array $payload + * @return array + */ + private static function secondaryDocument(array $payload): array + { + if (isset($payload['document']) && is_array($payload['document'])) { + return $payload['document']; + } + + foreach (['index', 'secondary', 'secondary_index', 'secondarySearchIndex', 'type', 'entity', 'entity_type'] as $metadataKey) { + unset($payload[$metadataKey]); + } + + return $payload; + } + + private static function logUnknownAction(string $action, string $entityType, int $entityId): void + { + Log::warning('Search outbox row skipped because it has an unknown action', [ + 'action' => $action, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + ]); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 6a95e2684..867c51f0e 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -51,11 +51,7 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->validateCsrfTokens(except: [ 'failed', - 'admin/*', 'btcpay/webhook', - 'contact-us', - 'cart', - 'cart/*', ]); $middleware->prepend(DegradeWhenRedisUnreachable::class); diff --git a/database/migrations/2026_06_10_000000_create_trusted_devices_table.php b/database/migrations/2026_06_10_000000_create_trusted_devices_table.php index de97658a5..dee117e2b 100644 --- a/database/migrations/2026_06_10_000000_create_trusted_devices_table.php +++ b/database/migrations/2026_06_10_000000_create_trusted_devices_table.php @@ -10,9 +10,15 @@ return new class extends Migration { public function up(): void { + // Clean up any partially-created table from a previously failed run + // (the table can be created before the foreign-key ALTER statement runs). + Schema::dropIfExists('trusted_devices'); + Schema::create('trusted_devices', function (Blueprint $table): void { $table->id(); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + // users.id is INT UNSIGNED in this schema, so the FK column must match + // its type or MariaDB raises errno 150 "incorrectly formed" constraint. + $table->unsignedInteger('user_id'); $table->string('token_hash', 64)->unique(); $table->timestamp('expires_at')->index(); $table->timestamp('last_used_at')->nullable(); @@ -20,6 +26,8 @@ return new class extends Migration $table->string('user_agent', 500)->nullable(); $table->timestamps(); + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + $table->index(['user_id', 'expires_at']); }); } diff --git a/database/migrations/2026_06_11_000000_create_password_reset_tokens_table.php b/database/migrations/2026_06_11_000000_create_password_reset_tokens_table.php new file mode 100644 index 000000000..469db4f9d --- /dev/null +++ b/database/migrations/2026_06_11_000000_create_password_reset_tokens_table.php @@ -0,0 +1,28 @@ +string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('password_reset_tokens'); + } +}; diff --git a/database/migrations/2026_06_11_000001_add_resetguid_to_users_table.php b/database/migrations/2026_06_11_000001_add_resetguid_to_users_table.php new file mode 100644 index 000000000..b57563309 --- /dev/null +++ b/database/migrations/2026_06_11_000001_add_resetguid_to_users_table.php @@ -0,0 +1,28 @@ +string('resetguid')->nullable()->after('password'); + } + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + if (Schema::hasColumn('users', 'resetguid')) { + $table->dropColumn('resetguid'); + } + }); + } +}; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index d10c93526..bcb38626a 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -318,36 +318,12 @@ parameters: count: 1 path: app/Http/Controllers/Api/ApiController.php - - - message: '#^Unreachable statement \- code above always terminates\.$#' - identifier: deadCode.unreachable - count: 1 - path: app/Http/Controllers/Api/ApiController.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$is_disabled\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ApiInformController.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$is_disabled\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ApiV2Controller.php - - message: '#^Call to function is_array\(\) with bool\|int\|string will always evaluate to false\.$#' identifier: function.impossibleType count: 1 path: app/Http/Controllers/Api/ApiV2Controller.php - - - message: '#^Cannot call method map\(\) on list\\.$#' - identifier: method.nonObject - count: 1 - path: app/Http/Controllers/Api/ApiV2Controller.php - - message: '#^Offset 0 on \*NEVER\* on left side of \?\? always exists and is not nullable\.$#' identifier: nullCoalesce.offset @@ -444,18 +420,6 @@ parameters: count: 1 path: app/Http/Controllers/CoverController.php - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$is_disabled\.$#' - identifier: property.notFound - count: 2 - path: app/Http/Controllers/GetNzbController.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$is_disabled\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/RssController.php - - message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$roles_id\.$#' identifier: property.notFound @@ -474,12 +438,6 @@ parameters: count: 1 path: app/Http/Requests/Admin/RegistrationPeriodRequest.php - - - message: '#^Method App\\Models\\Category\:\:getForApi\(\) should return list\ but returns Illuminate\\Database\\Eloquent\\Collection\\.$#' - identifier: return.type - count: 1 - path: app/Models/Category.php - - message: '#^Method App\\Models\\Category\:\:getForMenu\(\) should return array\ but returns list\\.$#' identifier: return.type diff --git a/resources/views/emails/markdown/passwordReset.blade.php b/resources/views/emails/markdown/passwordReset.blade.php deleted file mode 100644 index aa4a89c0e..000000000 --- a/resources/views/emails/markdown/passwordReset.blade.php +++ /dev/null @@ -1,20 +0,0 @@ -@component('mail::message') -# Password reset successful - -Dear {{ $username }}, - -Your password has been successfully reset. - -@component('mail::panel') -**Your new temporary password:** - -`{{ $newPass }}` -@endcomponent - -For your security, we strongly recommend logging in and changing this password to something memorable as soon as possible. - -If you did not request this reset, please contact us immediately. - -Best regards,
-The {{ $site }} Team -@endcomponent diff --git a/routes/api.php b/routes/api.php index 5f3eca3e6..297dcc9e2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -52,7 +52,7 @@ Route::get('release/{id}/mediainfo', function ($id) { 'audio' => $audio ?: null, 'subs' => $subs ? $subs->subs : null, // @phpstan-ignore property.notFound ]); -}); +})->middleware('throttle:60,1'); Route::fallback(function () { return response()->json(['message' => 'Not Found!'], 404); diff --git a/routes/web.php b/routes/web.php index de28c2162..25b4e4baf 100644 --- a/routes/web.php +++ b/routes/web.php @@ -107,6 +107,9 @@ Route::get('forgottenpassword', [ForgotPasswordController::class, 'showLinkReque Route::post('forgottenpassword', [ForgotPasswordController::class, 'showLinkRequestForm'])->middleware('throttle:6,1')->withoutMiddleware(['auth']); Route::get('password/reset', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('password.request')->withoutMiddleware(['auth']); Route::post('password/reset', [ForgotPasswordController::class, 'showLinkRequestForm'])->middleware('throttle:6,1')->withoutMiddleware(['auth']); +Route::get('password/reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('password.reset')->withoutMiddleware(['auth']); +Route::post('password/update', [ResetPasswordController::class, 'reset'])->middleware('throttle:6,1')->name('password.update')->withoutMiddleware(['auth']); +Route::get('resetpassword', [ResetPasswordController::class, 'showLegacyResetForm'])->middleware('throttle:6,1')->name('resetpassword')->withoutMiddleware(['auth']); Route::match(['GET', 'POST'], 'terms-and-conditions', [TermsController::class, 'terms'])->name('terms-and-conditions'); Route::match(['GET', 'POST'], 'privacy-policy', [PrivacyPolicyController::class, 'privacyPolicy'])->name('privacy-policy'); @@ -114,12 +117,13 @@ Route::get('login', [LoginController::class, 'showLoginForm'])->name('login'); Route::post('login', [LoginController::class, 'login'])->name('login.post'); Route::post('logout', [LoginController::class, 'logout'])->name('logout'); Route::get('passkeys/authentication-options', GeneratePasskeyAuthenticationOptionsController::class) + ->middleware('throttle:6,1') ->name('passkeys.authentication_options'); -Route::post('passkeys/authenticate', PasskeyLoginController::class)->name('passkeys.login'); +Route::post('passkeys/authenticate', PasskeyLoginController::class)->middleware('throttle:6,1')->name('passkeys.login'); -Route::get('2fa/verify', [PasswordSecurityController::class, 'getVerify2fa'])->name('2fa.verify'); -Route::post('2fa/verify', [PasswordSecurityController::class, 'verify2fa'])->name('2fa.post'); -Route::post('2faVerify', [PasswordSecurityController::class, 'verify2fa'])->name('2faVerify'); +Route::get('2fa/verify', [PasswordSecurityController::class, 'getVerify2fa'])->middleware('throttle:6,1')->name('2fa.verify'); +Route::post('2fa/verify', [PasswordSecurityController::class, 'verify2fa'])->middleware('throttle:6,1')->name('2fa.post'); +Route::post('2faVerify', [PasswordSecurityController::class, 'verify2fa'])->middleware('throttle:6,1')->name('2faVerify'); // Search autocomplete and suggest API routes (no auth required for better UX) Route::get('api/search/autocomplete', [SearchSuggestController::class, 'autocomplete'])->name('api.search.autocomplete'); @@ -139,7 +143,6 @@ Route::middleware(['auth', 'isVerified'])->group(function () { Route::delete('passkeys/{passkey}', [PasskeyManagementController::class, 'destroy']) ->name('passkeys.destroy'); - Route::match(['GET', 'POST'], 'resetpassword', [ResetPasswordController::class, 'reset'])->name('resetpassword'); Route::match(['GET', 'POST'], 'profile', [ProfileController::class, 'show'])->name('profile'); Route::prefix('browse')->group(function () { @@ -164,7 +167,10 @@ Route::middleware(['auth', 'isVerified'])->group(function () { Route::match(['GET', 'POST'], 'apiv2help', [ApiHelpController::class, 'apiv2'])->name('apiv2help'); Route::match(['GET', 'POST'], 'browsegroup', [BrowseGroupController::class, 'show'])->name('browsegroup'); Route::match(['GET', 'POST'], 'content', [ContentController::class, 'show'])->name('content'); - Route::match(['GET', 'POST'], 'failed', [FailedReleasesController::class, 'failed'])->name('failed'); + Route::match(['GET', 'POST'], 'failed', [FailedReleasesController::class, 'failed']) + ->middleware('throttle:60,1') + ->withoutMiddleware(['auth', 'isVerified']) + ->name('failed'); Route::middleware('clearance')->group(function () { Route::match(['GET', 'POST'], 'Games', [GamesController::class, 'show'])->name('Games'); diff --git a/tests/Feature/Auth/PasswordResetSecurityTest.php b/tests/Feature/Auth/PasswordResetSecurityTest.php new file mode 100644 index 000000000..67e36c5b6 --- /dev/null +++ b/tests/Feature/Auth/PasswordResetSecurityTest.php @@ -0,0 +1,285 @@ + 'base64:'.base64_encode(random_bytes(32)), + 'cache.default' => 'array', + 'database.default' => 'sqlite', + 'database.connections.sqlite.database' => ':memory:', + 'hashing.bcrypt.rounds' => 4, + 'queue.default' => 'sync', + 'session.driver' => 'array', + 'captcha.enabled' => false, + 'captcha.recaptcha.enabled' => false, + 'captcha.turnstile.enabled' => false, + ]); + + DB::purge(); + DB::reconnect(); + + PasswordRule::defaults(fn () => PasswordRule::min(8)); + + $this->createSchema(); + $this->seedSettings(); + } + + public function test_password_reset_link_request_sends_tokenized_link_and_clears_legacy_guid(): void + { + Mail::fake(); + $user = $this->createUser('forgotten@example.test', ['resetguid' => 'legacy-reset-guid']); + + $response = $this->post(route('forgottenpassword'), [ + 'email' => $user->email, + ]); + + $response + ->assertRedirect(route('forgottenpassword')) + ->assertSessionHas('success', 'Password reset email has been sent!'); + + $this->assertDatabaseHas('password_reset_tokens', [ + 'email' => $user->email, + ]); + $this->assertNull($user->fresh()->resetguid); + + Mail::assertSent(ForgottenPassword::class, function (ForgottenPassword $mail) use ($user): bool { + $query = parse_url($mail->resetLink, PHP_URL_QUERY); + parse_str((string) $query, $params); + + return $mail->hasTo($user->email) + && str_contains($mail->resetLink, '/password/reset/') + && ($params['email'] ?? null) === $user->email + && ! array_key_exists('password', $params) + && ! str_contains($mail->resetLink, 'legacy-reset-guid'); + }); + } + + public function test_legacy_guid_redirects_to_secure_reset_form_and_invalidates_guid(): void + { + $user = $this->createUser('legacy@example.test', ['resetguid' => 'legacy-guid']); + + $response = $this->get(route('resetpassword', ['guid' => 'legacy-guid'])); + + $response->assertStatus(302); + $location = (string) $response->headers->get('Location'); + + $this->assertStringContainsString('/password/reset/', $location); + $this->assertStringContainsString('email=legacy%40example.test', $location); + $this->assertDatabaseHas('password_reset_tokens', [ + 'email' => $user->email, + ]); + $this->assertNull($user->fresh()->resetguid); + + $this->get(route('resetpassword', ['guid' => 'legacy-guid'])) + ->assertRedirect(route('password.request')) + ->assertSessionHas('error', 'Bad reset code provided.'); + } + + public function test_legacy_reset_rejects_api_token_alias_as_reset_guid(): void + { + $apiToken = 'api-token-is-not-a-reset-code'; + $user = $this->createUser('api-token-reset@example.test'); + $user->forceFill([ + 'api_token' => $apiToken, + 'resetguid' => $apiToken, + ])->saveQuietly(); + + $response = $this->get(route('resetpassword', ['guid' => $apiToken])); + + $response + ->assertRedirect(route('password.request')) + ->assertSessionHas('error', 'Bad reset code provided.'); + + $this->assertDatabaseMissing('password_reset_tokens', [ + 'email' => $user->email, + ]); + $this->assertSame($apiToken, $user->fresh()->api_token); + $this->assertSame($apiToken, $user->fresh()->resetguid); + } + + public function test_token_password_reset_updates_password_clears_guid_and_fires_event(): void + { + Event::fake([PasswordReset::class]); + $user = $this->createUser('reset@example.test', ['resetguid' => 'legacy-guid']); + $token = Password::broker()->createToken($user); + + $response = $this->post(route('password.update'), [ + 'token' => $token, + 'email' => $user->email, + 'password' => 'new-secure-password', + 'password_confirmation' => 'new-secure-password', + ]); + + $response->assertRedirect(route('login')); + + $user->refresh(); + $this->assertTrue(Hash::check('new-secure-password', $user->password)); + $this->assertNull($user->resetguid); + $this->assertNotNull($user->remember_token); + $this->assertDatabaseMissing('password_reset_tokens', [ + 'email' => $user->email, + ]); + Event::assertDispatched( + PasswordReset::class, + fn (PasswordReset $event): bool => $event->user instanceof User && $event->user->is($user), + ); + } + + public function test_invalid_token_does_not_change_password_or_clear_legacy_guid(): void + { + $user = $this->createUser('invalid-token@example.test', ['resetguid' => 'legacy-guid']); + $originalPassword = $user->password; + + $response = $this->from(route('password.reset', ['token' => 'invalid-token', 'email' => $user->email])) + ->post(route('password.update'), [ + 'token' => 'invalid-token', + 'email' => $user->email, + 'password' => 'new-secure-password', + 'password_confirmation' => 'new-secure-password', + ]); + + $response + ->assertRedirect(route('password.reset', ['token' => 'invalid-token', 'email' => $user->email])) + ->assertSessionHasErrors(['email']); + + $user->refresh(); + $this->assertSame($originalPassword, $user->password); + $this->assertSame('legacy-guid', $user->resetguid); + } + + protected function createSchema(): void + { + Schema::create('settings', function (Blueprint $table): void { + $table->string('name')->primary(); + $table->text('value')->nullable(); + }); + + Schema::create('roles', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->string('guard_name'); + $table->integer('rate_limit')->default(60); + $table->boolean('isdefault')->default(false); + $table->unsignedInteger('defaultinvites')->default(0); + $table->timestamps(); + }); + + Schema::create('permissions', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + }); + + Schema::create('users', function (Blueprint $table): void { + $table->increments('id'); + $table->string('username'); + $table->string('email')->unique(); + $table->string('password'); + $table->string('resetguid')->nullable(); + $table->unsignedInteger('roles_id')->default(1); + $table->integer('rate_limit')->default(60); + $table->string('api_token')->nullable(); + $table->boolean('verified')->default(true); + $table->boolean('can_post')->default(true); + $table->timestamp('email_verified_at')->nullable(); + $table->timestamp('lastlogin')->nullable(); + $table->string('host')->nullable(); + $table->rememberToken(); + $table->string('session_token', 60)->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table): void { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('model_has_roles', function (Blueprint $table): void { + $table->unsignedInteger('role_id'); + $table->string('model_type'); + $table->unsignedInteger('model_id'); + $table->primary(['role_id', 'model_id', 'model_type']); + }); + + Schema::create('model_has_permissions', function (Blueprint $table): void { + $table->unsignedInteger('permission_id'); + $table->string('model_type'); + $table->unsignedInteger('model_id'); + $table->primary(['permission_id', 'model_id', 'model_type']); + }); + + Schema::create('role_has_permissions', function (Blueprint $table): void { + $table->unsignedInteger('permission_id'); + $table->unsignedInteger('role_id'); + $table->primary(['permission_id', 'role_id']); + }); + + Schema::create('user_activities', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('user_id')->nullable(); + $table->string('username'); + $table->string('activity_type', 50); + $table->text('description'); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + } + + protected function seedSettings(): void + { + DB::table('settings')->insert([ + ['name' => 'title', 'value' => 'NNTmux Test'], + ['name' => 'home_link', 'value' => '/'], + ['name' => 'categorizeforeign', 'value' => '0'], + ['name' => 'catwebdl', 'value' => '0'], + ]); + } + + /** + * @param array $overrides + */ + protected function createUser(string $email, array $overrides = []): User + { + $role = Role::query()->firstOrCreate( + ['name' => 'User', 'guard_name' => 'web'], + ['rate_limit' => 60, 'isdefault' => true, 'defaultinvites' => 1], + ); + + return User::query()->create(array_merge([ + 'username' => 'user_'.md5($email), + 'email' => $email, + 'password' => Hash::make('old-secure-password'), + 'roles_id' => $role->id, + 'rate_limit' => 60, + 'api_token' => md5($email), + 'verified' => true, + 'email_verified_at' => now(), + 'lastlogin' => now(), + ], $overrides)); + } +} diff --git a/tests/Feature/FailedReleasesControllerSecurityTest.php b/tests/Feature/FailedReleasesControllerSecurityTest.php new file mode 100644 index 000000000..10d41438c --- /dev/null +++ b/tests/Feature/FailedReleasesControllerSecurityTest.php @@ -0,0 +1,132 @@ + 'base64:'.base64_encode(random_bytes(32)), + 'cache.default' => 'array', + 'database.default' => 'sqlite', + 'database.connections.sqlite.database' => ':memory:', + 'session.driver' => 'array', + ]); + DB::purge(); + DB::reconnect(); + $this->createSchema(); + $this->seedSettings(); + } + public function test_failed_callback_rejects_userid_without_session_or_api_token(): void + { + $this->createUser(1, 'rss-token'); + Search::shouldReceive('searchReleases')->never(); + $this->get(route('failed', [ + 'guid' => 'failed-guid', + 'userid' => 1, + ]))->assertStatus(401) + ->assertHeader('X-DNZB-RCode', 401); + } + public function test_failed_callback_uses_api_token_user_and_preserves_token_for_alternate_download(): void + { + $this->createUser(1, 'rss-token'); + $this->createUser(999, 'other-token'); + $this->createRelease(10, 'failed-guid', 'Show.Name.1080p.WEB-DL-GROUP', 5000, '2026-06-11 00:00:00'); + $this->createRelease(20, 'alternate-guid', 'Show.Name.1080p.PROPER-GROUP', 5000, '2026-06-12 00:00:00'); + Search::shouldReceive('searchReleases') + ->once() + ->with('Show.Name.1080p', 10) + ->andReturn([20]); + $response = $this->get(route('failed', [ + 'guid' => 'failed-guid', + 'userid' => 999, + 'api_token' => 'rss-token', + ])); + $response->assertOk(); + $this->assertSame( + url('/getnzb').'?id=alternate-guid&r=rss-token', + $response->headers->get('Location'), + ); + $this->assertDatabaseHas('dnzb_failures', [ + 'release_id' => 10, + 'users_id' => 1, + 'failed' => 1, + ]); + $this->assertDatabaseMissing('dnzb_failures', [ + 'release_id' => 10, + 'users_id' => 999, + ]); + } + private function createSchema(): void + { + Schema::create('settings', function (Blueprint $table): void { + $table->string('name')->primary(); + $table->text('value')->nullable(); + }); + Schema::create('users', function (Blueprint $table): void { + $table->increments('id'); + $table->string('username'); + $table->string('email')->unique(); + $table->string('password'); + $table->unsignedInteger('roles_id')->default(1); + $table->string('api_token')->nullable(); + $table->boolean('verified')->default(true); + $table->timestamp('email_verified_at')->nullable(); + $table->rememberToken(); + $table->timestamps(); + $table->softDeletes(); + }); + Schema::create('releases', function (Blueprint $table): void { + $table->increments('id'); + $table->string('guid')->unique(); + $table->string('searchname'); + $table->unsignedInteger('categories_id'); + $table->timestamp('postdate')->nullable(); + }); + Schema::create('dnzb_failures', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('release_id'); + $table->unsignedInteger('users_id'); + $table->boolean('failed')->default(true); + }); + } + private function seedSettings(): void + { + DB::table('settings')->insert([ + ['name' => 'innerfileblacklist', 'value' => ''], + ['name' => 'categorizeforeign', 'value' => '0'], + ['name' => 'catwebdl', 'value' => '0'], + ]); + } + private function createUser(int $id, string $apiToken): void + { + DB::table('users')->insert([ + 'id' => $id, + 'username' => 'user'.$id, + 'email' => 'user'.$id.'@example.test', + 'password' => 'unused', + 'roles_id' => 1, + 'api_token' => $apiToken, + 'verified' => true, + 'email_verified_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + private function createRelease(int $id, string $guid, string $searchName, int $categoryId, string $postDate): void + { + DB::table('releases')->insert([ + 'id' => $id, + 'guid' => $guid, + 'searchname' => $searchName, + 'categories_id' => $categoryId, + 'postdate' => $postDate, + ]); + } +} diff --git a/tests/Feature/Mail/MailRenderingTest.php b/tests/Feature/Mail/MailRenderingTest.php index 1c702ee33..f19a8398f 100644 --- a/tests/Feature/Mail/MailRenderingTest.php +++ b/tests/Feature/Mail/MailRenderingTest.php @@ -15,7 +15,6 @@ use App\Mail\ForgottenPassword; use App\Mail\IncidentDetected; use App\Mail\InvitationMail; use App\Mail\NewAccountCreatedEmail; -use App\Mail\PasswordReset; use App\Mail\WelcomeEmail; use App\Models\Invitation; use App\Models\ServiceIncident; @@ -69,7 +68,6 @@ class MailRenderingTest extends TestCase yield 'WelcomeEmail' => [static fn () => new WelcomeEmail($userFactory())]; yield 'ForgottenPassword' => [static fn () => new ForgottenPassword('https://example.test/reset/abc123')]; - yield 'PasswordReset' => [static fn () => new PasswordReset($userFactory(), 'TempPass!2026')]; yield 'NewAccountCreatedEmail' => [static fn () => new NewAccountCreatedEmail($userFactory())]; yield 'AccountChange' => [static fn () => new AccountChange($userFactory())]; yield 'AccountWillExpire' => [static fn () => new AccountWillExpire($userFactory(), 5)]; @@ -205,7 +203,6 @@ class MailRenderingTest extends TestCase $cases = [ [new WelcomeEmail($user), '[NNTmux] Welcome to NNTmux'], [new ForgottenPassword('http://example.test/x'), '[NNTmux] Reset your password'], - [new PasswordReset($user, 'tmp'), '[NNTmux] Your password has been reset'], [new NewAccountCreatedEmail($user), '[NNTmux] New account registered'], [new AccountChange($user), '[NNTmux] Account level changed'], [new AccountWillExpire($user, 1), '[NNTmux] Your account is about to expire'],