Skip to content

Commit b6d8cad

Browse files
authored
Support a caller-supplied redirect on /reset-remember-wayf (#2097)
* Support a caller-supplied redirect on /reset-remember-wayf Let /reset-remember-wayf accept an optional `redirect` query parameter so callers such as Profile can send the user back to the page they came from once their remembered per-SP WAYF choices are cleared, instead of always sending everyone to the single operator-configured URL from #2085. Every redirect (whether the caller-supplied target or the configured fallback) now also carries a `wayfReset=removed|none` query parameter, so the caller can tell whether a cookie actually existed and was cleared. - New config wayf.reset_choice_allowed_redirect_hosts is a host allowlist (defaulting to profile.dev.openconext.local in dev/CI): the `redirect` parameter is only honoured when it parses to an http(s) URL whose host appears in this list, otherwise the endpoint silently falls back to the existing wayf.reset_choice_per_idp_redirect target. This mirrors the "parse and check an allowed value" shape of the existing AllowedSchemeValidator rather than introducing a new general-purpose URL validation abstraction for a single call site. - Resolving the redirect target and appending the wayfReset signal are both extracted into small private methods so the __invoke method reads as a straight sequence: clear/log the cookie if present, resolve where to send the user, tell them what happened. - The entry-count/log-message behaviour from #2085 is unchanged: a present-but-unparseable cookie is still treated the same as a valid one for logging and for the wayfReset signal (both are cleared and reported), since from the caller's perspective the cookie no longer exists either way. - Existing unit/functional tests were updated for the new `?wayfReset=` suffix on every redirect, and new cases cover an allowed redirect host, a redirect with its own query string, a disallowed host, a malformed URL, and a disallowed scheme. Needed by OpenConext/OpenConext-profile#345, which links back to Profile's "my personal data" page after resetting a user's remembered WAYF choices. * Keep the URL fragment after the wayfReset query parameter # If applied, this commit will Fix redirects whose target URL contains a fragment so wayfReset actually reaches the destination app as a real query parameter. # Why is this change needed? Prior to this change, appendQueryParameter() always concatenated the wayfReset parameter onto the end of the URL string. For a target URL with a fragment, that put the query parameter after the '#', where it is treated as part of the fragment by browsers and never seen as a real query parameter. # How does it address the issue? This change splits off any fragment before appending the query parameter, then re-appends the fragment at the very end, producing the conventional ...?wayfReset=...#fragment shape. # Provide links to any relevant tickets, articles or other resources Found during review of PR #2097. * Document the redirect query parameter of the reset endpoint # Why is this change needed? Prior to this change, the redirect query parameter, the allowed redirect hosts and the wayfReset result parameter of /reset-remember-wayf were only described in parameters.yml.dist. Operators need to know about the open redirect protection and integrators about the result parameter. # How does it address the issue? This change extends the reset section in docs/wayf_remember_choice.md and adds an entry to the CHANGELOG. * Harden the redirect validation of the reset endpoint Only accept https redirects and reject backslashes, control characters, whitespace and userinfo in the redirect query parameter. Prior to this change, the redirect value was only checked with parse_url() against the host allowlist. Browsers normalise a value such as https://evil.example.org\@profile.example.org/ differently from parse_url(), so the host check could pass while the browser navigated to another host (open redirect). Plain http was also accepted without a need for it. This change rejects values containing a backslash, control character or whitespace, values with credentials, and any scheme other than https. Unit tests cover each of these cases and the docs and changelog describe the stricter rules. Review feedback on the redirect parameter pull request. * Explain what the redirect query parameter is for in the CHANGELOG # If applied, this commit will... Reword the CHANGELOG entry for the `redirect` query parameter so it explains the purpose of the parameter rather than leading with its https-only validation detail. # Why is this change needed? Prior to this change, the entry opened with the fact that only https URLs with an allowed host are accepted, without saying what the parameter is actually for. A system operator reading the CHANGELOG had no indication of why they might want to configure wayf.reset_choice_allowed_redirect_hosts. # How does it address the issue? This change leads with the purpose: callers such as Profile can send the user back to the page they came from after clearing their remembered per-SP WAYF choices, instead of always landing on the operator-configured wayf.reset_choice_per_idp_redirect. The validation rule and the wayfReset query parameter are kept as supporting detail. # Provide links to any relevant tickets, articles or other resources Addresses review comment on PR #2097. * Use the WHATWG URL parser to validate the reset redirect Prior to this change, the redirect query parameter of /reset-remember-wayf was validated with a regular expression and parse_url(). That meant anticipating every difference between parse_url() and the way browsers interpret a URL. This change parses the value with Uri\WhatWg\Url, the same specification browsers follow, so the host that is checked is the host the browser navigates to. The validated URL is returned in its normalised ASCII form and used as the redirect target. Inputs a browser would also accept, such as a leading space or a tab, are now accepted and normalised instead of rejected.
1 parent 1d22124 commit b6d8cad

7 files changed

Lines changed: 330 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ Features:
2626
* Added the endpoint `/reset-remember-wayf`, which removes the per-SP `rememberedidps` cookie and redirects to the URL
2727
configured in `wayf.reset_choice_per_idp_redirect`. This new parameter must not be empty. See
2828
`docs/wayf_remember_choice.md`.
29+
* The `/reset-remember-wayf` endpoint now accepts an optional `redirect` query parameter, so callers such as Profile
30+
can send the user back to the page they came from after clearing their remembered per-SP WAYF choices, instead of
31+
always sending them to the operator-configured `wayf.reset_choice_per_idp_redirect`. The target must be an `https`
32+
URL whose host is listed in the new parameter `wayf.reset_choice_allowed_redirect_hosts`; any other value falls
33+
back to `wayf.reset_choice_per_idp_redirect`. The endpoint also appends `wayfReset=removed` or `wayfReset=none` to
34+
the redirect URL, so the caller can tell whether a cookie was actually cleared.
2935

3036
Updated:
3137

‎config/packages/parameters.yml.dist‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,13 @@ parameters:
194194
## real destination for their deployment; it must never be left empty, as the endpoint
195195
## refuses to serve requests (failing fast at construction time) when it is blank.
196196
wayf.reset_choice_per_idp_redirect: 'https://engine.dev.openconext.local/'
197+
## Hostnames that the /reset-remember-wayf endpoint is allowed to redirect back to via
198+
## its `redirect` query parameter (e.g. so Profile can send the user back to the page
199+
## they came from). When the query parameter is missing, malformed, or points at a host
200+
## that is not in this list, the endpoint falls back to
201+
## `wayf.reset_choice_per_idp_redirect` instead.
202+
wayf.reset_choice_allowed_redirect_hosts:
203+
- profile.dev.openconext.local
197204

198205
## Toggle the default IdP quick link banner on the WAYF.
199206
wayf.display_default_idp_banner_on_wayf: true

‎config/services/controllers/authentication.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ services:
7979
$rememberedIdpCookie: '@OpenConext\EngineBlock\Service\Wayf\RememberedIdpCookie'
8080
$logger: '@engineblock.compat.logger'
8181
$redirectUrl: '%wayf.reset_choice_per_idp_redirect%'
82+
$allowedRedirectHosts: '%wayf.reset_choice_allowed_redirect_hosts%'
8283

8384
OpenConext\EngineBlock\Service\RequestAccessMailer:
8485
arguments:

‎docs/wayf_remember_choice.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,23 @@ The endpoint removes the `rememberedidps` cookie and redirects the user (HTTP 30
105105
The endpoint only accepts `GET` requests and needs no authentication, because it only clears a cookie in the user's own
106106
browser. It does nothing when the cookie is not present. The `rememberchoice` cookie of the global mode is not touched.
107107

108+
The caller can choose where the user lands with the `redirect` query parameter, for example
109+
`/reset-remember-wayf?redirect=https://profile.dev.openconext.local/my-choices`. To prevent an open redirect, the
110+
`redirect` value is only used when it is an absolute `https` URL whose host is listed in:
111+
112+
# Hosts that may be used in the redirect query parameter
113+
wayf.reset_choice_allowed_redirect_hosts:
114+
- profile.dev.openconext.local
115+
116+
A missing, malformed or unlisted `redirect` falls back to `wayf.reset_choice_per_idp_redirect`. Plain `http` URLs and
117+
URLs with credentials (`user@host`) are rejected as well. The value is parsed with the WHATWG URL parser, the same
118+
specification browsers use, so the host that is checked is the host the browser navigates to. The endpoint redirects to
119+
the normalised form of the URL (for example with a lowercase host). The value in `parameters.yml.dist` is the local
120+
development host. Replace it with the hosts of your own deployment.
121+
122+
The endpoint appends the query parameter `wayfReset` to the final URL, before any fragment. Its value is `removed` when
123+
a cookie was removed and `none` when there was nothing to remove, so the target page can show a matching message.
124+
108125
## Switching modes
109126

110127
Cookies written in one mode are not read in the other. After switching, users have to make their choice once more.

‎src/OpenConext/EngineBlockBundle/Controller/ResetRememberedWayfController.php‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,15 @@
2727
use Symfony\Component\HttpFoundation\Request;
2828
use Symfony\Component\HttpFoundation\Response;
2929
use Symfony\Component\Routing\Attribute\Route;
30+
use Uri\WhatWg\Url;
3031

3132
final class ResetRememberedWayfController
3233
{
3334
public function __construct(
3435
private readonly RememberedIdpCookie $rememberedIdpCookie,
3536
private readonly LoggerInterface $logger,
3637
private readonly string $redirectUrl,
38+
private readonly array $allowedRedirectHosts = [],
3739
) {
3840
if ($this->redirectUrl === '') {
3941
throw new InvalidArgumentException(
@@ -56,6 +58,49 @@ public function __invoke(Request $request): RedirectResponse
5658
));
5759
}
5860

59-
return new RedirectResponse($this->redirectUrl, Response::HTTP_FOUND);
61+
$target = $this->resolveRedirectTarget($request->query->get('redirect'));
62+
$location = $this->appendQueryParameter($target, 'wayfReset', $raw !== null ? 'removed' : 'none');
63+
64+
return new RedirectResponse($location, Response::HTTP_FOUND);
65+
}
66+
67+
private function resolveRedirectTarget(?string $redirect): string
68+
{
69+
if ($redirect === null || $redirect === '') {
70+
return $this->redirectUrl;
71+
}
72+
73+
return $this->validatedRedirect($redirect) ?? $this->redirectUrl;
74+
}
75+
76+
private function validatedRedirect(string $redirect): ?string
77+
{
78+
$url = Url::parse($redirect);
79+
80+
if ($url === null
81+
|| $url->getScheme() !== 'https'
82+
|| $url->getUsername() !== null
83+
|| $url->getPassword() !== null
84+
|| !in_array($url->getAsciiHost(), $this->allowedRedirectHosts, true)
85+
) {
86+
return null;
87+
}
88+
89+
return $url->toAsciiString();
90+
}
91+
92+
private function appendQueryParameter(string $url, string $key, string $value): string
93+
{
94+
$fragment = '';
95+
$hashPosition = strpos($url, '#');
96+
97+
if ($hashPosition !== false) {
98+
$fragment = substr($url, $hashPosition);
99+
$url = substr($url, 0, $hashPosition);
100+
}
101+
102+
$separator = str_contains($url, '?') ? '&' : '?';
103+
104+
return $url . $separator . $key . '=' . rawurlencode($value) . $fragment;
60105
}
61106
}

‎tests/functional/OpenConext/EngineBlockBundle/Controller/ResetRememberedWayfControllerTest.php‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public function visiting_the_endpoint_with_a_remembered_idp_cookie_clears_it_and
4646
$response = $client->getResponse();
4747
$this->assertSame(Response::HTTP_FOUND, $response->getStatusCode());
4848
$this->assertSame(
49-
self::getContainer()->getParameter('wayf.reset_choice_per_idp_redirect'),
49+
self::getContainer()->getParameter('wayf.reset_choice_per_idp_redirect') . '?wayfReset=removed',
5050
$response->headers->get('Location')
5151
);
5252
}
@@ -61,7 +61,45 @@ public function visiting_the_endpoint_without_a_remembered_idp_cookie_still_redi
6161
$response = $client->getResponse();
6262
$this->assertSame(Response::HTTP_FOUND, $response->getStatusCode());
6363
$this->assertSame(
64-
self::getContainer()->getParameter('wayf.reset_choice_per_idp_redirect'),
64+
self::getContainer()->getParameter('wayf.reset_choice_per_idp_redirect') . '?wayfReset=none',
65+
$response->headers->get('Location')
66+
);
67+
}
68+
69+
#[Test]
70+
public function a_redirect_parameter_pointing_at_an_allowed_host_is_honoured(): void
71+
{
72+
$client = self::createClient();
73+
74+
$client->request(
75+
'GET',
76+
'https://engine.dev.openconext.local/reset-remember-wayf'
77+
. '?redirect=' . urlencode('https://profile.dev.openconext.local/my-profile')
78+
);
79+
80+
$response = $client->getResponse();
81+
$this->assertSame(Response::HTTP_FOUND, $response->getStatusCode());
82+
$this->assertSame(
83+
'https://profile.dev.openconext.local/my-profile?wayfReset=none',
84+
$response->headers->get('Location')
85+
);
86+
}
87+
88+
#[Test]
89+
public function a_redirect_parameter_pointing_at_a_disallowed_host_falls_back_to_the_configured_redirect(): void
90+
{
91+
$client = self::createClient();
92+
93+
$client->request(
94+
'GET',
95+
'https://engine.dev.openconext.local/reset-remember-wayf'
96+
. '?redirect=' . urlencode('https://evil.example.org/phishing')
97+
);
98+
99+
$response = $client->getResponse();
100+
$this->assertSame(Response::HTTP_FOUND, $response->getStatusCode());
101+
$this->assertSame(
102+
self::getContainer()->getParameter('wayf.reset_choice_per_idp_redirect') . '?wayfReset=none',
65103
$response->headers->get('Location')
66104
);
67105
}

0 commit comments

Comments
 (0)