Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project are documented in this file. The format foll

## [Unreleased]

## [1.2.2] - 2026-10-10

### Fixed

- Behind a reverse proxy that mounts the shop under a prefix (`X-Forwarded-Prefix`, e.g. `example.com/store`), *Reset two-factor* and *Turn off* (grid and edit page) went back to the bare request path, lost the prefix and landed on whatever lives at the root of the domain. The forms now carry the page's full address and the controller accepts it as long as it points at this site.

## [1.2.1] - 2026-10-05

### Fixed
Expand Down Expand Up @@ -45,7 +51,8 @@ All notable changes to this project are documented in this file. The format foll
- Access to the 2FA pages granted by route name; the policy condition and the admin form renderer apply to administrators only.
- English and Polish translations.

[Unreleased]: https://github.com/calmfoxpl/sylius-admin-two-factor-plugin/compare/v1.2.1...HEAD
[Unreleased]: https://github.com/calmfoxpl/sylius-admin-two-factor-plugin/compare/v1.2.2...HEAD
[1.2.2]: https://github.com/calmfoxpl/sylius-admin-two-factor-plugin/compare/v1.2.1...v1.2.2
[1.2.1]: https://github.com/calmfoxpl/sylius-admin-two-factor-plugin/compare/v1.2.0...v1.2.1
[1.2.0]: https://github.com/calmfoxpl/sylius-admin-two-factor-plugin/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/calmfoxpl/sylius-admin-two-factor-plugin/compare/v1.0.0...v1.1.0
Expand Down
18 changes: 16 additions & 2 deletions src/Controller/ManageAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,25 @@ public function __invoke(Request $request, int|string $id, string $operation): R
]);
}

return new RedirectResponse($this->redirectTarget($request));
}

/**
* Back where the button was (list or edit page). The form carries the page's full address:
* behind a proxy that mounts the shop under a prefix (`X-Forwarded-Prefix`), a bare path
* would lose that prefix and land on whatever else lives at the root of the domain.
* Only this site is a valid target, never another one.
*/
private function redirectTarget(Request $request): string
{
$target = (string) $request->request->get('_redirect');

return new RedirectResponse(str_starts_with($target, '/') && !str_starts_with($target, '//')
$local = str_starts_with($target, '/') && !str_starts_with($target, '//');
$thisSite = str_starts_with($target, $request->getSchemeAndHttpHost() . '/');

return $local || $thisSite
? $target
: $this->urlGenerator->generate('sylius_admin_admin_user_index'));
: $this->urlGenerator->generate('sylius_admin_admin_user_index');
}

public static function csrfTokenId(string $operation, int|string $id): string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
{% for operation in ['reset', 'disable'] %}
<form id="calmfox-admin-two-factor-{{ operation }}" action="{{ path('calmfox_admin_two_factor_manage', {id: adminUser.id, operation: operation}) }}" method="post" hidden>
<input type="hidden" name="_csrf_token" value="{{ csrf_token(calmfox_two_factor_csrf_id(operation, adminUser.id)) }}">
<input type="hidden" name="_redirect" value="{{ app.request.requestUri }}">
<input type="hidden" name="_redirect" value="{{ app.request.uri }}">
</form>
{% endfor %}
2 changes: 1 addition & 1 deletion templates/admin/grid/action/reset_two_factor.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<form action="{{ path('calmfox_admin_two_factor_manage', {id: data.id, operation: 'reset'}) }}" method="post" class="d-inline"
onsubmit="return confirm({{ 'calmfox_admin_two_factor.manage.reset_confirm'|trans({'%email%': data.email})|json_encode }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token(calmfox_two_factor_csrf_id('reset', data.id)) }}">
<input type="hidden" name="_redirect" value="{{ app.request.requestUri }}">
<input type="hidden" name="_redirect" value="{{ app.request.uri }}">
<button class="btn btn-icon" type="submit" data-bs-toggle="tooltip" data-bs-title="{{ action.label|trans }}" aria-label="{{ action.label|trans }}">
{{ ux_icon(action.icon|default('tabler:shield-x')) }}
</button>
Expand Down
47 changes: 47 additions & 0 deletions tests/Functional/PanelManagementTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,53 @@ public function testResetRemovesTheMethodsAndRequiresANewPairing(): void
self::assertResponseRedirects('/admin/two-factor/setup', null, 'a reset account pairs again even with an optional policy');
}

/**
* Behind a reverse proxy that mounts the shop under a prefix (`X-Forwarded-Prefix: /store`),
* the forms must carry the page's full address: a bare request path would lose the prefix
* and the browser would land on whatever else lives at the root of the domain.
*/
public function testBehindAPrefixTheFormsGoBackToTheFullAddress(): void
{
$prefix = ['HTTP_X_FORWARDED_PREFIX' => '/store'];
$target = $this->createAdmin(self::TOTP_SECRET);
$this->client->loginUser($this->createAdmin(self::TOTP_SECRET), 'admin');

$crawler = $this->client->request('GET', '/admin/users/', [], [], $prefix);
self::assertResponseIsSuccessful();
self::assertSame(
'http://localhost/store/admin/users/',
$crawler->filter('form[action="/store/admin/users/' . self::id($target) . '/two-factor/reset"] input[name="_redirect"]')->attr('value'),
);

$crawler = $this->client->request('GET', '/admin/users/' . self::id($target) . '/edit', [], [], $prefix);
$form = $crawler->filter('form#calmfox-admin-two-factor-reset');
self::assertSame('http://localhost/store/admin/users/' . self::id($target) . '/edit', $form->filter('input[name="_redirect"]')->attr('value'));

// the proxy strips the prefix before the request reaches the application
$this->client->request('POST', '/admin/users/' . self::id($target) . '/two-factor/reset', [
'_csrf_token' => $form->filter('input[name="_csrf_token"]')->attr('value'),
'_redirect' => $form->filter('input[name="_redirect"]')->attr('value'),
], [], $prefix);

self::assertResponseRedirects('http://localhost/store/admin/users/' . self::id($target) . '/edit');
self::assertTrue($this->reload($target)->isTwoFactorSetupRequired());
}

public function testAnotherSiteIsNeverARedirectTarget(): void
{
$target = $this->createAdmin(self::TOTP_SECRET);
$this->client->loginUser($this->createAdmin(self::TOTP_SECRET), 'admin');

$crawler = $this->client->request('GET', '/admin/users/' . self::id($target) . '/edit');
$form = $crawler->filter('form#calmfox-admin-two-factor-disable');
$this->client->request('POST', '/admin/users/' . self::id($target) . '/two-factor/disable', [
'_csrf_token' => $form->filter('input[name="_csrf_token"]')->attr('value'),
'_redirect' => 'https://evil.example/',
]);

self::assertResponseRedirects('/admin/users/');
}

public function testTurningOffRemovesTheMethodsAndLeavesTheRestToThePolicy(): void
{
$target = $this->createAdmin(self::TOTP_SECRET);
Expand Down
6 changes: 6 additions & 0 deletions tests/TestApplication/config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,9 @@ security:
check_path: calmfox_admin_two_factor_login_check
default_target_path: sylius_admin_dashboard
enable_csrf: true

# The test client comes from 127.0.0.1: trust it, so a test can mount the application under a
# prefix with X-Forwarded-Prefix the way a reverse proxy does.
framework:
trusted_proxies: '127.0.0.1'
trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'x-forwarded-prefix']
Loading