Skip to content

Support a caller-supplied redirect on /reset-remember-wayf - #2097

Open
kayjoosten wants to merge 5 commits into
wayf-reset-endpointfrom
wayf-reset-redirect-param
Open

kayjoosten wants to merge 5 commits into
wayf-reset-endpointfrom
wayf-reset-redirect-param

Conversation

@kayjoosten

Copy link
Copy Markdown
Contributor

What

Extends GET /reset-remember-wayf (introduced in #2085 / #2094) so it
accepts an optional redirect query parameter, and always appends a
wayfReset=removed|none query parameter to whichever redirect target is
used. This is a prerequisite for
OpenConext/OpenConext-profile#345,
which wants to link from Profile's "my personal data" page to this
endpoint and land the user back on that same page afterwards, knowing
whether a cookie was actually cleared.

Stacked on #2094

This branches from wayf-reset-endpoint (#2094, not yet merged), since it
directly extends ResetRememberedWayfController added there. The diff
shown here is the incremental change only; once #2094 merges to main,
this PR's base should be retargeted to main.

Details

  • 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
    extracted into small private methods, so __invoke 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 Add endpoint to remove "remember my choice" cookie #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.

Testing

All run inside the Docker dev container (PHP 8.5):

  • phpmd / phpcs / phpcs-legacy / docheader: clean.
  • eb4: 244/244. unit: 1008/1008 (1003 baseline + 5 new). functional
    (APP_ENV=test): 123/123 (121 baseline + 2 new). integration:
    105/105.
  • Behat default suite: 300/300 scenarios, 5557/5557 steps (unchanged —
    no new scenarios needed, this is pure PHPUnit-covered logic).
  • Twig lint: 114/114.

Refs: OpenConext/OpenConext-profile#345

kayjoosten added a commit that referenced this pull request Sep 22, 2026
# 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.
kayjoosten added a commit that referenced this pull request Sep 24, 2026
# 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.
@kayjoosten
kayjoosten force-pushed the wayf-reset-redirect-param branch from 924a15c to d64ba78 Compare September 24, 2026 07:21
@OpenConext OpenConext deleted a comment from ontmoetmid Sep 28, 2026

@johanib johanib left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.

I think AI still found a valid looking case that seems to reproduce.
Seems good to close that hole and apply the basic sanitization.

Comment thread src/OpenConext/EngineBlockBundle/Controller/ResetRememberedWayfController.php Outdated
return $this->redirectUrl;
}

$parts = parse_url($redirect);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reject browser-normalized backslashes before allowlisting

parse_url() reports https://evil.example.org\@profile.example.org/ as having the allowed host profile.example.org, so this branch returns it. Browsers normalize the backslash to / and navigate to evil.example.org, allowing an attacker to bypass the host allowlist and turn the endpoint into an open redirect.

Relevant lines: /home/johan/project/surf/OpenConext-engineblock/.worktrees/pr-review-2097/src/OpenConext/EngineBlockBundle/Controller/ResetRememberedWayfController.php:74-88

Suggested approach: Use browser-compatible URL parsing and canonicalization, or reject backslashes and other ambiguous authority characters before comparing the host; only return the canonical URL after validation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ff0404b. Values containing a backslash, whitespace or control characters are rejected before parse_url(), URLs with userinfo are rejected, and only https is allowed. Unit tests cover the backslash bypass from this comment, userinfo, tab/newline, a leading space, protocol-relative URLs and a lookalike host. Docs and changelog are updated.

@kayjoosten
kayjoosten force-pushed the wayf-reset-endpoint branch from a362211 to 0f9a300 Compare October 8, 2026 08:27
@kayjoosten
kayjoosten force-pushed the wayf-reset-redirect-param branch from d64ba78 to 51cb8a0 Compare October 8, 2026 08:27
kayjoosten added a commit that referenced this pull request Oct 8, 2026
# 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.
@kayjoosten
kayjoosten force-pushed the wayf-reset-endpoint branch from 0f9a300 to a7aacbc Compare October 8, 2026 08:49
kayjoosten added a commit that referenced this pull request Oct 8, 2026
# 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.
@kayjoosten
kayjoosten force-pushed the wayf-reset-redirect-param branch from 51cb8a0 to 357be6c Compare October 8, 2026 08:49
Comment thread CHANGELOG.md Outdated
* The `/reset-remember-wayf` endpoint accepts a `redirect` query parameter. Only `https` URLs with a host listed in the
new parameter `wayf.reset_choice_allowed_redirect_hosts` are accepted, other values fall back to
`wayf.reset_choice_per_idp_redirect`. The endpoint also appends `wayfReset=removed` or `wayfReset=none` to the
redirect URL.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The /reset-remember-wayf endpoint accepts a redirect.

Is it really interesting for the reader to know that it only accepts https? Or, should it instead say what it is used for?

Please rewrite so system operators know what to do and what its for.

@johanib

johanib commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Thanks, the feedback is addressed. https-only, the rejection of backslash/whitespace/control characters and userinfo, and the tests all look good. I also tried a few more bypasses (https://evil.com#@allowed/, ?@, [::1]@) and they're rejected correctly.

One non-blocking suggestion: since we require PHP 8.5, we could use the native WHATWG URL parser (Uri\WhatWg\Url) instead of a regex plus parse_url(). It follows the same spec browsers use, so the host we check is the host the browser navigates to. That means we no longer have to anticipate parser differences ourselves.

private function isAllowedRedirect(string $redirect): ?string
{
    $url = \Uri\WhatWg\Url::parse($redirect);

    if ($url === null
        || $url->getScheme() !== 'https'
        || $url->getUsername() !== null
        || $url->getPassword() !== null
        || !in_array($url->getAsciiHost(), $this->allowedRedirectHosts, true)
    ) {
        return null;
    }

    return $url->toAsciiString();
}

Quick check against the earlier cases:

  • https://evil.example.org\@profile.example.org/ gives host evil.example.org, so it's rejected.
  • https://profile.example.org\t.evil.com/ gives host profile.example.org.evil.com, so it's rejected.
  • https://PROFILE.Example.ORG/ gives host profile.example.org, so it's allowed and canonicalised.

Returning toAsciiString() means we redirect to exactly the URL we validated. It differs from the current code in two ways: it accepts a few odd inputs the browser would also accept (such as a leading space or https:\\host), and it normalises them.

I didn't find a Symfony component for this. The Validator Url constraint has no host allowlist, and HtmlSanitizer's UrlSanitizer isn't installed and lets subdomains through.

Fine to merge as is. Your call whether to switch.

@johanib johanib left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 small issues, if fixed, good to merge! 👍

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.
# 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.
# 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.
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.
# 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.
@kayjoosten
kayjoosten force-pushed the wayf-reset-redirect-param branch from ff0404b to cb5b277 Compare October 12, 2026 13:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants