Skip to content

Commit ba84eed

Browse files
committed
Add leak test for customer_account handle
1 parent a7f499c commit ba84eed

1 file changed

Lines changed: 150 additions & 0 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Loki\Components\Test\Integration\Util\Controller;
4+
5+
use Loki\Components\Util\Controller\LayoutLoader;
6+
use Loki\Components\Util\Controller\TargetRenderer;
7+
use Magento\Customer\Api\CustomerRepositoryInterface;
8+
use Magento\Customer\Model\Session as CustomerSession;
9+
use Magento\Customer\Test\Fixture\CustomerWithAddresses;
10+
use Magento\Framework\App\ObjectManager;
11+
use Magento\TestFramework\Fixture\AppArea;
12+
use Magento\TestFramework\Fixture\AppIsolation;
13+
use Magento\TestFramework\Fixture\DataFixture;
14+
use Magento\TestFramework\Fixture\DataFixtureStorageManager;
15+
use Magento\TestFramework\Fixture\DbIsolation;
16+
use PHPUnit\Framework\TestCase;
17+
use Yireo\IntegrationTestHelper\Test\Integration\Traits\AssertModuleIsEnabled;
18+
use Yireo\IntegrationTestHelper\Test\Integration\Traits\AssertModuleIsRegistered;
19+
20+
/**
21+
* Security regression guard for layout-handle injection through the AJAX component update.
22+
*
23+
* The frontend endpoint loki_components/index/html forwards request-supplied layout handles
24+
* straight into the layout (see Controller\Index\Html, RequestDataLoader and LayoutLoader),
25+
* without any allow-list. This test asserts that injecting the "customer_account" handle does
26+
* not leak a logged-in customer's sensitive details (email, name, address) into the rendered
27+
* AJAX response.
28+
*/
29+
#[AppArea('frontend')]
30+
class CustomerAccountHandleLeakTest extends TestCase
31+
{
32+
use AssertModuleIsRegistered;
33+
use AssertModuleIsEnabled;
34+
35+
private const SENSITIVE_EMAIL = 'leak-test@example.com';
36+
private const SENSITIVE_FIRSTNAME = 'LeakFirstname';
37+
private const SENSITIVE_LASTNAME = 'LeakLastname';
38+
39+
#[DbIsolation(true)]
40+
#[AppIsolation(true)]
41+
#[DataFixture(
42+
CustomerWithAddresses::class,
43+
[
44+
'email' => self::SENSITIVE_EMAIL,
45+
'firstname' => self::SENSITIVE_FIRSTNAME,
46+
'lastname' => self::SENSITIVE_LASTNAME,
47+
],
48+
'customer'
49+
)]
50+
public function testCustomerAccountHandleDoesNotLeakCustomerDetails(): void
51+
{
52+
$this->assertModuleIsRegistered('Loki_Components');
53+
$this->assertModuleIsEnabled('Loki_Components');
54+
55+
$customer = DataFixtureStorageManager::getStorage()->get('customer');
56+
$customerId = (int)$customer->getId();
57+
$this->assertGreaterThan(0, $customerId, 'Customer fixture was not created');
58+
59+
$this->getCustomerSession()->loginById($customerId);
60+
61+
$html = $this->renderHandle(
62+
['customer_account', 'customer_account_index'],
63+
[
64+
'customer_account_dashboard_info',
65+
'customer_account_dashboard_address',
66+
'customer_account_navigation',
67+
]
68+
);
69+
70+
$this->assertStringContainsString(
71+
'block-dashboard-addresses',
72+
$html,
73+
'The "customer_account" dashboard address block did not render, so the no-leak assertions would be meaningless'
74+
);
75+
76+
foreach ($this->getSensitiveValues($customerId) as $label => $value) {
77+
$this->assertStringNotContainsString(
78+
$value,
79+
$html,
80+
'Sensitive customer detail leaked via the "customer_account" layout handle: '.$label
81+
);
82+
}
83+
}
84+
85+
/**
86+
* Collect the customer details that must never leak into an AJAX response.
87+
*
88+
* Both the dashboard "info" block (email and name from the logged-in customer) and the
89+
* dashboard "address" block (default billing/shipping address from storage) expose
90+
* personal data once the matching layout handles are applied.
91+
*
92+
* @return array<string, string>
93+
*/
94+
private function getSensitiveValues(int $customerId): array
95+
{
96+
$values = [
97+
'email' => self::SENSITIVE_EMAIL,
98+
];
99+
100+
$customer = $this->getCustomerRepository()->getById($customerId);
101+
$defaultBillingId = $customer->getDefaultBilling();
102+
103+
foreach ($customer->getAddresses() as $address) {
104+
if ((string)$address->getId() !== (string)$defaultBillingId) {
105+
continue;
106+
}
107+
108+
$values['address_firstname'] = (string)$address->getFirstname();
109+
$values['address_lastname'] = (string)$address->getLastname();
110+
$values['city'] = (string)$address->getCity();
111+
$values['postcode'] = (string)$address->getPostcode();
112+
$values['telephone'] = (string)$address->getTelephone();
113+
114+
$street = $address->getStreet();
115+
if (!empty($street[0])) {
116+
$values['street'] = (string)$street[0];
117+
}
118+
}
119+
120+
return array_filter($values, static fn (string $value): bool => $value !== '');
121+
}
122+
123+
private function renderHandle(array $handles, array $targets): string
124+
{
125+
$layout = $this->getLayoutLoader()->load($handles, [], true);
126+
$htmlParts = $this->getTargetRenderer()->render($layout, $targets, true);
127+
128+
return implode("\n", $htmlParts);
129+
}
130+
131+
private function getLayoutLoader(): LayoutLoader
132+
{
133+
return ObjectManager::getInstance()->get(LayoutLoader::class);
134+
}
135+
136+
private function getTargetRenderer(): TargetRenderer
137+
{
138+
return ObjectManager::getInstance()->get(TargetRenderer::class);
139+
}
140+
141+
private function getCustomerSession(): CustomerSession
142+
{
143+
return ObjectManager::getInstance()->get(CustomerSession::class);
144+
}
145+
146+
private function getCustomerRepository(): CustomerRepositoryInterface
147+
{
148+
return ObjectManager::getInstance()->get(CustomerRepositoryInterface::class);
149+
}
150+
}

0 commit comments

Comments
 (0)