Skip to content
Open
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
7 changes: 7 additions & 0 deletions config/packages/parameters.yml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,13 @@ parameters:
## real destination for their deployment; it must never be left empty, as the endpoint
## refuses to serve requests (failing fast at construction time) when it is blank.
wayf.reset_choice_per_idp_redirect: 'https://engine.dev.openconext.local/'
## Hostnames that the /reset-remember-wayf endpoint is allowed to redirect back to via
## its `redirect` query parameter (e.g. so Profile can send the user back to the page
## they came from). When the query parameter is missing, malformed, or points at a host
## that is not in this list, the endpoint falls back to
## `wayf.reset_choice_per_idp_redirect` instead.
wayf.reset_choice_allowed_redirect_hosts:
- profile.dev.openconext.local

## Toggle the default IdP quick link banner on the WAYF.
wayf.display_default_idp_banner_on_wayf: true
Expand Down
1 change: 1 addition & 0 deletions config/services/controllers/authentication.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ services:
$rememberedIdpCookie: '@OpenConext\EngineBlock\Service\Wayf\RememberedIdpCookie'
$logger: '@engineblock.compat.logger'
$redirectUrl: '%wayf.reset_choice_per_idp_redirect%'
$allowedRedirectHosts: '%wayf.reset_choice_allowed_redirect_hosts%'

OpenConext\EngineBlock\Service\RequestAccessMailer:
arguments:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,13 @@

final class ResetRememberedWayfController
{
private const ALLOWED_REDIRECT_SCHEMES = ['http', 'https'];

public function __construct(
private readonly RememberedIdpCookie $rememberedIdpCookie,
private readonly LoggerInterface $logger,
private readonly string $redirectUrl,
private readonly array $allowedRedirectHosts = [],
) {
if ($this->redirectUrl === '') {
throw new InvalidArgumentException(
Expand All @@ -56,6 +59,39 @@ public function __invoke(Request $request): RedirectResponse
));
}

return new RedirectResponse($this->redirectUrl, Response::HTTP_FOUND);
$target = $this->resolveRedirectTarget($request->query->get('redirect'));
$location = $this->appendQueryParameter($target, 'wayfReset', $raw !== null ? 'removed' : 'none');

return new RedirectResponse($location, Response::HTTP_FOUND);
}

private function resolveRedirectTarget(?string $redirect): string
{
if ($redirect === null || $redirect === '') {
return $this->redirectUrl;
}

$parts = parse_url($redirect);

if ($parts === false || !isset($parts['scheme'], $parts['host'])) {
return $this->redirectUrl;
}

if (!in_array($parts['scheme'], self::ALLOWED_REDIRECT_SCHEMES, true)) {
return $this->redirectUrl;
}

if (!in_array($parts['host'], $this->allowedRedirectHosts, true)) {
return $this->redirectUrl;
}

return $redirect;
}

private function appendQueryParameter(string $url, string $key, string $value): string
{
$separator = str_contains($url, '?') ? '&' : '?';

return $url . $separator . $key . '=' . rawurlencode($value);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public function visiting_the_endpoint_with_a_remembered_idp_cookie_clears_it_and
// constructor validation ever throws (e.g. due to a blank redirect URL), since the
// app's global exception listener also turns uncaught exceptions into a 302 elsewhere.
$this->assertSame(
self::getContainer()->getParameter('wayf.reset_choice_per_idp_redirect'),
self::getContainer()->getParameter('wayf.reset_choice_per_idp_redirect') . '?wayfReset=removed',
$response->headers->get('Location')
);
}
Expand All @@ -65,7 +65,45 @@ public function visiting_the_endpoint_without_a_remembered_idp_cookie_still_redi
$response = $client->getResponse();
$this->assertSame(Response::HTTP_FOUND, $response->getStatusCode());
$this->assertSame(
self::getContainer()->getParameter('wayf.reset_choice_per_idp_redirect'),
self::getContainer()->getParameter('wayf.reset_choice_per_idp_redirect') . '?wayfReset=none',
$response->headers->get('Location')
);
}

#[Test]
public function a_redirect_parameter_pointing_at_an_allowed_host_is_honoured(): void
{
$client = self::createClient();

$client->request(
'GET',
'https://engine.dev.openconext.local/reset-remember-wayf'
. '?redirect=' . urlencode('https://profile.dev.openconext.local/my-profile')
);

$response = $client->getResponse();
$this->assertSame(Response::HTTP_FOUND, $response->getStatusCode());
$this->assertSame(
'https://profile.dev.openconext.local/my-profile?wayfReset=none',
$response->headers->get('Location')
);
}

#[Test]
public function a_redirect_parameter_pointing_at_a_disallowed_host_falls_back_to_the_configured_redirect(): void
{
$client = self::createClient();

$client->request(
'GET',
'https://engine.dev.openconext.local/reset-remember-wayf'
. '?redirect=' . urlencode('https://evil.example.org/phishing')
);

$response = $client->getResponse();
$this->assertSame(Response::HTTP_FOUND, $response->getStatusCode());
$this->assertSame(
self::getContainer()->getParameter('wayf.reset_choice_per_idp_redirect') . '?wayfReset=none',
$response->headers->get('Location')
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class ResetRememberedWayfControllerTest extends TestCase
private const int MAX_ENTRIES = 16;
private const string COOKIE_DOMAIN = 'engine.example.org';
private const string COOKIE_PATH = '/';
private const array ALLOWED_REDIRECT_HOSTS = ['profile.example.org'];

#[Test]
public function cookie_with_valid_entries_is_cleared_and_logged_with_entry_count(): void
Expand All @@ -66,7 +67,7 @@ public function cookie_with_valid_entries_is_cleared_and_logged_with_entry_count
$response = $controller($this->buildRequest($raw));

$this->assertSame(Response::HTTP_FOUND, $response->getStatusCode());
$this->assertSame(self::REDIRECT_URL, $response->getTargetUrl());
$this->assertSame(self::REDIRECT_URL . '?wayfReset=removed', $response->getTargetUrl());
Phake::verify($cookieService)->clearCookieWithSameSite(
RememberedIdpCookie::NAME,
self::COOKIE_PATH,
Expand Down Expand Up @@ -95,7 +96,7 @@ public function invalid_cookie_is_still_cleared_and_logged_with_zero_entries():
$response = $controller($this->buildRequest('not valid base64 or deflated data!'));

$this->assertSame(Response::HTTP_FOUND, $response->getStatusCode());
$this->assertSame(self::REDIRECT_URL, $response->getTargetUrl());
$this->assertSame(self::REDIRECT_URL . '?wayfReset=removed', $response->getTargetUrl());
Phake::verify($cookieService)->clearCookieWithSameSite(Phake::anyParameters());
}

Expand All @@ -113,7 +114,7 @@ public function missing_cookie_is_not_cleared_or_logged_but_still_redirects(): v
$response = $controller($this->buildRequest(null));

$this->assertSame(Response::HTTP_FOUND, $response->getStatusCode());
$this->assertSame(self::REDIRECT_URL, $response->getTargetUrl());
$this->assertSame(self::REDIRECT_URL . '?wayfReset=none', $response->getTargetUrl());
Phake::verifyNoInteraction($cookieService);
}

Expand All @@ -129,6 +130,117 @@ public function constructor_rejects_an_empty_redirect_url(): void
);
}

#[Test]
public function a_redirect_parameter_pointing_at_an_allowed_host_is_honoured(): void
{
$cookieService = Phake::mock(CookieService::class);
$rememberedIdpCookie = $this->buildRememberedIdpCookie($cookieService);

$logger = Mockery::mock(LoggerInterface::class);
$logger->shouldNotReceive('info');

$controller = new ResetRememberedWayfController(
$rememberedIdpCookie,
$logger,
self::REDIRECT_URL,
self::ALLOWED_REDIRECT_HOSTS,
);

$response = $controller($this->buildRequest(null, 'https://profile.example.org/my-profile'));

$this->assertSame(
'https://profile.example.org/my-profile?wayfReset=none',
$response->getTargetUrl()
);
}

