Skip to content

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

Merged
kayjoosten merged 6 commits into
mainfrom
wayf-reset-redirect-param
Oct 13, 2026
Merged

kayjoosten merged 6 commits into
mainfrom
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.

@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 Resolved
@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! 👍

kayjoosten added a commit that referenced this pull request Oct 12, 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 Oct 12, 2026
# 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
Base automatically changed from wayf-reset-endpoint to main October 13, 2026 08:03
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 cb5b277 to c6e65c7 Compare October 13, 2026 08:10
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.

@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.

thanks, looks good now with the native php 8.5 url validation

@kayjoosten
kayjoosten merged commit b6d8cad into main Oct 13, 2026
2 checks passed
@kayjoosten
kayjoosten deleted the wayf-reset-redirect-param branch October 13, 2026 12:11
kayjoosten added a commit to OpenConext/OpenConext-profile that referenced this pull request Oct 14, 2026
* Add link to reset remembered WAYF login-method choices

Add a "Login method choices" section to the bottom of the "my personal
data" page that lets a user reset every per-application login-method
choice they've made in the WAYF, behind a confirmation popup. Confirming
sends the user to EngineBlock's /reset-remember-wayf endpoint (#2085),
which clears the cookie and redirects back here.

- WayfResetLinkBuilder is a small, pure service that appends a `redirect`
  query parameter (the absolute URL of this same page) to the configured
  wayf_reset_url. It's factored out of MyProfileController and unit
  tested in isolation because this repo has no controller/functional
  test infrastructure yet, and the interesting logic (building the
  link) doesn't need a kernel to test.
- New wayf_reset_url parameter follows the existing pattern of simple
  string config values in parameters.yaml(.dist), pointing at
  EngineBlock's reset endpoint.
- The new section reuses the shared modal.html.twig confirmation
  component (the same one used for deleting a connection), wrapped in a
  single-iteration `for` loop: the helper unconditionally derives its
  element ids from `loop.index`, which only exists inside a loop, and
  its one existing caller is always used from within a real loop over
  services.
- SCSS mirrors the structure/naming of the existing "Download" section
  on the SURFconext dashboard (mySurfConext__download*), including its
  IE11 fallback rules, since both are a text explanation next to a
  single button.
- New translation keys were added under the existing
  profile.my_profile key in all three locales (en/nl/pt). Since exact
  copy from design isn't available, the wording is a best-effort draft
  that should be reviewed against the actual mockups; the confirm
  button text ("Reset all") is the one piece of copy specified by the
  ticket itself.

This depends on OpenConext/OpenConext-engineblock#2097 adding `redirect`
support to /reset-remember-wayf; without it the endpoint still works but
always redirects to its own configured default instead of back here.

Refs: #345

* Require CSRF protection for the WAYF-reset action

# If applied, this commit will
Stop the 'reset login method choices' action from being triggerable
by a plain, unauthenticated cross-site GET request.

# Why is this change needed?
Prior to this change, the confirm button in the reset modal was a
plain <a href> pointing directly at EngineBlock's cross-origin
/reset-remember-wayf endpoint. The confirmation modal was a
client-side-only gate; the underlying action was a GET with no CSRF
token, so it could be triggered by cross-site navigation or an
auto-submitting link, silently clearing a user's remembered WAYF
choices without their intent.

# How does it address the issue?
This change replaces the link with a same-origin POST form carrying
a Symfony CSRF token. A new route validates the token and only then
redirects the browser on to EngineBlock's existing reset endpoint,
preserving the same redirect-back behavior for legitimate
same-origin submissions.

# Provide links to any relevant tickets, articles or other resources
Found during review of PR #353.

* Fix modal button layout and document wayf_reset_url param

# If applied, this commit will...
Fix the reset-choice confirmation button's layout in the modal and add
changelog guidance for the new required parameter.

# Why is this change needed?
Prior to this change, the modal button used a width:100% rule on the
form wrapper that broke the button's intended layout, and the
CHANGELOG did not mention that the new wayf_reset_url parameter must
be set by deployments.

# How does it address the issue?
This change replaces the width override with display:contents on the
form wrapper so the button retains its natural sizing, and adds
CHANGELOG entries describing the feature and the required parameter.

# Provide links to any relevant tickets, articles or other resources
#353
kayjoosten added a commit to OpenConext/OpenConext-profile that referenced this pull request Oct 19, 2026
* Add link to reset remembered WAYF login-method choices

