Problem/Motivation

When using a multilingual site using domain detection, line 326 of openid_connect/src/Controller/OpenIDConnectRedirectController.php

$redirect = Url::fromUri('internal:/' . ltrim($destination, '/'), ['language' => $language])->toString();

throws an invalid argument exception.

"The internal path component
is external. You are not allowed to specify an external URL together with internal:/."

This happens due to the URL being saved to the session data "openid_connect_destination" in OpenIdConnectSession::saveDestination method, being generated as an absolute URL when processed via LanguageNegorationUrl::processOutbound.

UrlHelper::isExternal is flagging this as external and an exception is thrown.

Steps to reproduce

Add a language and set URL detection to domain and add the domain and an alternative.
Add the OpenIDConnectLoginForm to a page and attempt to login via the form.

CommentFileSizeAuthor
#3 3478572-3.patch1.08 KBj-lee
Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

shamrockonov created an issue. See original summary.

j-lee’s picture

Currently it is line 350.

But it is hard to test without the multi-domain language setup in Drupal.
What is provided in $destination? Something like https://my-language-domain.example.com?

Maybe, we can check if $destination is an external url and than remove the 'internal:/':

// The destination parameter should be a prepared uri and include any query
// parameters or fragments already.
//
// @see \Drupal\openid_connect\OpenIDConnectSessionInterface::saveDestination()
$session = $this->session->retrieveDestination();
$destination = $session['destination'] ?: $this->configFactory->get('openid_connect.settings')->get('redirect_login');
$langcode = $session['langcode'] ?: $this->languageManager->getCurrentLanguage()->getId();
$language = $this->languageManager->getLanguage($langcode);

if (UrlHelper::isExternal($destination)) {
    $redirect = $destination;
}
else {
    $redirect = Url::fromUserInput('/' . ltrim($destination, '/'), ['language' => $language])->toString();
}

return new RedirectResponse($redirect);
j-lee’s picture

Status: Active » Needs review
StatusFileSize
new1.08 KB

I have attached a patch file for initial review/testing before I create an MR.

pfrilling made their first commit to this issue’s fork.

pfrilling’s picture

Version: 3.0.0-alpha3 » 3.x-dev

I refactored the destination redirect logic into it's own method and added some validation to confirm if it is external and to confirm it is of the local domain.

I tested with the login block and domain based language detection and all seems to be working correctly.

benjifisher’s picture

Status: Needs review » Needs work

@pfrilling:

The changes for this issue are a little complicated, and I will need at least two rounds of review to understand them well. For this first round of review, I made some low-level suggestions on the MR.

benjifisher’s picture

I have the same question as in Comment #2:

What is provided in $destination? Something like https://my-language-domain.example.com?

I think this question should be answered in the "Steps to Reproduce" section of the issue summary.

As I said on the merge request, I do not see how the current version will fix the problem if $destination is something like https://de.example.com/some/page.

@pfrilling:

In Comment #6, you wrote,

I tested with the login block and domain based language detection and all seems to be working correctly.

Can you say a little more about how you tested? What destination parameter(s) did you try?

pfrilling’s picture

Status: Needs work » Needs review

@benjifisher, I updated the MR with your feedback and addressed some of the comments there.

I tested locally by:
- enabling language negotiation with a subdomain and added a subdomain as a FQDN within ddev.
- placing the login block provided by the module.
- Browsing to `node/1` and clicking login either in the default language or the subdomain, which provided the absolute url as the destination.

I also added an absolute url as the `redirect_login`, which was throwing in certain scenarios. That is why I used `` as the fallback within that method.

benjifisher’s picture

I wonder if the change can be simplified either because Drupal core already sanitizes the destination query parameter or because we can borrow some of the code it uses to do that.

I asked Claude AI how Drupal core handles the destination query parameter. I am dumping the response here until I have time to validate it and see whether it can help:

⏺ Here's how the destination query parameter is managed in Drupal core:

Three layers of handling

1. Early sanitization — RequestSanitizer (line 96-119)

File: docroot/core/lib/Drupal/Core/Security/RequestSanitizer.php

Called very early in the bootstrap (before services are available). It:
- Removes the destination parameter entirely if it contains dangerous keys (array keys starting with #)
- Removes it if UrlHelper::isExternal($destination) returns true — preventing open redirect to external domains

2. Service-level access — RedirectDestination

File: docroot/core/lib/Drupal/Core/Routing/RedirectDestination.php

A service (redirect.destination) that modules use to read the destination. Its get() method (line 57-72):
- Reads ?destination= from the current request
- If the value is an external URL, it falls back to /
- If no destination param exists, it generates the current page URL as the destination

3. Response-time override — RedirectResponseSubscriber

File: docroot/core/lib/Drupal/Core/EventSubscriber/RedirectResponseSubscriber.php

This is the key piece that actually performs the redirect. Its checkRedirectUrl() method (line 53-97):
- Intercepts every RedirectResponse on KernelEvents::RESPONSE
- If a destination query parameter is present (and $this->ignoreDestination is false), it overrides the redirect target with that destination
- Converts the destination to an absolute URL via getDestinationAsAbsoluteUrl() (handles both /path style and legacy internal paths)
- If the response is not already a SecuredRedirectResponse, it wraps it in a LocalRedirectResponse — which rejects external URLs and returns a
400 error instead

Security controls summary

Layer Protection
RequestSanitizer Strips external URLs and dangerous keys early
RedirectDestination::get() Falls back to / for external URLs
RedirectResponseSubscriber Wraps in LocalRedirectResponse, rejects non-local targets with 400

The setIgnoreDestination() method on the subscriber (line 145) allows code to opt out of the destination override when needed (e.g., user login flows that must redirect to a specific place regardless of the query parameter).