diff --git a/.env.example b/.env.example index 0098bb9..36b80eb 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,10 @@ CLOUDFLARE_R2_SECRET_ACCESS_KEY= CLOUDFLARE_R2_BUCKET= CLOUDFLARE_R2_ENDPOINT= CLOUDFLARE_R2_URL= +CLOUDFLARE_R2_USE_PATH_STYLE_ENDPOINT=false # set to true if using localstack CLOUDFLARE_R2_BACKUP_BUCKET= +HMAC_SECRET= + VITE_APP_NAME="${APP_NAME}" +VITE_API_URL="${APP_URL}/api" diff --git a/app/Console/Commands/CreateBackup.php b/app/Console/Commands/CreateBackup.php index cad3e23..0938ff7 100644 --- a/app/Console/Commands/CreateBackup.php +++ b/app/Console/Commands/CreateBackup.php @@ -6,10 +6,12 @@ use Illuminate\Encryption\Encrypter; use Illuminate\Encryption\EncryptionServiceProvider; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Storage; class CreateBackup extends Command { - private const THREE_DAYS_IN_SECONDS = 259200; + public const TIME_LIMIT_IN_SECONDS = 259200; // 3 days + public const MIN_BACKUPS_TO_KEEP = 3; /** * The name and signature of the console command. @@ -57,17 +59,15 @@ public function handle(): void { protected function pgSqlBackup(string $user, string $password, string $host, string $port, string $database): void { $backup_file_name = date('Y-m-d_H-i-s') . '_backup.dump'; - $temp_file_path = storage_path('backups/' . $backup_file_name); + $temp_file_path = Storage::disk('local')->path('backups/' . $backup_file_name); try { - $backups_path = storage_path('backups'); - if (!is_dir($backups_path)) { - mkdir($backups_path, 0755, true); + if (!Storage::disk('local')->exists('backups')) { + Storage::disk('local')->makeDirectory('backups'); } $command = sprintf( - 'PGPASSWORD=%s pg_dump --username=%s --host=%s --port=%s --dbname=%s --file=%s --no-password -F c -Z 3 -c --if-exists', - escapeshellarg($password), + 'pg_dump --username=%s --host=%s --port=%s --dbname=%s --file=%s --no-password -F c -Z 3 -c --if-exists', escapeshellarg($user), escapeshellarg($host), escapeshellarg($port), @@ -75,20 +75,18 @@ protected function pgSqlBackup(string $user, string $password, string $host, str escapeshellarg($temp_file_path) ); - $result = 0; - $output = []; - exec($command, $output, $result); - if ($result !== 0) { - $this->error("Failed to create backup. Command output: " . implode("\n", $output)); + $result = Process::env(['PGPASSWORD' => $password])->run($command); + if ($result->failed()) { + $this->error('Failed to create backup: ' . $result->errorOutput()); return; } $files = Storage::disk('backups')->files(); - if (count($files) > 3) { + if (count($files) >= static::MIN_BACKUPS_TO_KEEP) { $now = time(); foreach ($files as $file) { $last_modified = Storage::disk('backups')->lastModified($file); - if ($now - $last_modified > static::THREE_DAYS_IN_SECONDS) { + if ($now - $last_modified > static::TIME_LIMIT_IN_SECONDS) { Storage::disk('backups')->delete($file); } } @@ -96,12 +94,14 @@ protected function pgSqlBackup(string $user, string $password, string $host, str Storage::disk('backups')->put( $backup_file_name, - $this->encrypter->encryptString(file_get_contents($temp_file_path)), + $this->encrypter->encryptString(Storage::disk('local')->get('backups/' . $backup_file_name)), 'private' ); + + $this->info('Backup created successfully: ' . $backup_file_name); } finally { - if (file_exists($temp_file_path)) { - unlink($temp_file_path); + if (Storage::disk('local')->exists('backups/' . $backup_file_name)) { + Storage::disk('local')->delete('backups/' . $backup_file_name); } } } @@ -110,7 +110,6 @@ protected function parseKey(string $key): string { $provider = new EncryptionServiceProvider(app()); $reflection = new \ReflectionClass($provider); $method = $reflection->getMethod('parseKey'); - $method->setAccessible(true); return $method->invoke($provider, ['key' => $key]); } } diff --git a/app/Http/Controllers/WebHooksController.php b/app/Http/Controllers/WebHooksController.php new file mode 100644 index 0000000..f6168d6 --- /dev/null +++ b/app/Http/Controllers/WebHooksController.php @@ -0,0 +1,22 @@ +middleware(VerifyHMAC::class); + } + + public function healthCheck() { + return response()->json(['success' => true, 'message' => 'OK']); + } + + public function createBackup() { + Artisan::call('db:backup'); + return response()->json(['success' => true, 'message' => 'Backup created.']); + } +} diff --git a/app/Http/Middleware/VerifyHMAC.php b/app/Http/Middleware/VerifyHMAC.php new file mode 100644 index 0000000..1c54c9c --- /dev/null +++ b/app/Http/Middleware/VerifyHMAC.php @@ -0,0 +1,65 @@ +header('X-Signature'); + if (empty($signature)) { + abort(Response::HTTP_UNAUTHORIZED, 'Unauthorized.'); + } + + $datetime = $request->header('X-Time'); + if (empty($datetime)) { + abort(Response::HTTP_UNAUTHORIZED, 'Unauthorized.'); + } + + $datetime_parsed = $this->parseTime($datetime); + $valid_from = Carbon::now('UTC')->subMinutes(static::VALID_MINUTES); + if ($datetime_parsed->lt($valid_from) || $datetime_parsed->gt(Carbon::now('UTC')->addSeconds(static::VALID_SECONDS))) { + abort(Response::HTTP_UNAUTHORIZED, 'Unauthorized.'); + } + + $url = $request->url(); + $payload = $request->all(); + $hash = static::generateHMAC($url, $datetime, $payload); + + if (!hash_equals($hash, $signature)) { + abort(Response::HTTP_UNAUTHORIZED, 'Unauthorized.'); + } + + set_time_limit(static::FIFTEEN_MINUTES_IN_SECONDS); + return $next($request); + } + + public static function generateHMAC(string $url, string $datetime, array $payload): string { + ksort($payload); + $data = $url . $datetime . json_encode($payload); + + return hash_hmac('sha256', $data, config('app.hmac.secret')); + } + + private function parseTime(string $datetime): Carbon { + try { + return Carbon::parse($datetime, 'UTC'); + } catch (\Exception) { + abort(Response::HTTP_UNAUTHORIZED, 'Unauthorized.'); + } + } +} diff --git a/config/app.php b/config/app.php index 4707728..f84c702 100644 --- a/config/app.php +++ b/config/app.php @@ -101,6 +101,10 @@ 'backups-key' => env('APP_BACKUPS_KEY'), + 'hmac' => [ + 'secret' => env('HMAC_SECRET'), + ], + 'previous_keys' => [ ...array_filter( explode(',', env('APP_PREVIOUS_KEYS', '')) diff --git a/phpunit.xml b/phpunit.xml index bd34f3f..9aacff9 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -20,6 +20,8 @@ + + @@ -30,5 +32,6 @@ + diff --git a/routes/web.php b/routes/web.php index c4dc36f..958f3e4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ name('forgot.password.token'); +Route::controller(WebHooksController::class)->prefix('hooks')->group(function() { + Route::any('/health', 'healthCheck')->name('hook.health'); + Route::post('/db/backup', 'createBackup')->name('hook.db.backup'); +}); + Route::fallback(function() { return view('app'); }); \ No newline at end of file diff --git a/tests/Feature/Public/Middleware/VerifyHMACTest.php b/tests/Feature/Public/Middleware/VerifyHMACTest.php new file mode 100644 index 0000000..5199edd --- /dev/null +++ b/tests/Feature/Public/Middleware/VerifyHMACTest.php @@ -0,0 +1,130 @@ +logout(); + } + + #[Test] + public function aborts_without_signature(): void { + $response = $this->post(route('hook.health'), []); + $response->assertStatus(Response::HTTP_UNAUTHORIZED); + } + + #[Test] + public function aborts_without_timestamp(): void { + $response = $this->post(route('hook.health'), [], [ + 'X-Signature' => 'some-signature', + ]); + + $response->assertStatus(Response::HTTP_UNAUTHORIZED); + } + + #[Test] + public function aborts_with_garbage_timestamp(): void { + $response = $this->post(route('hook.health'), [], [ + 'X-Signature' => 'some-signature', + 'X-Time' => 'not-a-timestamp', + ]); + + $response->assertStatus(Response::HTTP_UNAUTHORIZED); + } + + #[Test] + public function aborts_with_expired_timestamp(): void { + $response = $this->post(route('hook.health'), [], [ + 'X-Signature' => 'some-signature', + 'X-Time' => Carbon::now('UTC')->subMinutes(VerifyHMAC::VALID_MINUTES)->toIso8601String(), + ]); + + $response->assertStatus(Response::HTTP_UNAUTHORIZED); + } + + #[Test] + public function aborts_with_future_timestamp(): void { + $response = $this->post(route('hook.health'), [], [ + 'X-Signature' => 'some-signature', + 'X-Time' => Carbon::now('UTC')->addSeconds(VerifyHMAC::VALID_SECONDS + 5)->toIso8601String(), + ]); + + $response->assertStatus(Response::HTTP_UNAUTHORIZED); + } + + #[Test] + public function aborts_with_invalid_signature(): void { + $response = $this->post(route('hook.health'), [], [ + 'X-Signature' => 'some-signature', + 'X-Time' => Carbon::now('UTC')->toIso8601String(), + ]); + + $response->assertStatus(Response::HTTP_UNAUTHORIZED); + } + + #[Test] + public function aborts_with_tampered_signature(): void { + $url = route('hook.health'); + $datetime = Carbon::now('UTC')->toIso8601String(); + $signature = VerifyHMAC::generateHMAC( + route('hook.health'), + Carbon::now('UTC')->toIso8601String(), + [] + ); + + $response = $this->post($url, ['invalid' => 'data'], [ + 'X-Signature' => $signature, + 'X-Time' => $datetime, + ]); + + $response->assertStatus(Response::HTTP_UNAUTHORIZED); + } + + #[Test] + public function validates_valid_post_request(): void { + $url = route('hook.health'); + $datetime = Carbon::now('UTC')->toIso8601String(); + $payload = ['some' => 'data', 'another' => 'field']; + $signature = VerifyHMAC::generateHMAC( + route('hook.health'), + Carbon::now('UTC')->toIso8601String(), + $payload + ); + + $response = $this->post($url, $payload, [ + 'X-Signature' => $signature, + 'X-Time' => $datetime, + ]); + + $response->assertStatus(Response::HTTP_OK); + $response->assertJson(['success' => true, 'message' => 'OK']); + } + + #[Test] + public function validates_valid_get_request(): void { + $url = route('hook.health'); + $datetime = Carbon::now('UTC')->toIso8601String(); + $payload = ['some' => 'data', 'another' => 'field']; + $signature = VerifyHMAC::generateHMAC( + route('hook.health'), + Carbon::now('UTC')->toIso8601String(), + $payload + ); + + $response = $this->get($url . '?' . http_build_query($payload), [ + 'X-Signature' => $signature, + 'X-Time' => $datetime, + ]); + + $response->assertStatus(Response::HTTP_OK); + $response->assertJson(['success' => true, 'message' => 'OK']); + } +} diff --git a/tests/Feature/Public/WebhooksTest.php b/tests/Feature/Public/WebhooksTest.php new file mode 100644 index 0000000..d71fc51 --- /dev/null +++ b/tests/Feature/Public/WebhooksTest.php @@ -0,0 +1,33 @@ +logout(); + } + + #[Test] + public function db_backup_route_calls_backup_command(): void { + Artisan::expects('call')->with('db:backup')->once(); + + $url = route('hook.db.backup'); + $datetime = Carbon::now('UTC')->toIso8601String(); + $signature = VerifyHMAC::generateHMAC($url, $datetime, []); + $response = $this->post($url, [], [ + 'X-Signature' => $signature, + 'X-Time' => $datetime, + ]); + + $response->assertStatus(Response::HTTP_OK); + } +} diff --git a/tests/Unit/App/Console/Commands/CreateBackupTest.php b/tests/Unit/App/Console/Commands/CreateBackupTest.php new file mode 100644 index 0000000..50abea2 --- /dev/null +++ b/tests/Unit/App/Console/Commands/CreateBackupTest.php @@ -0,0 +1,183 @@ +connection = config('database.default'); + config(['database.default' => 'pgsql']); + + Storage::expects('disk')->zeroOrMoreTimes()->andReturnSelf(); + Storage::expects('path')->atLeast()->never()->andReturnArg(0); + + // get current test name + $test_name = $this->name(); + if (!str_contains($test_name, 'create') || !str_contains($test_name, 'directory')) { + Storage::expects('makeDirectory')->atLeast()->never(); + } + } + + #[Test] + public function does_nothing_for_unsupported_db_connection(): void { + $connection = Mockery::mock(Connection::class); + $connection->expects('getDriverName') + ->once() + ->andReturn('random-connection'); + + DB::expects('connection') + ->once() + ->andReturn($connection); + + $this->artisan('db:backup') + ->expectsOutput("Database driver 'random-connection' is not supported for backups.") + ->assertExitCode(0); + } + + #[Test] + public function creates_directory_if_not_exists(): void { + Process::fake([ + '*' => Process::result( + errorOutput: 'Some error occurred', + exitCode: 1, + ), + ]); + + Storage::expects('exists')->twice()->andReturnFalse(); + Storage::expects('makeDirectory')->once()->with('backups'); + + $this->artisan('db:backup'); + } + + #[Test] + public function does_not_create_directory_if_exists(): void { + Process::fake([ + '*' => Process::result( + errorOutput: 'Some error occurred', + exitCode: 1, + ), + ]); + + Storage::expects('exists')->twice()->andReturn(true, false); + Storage::expects('makeDirectory')->never(); + + $this->artisan('db:backup'); + } + + #[Test] + public function errors_when_dump_command_fails(): void { + $error = 'Some error occurred'; + + Process::fake([ + '*' => Process::result( + errorOutput: $error, + exitCode: 1, + ), + ]); + + Storage::expects('exists')->twice()->andReturnFalse(); + Storage::expects('delete')->never(); + + $this->artisan('db:backup') + ->expectsOutputToContain('Failed to create backup: ' . $error) + ->assertExitCode(0); + } + + #[Test] + public function should_delete_temp_file_on_storage_failures(): void { + Process::fake([ + '*' => Process::result(exitCode: 0), + ]); + + Storage::expects('files')->once()->andThrow(new \Exception('Storage failure')); + Storage::expects('exists')->twice()->andReturnTrue(); + Storage::expects('delete')->once(); + + $this->expectExceptionMessage('Storage failure'); + $this->artisan('db:backup'); + } + + #[Test] + public function succeeds_and_does_not_delete_when_less_than_min_number_of_backups_exist(): void { + Process::fake([ + '*' => Process::result(exitCode: 0), + ]); + + Storage::expects('files')->once()->andReturn(array_fill(0, CreateBackup::MIN_BACKUPS_TO_KEEP - 1, 'backup.dump')); + Storage::expects('lastModified')->never(); + $this->expectBackupFileUploaded(); + + $this->artisan('db:backup') + ->expectsOutputToContain('Backup created successfully') + ->assertExitCode(0); + } + + #[Test] + public function succeeds_and_does_not_delete_when_backups_are_newer_than_days_limit(): void { + Process::fake([ + '*' => Process::result(exitCode: 0), + ]); + + $now = time(); + Storage::expects('files')->once()->andReturn(array_fill(0, CreateBackup::MIN_BACKUPS_TO_KEEP, 'backup.dump')); + Storage::expects('lastModified')->times(CreateBackup::MIN_BACKUPS_TO_KEEP)->andReturn($now); + $this->expectBackupFileUploaded(); + + $this->artisan('db:backup') + ->expectsOutputToContain('Backup created successfully') + ->assertExitCode(0); + } + + #[Test] + public function succeeds_and_deletes_oldest_backup_only(): void { + Process::fake([ + '*' => Process::result(exitCode: 0), + ]); + + $now = time(); + Storage::expects('files')->once()->andReturn(array_fill(0, CreateBackup::MIN_BACKUPS_TO_KEEP, 'backup.dump')); + Storage::expects('lastModified') + ->times(CreateBackup::MIN_BACKUPS_TO_KEEP) + ->andReturnValues(array_merge( + [$now - CreateBackup::TIME_LIMIT_IN_SECONDS - 10], + array_fill(0, CreateBackup::MIN_BACKUPS_TO_KEEP - 1, $now) + )); + + $this->expectBackupFileUploaded(1); + + $this->artisan('db:backup') + ->expectsOutputToContain('Backup created successfully') + ->assertExitCode(0); + } + + protected function expectBackupFileUploaded(int $old_backups_to_delete = 0): void { + $content = 'backup content'; + Storage::expects('get')->once()->andReturn($content); + + // In test environment app key and app backups key are the same + Storage::expects('put')->once()->with(new AnyArgs(), encrypt($content), 'private'); + + // Delete should be called once for the temporary file plus any old backups + Storage::expects('exists')->twice()->andReturnTrue(); + Storage::expects('delete')->times(1 + $old_backups_to_delete); + } + + protected function tearDown(): void { + config(['database.default' => $this->connection]); + + parent::tearDown(); + } +}