From 2ad6e28318731c5d34c4fff48495d8f7b06209f0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 2 Sep 2026 19:25:16 +0930 Subject: [PATCH] Add timeout, retry and error handling to remote auth HTTP calls RemoteAuthService::getVerifyCredentials, getFollowing and getToken made outbound HTTP requests to a user-controlled remote instance during the Mastodon login flow with no timeout, no retry and no exception handling. A slow or hostile instance could hang the request or surface an uncaught exception. Wrap all three in timeout(20)->retry(3, 750) with try/catch that returns false on failure, matching the existing pattern in isDomainCompatible(). Callers already treat a falsy return as a failure; add the missing guard at the one verify_credentials call site that accessed the result array without checking it first. Adds RemoteAuthServiceTest covering connection failure, server error and success paths. --- app/Http/Controllers/RemoteAuthController.php | 1 + app/Services/Account/RemoteAuthService.php | 51 ++++++++-- tests/Feature/RemoteAuthServiceTest.php | 99 +++++++++++++++++++ 3 files changed, 141 insertions(+), 10 deletions(-) create mode 100644 tests/Feature/RemoteAuthServiceTest.php diff --git a/app/Http/Controllers/RemoteAuthController.php b/app/Http/Controllers/RemoteAuthController.php index 4b889c933..00bfa397a 100644 --- a/app/Http/Controllers/RemoteAuthController.php +++ b/app/Http/Controllers/RemoteAuthController.php @@ -326,6 +326,7 @@ class RemoteAuthController extends Controller $token = $request->session()->get('oauth_remote_session_token'); $res = RemoteAuthService::getVerifyCredentials($domain, $token); + abort_if(! $res || ! isset($res['acct']), 403, 'Invalid credentials'); $res['_webfinger'] = strtolower('@'.$res['acct'].'@'.$domain); $res['_domain'] = strtolower($domain); $request->session()->put('oauth_remasto_id', $res['id']); diff --git a/app/Services/Account/RemoteAuthService.php b/app/Services/Account/RemoteAuthService.php index 3e59bf64e..bc9a98ed8 100644 --- a/app/Services/Account/RemoteAuthService.php +++ b/app/Services/Account/RemoteAuthService.php @@ -71,14 +71,23 @@ class RemoteAuthService } $url = 'https://'.$domain.'/oauth/token'; - $res = Http::asForm()->post($url, [ - 'code' => $code, - 'grant_type' => 'authorization_code', - 'client_id' => $raw->client_id, - 'client_secret' => $raw->client_secret, - 'redirect_uri' => $raw->redirect_uri, - 'scope' => 'read', - ]); + + try { + $res = Http::asForm()->timeout(20)->retry(3, 750)->post($url, [ + 'code' => $code, + 'grant_type' => 'authorization_code', + 'client_id' => $raw->client_id, + 'client_secret' => $raw->client_secret, + 'redirect_uri' => $raw->redirect_uri, + 'scope' => 'read', + ]); + } catch (RequestException $e) { + return false; + } catch (ConnectionException $e) { + return false; + } catch (\Exception $e) { + return false; + } return $res; } @@ -92,7 +101,18 @@ class RemoteAuthService $url = 'https://'.$domain.'/api/v1/accounts/verify_credentials'; - $res = Http::withToken($code)->get($url); + try { + $res = Http::withToken($code)->timeout(20)->retry(3, 750)->get($url); + if (! $res->ok()) { + return false; + } + } catch (RequestException $e) { + return false; + } catch (ConnectionException $e) { + return false; + } catch (\Exception $e) { + return false; + } return $res->json(); } @@ -108,7 +128,18 @@ class RemoteAuthService $key = self::CACHE_KEY.'get-following:code:'.substr($code, 0, 16).substr($code, -5).':domain:'.$domain.':id:'.$id; return Cache::remember($key, 3600, function () use ($url, $code) { - $res = Http::withToken($code)->get($url); + try { + $res = Http::withToken($code)->timeout(20)->retry(3, 750)->get($url); + if (! $res->ok()) { + return false; + } + } catch (RequestException $e) { + return false; + } catch (ConnectionException $e) { + return false; + } catch (\Exception $e) { + return false; + } return $res->json(); }); diff --git a/tests/Feature/RemoteAuthServiceTest.php b/tests/Feature/RemoteAuthServiceTest.php new file mode 100644 index 000000000..af67195d8 --- /dev/null +++ b/tests/Feature/RemoteAuthServiceTest.php @@ -0,0 +1,99 @@ + $domain, + 'client_id' => 'cid', + 'client_secret' => 'secret', + 'redirect_uri' => url('/auth/mastodon/callback'), + 'active' => true, + 'banned' => false, + ]); + } + + #[Test] + public function verify_credentials_returns_false_on_connection_failure() + { + $this->activeInstance(); + + Http::fake(function () { + throw new ConnectionException('timed out'); + }); + + $res = RemoteAuthService::getVerifyCredentials('mastodon.example', 'token'); + + $this->assertFalse($res); + } + + #[Test] + public function verify_credentials_returns_false_on_server_error() + { + $this->activeInstance(); + + Http::fake([ + '*' => Http::response('nope', 500), + ]); + + $res = RemoteAuthService::getVerifyCredentials('mastodon.example', 'token'); + + $this->assertFalse($res); + } + + #[Test] + public function verify_credentials_returns_json_on_success() + { + $this->activeInstance(); + + Http::fake([ + '*' => Http::response(['acct' => 'alice', 'id' => '1'], 200), + ]); + + $res = RemoteAuthService::getVerifyCredentials('mastodon.example', 'token'); + + $this->assertIsArray($res); + $this->assertSame('alice', $res['acct']); + } + + #[Test] + public function get_following_returns_false_on_connection_failure() + { + $this->activeInstance(); + + Http::fake(function () { + throw new ConnectionException('timed out'); + }); + + $res = RemoteAuthService::getFollowing('mastodon.example', 'token', 42); + + $this->assertFalse($res); + } + + #[Test] + public function get_token_returns_false_on_connection_failure() + { + $this->activeInstance(); + + Http::fake(function () { + throw new ConnectionException('timed out'); + }); + + $res = RemoteAuthService::getToken('mastodon.example', 'code'); + + $this->assertFalse($res); + } +}