Version in base suite: 2.21.2+ds-1+deb13u2 Base version: lemonldap-ng_2.21.2+ds-1+deb13u2 Target version: lemonldap-ng_2.21.2+ds-1+deb13u3 Base file: /srv/ftp-master.debian.org/ftp/pool/main/l/lemonldap-ng/lemonldap-ng_2.21.2+ds-1+deb13u2.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/l/lemonldap-ng/lemonldap-ng_2.21.2+ds-1+deb13u3.dsc changelog | 7 patches/CVE-2026-12804.patch | 370 ++++++++++++++++++++++++ patches/CVE-2026-19349.patch | 659 +++++++++++++++++++++++++++++++++++++++++++ patches/series | 2 4 files changed, 1038 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpew8q7wyn/lemonldap-ng_2.21.2+ds-1+deb13u2.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpew8q7wyn/lemonldap-ng_2.21.2+ds-1+deb13u3.dsc: no acceptable signature found diff -Nru lemonldap-ng-2.21.2+ds/debian/changelog lemonldap-ng-2.21.2+ds/debian/changelog --- lemonldap-ng-2.21.2+ds/debian/changelog 2026-05-01 06:23:09.000000000 +0000 +++ lemonldap-ng-2.21.2+ds/debian/changelog 2026-08-08 21:08:26.000000000 +0000 @@ -1,3 +1,10 @@ +lemonldap-ng (2.21.2+ds-1+deb13u3) trixie-security; urgency=medium + + * Use OTP to store GitHub/LinkedIn states (Closes: CVE-2026-19349) + * Improve CDC filtering (Closes: CVE-2026-12804) + + -- Xavier Guimard Sat, 08 Aug 2026 23:08:26 +0200 + lemonldap-ng (2.21.2+ds-1+deb13u2) trixie; urgency=medium * Import main 2.21.4 changes: diff -Nru lemonldap-ng-2.21.2+ds/debian/patches/CVE-2026-12804.patch lemonldap-ng-2.21.2+ds/debian/patches/CVE-2026-12804.patch --- lemonldap-ng-2.21.2+ds/debian/patches/CVE-2026-12804.patch 1970-01-01 00:00:00.000000000 +0000 +++ lemonldap-ng-2.21.2+ds/debian/patches/CVE-2026-12804.patch 2026-08-08 21:08:26.000000000 +0000 @@ -0,0 +1,370 @@ +Description: Improve CDC filtering +Author: Xavier Guimard +Origin: upstream, https://gitlab.ow2.org/lemonldap-ng/lemonldap-ng/-/merge_requests/994 +Bug: https://gitlab.ow2.org/lemonldap-ng/lemonldap-ng/-/merge_requests/994 +Forwarded: not-needed +Applied-Upstream: 2.21.5, https://gitlab.ow2.org/lemonldap-ng/lemonldap-ng/-/merge_requests/994 + +--- a/doc/sources/admin/upgrade_2_x.rst ++++ b/doc/sources/admin/upgrade_2_x.rst +@@ -21,6 +21,31 @@ + If you use both CAS applications and CAS servers on your LemonLDAP::NG install, + we recommend waiting for 2.21.1 to be released. + ++2.21.5 ++------ ++ ++SAML Common Domain Cookie now validates the return URL ++~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ++ ++The SAML Common Domain Cookie (CDC) endpoint used to redirect to the ``url`` ++parameter without any control on its origin, which allowed it to be abused as ++an open redirector (CWE-601). ++ ++Starting from this version, the CDC endpoint only redirects to a return URL ++whose host belongs to: ++ ++* a registered SAML federation member (any host found in the ``entityID`` or ++ endpoint ``Location`` of your declared SAML SP/IdP metadata), or ++* a domain listed in the ``trustedDomains`` configuration parameter, or ++* the portal hosting the CDC. ++ ++Any other return URL is now rejected with an HTTP 400 error. ++ ++Existing federations keep working without any change, since their members are ++already declared through SAML metadata. **However**, if your CDC is shared with ++partners whose return URL host is not present in the SAML metadata (for example ++non-LL::NG service providers), you must add their domain to ``trustedDomains``. ++ + 2.21.1 + ------ + +--- a/lemonldap-ng-portal/MANIFEST ++++ b/lemonldap-ng-portal/MANIFEST +@@ -673,6 +673,7 @@ + t/30-Auth-ResolutionRule.t + t/30-Auth-Saml-Reauth.t + t/30-Auth-SAML-with-choice.t ++t/30-CDC-open-redirect.t + t/30-CDC.t + t/30-SAML-Federation-Config.t + t/30-SAML-Head-to-Tail-POST.t +--- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/CDC.pm ++++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/CDC.pm +@@ -8,10 +8,11 @@ + use strict; + use Mouse; + use MIME::Base64; ++use Regexp::Assemble; + use Lemonldap::NG::Common::FormEncode; + use URI; + +-our $VERSION = '2.16.3'; ++our $VERSION = '2.21.5'; + + extends 'Lemonldap::NG::Common::PSGI'; + +@@ -23,6 +24,7 @@ + has cookieExpiration => ( is => 'rw' ); + has oldStyleUrl => ( is => 'rw' ); + has cdc_values => ( is => 'rw' ); ++has trustedDomainsRe => ( is => 'rw' ); + + # INITIALIZATION + +@@ -54,9 +56,96 @@ + foreach (qw(httpOnly cookieExpiration oldStyleUrl)) { + $self->$_( $conf->{$_} ); + } ++ ++ # Build the trusted domains regexp used to control redirection URLs ++ # (see _isTrustedRedirectUrl()). This prevents the CDC endpoint from being ++ # used as an open redirector (CWE-601). ++ $self->_buildTrustedDomainsRe($conf); ++ + return 1; + } + ++## @method void _buildTrustedDomainsRe($conf) ++# Build the regexp of allowed redirection hosts from the configuration. ++# @param $conf full configuration hashref ++sub _buildTrustedDomainsRe { ++ my ( $self, $conf ) = @_; ++ ++ # Wildcard trustedDomains: trust any http(s) destination ++ if ( $conf->{trustedDomains} ++ and $conf->{trustedDomains} =~ /^\s*\*\s*$/ ) ++ { ++ $self->trustedDomainsRe(qr#^https?://#); ++ return; ++ } ++ ++ my $re = Regexp::Assemble->new(); ++ my $count = 0; ++ ++ # 1. Hosts of every registered SAML federation member (SP and IdP). ++ # We extract them from the raw metadata (entityID and endpoint ++ # Location URLs) with a simple scan, avoiding a full XML/Lasso parse. ++ foreach my $key (qw(samlSPMetaDataXML samlIDPMetaDataXML)) { ++ my $branch = $conf->{$key}; ++ next unless ref($branch) eq 'HASH'; ++ foreach my $entry ( values %$branch ) { ++ my $xml = ++ ref($entry) eq 'HASH' ? ( $entry->{$key} // '' ) : ( $entry // '' ); ++ next unless $xml; ++ while ( ++ $xml =~ /\b(?:Location|entityID)\s*=\s*"https?:\/\/([^"\/:]+)/gi ) ++ { ++ $re->add( quotemeta($1) ); ++ $count++; ++ } ++ } ++ } ++ ++ # 2. Explicitly trusted domains ++ if ( my $td = $conf->{trustedDomains} ) { ++ $td =~ s/^\s*(.*?)\s*$/$1/; ++ foreach ( split( /\s+/, $td ) ) { ++ next unless ($_); ++ s#^\.#([^/]+\.)?#; ++ s/\./\\./g; ++ s/\*\\\./(?:(?:[a-zA-Z0-9][-a-zA-Z0-9]*)?[a-zA-Z0-9]\\.)*/g; ++ $re->add($_); ++ $count++; ++ } ++ } ++ ++ # 3. The portal hosting this CDC ++ if ( my $portal = $conf->{portal} ) { ++ $portal =~ s#https?://([^/]*).*$#$1#; ++ if ($portal) { ++ $re->add( quotemeta($portal) ); ++ $count++; ++ } ++ } ++ ++ unless ($count) { ++ ++ # Nothing is trusted: only local relative paths will be allowed ++ $self->trustedDomainsRe(undef); ++ return; ++ } ++ ++ my $tmp = '^https?://' . $re->as_string . '(?::\d+)?(?:/|$)'; ++ $self->trustedDomainsRe(qr/$tmp/); ++ return; ++} ++ ++## @method boolean _isTrustedRedirectUrl($url) ++# @param $url decoded (and CRLF-stripped) destination URL ++# @return boolean ++sub _isTrustedRedirectUrl { ++ my ( $self, $url ) = @_; ++ return 0 unless defined $url and length $url; ++ my $re = $self->trustedDomainsRe; ++ return 0 unless $re; ++ return $url =~ $re ? 1 : 0; ++} ++ + ## @method int process() + # Main method to process CDC requests + # @return portal error code +@@ -72,13 +161,6 @@ + my $action = $req->param('action') || ""; # What we do + my $idp = $req->param('idp'); # IDP ID in write mode + +- # TODO: Control URL +- #my $control_url = $self->_sub('controlUrlOrigin'); +- #unless ( $control_url == PE_OK ) { +- # $self->logger->error( "[CDC] Bad URL"); +- # return $control_url; +- #} +- + # Get cookie + $cdc_cookie = $req->cookies->{ $self->cdc_name }; + +@@ -149,6 +231,17 @@ + } + my $urldc = decode_base64($url); + ++ # Strip CR/LF to prevent HTTP response splitting in the Location header ++ $urldc =~ s/[\r\n]//g; ++ ++ # Control URL origin to avoid being used as an open redirector ++ # (CWE-601). Only local paths and trusted hosts are allowed. ++ unless ( $self->_isTrustedRedirectUrl($urldc) ) { ++ $self->logger->error( ++ "[CDC] Refusing redirection to untrusted URL: $urldc"); ++ return $self->sendError( $req, "Bad URL", 400 ); ++ } ++ + # Add CDC IDP in return URL if needed + # olStyleUrl can be set to 1 to use & instead of ; + $urldc .= ( +--- /dev/null ++++ b/lemonldap-ng-portal/t/30-CDC-open-redirect.t +@@ -0,0 +1,164 @@ ++use warnings; ++use Test::More; ++use strict; ++use MIME::Base64; ++ ++BEGIN { ++ require 't/test-lib.pm'; ++ require 't/saml-lib.pm'; ++} ++ ++# Non-regression test for the unauthenticated open redirect (CWE-601) in the ++# SAML Common Domain Cookie endpoint. The CDC `url` parameter is base64-encoded ++# and was redirected to without any origin control: an attacker could turn the ++# SSO host into an open redirector. ++# ++# The CDC is a *cross-domain* IdP discovery service, so the trusted return ++# targets are the federation members (SAML SP/IdP metadata hosts) and the ++# explicitly trusted domains -- NOT the local protected applications ++# (locationRules). See Lemonldap::NG::Portal::CDC::_buildTrustedDomainsRe. ++ ++my ( $cdc, $res ); ++ ++use_ok('Lemonldap::NG::Portal::CDC'); ++ ++ok( ++ $cdc = LLNG::Manager::Test->new( { ++ ini => { ++ logLevel => 'error', ++ samlCommonDomainCookieDomain => 'cdc.com', ++ ++ # Federation members declared through their SAML metadata: their ++ # endpoint hosts (auth.sp.com, auth.idp.com) must become trusted ++ # return targets. ++ samlSPMetaDataXML => { ++ 'sp' => { ++ samlSPMetaDataXML => ++ samlSPMetaDataXML( 'sp', 'HTTP-POST' ) ++ }, ++ }, ++ samlIDPMetaDataXML => { ++ 'idp' => { ++ samlIDPMetaDataXML => ++ samlIDPMetaDataXML( 'idp', 'HTTP-POST' ) ++ }, ++ }, ++ ++ # A non-SAML federation member, allowed via trustedDomains ++ trustedDomains => 'partner.example.org', ++ }, ++ class => 'Lemonldap::NG::Portal::CDC' ++ } ++ ), ++ 'CDC endpoint' ++); ++ ++# --- Legitimate return URLs (must keep working: 302) --- ++ ++# Federation member (SP) known through its SAML metadata ++my $spMember = 'http://auth.sp.com/?foo=bar'; ++ok( ++ $res = $cdc->_get( '/', query => 'url=' . encode_base64( $spMember, '' ) ), ++ 'Return URL of a SAML SP federation member' ++); ++expectRedirection( $res, qr#^http://auth\.sp\.com/# ); ++ ++# Federation member (IdP) known through its SAML metadata ++my $idpMember = 'http://auth.idp.com/'; ++ok( ++ $res = $cdc->_get( '/', query => 'url=' . encode_base64( $idpMember, '' ) ), ++ 'Return URL of a SAML IdP federation member' ++); ++expectRedirection( $res, $idpMember ); ++ ++# Federation member allowed through trustedDomains ++my $partner = 'https://partner.example.org/'; ++ok( ++ $res = $cdc->_get( '/', query => 'url=' . encode_base64( $partner, '' ) ), ++ 'Return URL allowed by trustedDomains' ++); ++expectRedirection( $res, $partner ); ++ ++# The portal hosting the CDC is also a valid target ++my $portal = 'http://auth.example.com/'; ++ok( ++ $res = $cdc->_get( '/', query => 'url=' . encode_base64( $portal, '' ) ), ++ 'Return URL pointing to the local portal' ++); ++expectRedirection( $res, $portal ); ++ ++# --- Open redirect attempts (must be rejected: 400) --- ++ ++# Arbitrary external host ++ok( ++ $res = $cdc->_get( ++ '/', query => 'url=' . encode_base64( 'https://attacker.example.com', '' ) ++ ), ++ 'External host is refused' ++); ++expectBadRequest($res); ++is( getHeader( $res, 'Location' ), ++ undef, 'No Location header for untrusted URL' ); ++ ++# A local protected application (locationRules) must NOT be a valid CDC target ++ok( ++ $res = $cdc->_get( ++ '/', query => 'url=' . encode_base64( 'http://test1.example.com/', '' ) ++ ), ++ 'Local protected vhost is not a CDC redirect target' ++); ++expectBadRequest($res); ++ ++# Protocol-relative URL (//attacker.example.com): rejected, not absolute http(s) ++ok( ++ $res = $cdc->_get( ++ '/', query => 'url=' . encode_base64( '//attacker.example.com', '' ) ++ ), ++ 'Protocol-relative URL is refused' ++); ++expectBadRequest($res); ++ ++# Backslash-prefixed protocol-relative variant (/\attacker.example.com) ++ok( ++ $res = $cdc->_get( ++ '/', query => 'url=' . encode_base64( '/\\attacker.example.com', '' ) ++ ), ++ 'Backslash protocol-relative URL is refused' ++); ++expectBadRequest($res); ++ ++# A relative path is not an absolute http(s) URL: refused ++ok( ++ $res = ++ $cdc->_get( '/', query => 'url=' . encode_base64( '/somewhere', '' ) ), ++ 'Relative path is refused' ++); ++expectBadRequest($res); ++ ++# Subdomain suffix trick (auth.sp.com.attacker.com) ++ok( ++ $res = $cdc->_get( ++ '/', ++ query => 'url=' . encode_base64( 'http://auth.sp.com.attacker.com/', '' ) ++ ), ++ 'Suffixed trusted host is refused' ++); ++expectBadRequest($res); ++ ++# CRLF / header injection attempt: even with a trusted host, the Location ++# header must never carry a CR/LF ++ok( ++ $res = $cdc->_get( ++ '/', ++ query => 'url=' ++ . encode_base64( ++ "https://partner.example.org/\r\nSet-Cookie: pwned=1", '' ++ ) ++ ), ++ 'CRLF injection attempt' ++); ++my $loc = getHeader( $res, 'Location' ) // ''; ++unlike( $loc, qr/[\r\n]/, 'Location header is free of CR/LF' ); ++ ++clean_sessions(); ++done_testing(); diff -Nru lemonldap-ng-2.21.2+ds/debian/patches/CVE-2026-19349.patch lemonldap-ng-2.21.2+ds/debian/patches/CVE-2026-19349.patch --- lemonldap-ng-2.21.2+ds/debian/patches/CVE-2026-19349.patch 1970-01-01 00:00:00.000000000 +0000 +++ lemonldap-ng-2.21.2+ds/debian/patches/CVE-2026-19349.patch 2026-08-08 21:08:26.000000000 +0000 @@ -0,0 +1,659 @@ +Description: use OTP for GitHub and LinkedIn states + In LemonLDAP::NG 2.0.0 through 2.23.2, the GitHub and + LinkedIn authentication backends store the OAuth2 state + parameter using an obsolete positional call to + getApacheSession(). The trailing arguments are silently + misparsed as a named-argument hash, so the session kind + defaults to SSO and the state is written to the global + session storage as a regular SSO session. +Author: Xavier Guimard +Origin: upstream, https://gitlab.ow2.org/lemonldap-ng/lemonldap-ng/-/merge_requests/1036 +Bug: https://gitlab.ow2.org/lemonldap-ng/lemonldap-ng/-/work_items/3680 +Forwarded: not-needed +Applied-Upstream: 2.23.3, 2.21.5, 2.16.9 +Last-Update: 2026-08-08 + +--- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Auth/GitHub.pm ++++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Auth/GitHub.pm +@@ -27,6 +27,23 @@ + } + ); + ++# Used to protect the authorization request against CSRF and to restore the ++# initial request across the GitHub round trip ++has state_ott => ( ++ is => 'rw', ++ lazy => 1, ++ default => sub { ++ my $ott = $_[0]->{p}->loadModule('::Lib::OneTimeToken'); ++ $ott->timeout( $_[0]->conf->{timeout} ); ++ ++ # GitHub sends the user back to any portal of the farm, so the state ++ # must not be stored in the local cache, whatever tokenUseGlobalStorage ++ # says ++ $ott->cache(undef); ++ return $ott; ++ } ++); ++ + has githubAuthorizationEndpoint => ( + is => 'ro', + lazy => 1, +@@ -101,6 +118,22 @@ + + # Code + if ($code) { ++ ++ # Restore initial request. This also protects against login CSRF: the ++ # state token can only have been created by this portal ++ unless ($state) { ++ $self->userLogger->error('GitHub response without state parameter'); ++ return PE_ERROR; ++ } ++ my $stateData = $self->state_ott->getToken($state); ++ unless ( $stateData and ( $stateData->{_type} // '' ) eq 'githubState' ) ++ { ++ $self->userLogger->error('Invalid or expired GitHub state'); ++ return PE_ERROR; ++ } ++ $req->urldc( $stateData->{urldc} ) if $stateData->{urldc}; ++ $req->{checkLogins} = $stateData->{checkLogins}; ++ + my %form; + $form{"code"} = $code; + $form{"state"} = $state if $state; +@@ -231,16 +264,6 @@ + $req->data->{githubData}->{"gpg_keys"} = $json_hash; + } + +- # Extract state +- if ($state) { +- my $stateSession = $self->p->getApacheSession( $state, 1 ); +- +- $req->urldc( $stateSession->data->{urldc} ); +- $req->{checkLogins} = $stateSession->data->{checkLogins}; +- +- $stateSession->remove; +- } +- + $req->user( + $req->data->{githubData}->{ $self->conf->{githubUserField} } ); + +@@ -254,15 +277,16 @@ + $self->logger->debug('Redirection to GitHub'); + + # Store state +- my $stateSession = +- $self->p->getApacheSession( undef, 1, 0, 'GitHubState' ); +- +- my $stateInfos = {}; +- $stateInfos->{_utime} = time() + $self->conf->{timeout}; +- $stateInfos->{urldc} = $req->urldc; +- $stateInfos->{checkLogins} = $req->{checkLogins}; +- +- $stateSession->update($stateInfos); ++ my $stateToken = $self->state_ott->createToken( { ++ _type => 'githubState', ++ urldc => $req->urldc, ++ checkLogins => $req->{checkLogins}, ++ } ++ ); ++ unless ($stateToken) { ++ $self->logger->error('Unable to create GitHub state token'); ++ return PE_ERROR; ++ } + + my $authn_uri = $self->githubAuthorizationEndpoint; + my $client_id = $self->conf->{githubClientID}; +@@ -273,7 +297,7 @@ + client_id => $client_id, + redirect_uri => $callback_url, + scope => $scope, +- state => $stateSession->id, ++ state => $stateToken, + ); + + $req->urldc($authn_uri); +--- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Auth/LinkedIn.pm ++++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Auth/LinkedIn.pm +@@ -27,6 +27,23 @@ + } + ); + ++# Used to protect the authorization request against CSRF and to restore the ++# initial request across the LinkedIn round trip ++has state_ott => ( ++ is => 'rw', ++ lazy => 1, ++ default => sub { ++ my $ott = $_[0]->{p}->loadModule('::Lib::OneTimeToken'); ++ $ott->timeout( $_[0]->conf->{timeout} ); ++ ++ # LinkedIn sends the user back to any portal of the farm, so the state ++ # must not be stored in the local cache, whatever tokenUseGlobalStorage ++ # says ++ $ott->cache(undef); ++ return $ott; ++ } ++); ++ + has linkedInAuthorizationEndpoint => ( + is => 'ro', + lazy => 1, +@@ -100,6 +117,24 @@ + + # Code + if ($code) { ++ ++ # Restore initial request. This also protects against login CSRF: the ++ # state token can only have been created by this portal ++ unless ($state) { ++ $self->userLogger->error( ++ 'LinkedIn response without state parameter'); ++ return PE_ERROR; ++ } ++ my $stateData = $self->state_ott->getToken($state); ++ unless ( $stateData ++ and ( $stateData->{_type} // '' ) eq 'linkedInState' ) ++ { ++ $self->userLogger->error('Invalid or expired LinkedIn state'); ++ return PE_ERROR; ++ } ++ $req->urldc( $stateData->{urldc} ) if $stateData->{urldc}; ++ $req->{checkLogins} = $stateData->{checkLogins}; ++ + my %form; + $form{"code"} = $code; + $form{"client_id"} = $self->conf->{linkedInClientID}; +@@ -202,16 +237,6 @@ + $self->logger->debug( + "Good LinkedIn authentication for " . $req->user ); + +- # Extract state +- if ($state) { +- my $stateSession = $self->p->getApacheSession( $state, 1 ); +- +- $req->urldc( $stateSession->data->{urldc} ); +- $req->{checkLogins} = $stateSession->data->{checkLogins}; +- +- $stateSession->remove; +- } +- + return PE_OK; + } + +@@ -220,15 +245,17 @@ + $self->logger->debug('Redirection to LinkedIn'); + + # Store state +- my $stateSession = +- $self->p->getApacheSession( undef, 1, 0, 'LinkedInState' ); +- +- my $stateInfos = {}; +- $stateInfos->{_utime} = time() + $self->conf->{timeout}; +- $stateInfos->{urldc} = $req->urldc; +- $stateInfos->{checkLogins} = $req->{checkLogins}; +- +- $stateSession->update($stateInfos); ++ my $stateToken = $self->state_ott->createToken( ++ { ++ _type => 'linkedInState', ++ urldc => $req->urldc, ++ checkLogins => $req->{checkLogins}, ++ } ++ ); ++ unless ($stateToken) { ++ $self->logger->error('Unable to create LinkedIn state token'); ++ return PE_ERROR; ++ } + + my $authn_uri = $self->linkedInAuthorizationEndpoint; + my $client_id = $self->conf->{linkedInClientID}; +@@ -239,7 +266,7 @@ + client_id => $client_id, + redirect_uri => $callback_url, + scope => $scope, +- state => $stateSession->id, ++ state => $stateToken, + ); + + $req->urldc($authn_uri); +--- /dev/null ++++ b/lemonldap-ng-portal/t/33-Auth-GitHub.t +@@ -0,0 +1,202 @@ ++use warnings; ++use strict; ++use utf8; ++use Test::More; ++use LWP::UserAgent; ++use LWP::Protocol::PSGI; ++use JSON qw(to_json); ++use URI; ++ ++BEGIN { ++ require 't/test-lib.pm'; ++} ++ ++my $clientID = 'Iv1.0123456789abcdef'; ++my $clientSecret = 's3cr3t'; ++my $token = 'gho_TOKEN'; ++ ++my $ghUser = { ++ login => 'dwho', ++ id => 42, ++ name => 'Doctor Who', ++ email => 'dwho@badwolf.org', ++}; ++ ++LWP::Protocol::PSGI->register( ++ sub { ++ my $req = Plack::Request->new(@_); ++ ++ # 1. Token endpoint ++ if ( $req->path =~ m#/access_token$# ) { ++ my $form = $req->body_parameters; ++ is( $form->{client_id}, $clientID, ' Good client_id' ); ++ is( $form->{client_secret}, $clientSecret, ' Good client_secret' ); ++ is( $form->{code}, 'GHCODE', ' Good code' ); ++ count(3); ++ return [ ++ 200, ++ [ 'Content-Type' => 'application/json' ], ++ [ ++ to_json( ++ { access_token => $token, token_type => 'bearer' } ++ ) ++ ] ++ ]; ++ } ++ ++ # 2. User endpoint ++ elsif ( $req->path =~ m#/user$# ) { ++ is( $req->header('Authorization'), ++ "token $token", ' Good access token' ); ++ count(1); ++ return [ ++ 200, ++ [ 'Content-Type' => 'application/json' ], ++ [ to_json($ghUser) ] ++ ]; ++ } ++ ++ fail( 'Unexpected GitHub request ' . $req->uri ); ++ count(1); ++ return [ 500, [], [] ]; ++ } ++); ++ ++# Read a state token from the global session storage ++sub getStateSession { ++ my $id = shift; ++ $id = $ENV{LLNG_HASHED_SESSION_STORE} ? id2storage($id) : $id; ++ return Lemonldap::NG::Common::Session->new( { ++ storageModule => 'Apache::Session::File', ++ storageModuleOptions => { ++ Directory => $main::tmpDir, ++ LockDirectory => "$main::tmpDir/lock", ++ }, ++ kind => 'TOKEN', ++ id => $id, ++ } ++ ); ++} ++ ++my $client = LLNG::Manager::Test->new( { ++ ini => { ++ logLevel => 'error', ++ useSafeJail => 1, ++ authentication => 'GitHub', ++ userDB => 'Null', ++ restSessionServer => 1, ++ githubClientID => $clientID, ++ githubClientSecret => $clientSecret, ++ githubUserField => 'login', ++ } ++ } ++); ++ ++my $res; ++ ++# 1. Unauthenticated user asking for an application is redirected to GitHub ++ok( ++ $res = $client->_get( ++ '/', ++ query => 'url=aHR0cDovL3Rlc3QxLmV4YW1wbGUuY29tLw==', ++ accept => 'text/html' ++ ), ++ 'Unauth request' ++); ++count(1); ++my ($query) = expectRedirection( $res, ++ qr#^https://github\.com/login/oauth/authorize\?(.*)$# ); ++ ++my %params = URI->new("http://x/?$query")->query_form; ++is( $params{client_id}, $clientID, ' Good client_id' ); ++is( $params{response_type}, 'code', ' Good response_type' ); ++ok( $params{state}, ' State is set' ); ++count(3); ++my $state = $params{state}; ++ ++# The user may come back on any portal of the farm: the state must be stored ++# in the global storage even though tokenUseGlobalStorage is not set, and as a ++# token, so that it cannot be replayed as a SSO session ++my $stateSession = getStateSession($state); ++ok( !$stateSession->error, ' State stored in global storage as a token' ) ++ or explain( $stateSession->error ); ++is( $stateSession->data->{_type}, 'githubState', ' Good state type' ); ++count(2); ++ ++# 2. The state must not be usable as a session cookie ++ok( ++ $res = $client->_get( ++ '/', ++ cookie => "lemonldap=$state", ++ accept => 'text/html' ++ ), ++ 'Try to replay state as a session cookie' ++); ++count(1); ++ok( ++ $res->[0] != 200 || $res->[2]->[0] !~ /appslist/, ++ ' State is not accepted as a session' ++) or explain( $res->[0], 'not an authenticated portal page' ); ++count(1); ++ ++# 3. A response without state must be rejected ++ok( ++ $res = $client->_get( ++ '/', ++ query => 'code=GHCODE', ++ accept => 'text/html' ++ ), ++ 'GitHub response without state' ++); ++count(1); ++expectPortalError( $res, 24 ); ++ ++# 4. A response with an unknown state must be rejected ++ok( ++ $res = $client->_get( ++ '/', ++ query => 'code=GHCODE&state=xxxxxxxx', ++ accept => 'text/html' ++ ), ++ 'GitHub response with bad state' ++); ++count(1); ++expectPortalError( $res, 24 ); ++ ++# 5. Nominal case ++ok( ++ $res = $client->_get( ++ '/', ++ query => "code=GHCODE&state=$state", ++ accept => 'text/html' ++ ), ++ 'GitHub response' ++); ++count(1); ++ ++# The state must have restored the URL initially requested ++expectRedirection( $res, 'http://test1.example.com/' ); ++my $id = expectCookie($res); ++expectSessionAttributes( ++ $client, $id, ++ _user => $ghUser->{login}, ++ github_login => $ghUser->{login}, ++ github_name => $ghUser->{name}, ++ github_email => $ghUser->{email}, ++); ++ ++# 6. State is a one-shot token: replaying it must fail ++ok( ++ $res = $client->_get( ++ '/', ++ query => "code=GHCODE&state=$state", ++ accept => 'text/html' ++ ), ++ 'Replayed GitHub response' ++); ++count(1); ++expectPortalError( $res, 24 ); ++ ++clean_sessions(); ++ ++done_testing( count() ); +--- /dev/null ++++ b/lemonldap-ng-portal/t/33-Auth-LinkedIn.t +@@ -0,0 +1,231 @@ ++use warnings; ++use strict; ++use utf8; ++use Test::More; ++use LWP::UserAgent; ++use LWP::Protocol::PSGI; ++use JSON qw(to_json); ++use URI; ++ ++BEGIN { ++ require 't/test-lib.pm'; ++} ++ ++my $clientID = '77abcdef123456'; ++my $clientSecret = 's3cr3t'; ++my $token = 'AQV_TOKEN'; ++ ++my $liUser = { ++ id => 'dwho', ++ localizedFirstName => 'Doctor', ++ localizedLastName => 'Who', ++}; ++my $liEmail = 'dwho@badwolf.org'; ++ ++LWP::Protocol::PSGI->register( ++ sub { ++ my $req = Plack::Request->new(@_); ++ ++ # 1. Token endpoint ++ if ( $req->path =~ m#/accessToken$# ) { ++ my $form = $req->body_parameters; ++ is( $form->{client_id}, $clientID, ' Good client_id' ); ++ is( $form->{client_secret}, $clientSecret, ' Good client_secret' ); ++ is( $form->{code}, 'LICODE', ' Good code' ); ++ is( $form->{grant_type}, 'authorization_code', ' Good grant_type' ); ++ count(4); ++ return [ ++ 200, ++ [ 'Content-Type' => 'application/json' ], ++ [ ++ to_json( ++ { access_token => $token, expires_in => 5184000 } ++ ) ++ ] ++ ]; ++ } ++ ++ # 2. People endpoint ++ elsif ( $req->path =~ m#/v2/me$# ) { ++ is( ++ $req->header('Authorization'), ++ "Bearer $token", ++ ' Good access token' ++ ); ++ count(1); ++ return [ ++ 200, ++ [ 'Content-Type' => 'application/json' ], ++ [ to_json($liUser) ] ++ ]; ++ } ++ ++ # 3. Email endpoint ++ elsif ( $req->path =~ m#/v2/emailAddress$# ) { ++ is( ++ $req->header('Authorization'), ++ "Bearer $token", ++ ' Good access token' ++ ); ++ count(1); ++ return [ ++ 200, ++ [ 'Content-Type' => 'application/json' ], ++ [ ++ to_json( ++ { ++ elements => ++ [ { 'handle~' => { emailAddress => $liEmail } } ] ++ } ++ ) ++ ] ++ ]; ++ } ++ ++ fail( 'Unexpected LinkedIn request ' . $req->uri ); ++ count(1); ++ return [ 500, [], [] ]; ++ } ++); ++ ++# Read a state token from the global session storage ++sub getStateSession { ++ my $id = shift; ++ $id = $ENV{LLNG_HASHED_SESSION_STORE} ? id2storage($id) : $id; ++ return Lemonldap::NG::Common::Session->new( ++ { ++ storageModule => 'Apache::Session::File', ++ storageModuleOptions => { ++ Directory => $main::tmpDir, ++ LockDirectory => "$main::tmpDir/lock", ++ }, ++ kind => 'TOKEN', ++ id => $id, ++ } ++ ); ++} ++ ++my $client = LLNG::Manager::Test->new( ++ { ++ ini => { ++ logLevel => 'error', ++ useSafeJail => 1, ++ authentication => 'LinkedIn', ++ userDB => 'Null', ++ restSessionServer => 1, ++ linkedInClientID => $clientID, ++ linkedInClientSecret => $clientSecret, ++ linkedInUserField => 'id', ++ linkedInScope => 'r_liteprofile r_emailaddress', ++ } ++ } ++); ++ ++my $res; ++ ++# 1. Unauthenticated user asking for an application is redirected to LinkedIn ++ok( ++ $res = $client->_get( ++ '/', ++ query => 'url=aHR0cDovL3Rlc3QxLmV4YW1wbGUuY29tLw==', ++ accept => 'text/html' ++ ), ++ 'Unauth request' ++); ++count(1); ++my ($query) = expectRedirection( $res, ++ qr#^https://www\.linkedin\.com/oauth/v2/authorization\?(.*)$# ); ++ ++my %params = URI->new("http://x/?$query")->query_form; ++is( $params{client_id}, $clientID, ' Good client_id' ); ++is( $params{response_type}, 'code', ' Good response_type' ); ++ok( $params{state}, ' State is set' ); ++count(3); ++my $state = $params{state}; ++ ++# The user may come back on any portal of the farm: the state must be stored ++# in the global storage even though tokenUseGlobalStorage is not set, and as a ++# token, so that it cannot be replayed as a SSO session ++my $stateSession = getStateSession($state); ++ok( !$stateSession->error, ' State stored in global storage as a token' ) ++ or explain( $stateSession->error ); ++is( $stateSession->data->{_type}, 'linkedInState', ' Good state type' ); ++count(2); ++ ++# 2. The state must not be usable as a session cookie ++ok( ++ $res = $client->_get( ++ '/', ++ cookie => "lemonldap=$state", ++ accept => 'text/html' ++ ), ++ 'Try to replay state as a session cookie' ++); ++count(1); ++ok( ++ $res->[0] != 200 || $res->[2]->[0] !~ /appslist/, ++ ' State is not accepted as a session' ++) or explain( $res->[0], 'not an authenticated portal page' ); ++count(1); ++ ++# 3. A response without state must be rejected ++ok( ++ $res = $client->_get( ++ '/', ++ query => 'code=LICODE', ++ accept => 'text/html' ++ ), ++ 'LinkedIn response without state' ++); ++count(1); ++expectPortalError( $res, 24 ); ++ ++# 4. A response with an unknown state must be rejected ++ok( ++ $res = $client->_get( ++ '/', ++ query => 'code=LICODE&state=xxxxxxxx', ++ accept => 'text/html' ++ ), ++ 'LinkedIn response with bad state' ++); ++count(1); ++expectPortalError( $res, 24 ); ++ ++# 5. Nominal case ++ok( ++ $res = $client->_get( ++ '/', ++ query => "code=LICODE&state=$state", ++ accept => 'text/html' ++ ), ++ 'LinkedIn response' ++); ++count(1); ++ ++# The state must have restored the URL initially requested ++expectRedirection( $res, 'http://test1.example.com/' ); ++my $id = expectCookie($res); ++expectSessionAttributes( ++ $client, $id, ++ _user => $liUser->{id}, ++ linkedIn_id => $liUser->{id}, ++ linkedIn_localizedFirstName => $liUser->{localizedFirstName}, ++ linkedIn_emailAddress => $liEmail, ++); ++ ++# 6. State is a one-shot token: replaying it must fail ++ok( ++ $res = $client->_get( ++ '/', ++ query => "code=LICODE&state=$state", ++ accept => 'text/html' ++ ), ++ 'Replayed LinkedIn response' ++); ++count(1); ++expectPortalError( $res, 24 ); ++ ++clean_sessions(); ++ ++done_testing( count() ); diff -Nru lemonldap-ng-2.21.2+ds/debian/patches/series lemonldap-ng-2.21.2+ds/debian/patches/series --- lemonldap-ng-2.21.2+ds/debian/patches/series 2026-05-01 06:23:09.000000000 +0000 +++ lemonldap-ng-2.21.2+ds/debian/patches/series 2026-08-08 21:08:26.000000000 +0000 @@ -9,3 +9,5 @@ really-hide-password-in-session-explorer.patch fix-oidc-frontchannel.patch workaround-nginx-issue.patch +CVE-2026-19349.patch +CVE-2026-12804.patch