From 56034a605d8c08f2aeb9c5508fcaac43b40210b2 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Mon, 20 Jul 2026 14:23:33 +0600 Subject: [PATCH] fix(api): omit customer secrets from profile/add responses Web API returned password hash and msCustomer.token via toArray(). Use an allowlisted public DTO for PUT profile and POST add. --- .../Api/Web/CustomerProfileController.php | 5 +- .../Services/Customer/CustomerPublicDto.php | 52 +++++++++++ .../minishop3/tests/CustomerPublicDtoTest.php | 90 +++++++++++++++++++ 3 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 core/components/minishop3/src/Services/Customer/CustomerPublicDto.php create mode 100644 core/components/minishop3/tests/CustomerPublicDtoTest.php diff --git a/core/components/minishop3/src/Controllers/Api/Web/CustomerProfileController.php b/core/components/minishop3/src/Controllers/Api/Web/CustomerProfileController.php index bc5e3348..82853ca5 100644 --- a/core/components/minishop3/src/Controllers/Api/Web/CustomerProfileController.php +++ b/core/components/minishop3/src/Controllers/Api/Web/CustomerProfileController.php @@ -4,6 +4,7 @@ use MiniShop3\MiniShop3; use MiniShop3\Model\msCustomer; +use MiniShop3\Services\Customer\CustomerPublicDto; use MODX\Revolution\modX; use Rakit\Validation\Validator; @@ -126,7 +127,7 @@ public function update(array $data): array return $this->success( $this->modx->lexicon('ms3_customer_profile_updated'), - ['customer' => $customer->toArray()] + ['customer' => CustomerPublicDto::fromCustomer($customer)] ); } @@ -204,7 +205,7 @@ public function updateField(array $data): array $this->modx->lexicon('ms3_customer_profile_updated'), [ $key => $customer->get($key), - 'customer' => $customer->toArray(), + 'customer' => CustomerPublicDto::fromCustomer($customer), ] ); } diff --git a/core/components/minishop3/src/Services/Customer/CustomerPublicDto.php b/core/components/minishop3/src/Services/Customer/CustomerPublicDto.php new file mode 100644 index 00000000..3b43041c --- /dev/null +++ b/core/components/minishop3/src/Services/Customer/CustomerPublicDto.php @@ -0,0 +1,52 @@ + + */ + private const PUBLIC_FIELDS = [ + 'id', + 'first_name', + 'last_name', + 'email', + 'phone', + 'email_verified_at', + 'is_active', + 'created_at', + 'updated_at', + 'last_login_at', + 'orders_count', + 'total_spent', + 'last_order_at', + 'privacy_accepted_at', + ]; + + /** + * @param array $customerFields + * @return array + */ + public static function fromArray(array $customerFields): array + { + return array_intersect_key($customerFields, array_flip(self::PUBLIC_FIELDS)); + } + + /** + * @return array + */ + public static function fromCustomer(msCustomer $customer): array + { + return self::fromArray($customer->toArray()); + } +} diff --git a/core/components/minishop3/tests/CustomerPublicDtoTest.php b/core/components/minishop3/tests/CustomerPublicDtoTest.php new file mode 100644 index 00000000..c8bb1ccf --- /dev/null +++ b/core/components/minishop3/tests/CustomerPublicDtoTest.php @@ -0,0 +1,90 @@ + 42, + 'user_id' => 7, + 'first_name' => 'Ada', + 'last_name' => 'Lovelace', + 'email' => 'ada@example.com', + 'phone' => '+10000000000', + 'password' => '$2y$10$notarealhash', + 'token' => 'session-or-api-secret', + 'email_verified_at' => '2026-01-02 03:04:05', + 'is_active' => true, + 'is_blocked' => true, + 'privacy_ip' => '203.0.113.10', + 'failed_login_attempts' => 3, + 'blocked_until' => '2026-01-01 00:00:00', + 'created_at' => '2025-01-01 00:00:00', + 'updated_at' => '2025-06-01 00:00:00', + 'last_login_at' => '2025-12-01 00:00:00', + 'orders_count' => 2, + 'total_spent' => 99.5, + 'last_order_at' => '2025-11-01 00:00:00', + 'privacy_accepted_at' => '2025-01-01 00:00:00', + 'future_secret_column' => 'must-not-leak', +]; + +$expected = [ + 'id' => 42, + 'first_name' => 'Ada', + 'last_name' => 'Lovelace', + 'email' => 'ada@example.com', + 'phone' => '+10000000000', + 'email_verified_at' => '2026-01-02 03:04:05', + 'is_active' => true, + 'created_at' => '2025-01-01 00:00:00', + 'updated_at' => '2025-06-01 00:00:00', + 'last_login_at' => '2025-12-01 00:00:00', + 'orders_count' => 2, + 'total_spent' => 99.5, + 'last_order_at' => '2025-11-01 00:00:00', + 'privacy_accepted_at' => '2025-01-01 00:00:00', +]; + +$public = CustomerPublicDto::fromArray($input); + +if ($public !== $expected) { + $fail(sprintf( + "public payload mismatch:\nexpected: %s\nactual: %s", + json_encode($expected, JSON_UNESCAPED_SLASHES), + json_encode($public, JSON_UNESCAPED_SLASHES) + )); +} + +foreach (['password', 'token', 'privacy_ip', 'failed_login_attempts', 'blocked_until', 'is_blocked', 'user_id', 'future_secret_column'] as $hidden) { + if (array_key_exists($hidden, $public)) { + $fail("hidden field still present: {$hidden}"); + } +} + +$controllerSrc = file_get_contents(__DIR__ . '/../src/Controllers/Api/Web/CustomerProfileController.php'); +if ($controllerSrc === false) { + $fail('unable to read CustomerProfileController.php'); +} +if (str_contains($controllerSrc, '$customer->toArray()')) { + $fail('CustomerProfileController still serializes full customer via toArray()'); +} +if (!str_contains($controllerSrc, 'CustomerPublicDto::fromCustomer')) { + $fail('CustomerProfileController missing CustomerPublicDto::fromCustomer'); +} + +fwrite(STDOUT, "OK CustomerPublicDtoTest\n"); +exit(0);