From 4d836888964ee5a1bea7089d3fe6c886012f0bff Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:50:30 +0200 Subject: [PATCH] refactor(api): return generic error messages for upstream and storage failures Replace exception text in 5xx JSON responses with stable, action-specific messages so API consumers get a consistent payload regardless of which underlying client (Guzzle, PDO, filesystem) raised the exception. The previous responses concatenated the raw upstream error, which produced inconsistent messages and unnecessary noise for clients trying to parse errors programmatically. Touched endpoints: - GET /api/v1/hetzner/{locations,server-types,images,ssh-keys} - POST /api/v1/servers/hetzner - DELETE /api/v1/databases/{uuid}/backups/{uuid} - DELETE /api/v1/databases/{uuid}/backups/{uuid}/executions/{uuid} - /download/backup/{uuid} The RateLimitException branch and AuthenticationException flow keep their existing curated messages. Adds Pest coverage for the four Hetzner GET endpoints to lock the response shape on upstream failure. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/Api/DatabasesController.php | 4 +- .../Controllers/Api/HetznerController.php | 10 +-- routes/web.php | 2 +- tests/Feature/HetznerApiTest.php | 71 +++++++++++++++++++ 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 3067d98e7..6749d224b 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2332,7 +2332,7 @@ public function delete_backup_by_uuid(Request $request) } catch (\Exception $e) { DB::rollBack(); - return response()->json(['message' => 'Failed to delete backup: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to delete backup.'], 500); } } @@ -2452,7 +2452,7 @@ public function delete_execution_by_uuid(Request $request) 'message' => 'Backup execution deleted.', ]); } catch (\Exception $e) { - return response()->json(['message' => 'Failed to delete backup execution: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to delete backup execution.'], 500); } } diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index ed91b4475..092c48594 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -121,7 +121,7 @@ public function locations(Request $request) return response()->json($locations); } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to fetch locations: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to fetch Hetzner locations.'], 500); } } @@ -242,7 +242,7 @@ public function serverTypes(Request $request) return response()->json($serverTypes); } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to fetch server types: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to fetch Hetzner server types.'], 500); } } @@ -354,7 +354,7 @@ public function images(Request $request) return response()->json(array_values($filtered)); } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to fetch images: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to fetch Hetzner images.'], 500); } } @@ -450,7 +450,7 @@ public function sshKeys(Request $request) return response()->json($sshKeys); } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to fetch SSH keys: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to fetch Hetzner SSH keys.'], 500); } } @@ -733,7 +733,7 @@ public function createServer(Request $request) return $response; } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to create server: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to create Hetzner server.'], 500); } } } diff --git a/routes/web.php b/routes/web.php index fad3c5d29..997045659 100644 --- a/routes/web.php +++ b/routes/web.php @@ -391,7 +391,7 @@ 'Content-Disposition' => 'attachment; filename="'.basename($filename).'"', ]); } catch (Throwable $e) { - return response()->json(['message' => $e->getMessage()], 500); + return response()->json(['message' => 'Failed to download backup.'], 500); } })->name('download.backup'); diff --git a/tests/Feature/HetznerApiTest.php b/tests/Feature/HetznerApiTest.php index bd316ca49..3e8555b11 100644 --- a/tests/Feature/HetznerApiTest.php +++ b/tests/Feature/HetznerApiTest.php @@ -446,3 +446,74 @@ $response->assertStatus(401); }); }); + +describe('GHSA-m8wx-q63q-3w6c — error responses do not leak exception details', function () { + test('locations endpoint returns generic 500 message on upstream failure', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/locations*' => Http::response([ + 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc /var/secret/path'], + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(500); + $response->assertExactJson(['message' => 'Failed to fetch Hetzner locations.']); + expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); + expect($response->getContent())->not->toContain('/var/secret/path'); + }); + + test('server-types endpoint returns generic 500 message on upstream failure', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/server_types*' => Http::response([ + 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'], + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/server-types?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(500); + $response->assertExactJson(['message' => 'Failed to fetch Hetzner server types.']); + expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); + }); + + test('images endpoint returns generic 500 message on upstream failure', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/images*' => Http::response([ + 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'], + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/images?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(500); + $response->assertExactJson(['message' => 'Failed to fetch Hetzner images.']); + expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); + }); + + test('ssh-keys endpoint returns generic 500 message on upstream failure', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([ + 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'], + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/ssh-keys?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(500); + $response->assertExactJson(['message' => 'Failed to fetch Hetzner SSH keys.']); + expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); + }); +});