Add a "Login method choices" section to the bottom of the "my personal
data" page that lets a user reset every per-application login-method
choice they've made in the WAYF, behind a confirmation popup. Confirming
sends the user to EngineBlock's /reset-remember-wayf endpoint (#2085),
which clears the cookie and redirects back here.

- WayfResetLinkBuilder is a small, pure service that appends a `redirect`
  query parameter (the absolute URL of this same page) to the configured
  wayf_reset_url. It's factored out of MyProfileController and unit
  tested in isolation because this repo has no controller/functional
  test infrastructure yet, and the interesting logic (building the
  link) doesn't need a kernel to test.
- New wayf_reset_url parameter follows the existing pattern of simple
  string config values in parameters.yaml(.dist), pointing at
  EngineBlock's reset endpoint.
- The new section reuses the shared modal.html.twig confirmation
  component (the same one used for deleting a connection), wrapped in a
  single-iteration `for` loop: the helper unconditionally derives its
  element ids from `loop.index`, which only exists inside a loop, and
  its one existing caller is always used from within a real loop over
  services.
- SCSS mirrors the structure/naming of the existing "Download" section
  on the SURFconext dashboard (mySurfConext__download*), including its
  IE11 fallback rules, since both are a text explanation next to a
  single button.
- New translation keys were added under the existing
  profile.my_profile key in all three locales (en/nl/pt). Since exact
  copy from design isn't available, the wording is a best-effort draft
  that should be reviewed against the actual mockups; the confirm
  button text ("Reset all") is the one piece of copy specified by the
  ticket itself.

This depends on OpenConext/OpenConext-engineblock#2097 adding `redirect`
support to /reset-remember-wayf; without it the endpoint still works but
always redirects to its own configured default instead of back here.

Refs: #345

* Require CSRF protection for the WAYF-reset action

# If applied, this commit will
Stop the 'reset login method choices' action from being triggerable
by a plain, unauthenticated cross-site GET request.

# Why is this change needed?
Prior to this change, the confirm button in the reset modal was a
plain <a href> pointing directly at EngineBlock's cross-origin
/reset-remember-wayf endpoint. The confirmation modal was a
client-side-only gate; the underlying action was a GET with no CSRF
token, so it could be triggered by cross-site navigation or an
auto-submitting link, silently clearing a user's remembered WAYF
choices without their intent.

# How does it address the issue?
This change replaces the link with a same-origin POST form carrying
a Symfony CSRF token. A new route validates the token and only then
redirects the browser on to EngineBlock's existing reset endpoint,
preserving the same redirect-back behavior for legitimate
same-origin submissions.

# Provide links to any relevant tickets, articles or other resources
Found during review of PR #353.

* Fix modal button layout and document wayf_reset_url param

# If applied, this commit will...
Fix the reset-choice confirmation button's layout in the modal and add
changelog guidance for the new required parameter.

# Why is this change needed?
Prior to this change, the modal button used a width:100% rule on the
form wrapper that broke the button's intended layout, and the
CHANGELOG did not mention that the new wayf_reset_url parameter must
be set by deployments.

# How does it address the issue?
This change replaces the width override with display:contents on the
form wrapper so the button retains its natural sizing, and adds
CHANGELOG entries describing the feature and the required parameter.

# Provide links to any relevant tickets, articles or other resources
#353

* Show feedback modal after resetting WAYF remembered choice

# If applied, this commit will
Show the user a modal confirming the outcome of resetting their
remembered WAYF login-method choice.

# Why is this change needed?
Prior to this change, resetting the "remember my choice" cookie via
the profile page redirected back silently. The user had no feedback
on whether a cookie was actually removed or whether there was none
to begin with.

# How does it address the issue?
This change reads the wayfReset query parameter set by EngineBlock's
reset endpoint (removed or none) through a new WayfResetResultResolver
service, and passes it to the overview template. The template renders
an auto-opening modal, reusing the existing modal helper, with copy
that matches the removed/none outcome. The modal helper is extended
with autoOpen and cleanParam options so the modal shows itself on
load and the query parameter is stripped from the URL afterwards,
and its header id is now derived from the modal's own linkId rather
than loop.index so multiple modals on one page never collide.

# Provide links to any relevant tickets, articles or other resources
#350
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