#[Test]
public function a_redirect_parameter_that_already_has_a_query_string_is_appended_to(): void
{
$cookieService = Phake::mock(CookieService::class);
$rememberedIdpCookie = $this->buildRememberedIdpCookie($cookieService);

$logger = Mockery::mock(LoggerInterface::class);
$logger->shouldNotReceive('info');

$controller = new ResetRememberedWayfController(
$rememberedIdpCookie,
$logger,
self::REDIRECT_URL,
self::ALLOWED_REDIRECT_HOSTS,
);

$response = $controller($this->buildRequest(null, 'https://profile.example.org/my-profile?foo=bar'));

$this->assertSame(
'https://profile.example.org/my-profile?foo=bar&wayfReset=none',
$response->getTargetUrl()
);
}

#[Test]
public function a_redirect_parameter_pointing_at_a_host_that_is_not_allowed_falls_back_to_the_configured_redirect(): void
{
$cookieService = Phake::mock(CookieService::class);
$rememberedIdpCookie = $this->buildRememberedIdpCookie($cookieService);

$logger = Mockery::mock(LoggerInterface::class);
$logger->shouldNotReceive('info');

$controller = new ResetRememberedWayfController(
$rememberedIdpCookie,
$logger,
self::REDIRECT_URL,
self::ALLOWED_REDIRECT_HOSTS,
);

$response = $controller($this->buildRequest(null, 'https://evil.example.org/phishing'));

$this->assertSame(self::REDIRECT_URL . '?wayfReset=none', $response->getTargetUrl());
}

#[Test]
public function a_malformed_redirect_parameter_falls_back_to_the_configured_redirect(): void
{
$cookieService = Phake::mock(CookieService::class);
$rememberedIdpCookie = $this->buildRememberedIdpCookie($cookieService);

$logger = Mockery::mock(LoggerInterface::class);
$logger->shouldNotReceive('info');

$controller = new ResetRememberedWayfController(
$rememberedIdpCookie,
$logger,
self::REDIRECT_URL,
self::ALLOWED_REDIRECT_HOSTS,
);

$response = $controller($this->buildRequest(null, 'not-a-url'));

$this->assertSame(self::REDIRECT_URL . '?wayfReset=none', $response->getTargetUrl());
}

#[Test]
public function a_redirect_parameter_with_a_disallowed_scheme_falls_back_to_the_configured_redirect(): void
{
$cookieService = Phake::mock(CookieService::class);
$rememberedIdpCookie = $this->buildRememberedIdpCookie($cookieService);

$logger = Mockery::mock(LoggerInterface::class);
$logger->shouldNotReceive('info');

$controller = new ResetRememberedWayfController(
$rememberedIdpCookie,
$logger,
self::REDIRECT_URL,
self::ALLOWED_REDIRECT_HOSTS,
);

$response = $controller($this->buildRequest(null, 'javascript:alert(1)//profile.example.org'));

$this->assertSame(self::REDIRECT_URL . '?wayfReset=none', $response->getTargetUrl());
}

private function buildRememberedIdpCookie(CookieService $cookieService): RememberedIdpCookie
{
return new RememberedIdpCookie(
Expand All @@ -142,9 +254,10 @@ private function buildRememberedIdpCookie(CookieService $cookieService): Remembe
);
}

private function buildRequest(?string $rememberedIdpsCookie): Request
private function buildRequest(?string $rememberedIdpsCookie, ?string $redirect = null): Request
{
$request = Request::create('/reset-remember-wayf');
$query = $redirect !== null ? ['redirect' => $redirect] : [];
$request = Request::create('/reset-remember-wayf', 'GET', $query);
if ($rememberedIdpsCookie !== null) {
$request->cookies->set(RememberedIdpCookie::NAME, $rememberedIdpsCookie);
}
Expand Down