Version in base suite: 2.21.2+ds-1+deb13u3 Base version: lemonldap-ng_2.21.2+ds-1+deb13u3 Target version: lemonldap-ng_2.21.2+ds-1+deb13u4 Base file: /srv/ftp-master.debian.org/ftp/pool/main/l/lemonldap-ng/lemonldap-ng_2.21.2+ds-1+deb13u3.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/l/lemonldap-ng/lemonldap-ng_2.21.2+ds-1+deb13u4.dsc changelog | 12 patches/3701-match-all-password-forms.patch | 110 ++ patches/3701-password-reveal-surrounding-form.patch | 81 + patches/3722-force-scalar-context-for-params.patch | 83 + patches/3726-pass-vhost-to-getLevel.patch | 55 + patches/CVE-2026-92288-CVE-2026-92289.patch | 285 +++++ patches/CVE-2026-95811.patch | 967 ++++++++++++++++++++ patches/series | 6 8 files changed, 1599 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpz3hq1w6p/lemonldap-ng_2.21.2+ds-1+deb13u3.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpz3hq1w6p/lemonldap-ng_2.21.2+ds-1+deb13u4.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-08-08 21:08:26.000000000 +0000 +++ lemonldap-ng-2.21.2+ds/debian/changelog 2026-09-24 14:56:00.000000000 +0000 @@ -1,3 +1,15 @@ +lemonldap-ng (2.21.2+ds-1+deb13u4) trixie-security; urgency=medium + + * Fix several security issues: + - Check client secret of public OIDC clients + (Closes: CVE-2026-92288, CVE-2026-92289) + - Test locationRules against the canonical URL (Closes: CVE-2026-95811) + - Force scalar context for request parameters (#3722) + - Pass vhost to getLevel() in grant() (#3726) + - Don't display passwords in clear text in portal forms (#3701) + + -- Xavier Guimard Thu, 24 Sep 2026 16:56:00 +0200 + lemonldap-ng (2.21.2+ds-1+deb13u3) trixie-security; urgency=medium * Use OTP to store GitHub/LinkedIn states (Closes: CVE-2026-19349) diff -Nru lemonldap-ng-2.21.2+ds/debian/patches/3701-match-all-password-forms.patch lemonldap-ng-2.21.2+ds/debian/patches/3701-match-all-password-forms.patch --- lemonldap-ng-2.21.2+ds/debian/patches/3701-match-all-password-forms.patch 1970-01-01 00:00:00.000000000 +0000 +++ lemonldap-ng-2.21.2+ds/debian/patches/3701-match-all-password-forms.patch 2026-09-24 14:56:00.000000000 +0000 @@ -0,0 +1,110 @@ +Description: match all password forms when hiding + Password masking (browsersDontStorePassword) used "#id" selectors that only + match the first element: when several forms share the same field id (auth + choice, password change), passwords were displayed in clear text. +Author: Maxime Besson +Origin: upstream, https://gitlab.ow2.org/lemonldap-ng/lemonldap-ng/-/commit/7e234410aa9a087a7531cfa1d43966352837c17b +Bug: https://gitlab.ow2.org/lemonldap-ng/lemonldap-ng/-/work_items/3701 +Forwarded: not-needed +Applied-Upstream: 2.21.6, commit:7e234410aa9a087a7531cfa1d43966352837c17b +Last-Update: 2026-09-24 + +--- a/lemonldap-ng-portal/site/htdocs/static/common/js/portal.js ++++ b/lemonldap-ng-portal/site/htdocs/static/common/js/portal.js +@@ -668,36 +668,18 @@ + } + } + }); +- // Functions to show/hide placeholder password inputs +- $('#passwordfield').on('input', function () { +- if ($('#passwordfield').get(0).value && datas['dontStorePassword']) { +- return $("#passwordfield").attr('class', 'form-control key'); +- } else { +- return $("#passwordfield").attr('class', 'form-control'); +- } +- }); +- $('#oldpassword').on('input', function () { +- if ($('#oldpassword').get(0).value && datas['dontStorePassword']) { +- return $("#oldpassword").attr('class', 'form-control key'); +- } else { +- return $("#oldpassword").attr('class', 'form-control'); +- } +- }); +- $('#newpassword').on('input', function () { +- if ($('#newpassword').get(0).value && datas['dontStorePassword']) { +- return $("#newpassword").attr('class', 'form-control key'); +- } else { +- return $("#newpassword").attr('class', 'form-control'); +- } +- }); +- $('#confirmpassword').on('input', function () { +- if ($('#confirmpassword').get(0).value && datas['dontStorePassword']) { +- return $("#confirmpassword").attr('class', 'form-control key'); ++ var togglePasswordHidingClass = function togglePasswordHidingClass(ev) { ++ if (ev.target.value && datas['dontStorePassword']) { ++ $(ev.target).attr('class', 'form-control key'); + } else { +- return $("#confirmpassword").attr('class', 'form-control'); ++ $(ev.target).attr('class', 'form-control'); + } +- }); +- //$('#formpass').on 'submit', changePwd ++ }; ++ // Functions to show/hide placeholder password inputs ++ $('input[id=passwordfield]').on('input', togglePasswordHidingClass); ++ $('input[id=oldpassword]').on('input', togglePasswordHidingClass); ++ $('input[id=newpassword]').on('input', togglePasswordHidingClass); ++ $('input[id=confirmpassword]').on('input', togglePasswordHidingClass); + $('.clear-finduser-field').on('click', function () { + return $(this).parent().find(':input').each(function () { + console.log('Clear search field ->', $(this).attr('name')); +--- a/lemonldap-ng-portal/site/js-src/portal.js ++++ b/lemonldap-ng-portal/site/js-src/portal.js +@@ -606,36 +606,19 @@ + } + } + }); +- // Functions to show/hide placeholder password inputs +- $('#passwordfield').on('input', function() { +- if ($('#passwordfield').get(0).value && datas['dontStorePassword']) { +- return $("#passwordfield").attr('class', 'form-control key'); +- } else { +- return $("#passwordfield").attr('class', 'form-control'); +- } +- }); +- $('#oldpassword').on('input', function() { +- if ($('#oldpassword').get(0).value && datas['dontStorePassword']) { +- return $("#oldpassword").attr('class', 'form-control key'); +- } else { +- return $("#oldpassword").attr('class', 'form-control'); +- } +- }); +- $('#newpassword').on('input', function() { +- if ($('#newpassword').get(0).value && datas['dontStorePassword']) { +- return $("#newpassword").attr('class', 'form-control key'); +- } else { +- return $("#newpassword").attr('class', 'form-control'); +- } +- }); +- $('#confirmpassword').on('input', function() { +- if ($('#confirmpassword').get(0).value && datas['dontStorePassword']) { +- return $("#confirmpassword").attr('class', 'form-control key'); ++ var togglePasswordHidingClass = function (ev) { ++ if (ev.target.value && datas['dontStorePassword']) { ++ $(ev.target).attr('class', 'form-control key'); + } else { +- return $("#confirmpassword").attr('class', 'form-control'); ++ $(ev.target).attr('class', 'form-control'); + } +- }); +- //$('#formpass').on 'submit', changePwd ++ } ++ // Functions to show/hide placeholder password inputs ++ $('input[id=passwordfield]').on('input', togglePasswordHidingClass ); ++ $('input[id=oldpassword]').on('input', togglePasswordHidingClass ); ++ $('input[id=newpassword]').on('input', togglePasswordHidingClass ); ++ $('input[id=confirmpassword]').on('input', togglePasswordHidingClass ); ++ + $('.clear-finduser-field').on('click', function() { + return $(this).parent().find(':input').each(function() { + console.log('Clear search field ->', $(this).attr('name')); diff -Nru lemonldap-ng-2.21.2+ds/debian/patches/3701-password-reveal-surrounding-form.patch lemonldap-ng-2.21.2+ds/debian/patches/3701-password-reveal-surrounding-form.patch --- lemonldap-ng-2.21.2+ds/debian/patches/3701-password-reveal-surrounding-form.patch 1970-01-01 00:00:00.000000000 +0000 +++ lemonldap-ng-2.21.2+ds/debian/patches/3701-password-reveal-surrounding-form.patch 2026-09-24 14:56:00.000000000 +0000 @@ -0,0 +1,81 @@ +Description: password reveal button only affects surrounding form + The reveal button changed every input with the same name in the page, and + only restored the first one: with browsersDontStorePassword, passwords typed + in other forms of the portal could stay displayed in clear text. +Author: Maxime Besson +Origin: upstream, https://gitlab.ow2.org/lemonldap-ng/lemonldap-ng/-/commit/f082d6a0dc8ef18178f2c6965bd2c3f8d07aded0 +Bug: https://gitlab.ow2.org/lemonldap-ng/lemonldap-ng/-/work_items/3701 +Forwarded: not-needed +Applied-Upstream: 2.21.6, commit:f082d6a0dc8ef18178f2c6965bd2c3f8d07aded0 +Last-Update: 2026-09-24 + +--- a/lemonldap-ng-portal/site/htdocs/static/common/js/portal.js ++++ b/lemonldap-ng-portal/site/htdocs/static/common/js/portal.js +@@ -621,14 +621,14 @@ + $(".toggle-password").on('mousedown touchstart', function () { + field = $(this).attr('id'); + field = field.replace(/^toggle_/, ''); +- console.log('Display', field); + $(this).toggleClass("fa-eye fa-eye-slash"); +- return $("input[name=".concat(field, "]")).attr('class', 'form-control'); ++ $(this).closest("form").find("input[name=".concat(field, "]")).removeClass('key'); + }); + $(".toggle-password").on('mouseup touchend', function () { + $(this).toggleClass("fa-eye fa-eye-slash"); +- if ($("input[name=".concat(field, "]")).get(0).value) { +- return $("input[name=".concat(field, "]")).attr('class', 'form-control key'); ++ var target = $(this).closest("form").find("input[name=".concat(field, "]")).get(0); ++ if (target && target.value) { ++ $(target).addClass('key'); + } + }); + } else { +@@ -637,11 +637,11 @@ + field = field.replace(/^toggle_/, ''); + console.log('Display', field); + $(this).toggleClass("fa-eye fa-eye-slash"); +- return $("input[name=".concat(field, "]")).attr("type", "text"); ++ $(this).closest("form").find("input[name=".concat(field, "]")).attr("type", "text"); + }); + $(".toggle-password").on('mouseup touchend', function () { + $(this).toggleClass("fa-eye fa-eye-slash"); +- return $("input[name=".concat(field, "]")).attr("type", "password"); ++ $(this).closest("form").find("input[name=".concat(field, "]")).attr("type", "password"); + }); + } + } +--- a/lemonldap-ng-portal/site/js-src/portal.js ++++ b/lemonldap-ng-portal/site/js-src/portal.js +@@ -559,14 +559,14 @@ + $(".toggle-password").on('mousedown touchstart', function() { + field = $(this).attr('id'); + field = field.replace(/^toggle_/, ''); +- console.log('Display', field); + $(this).toggleClass("fa-eye fa-eye-slash"); +- return $(`input[name=${field}]`).attr('class', 'form-control'); ++ $(this).closest("form").find(`input[name=${field}]`).removeClass('key'); + }); + $(".toggle-password").on('mouseup touchend', function() { + $(this).toggleClass("fa-eye fa-eye-slash"); +- if ($(`input[name=${field}]`).get(0).value) { +- return $(`input[name=${field}]`).attr('class', 'form-control key'); ++ var target = $(this).closest("form").find(`input[name=${field}]`).get(0); ++ if (target && target.value) { ++ $(target).addClass('key'); + } + }); + } else { +@@ -575,11 +575,11 @@ + field = field.replace(/^toggle_/, ''); + console.log('Display', field); + $(this).toggleClass("fa-eye fa-eye-slash"); +- return $(`input[name=${field}]`).attr("type", "text"); ++ $(this).closest("form").find(`input[name=${field}]`).attr("type", "text"); + }); + $(".toggle-password").on('mouseup touchend', function() { + $(this).toggleClass("fa-eye fa-eye-slash"); +- return $(`input[name=${field}]`).attr("type", "password"); ++ $(this).closest("form").find(`input[name=${field}]`).attr("type", "password"); + }); + } + } diff -Nru lemonldap-ng-2.21.2+ds/debian/patches/3722-force-scalar-context-for-params.patch lemonldap-ng-2.21.2+ds/debian/patches/3722-force-scalar-context-for-params.patch --- lemonldap-ng-2.21.2+ds/debian/patches/3722-force-scalar-context-for-params.patch 1970-01-01 00:00:00.000000000 +0000 +++ lemonldap-ng-2.21.2+ds/debian/patches/3722-force-scalar-context-for-params.patch 2026-09-24 14:56:00.000000000 +0000 @@ -0,0 +1,83 @@ +Description: force scalar context for request parameters + $req->param() was called in list context inside hash constructors: sending + a parameter several times shifted keys and values, allowing an attacker to + inject arbitrary keys (token exchange session data, spoofId, templates). +Author: Xavier Guimard +Origin: upstream, https://gitlab.ow2.org/lemonldap-ng/lemonldap-ng/-/commit/c2ffa8e4e64a02d0783757c6fa0c96d1f8340063 +Bug: https://gitlab.ow2.org/lemonldap-ng/lemonldap-ng/-/work_items/3722 +Forwarded: not-needed +Applied-Upstream: 2.21.6, commit:c2ffa8e4e64a02d0783757c6fa0c96d1f8340063 +Last-Update: 2026-09-24 + +--- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Lib/OIDCTokenExchange.pm ++++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Lib/OIDCTokenExchange.pm +@@ -72,7 +72,7 @@ + + # 2. Check for audience and authorization + my $targetClientId = $req->param('audience'); +- my $target = { audience => $req->param('audience') // undef, }; ++ my $target = { audience => scalar $req->param('audience') // undef, }; + if ( $target->{audience} ) { + if ( $target->{audience} eq $clientId ) { + $target->{rp} = $rp; +@@ -125,11 +125,11 @@ + $rp, + { + %{ $req->sessionInfo }, +- scope => $req->param('scope') || 'openid', ++ scope => scalar $req->param('scope') || 'openid', + client_id => $target->{audience} + || $self->oidc->rpOptions->{$rp}->{oidcRPMetaDataOptionsClientID}, + _session_uid => $uid, +- grant_type => $req->param('grant_type'), ++ grant_type => scalar $req->param('grant_type'), + }, + ); + unless ($refreshToken) { +--- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Lib/RESTProxy.pm ++++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Lib/RESTProxy.pm +@@ -69,7 +69,7 @@ + ( + $self->conf->{proxyAuthServiceImpersonation} + && $req->param('spoofId') +- ? ( spoofId => $req->param('spoofId') ) ++ ? ( spoofId => scalar $req->param('spoofId') ) + : () + ) + } +--- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Display.pm ++++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Display.pm +@@ -166,9 +166,9 @@ + CHOICE_VALUE => $req->data->{_authChoice}, + CHECK_LOGINS => $self->conf->{portalCheckLogins} + && $req->data->{login}, +- ASK_LOGINS => $req->param('checkLogins') ++ ASK_LOGINS => scalar $req->param('checkLogins') + || 0, +- ASK_STAYCONNECTED => $req->param('stayconnected') ++ ASK_STAYCONNECTED => scalar $req->param('stayconnected') + || 0, + CONFIRMKEY => $self->stamp(), + ( +@@ -195,8 +195,8 @@ + CHOICE_VALUE => $req->data->{_authChoice}, + CHECK_LOGINS => $self->conf->{portalCheckLogins} + && $req->data->{login}, +- ASK_LOGINS => $req->param('checkLogins') || 0, +- ASK_STAYCONNECTED => $req->param('stayconnected') || 0, ++ ASK_LOGINS => scalar $req->param('checkLogins') || 0, ++ ASK_STAYCONNECTED => scalar $req->param('stayconnected') || 0, + CONFIRMKEY => $self->stamp(), + LIST => $req->data->{list} || [], + LOGIN_HINT => $req->data->{suggestedLogin}, +@@ -425,8 +425,8 @@ + ACTIVE_FORM => 1, + DONT_STORE_PASSWORD => $self->conf->{browsersDontStorePassword}, + CHECK_LOGINS => $self->conf->{portalCheckLogins}, +- ASK_LOGINS => $req->param('checkLogins') || 0, +- ASK_STAYCONNECTED => $req->param('stayconnected') || 0, ++ ASK_LOGINS => scalar $req->param('checkLogins') || 0, ++ ASK_STAYCONNECTED => scalar $req->param('stayconnected') || 0, + DISPLAY_RESETPASSWORD => $self->conf->{portalDisplayResetPassword}, + DISPLAY_REGISTER => $self->conf->{portalDisplayRegister}, + DISPLAY_UPDATECERTIF => diff -Nru lemonldap-ng-2.21.2+ds/debian/patches/3726-pass-vhost-to-getLevel.patch lemonldap-ng-2.21.2+ds/debian/patches/3726-pass-vhost-to-getLevel.patch --- lemonldap-ng-2.21.2+ds/debian/patches/3726-pass-vhost-to-getLevel.patch 1970-01-01 00:00:00.000000000 +0000 +++ lemonldap-ng-2.21.2+ds/debian/patches/3726-pass-vhost-to-getLevel.patch 2026-09-24 14:56:00.000000000 +0000 @@ -0,0 +1,55 @@ +Description: pass vhost to getLevel() in grant() + When checking another virtual host (menu, REST/SOAP authorizationfor, + CheckUser), the authentication level of the current vhost was used instead + of the target one: apps requiring a higher level were reported as allowed. +Author: Xavier Guimard +Origin: upstream, https://gitlab.ow2.org/lemonldap-ng/lemonldap-ng/-/commit/8bc05c2422aaddd699d46d4f5bcff600237262e6 +Bug: https://gitlab.ow2.org/lemonldap-ng/lemonldap-ng/-/work_items/3726 +Forwarded: not-needed +Applied-Upstream: 2.21.6, commit:8bc05c2422aaddd699d46d4f5bcff600237262e6 +Last-Update: 2026-09-24 + +--- a/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Main/Run.pm ++++ b/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Main/Run.pm +@@ -372,7 +372,7 @@ + return $cond->( $req, $session ) if $cond; + + $vhost ||= $class->resolveAlias($req); +- my $level = $class->getLevel( $req, $uri ); ++ my $level = $class->getLevel( $req, $uri, $vhost ); + + # Using VH authentification level if exists + if ($level) { +--- a/lemonldap-ng-handler/t/60-Lemonldap-NG-Handler-PSGI.t ++++ b/lemonldap-ng-handler/t/60-Lemonldap-NG-Handler-PSGI.t +@@ -304,6 +304,30 @@ + ok( $res->[0] == 200, ' Code is 200' ) or explain( $res, 200 ); + count(2); + ++# grant() called for another vhost (menu, REST/SOAP authorizationfor, ++# CheckUser) must use the authentication level of that vhost, not the one of ++# the current request (#3726) ++{ ++ my $session = { authenticationLevel => 1 }; ++ my $req = Lemonldap::NG::Common::PSGI::Request->new( ++ { HTTP_HOST => 'test1.example.com', REQUEST_URI => '/' } ); ++ ok( ++ !Lemonldap::NG::Handler::Main->grant( ++ $req, $session, '/', undef, 'test2.example.com' ++ ), ++ 'test2 (level 5) refused from test1' ++ ); ++ $req = Lemonldap::NG::Common::PSGI::Request->new( ++ { HTTP_HOST => 'test2.example.com', REQUEST_URI => '/' } ); ++ ok( ++ Lemonldap::NG::Handler::Main->grant( ++ $req, $session, '/', undef, 'test1.example.com' ++ ), ++ 'test1 granted from test2 (level 5)' ++ ); ++ count(2); ++} ++ + done_testing( count() ); + + clean(); diff -Nru lemonldap-ng-2.21.2+ds/debian/patches/CVE-2026-92288-CVE-2026-92289.patch lemonldap-ng-2.21.2+ds/debian/patches/CVE-2026-92288-CVE-2026-92289.patch --- lemonldap-ng-2.21.2+ds/debian/patches/CVE-2026-92288-CVE-2026-92289.patch 1970-01-01 00:00:00.000000000 +0000 +++ lemonldap-ng-2.21.2+ds/debian/patches/CVE-2026-92288-CVE-2026-92289.patch 2026-09-24 14:56:00.000000000 +0000 @@ -0,0 +1,285 @@ +Description: check client secret of public OIDC clients + The secret of public clients was never checked but they were considered as + authenticated with client_secret_*: anyone knowing a client_id could use the + introspection endpoint and get token data including the user identifier. +Author: Xavier Guimard +Origin: upstream, https://gitlab.ow2.org/lemonldap-ng/lemonldap-ng/-/commit/458c155707b06e68535dea85fc21bfd4ba2bbde3 +Bug: https://gitlab.ow2.org/lemonldap-ng/lemonldap-ng/-/work_items/3719 +Bug-CVE: CVE-2026-92288, CVE-2026-92289 +Forwarded: not-needed +Applied-Upstream: 2.21.6, commit:458c155707b06e68535dea85fc21bfd4ba2bbde3 +Last-Update: 2026-09-24 + +--- a/lemonldap-ng-portal/MANIFEST ++++ b/lemonldap-ng-portal/MANIFEST +@@ -774,6 +774,7 @@ + t/32-OIDC-RP-rule.t + t/32-OIDC-strict-JWS-private_key_jwt.t + t/32-OIDC-Token-Exchange.t ++t/32-OIDC-Token-Introspection-public-client.t + t/32-OIDC-Token-Introspection.t + t/32-OIDC-Token-Security.t + t/33-Auth-and-issuer-OpenID2.t +--- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Lib/OpenIDConnect.pm ++++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Lib/OpenIDConnect.pm +@@ -1867,8 +1867,33 @@ + + # Check client_secret + if ( $self->rpOptions->{$rp}->{oidcRPMetaDataOptionsPublic} ) { +- $self->logger->debug( +- "Relying Party $rp is public, do not check client secret"); ++ ++ # A public client is only authenticated by the client secret ++ # registered for it: else endpoints must not consider it as such. A ++ # wrong secret is rejected, but a secret given to a public client ++ # without registered secret is ignored, as before. JWS methods ++ # (client_secret_jwt, private_key_jwt) are kept: their signature was ++ # already verified by getEndPointAuthenticationCredentials ++ if ( $method =~ /^client_secret_(?:basic|post)$/ ) { ++ my $secret = ++ $self->rpOptions->{$rp}->{oidcRPMetaDataOptionsClientSecret} ++ // ''; ++ if ( !length $secret ) { ++ $self->logger->debug( "Relying Party $rp is public without" ++ . " client secret, given secret is ignored" ) ++ if length( $client_secret // '' ); ++ $method = 'none'; ++ } ++ elsif ( !length( $client_secret // '' ) ) { ++ $self->logger->debug( ++ "Relying Party $rp is public, no client secret given"); ++ $method = 'none'; ++ } ++ elsif ( $client_secret ne $secret ) { ++ $self->logger->error("Wrong credentials for $rp"); ++ return undef; ++ } ++ } + } + else { + if ( $method eq "none" ) { +--- /dev/null ++++ b/lemonldap-ng-portal/t/32-OIDC-Token-Introspection-public-client.t +@@ -0,0 +1,222 @@ ++use warnings; ++use Test::More; ++use strict; ++use IO::String; ++use MIME::Base64; ++use JSON; ++use Crypt::JWT qw(encode_jwt); ++ ++BEGIN { ++ require 't/test-lib.pm'; ++ require 't/oidc-lib.pm'; ++} ++ ++# Public clients are not authenticated: they must not be allowed to use the ++# introspection endpoint (RFC 7662 section 2.1), whatever they send ++ ++my $debug = 'error'; ++ ++my $op = LLNG::Manager::Test->new( { ++ ini => { ++ logLevel => $debug, ++ domain => 'op.com', ++ portal => 'http://auth.op.com/', ++ authentication => 'Demo', ++ userDB => 'Same', ++ issuerDBOpenIDConnectActivation => 1, ++ oidcRPMetaDataExportedVars => { ++ rp => { email => "mail" }, ++ oauth => { email => "mail" }, ++ spa => { email => "mail" }, ++ pubsecret => { email => "mail" }, ++ }, ++ oidcRPMetaDataOptions => { ++ rp => { ++ oidcRPMetaDataOptionsDisplayName => "RP", ++ oidcRPMetaDataOptionsClientID => "rpid", ++ oidcRPMetaDataOptionsIDTokenSignAlg => "HS512", ++ oidcRPMetaDataOptionsClientSecret => "rpid", ++ oidcRPMetaDataOptionsUserIDAttr => "", ++ oidcRPMetaDataOptionsAccessTokenExpiration => 3600, ++ oidcRPMetaDataOptionsBypassConsent => 1, ++ oidcRPMetaDataOptionsRedirectUris => 'http://rp2.com/', ++ }, ++ ++ # Confidential resource server ++ oauth => { ++ oidcRPMetaDataOptionsDisplayName => "oauth", ++ oidcRPMetaDataOptionsClientID => "oauth", ++ oidcRPMetaDataOptionsClientSecret => "service", ++ oidcRPMetaDataOptionsUserIDAttr => "", ++ }, ++ ++ # Public client with its own user identifier ++ spa => { ++ oidcRPMetaDataOptionsDisplayName => "SPA", ++ oidcRPMetaDataOptionsClientID => "spa", ++ oidcRPMetaDataOptionsPublic => 1, ++ oidcRPMetaDataOptionsUserIDAttr => "mail", ++ oidcRPMetaDataOptionsRedirectUris => 'http://spa.com/', ++ }, ++ ++ # Public client with a registered secret: it may authenticate ++ pubsecret => { ++ oidcRPMetaDataOptionsDisplayName => "Public with secret", ++ oidcRPMetaDataOptionsClientID => "pubsecret", ++ oidcRPMetaDataOptionsClientSecret => "pubsecret", ++ oidcRPMetaDataOptionsPublic => 1, ++ oidcRPMetaDataOptionsUserIDAttr => "", ++ oidcRPMetaDataOptionsRedirectUris => "http://pub.com/", ++ }, ++ }, ++ oidcServicePrivateKeySig => oidc_key_op_private_sig, ++ oidcServicePublicKeySig => oidc_cert_op_public_sig, ++ } ++ } ++); ++ ++my $idpId = login( $op, "french" ); ++my $code = codeAuthorize( ++ $op, $idpId, ++ { ++ response_type => "code", ++ scope => "openid profile email", ++ client_id => "rpid", ++ state => "af0ifjsldkj", ++ redirect_uri => "http://rp2.com/" ++ } ++); ++my $token = ++ expectJSON( codeGrant( $op, "rpid", $code, "http://rp2.com/" ) ) ++ ->{access_token}; ++ok( $token, 'Access token issued to a confidential RP' ); ++ ++sub callIntrospection { ++ my ( $body, $authorization ) = @_; ++ return $op->_post( ++ "/oauth2/introspect", ++ IO::String->new($body), ++ accept => 'application/json', ++ length => length($body), ++ ( ++ $authorization ++ ? ( custom => { HTTP_AUTHORIZATION => $authorization } ) ++ : () ++ ), ++ ); ++} ++ ++# client_secret_jwt assertion signed with $key ++sub jwsAuth { ++ my ( $clientId, $key ) = @_; ++ my $jwt = encode_jwt( ++ payload => { ++ iss => $clientId, ++ sub => $clientId, ++ aud => "http://auth.op.com/oauth2/introspect", ++ exp => time + 100, ++ }, ++ alg => "HS256", ++ key => $key, ++ ); ++ return ++ "&client_id=$clientId" ++ . "&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer" ++ . "&client_assertion=$jwt"; ++} ++ ++# A wrong client secret or JWS gives invalid_client (400, or 401 with an ++# Authorization header), a missing client authentication unauthorized_client ++sub expectRefused { ++ my ( $res, $msg, $code, $error ) = @_; ++ is( $res->[0], $code, "$msg: HTTP code is $code" ); ++ is( eval { from_json( $res->[2]->[0] )->{error} }, ++ $error, "$msg: error is $error" ); ++ unlike( $res->[2]->[0], qr/"active"/, "$msg: no introspection response" ) ++ or diag( $res->[2]->[0] ); ++} ++ ++# Non regression: an authenticated confidential client gets the response ++my $res = ++ callIntrospection( "token=$token", ++ "Basic " . encode_base64( "oauth:service", '' ) ); ++my $json = expectJSON($res); ++ok( $json->{active}, 'Confidential client: token is active' ); ++is( $json->{sub}, 'french', 'Confidential client: sub of the calling RP' ); ++ ++# Non regression (#3156): a confidential client may use a JWS ++$json = expectJSON( ++ callIntrospection( "token=$token" . jwsAuth( "oauth", "service" ) ) ); ++ok( $json->{active}, ++ "Confidential client, client_secret_jwt: token is active" ); ++ ++# Public client without secret: a given secret is ignored, the client is not ++# authenticated ++$res = ++ callIntrospection( "token=$token", ++ "Basic " . encode_base64( "spa:anything", '' ) ); ++expectRefused( $res, 'Public client, Basic with an arbitrary secret', ++ 401, "unauthorized_client" ); ++ ++# The response would give the identifier of the user for the calling RP ++if ( $res->[0] == 200 ) { ++ my $leak = from_json( $res->[2]->[0] ); ++ diag( "Public client got sub=$leak->{sub} for a token of " ++ . "$leak->{client_id}" ); ++} ++ ++# Public client, Basic authentication with an empty secret ++$res = ++ callIntrospection( "token=$token", "Basic " . encode_base64( "spa:", '' ) ); ++expectRefused( $res, 'Public client, Basic with an empty secret', ++ 401, "unauthorized_client" ); ++ ++# Public client, client_secret_post with an arbitrary secret ++$res = callIntrospection("token=$token&client_id=spa&client_secret=anything"); ++expectRefused( $res, 'Public client, client_secret_post', ++ 401, "unauthorized_client" ); ++ ++# Public client, client_id only ++$res = callIntrospection("token=$token&client_id=spa"); ++expectRefused( $res, 'Public client, client_id only', ++ 401, "unauthorized_client" ); ++ ++# Public client with a registered secret: authenticated only with this secret ++$json = expectJSON( ++ callIntrospection( ++ "token=$token", "Basic " . encode_base64( "pubsecret:pubsecret", "" ) ++ ) ++); ++ok( $json->{active}, "Public client with its secret, Basic: token is active" ); ++ ++$json = expectJSON( ++ callIntrospection( "token=$token" . jwsAuth( "pubsecret", "pubsecret" ) ) ); ++ok( $json->{active}, ++ "Public client with its secret, client_secret_jwt: token is active" ); ++ ++$res = callIntrospection( "token=$token", ++ "Basic " . encode_base64( "pubsecret:wrong", "" ) ); ++expectRefused( $res, "Public client with a secret, Basic with a wrong secret", ++ 401, "invalid_client" ); ++ ++$res = callIntrospection( "token=$token", ++ "Basic " . encode_base64( "pubsecret:", "" ) ); ++expectRefused( $res, "Public client with a secret, Basic with an empty secret", ++ 401, "unauthorized_client" ); ++ ++$res = ++ callIntrospection("token=$token&client_id=pubsecret&client_secret=wrong"); ++expectRefused( $res, ++ "Public client with a secret, client_secret_post with a wrong secret", ++ 400, "invalid_client" ); ++ ++$res = callIntrospection( "token=$token" . jwsAuth( "pubsecret", "wrong" ) ); ++expectRefused( $res, "Public client with a secret, JWS signed with a wrong key", ++ 400, "invalid_client" ); ++ ++$res = callIntrospection( "token=$token" . jwsAuth( "spa", "anything" ) ); ++expectRefused( $res, "Public client without secret, JWS", ++ 400, "invalid_client" ); ++ ++clean_sessions(); ++done_testing(); diff -Nru lemonldap-ng-2.21.2+ds/debian/patches/CVE-2026-95811.patch lemonldap-ng-2.21.2+ds/debian/patches/CVE-2026-95811.patch --- lemonldap-ng-2.21.2+ds/debian/patches/CVE-2026-95811.patch 1970-01-01 00:00:00.000000000 +0000 +++ lemonldap-ng-2.21.2+ds/debian/patches/CVE-2026-95811.patch 2026-09-24 14:56:00.000000000 +0000 @@ -0,0 +1,967 @@ +Description: test locationRules against the canonical URL + Rules were tested against the raw REQUEST_URI while web servers route on the + decoded and normalized path: rules could be bypassed with percent-encoded or + dot-segment URLs (ie /my%73ession, /./admin). +Author: Xavier Guimard +Origin: upstream, https://gitlab.ow2.org/lemonldap-ng/lemonldap-ng/-/commit/e5b082c76468b317d8de4b739420b609b633e71e +Bug: https://gitlab.ow2.org/lemonldap-ng/lemonldap-ng/-/work_items/3723 +Bug-CVE: CVE-2026-95811 +Forwarded: not-needed +Applied-Upstream: 2.21.6, commit:e5b082c76468b317d8de4b739420b609b633e71e +Last-Update: 2026-09-24 + +--- a/doc/sources/admin/security.rst ++++ b/doc/sources/admin/security.rst +@@ -229,10 +229,38 @@ + Encoded characters + ~~~~~~~~~~~~~~~~~~ + +-Some characters are encoded in URLs by the browser (such as space,...). +-To avoid problems, LL::NG decode them using +-https://metacpan.org/pod/Apache2::URI#unescape_url. So write your rules +-using normal characters. ++Some characters are encoded in URLs by the browser (such as space,...) and ++web servers normalize the path before routing requests to applications ++(dot segments and duplicate slashes are removed). To avoid any mismatch, ++LL::NG tests rules against the same canonical URL, that is the decoded and ++normalized path (the query string is left untouched). So write your rules ++using normal characters: ++ ++===================================== ========================== ++URL received URL tested by the rules ++===================================== ========================== ++``/my%73ession`` ``/mysession`` ++``/./mysession`` ``/mysession`` ++``/foo/../mysession`` ``/mysession`` ++``//mysession`` ``/mysession`` ++``/index.php?url=%2Fhome`` ``/index.php?url=%2Fhome`` ++===================================== ========================== ++ ++.. attention:: ++ ++ A rule whose path contains percent-encoded characters (ie ``/foo%20bar``) ++ is therefore never applied: use the decoded form (ie ``/foo bar``). The ++ query string is not decoded, so a rule matching a GET parameter can still ++ be worked around by encoding it. ++ ++.. danger:: ++ ++ With the Nginx handler, rules are tested against ``X_ORIGINAL_URI`` as ++ given by Nginx. It must be set to ``$original_uri`` ++ (``set $original_uri $uri$is_args$args;``, see :doc:`configvhost`), which ++ Nginx has already decoded and normalized. The old ``$request_uri`` form ++ gives the URL as sent by the client: rules can then be bypassed with an ++ encoded URL. + + IP in rules + ~~~~~~~~~~~ +--- a/e2e-tests/test-nginx.conf ++++ b/e2e-tests/test-nginx.conf +@@ -20,7 +20,7 @@ + fastcgi_param HTTP_HOST $host; + + # Keep original request (LLNG server will received /llauth) +- fastcgi_param X_ORIGINAL_URI $request_uri; ++ fastcgi_param X_ORIGINAL_URI $original_uri; + + # OU TO USE uWSGI + #include /etc/nginx/uwsgi_params; +@@ -28,13 +28,14 @@ + #uwsgi_pass_request_body off; + #uwsgi_param CONTENT_LENGTH ""; + #uwsgi_param HTTP_HOST $host; +- #uwsgi_param X_ORIGINAL_URI $request_uri; ++ #uwsgi_param X_ORIGINAL_URI $original_uri; + } + + # Client requests + location / { + index index.pl; + try_files $uri $uri/ =404; ++ set $original_uri $uri$is_args$args; + auth_request /lmauth; + auth_request_set $lmremote_user $upstream_http_lm_remote_user; + auth_request_set $lmremote_custom $upstream_http_lm_remote_custom; +--- a/lemonldap-ng-common/lib/Lemonldap/NG/Common/PSGI/Request.pm ++++ b/lemonldap-ng-common/lib/Lemonldap/NG/Common/PSGI/Request.pm +@@ -40,6 +40,21 @@ + + sub uri { return $_[0]->{uri} } + ++# URI tested by locationRules. It must be the path the request is really ++# served at, else rules can be bypassed with an encoded URL: ++# - handlers protecting another application (Nginx, Traefik, mod_perl) set it ++# in ACCESS_CONTROL_URI, see Handler::Server::Main::setAccessControlUri() ++# and Handler::ApacheMP2::Request ++# - otherwise (portal, manager, api), it is PATH_INFO, the path the LLNG ++# router uses, followed by the query string as received ++sub access_control_uri { ++ my ($self) = @_; ++ my $env = $self->env; ++ return $env->{ACCESS_CONTROL_URI} if defined $env->{ACCESS_CONTROL_URI}; ++ my $qs = $env->{QUERY_STRING}; ++ return $env->{PATH_INFO} . ( defined $qs && length $qs ? "?$qs" : '' ); ++} ++ + sub request_id { return $_[0]->{request_id} } + + sub userData { +@@ -210,6 +225,14 @@ + + REQUEST_URI environment variable decoded. + ++=head2 access_control_uri ++ ++URI tested by L: the path the request is really served at. ++Handlers protecting another application set it in the C ++environment variable (see L and ++L); otherwise it is PATH_INFO, ++the path used by the LLNG router, followed by the query string as received. ++ + =head2 user + + REMOTE_USER environment variable. It contains username when a server +--- a/lemonldap-ng-handler/MANIFEST ++++ b/lemonldap-ng-handler/MANIFEST +@@ -81,6 +81,7 @@ + t/60-Lemonldap-NG-Handler-PSGI-unprotected.t + t/60-Lemonldap-NG-Handler-PSGI.t + t/61-Lemonldap-NG-Handler-PSGI-Server.t ++t/62-Lemonldap-NG-Handler-Apache.t + t/62-Lemonldap-NG-Handler-Nginx.t + t/62-Lemonldap-NG-Handler-Traefik.t + t/63-Lemonldap-NG-Handler-PSGI-Try.t +@@ -97,6 +98,7 @@ + t/69-Lemonldap-NG-Handler-PSGI-SecureToken.t + t/70-Lemonldap-NG-Handler-PSGI-AuthBasic.t + t/71-Lemonldap-NG-Handler-PSGI-OAuth2.t ++t/72-Lemonldap-NG-Handler-canonicalUri.t + t/99-pod.t + t/custom.pm + t/lmConf-1.json +--- a/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/ApacheMP2/Request.pm ++++ b/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/ApacheMP2/Request.pm +@@ -57,6 +57,11 @@ + my $uri = URI->new( "http://" . $r->hostname . $r->unparsed_uri ); + $env->{PATH_INFO} = uri_unescape( $uri->path ); + ++ # Access control is decided on the URI httpd routed on ($r->uri, decoded ++ # and normalized), not on PATH_INFO, which is rebuilt here from the raw ++ # unparsed URI and where dot segments survive ++ $env->{ACCESS_CONTROL_URI} = $uri_full; ++ + my $self = Lemonldap::NG::Common::PSGI::Request->new($env); + bless $self, $class; + return $self; +--- a/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Lib/CDA.pm ++++ b/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Lib/CDA.pm +@@ -8,7 +8,9 @@ + + sub run { + my ( $class, $req, $rule, $protection ) = @_; +- my $uri = $req->{env}->{REQUEST_URI}; ++ ++ # Use the routed URI: an encoded '?' would hide the CDA parameter here ++ my $uri = $req->access_control_uri; + my $cn = $class->tsv->{cookieName}; + my ( $id, $session ); + if ( $uri =~ m/[\?&;]${cn}cda=(\w+)/i ) { +--- a/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Lib/SecureToken.pm ++++ b/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Lib/SecureToken.pm +@@ -37,7 +37,7 @@ + return $ret unless ( $ret == $class->OK ); + + # Get current URI +- my $uri = $r->{env}->{REQUEST_URI}; ++ my $uri = $r->access_control_uri; + + # Catch Secure Token parameters + my $localConfig = $class->localConfig; +--- a/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Lib/ZimbraPreAuth.pm ++++ b/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Lib/ZimbraPreAuth.pm +@@ -23,7 +23,7 @@ + return $ret unless ( $ret == $class->OK ); + + # Get current URI +- my $uri = $req->{env}->{REQUEST_URI}; ++ my $uri = $req->access_control_uri; + + # Get Zimbra parameters + my $localConfig = $class->localConfig; +--- a/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Main/Reload.pm ++++ b/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Main/Reload.pm +@@ -342,6 +342,20 @@ + next; + } + ++ # The path of the URL is tested against its canonical form ++ # (decoded and normalized), so percent-encoded characters can't ++ # match any more there. The query string is left untouched, so ++ # only look at the part before the '?' of the rule, which is ++ # escaped since rules are regular expressions (see canonicalUri()) ++ my $rulePath = $url; ++ $rulePath =~ s/\\\?.*$//s; ++ if ( $rulePath =~ /%[0-9A-Fa-f]{2}/ ) { ++ $class->logger->warn( ++ "Rule '$url' of virtual host $vhost contains " ++ . "percent-encoded characters in its path: it can't " ++ . "match, rules are tested against the decoded URL" ); ++ } ++ + if ( $url eq 'default' ) { + $class->tsv->{defaultCondition}->{$vhost} = $cond; + $class->tsv->{defaultProtection}->{$vhost} = $prot; +--- a/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Main/Run.pm ++++ b/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Main/Run.pm +@@ -141,7 +141,7 @@ + } + + # Authentication process +- my $uri = $req->{env}->{REQUEST_URI}; ++ my $uri = $req->access_control_uri; + my ($cond); + + ( $cond, $protection ) = $class->conditionSub($rule) if ($rule); +@@ -329,10 +329,64 @@ + return 0; + } + ++## @rmethod string canonicalUri(string uri) ++# Return the canonical form of an URI: as web servers do before routing ++# requests to applications, the path is percent-decoded and normalized ++# (dot segments and duplicate slashes removed). The query string is left ++# untouched since some rules contain GET parameters. ++# ++# This is only a guess of the path the web server will route on: each server ++# has its own rules (encoded slashes, duplicate slashes, dot segments,...). ++# Don't use it when the server gives this path (X_ORIGINAL_URI with Nginx, ++# $r->uri with mod_perl, PATH_INFO for LLNG's own applications, see ++# access_control_uri()): only when LL::NG receives the URI as sent by the ++# client (forward-auth) or a URL given by the caller (menu, authorizationfor, ++# CheckUser, urldc,...). ++# ++# @param $uri URI to canonicalize ++# @return canonical URI ++sub canonicalUri { ++ my ( $class, $uri ) = @_; ++ return '/' unless defined $uri; ++ ++ # Absolute-URI form (RFC 7230 5.3.2): applications only see the path ++ $uri =~ s#^[a-zA-Z][a-zA-Z0-9+.\-]*://[^/?]*/?#/#; ++ ++ # An empty path is "/" (RFC 3986 6.2.3) ++ $uri = "/$uri" if $uri =~ /^(?:\?|\z)/; ++ ++ my ( $path, $query ) = ( $uri, undef ); ++ if ( $uri =~ /^([^?]*)\?(.*)$/s ) { ++ ( $path, $query ) = ( $1, $2 ); ++ } ++ ++ # Only absolute paths are normalized, others (OPTIONS *, ...) are kept as is ++ if ( $path =~ m{^/} ) { ++ $path = uri_unescape($path); ++ ++ # A path ending with "/", "/." or "/.." keeps its trailing slash ++ my $trailingSlash = ( $path =~ m{(?:/|/\.\.?)$} ); ++ my @segments; ++ foreach my $segment ( split m{/+}, $path ) { ++ next if ( $segment eq '' or $segment eq '.' ); ++ if ( $segment eq '..' ) { ++ pop @segments; ++ } ++ else { ++ push @segments, $segment; ++ } ++ } ++ $path = '/' . join( '/', @segments ); ++ $path .= '/' if ( $trailingSlash and $path ne '/' ); ++ } ++ ++ return defined $query ? "$path?$query" : $path; ++} ++ + ## @rmethod int getLevel(string uri, string $vhost) + # Return required authentication level for this URI + # default to vhost authentication level +-# @param $uri URI ++# @param $uri URI, must be canonical (see canonicalUri()) + # @param $vhost vhost name, default to current request + sub getLevel { + my ( $class, $req, $uri, $vhost ) = @_; +@@ -363,7 +417,7 @@ + + ## @rmethod boolean grant(string uri, string cond) + # Grant or refuse client using compiled regexp and functions +-# @param $uri URI ++# @param $uri URI, must be canonical (see canonicalUri()) + # @param $cond optional Function granting access + # @return True if the user is granted to access to the current URL + sub grant { +@@ -741,7 +795,7 @@ + } + + ## @rmethod protected int isUnprotected() +-# @param $uri URI ++# @param $uri URI, as the request is routed on (see access_control_uri()) + # @return 0 if URI is protected, + # $class->UNPROTECT if it is unprotected by "unprotect", + # SKIP if unprotected by "skip" +--- a/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Server/Main.pm ++++ b/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Server/Main.pm +@@ -16,6 +16,21 @@ + # In server mode, headers are not passed to a PSGI application but returned + # to the server + ++## @method void setAccessControlUri(hashRef env) ++# Set in the environment the URI on which access control must be decided. ++# Nginx gives in X_ORIGINAL_URI the URI it routed on ($original_uri, already ++# decoded and normalized): it is used as is, decoding it again would give a ++# path the application doesn't serve. Without it (Traefik and Caddy, where ++# REQUEST_URI is set from X-Forwarded-Uri), REQUEST_URI is the URI as sent by ++# the client and is canonicalized (see Handler::Main::canonicalUri()). ++# @param $env PSGI environment ++sub setAccessControlUri { ++ my ( $class, $env ) = @_; ++ $env->{ACCESS_CONTROL_URI} = $env->{X_ORIGINAL_URI} ++ || $class->canonicalUri( $env->{REQUEST_URI} ); ++ return; ++} ++ + ## @method void set_header_in(hash headers) + # sets or modifies request headers + # @param headers hash containing header names => header value +--- a/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Server/Nginx.pm ++++ b/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Server/Nginx.pm +@@ -31,6 +31,9 @@ + return $self->psgiAdapter( + sub { + my $req = $_[0]; ++ ++ # Nginx gives the request URI it routed on in X_ORIGINAL_URI ++ $self->api->setAccessControlUri( $req->{env} ); + my $res = $self->_authAndTrace($req); + + # Transform 302 responses in 401 since Nginx refuse it +--- a/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Server/Traefik.pm ++++ b/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Server/Traefik.pm +@@ -30,6 +30,10 @@ + my $env = $_[0]; + $env->{HTTP_HOST} = $env->{HTTP_X_FORWARDED_HOST}; + $env->{REQUEST_URI} = $env->{HTTP_X_FORWARDED_URI}; ++ ++ # Traefik/Caddy don't expose the routed URI: X-Forwarded-Uri is all ++ # we have, so it is the source of the access control URI ++ $self->api->setAccessControlUri($env); + return $app->($env); + } + } +--- a/lemonldap-ng-handler/t/60-Lemonldap-NG-Handler-PSGI.t ++++ b/lemonldap-ng-handler/t/60-Lemonldap-NG-Handler-PSGI.t +@@ -328,6 +328,20 @@ + count(2); + } + ++# Encoded URLs (#3723): on LLNG's own PSGI vhosts (portal, manager, api), ++# REQUEST_URI is raw and PATH_INFO is the decoded path the web server routes ++# on, so rules are tested against PATH_INFO - the value the router uses ++ok( ++ $res = $client->_get( ++ '/deny', undef, undef, "lemonldap=$sessionId", ++ X_ORIGINAL_URI => undef, ++ REQUEST_URI => '/%64eny' ++ ), ++ 'Self-protected vhost: /%64eny' ++); ++ok( $res->[0] == 403, ' Code is 403' ) or explain( $res->[0], 403 ); ++count(2); ++ + done_testing( count() ); + + clean(); +--- /dev/null ++++ b/lemonldap-ng-handler/t/62-Lemonldap-NG-Handler-Apache.t +@@ -0,0 +1,56 @@ ++use Test::More; ++use Lemonldap::NG::Handler::ApacheMP2::Request; ++ ++require 't/test-psgi-lib.pm'; ++ ++init('Lemonldap::NG::Handler::PSGI'); ++ ++my $res; ++ ++# Encoded URLs (#3723) ++# -------------------- ++# mod_perl: rules are tested against $r->uri, decoded and normalized by httpd, ++# while PATH_INFO is rebuilt from the raw unparsed URI, where dot segments ++# survive. Values measured on Apache 2.4.68 + mod_perl 2.0.13 for GET /./deny. ++my $req = ++ Lemonldap::NG::Handler::ApacheMP2::Request->new( ApacheMP2FakeRequest->new ); ++is( $req->path, '/./deny', 'PATH_INFO keeps dot segments' ); ++is( $req->access_control_uri, '/deny', 'Access control URI is $r->uri' ); ++count(2); ++ ++# Same environment through the handler ++ok( ++ $res = $client->app->( ++ { %{ $req->env }, HTTP_COOKIE => "lemonldap=$sessionId" } ++ ), ++ 'Query /./deny' ++); ++ok( $res->[0] == 403, ' Code is 403' ) or explain( $res->[0], 403 ); ++count(2); ++ ++done_testing( count() ); ++ ++clean(); ++ ++sub Lemonldap::NG::Handler::PSGI::handler { ++ return [ 200, [ 'Content-Type', 'text/plain' ], ['Hello'] ]; ++} ++ ++# Minimal fake Apache request for GET /./deny: httpd decodes and normalizes ++# $r->uri, but $r->unparsed_uri keeps what the client sent ++package ApacheMP2FakeRequest; ++ ++sub new { return bless {}, shift } ++sub uri { return '/deny' } ++sub args { return '' } ++sub hostname { return 'test1.example.com' } ++sub useragent_ip { return '127.0.0.1' } ++sub get_server_port { return 80 } ++sub method { return 'GET' } ++sub subprocess_env { return undef } ++sub unparsed_uri { return '/./deny' } ++sub headers_in { return bless {}, 'ApacheMP2FakeRequest::Headers' } ++ ++package ApacheMP2FakeRequest::Headers; ++ ++sub do { return 1 } +--- a/lemonldap-ng-handler/t/62-Lemonldap-NG-Handler-Nginx.t ++++ b/lemonldap-ng-handler/t/62-Lemonldap-NG-Handler-Nginx.t +@@ -282,6 +282,50 @@ + count(2); + } + ++# Encoded URLs (#3723) ++# -------------------- ++# Nginx gives in X_ORIGINAL_URI the URI it routed on ($original_uri, already ++# decoded and normalized), while the other variables are those of the /lmauth ++# subrequest: rules are tested against X_ORIGINAL_URI as is. Nginx transforms ++# 302 redirections in 401. ++foreach my $t ( ++ [ '/deny', 403, $sessionId ], ++ [ '/alwaysskip', 200 ], ++ [ '/%61lwaysskip', 401 ], # Nginx value for /%2561lwaysskip ++ ) ++{ ++ my ( $uri, $code, $id ) = @$t; ++ ok( ++ $res = $client->_get( ++ '/lmauth', undef, undef, ++ ( $id ? "lemonldap=$id" : undef ), ++ X_ORIGINAL_URI => $uri ++ ), ++ "X_ORIGINAL_URI $uri" ++ ); ++ ok( $res->[0] == $code, " Code is $code" ) or explain( $res->[0], $code ); ++ count(2); ++} ++ ++# Without X_ORIGINAL_URI, REQUEST_URI is the URI as sent by the client: it is ++# decoded and normalized before testing the rules ++foreach my $uri ( ++ '/deny', '/%64eny', '/den%79', '/./deny', ++ '/foo/../deny', '//deny', '/%2Fdeny' ++ ) ++{ ++ ok( ++ $res = $client->_get( ++ '/lmauth', undef, undef, "lemonldap=$sessionId", ++ X_ORIGINAL_URI => undef, ++ REQUEST_URI => $uri ++ ), ++ "REQUEST_URI $uri" ++ ); ++ ok( $res->[0] == 403, " Code is 403" ) or explain( $res->[0], 403 ); ++ count(2); ++} ++ + done_testing( count() ); + + clean(); +--- a/lemonldap-ng-handler/t/62-Lemonldap-NG-Handler-Traefik.t ++++ b/lemonldap-ng-handler/t/62-Lemonldap-NG-Handler-Traefik.t +@@ -195,6 +195,34 @@ + ); + count(3); + ++# Encoded URLs (#3723) ++# -------------------- ++# Forward-auth: the handler receives a GET on the forwardAuth address (PATH_INFO ++# is usually "/") and the requested URI, as sent by the client, travels in ++# X-Forwarded-Uri. It is decoded and normalized before testing the rules. ++foreach my $t ( ++ [ '/deny', 403, $sessionId ], ++ [ '/%64eny', 403, $sessionId ], ++ [ '/./deny', 403, $sessionId ], ++ [ '/foo/../deny', 403, $sessionId ], ++ [ '/%2Fdeny', 403, $sessionId ], ++ [ '/%61lwaysskip', 200 ], ++ [ '/%2561lwaysskip', 302 ], # decoded once only ++ ) ++{ ++ my ( $uri, $code, $id ) = @$t; ++ ok( ++ $res = $client->_get( ++ '/', undef, undef, ( $id ? "lemonldap=$id" : undef ), ++ X_ORIGINAL_URI => undef, ++ HTTP_X_FORWARDED_URI => $uri ++ ), ++ "X-Forwarded-Uri $uri" ++ ); ++ ok( $res->[0] == $code, " Code is $code" ) or explain( $res->[0], $code ); ++ count(2); ++} ++ + done_testing( count() ); + + clean(); +--- /dev/null ++++ b/lemonldap-ng-handler/t/72-Lemonldap-NG-Handler-canonicalUri.t +@@ -0,0 +1,74 @@ ++package main; ++use strict; ++use warnings; ++require 't/test.pm'; ++ ++use Test::More; ++ ++BEGIN { use_ok('Lemonldap::NG::Handler::Main') } ++ ++my $h = 'Lemonldap::NG::Handler::Main'; ++ ++# Rules are tested against the canonical URI: the path must be decoded and ++# normalized like web servers do before routing requests to applications, ++# otherwise rules can be bypassed with an encoded URL ++my @tests = ( ++ ++ # URI Canonical URI ++ [ '/', '/' ], ++ [ '/mysession', '/mysession' ], ++ [ '/index.php', '/index.php' ], ++ ++ # Percent-encoded characters ++ [ '/mysess%69on', '/mysession' ], ++ [ '/my%73ession', '/mysession' ], ++ [ '/%6dysession', '/mysession' ], ++ [ '/%6Dysession', '/mysession' ], ++ [ '/caf%C3%A9', "/caf\xc3\xa9" ], ++ ++ # Encoded slashes and duplicate slashes ++ [ '/x%2Fy', '/x/y' ], ++ [ '/%2Fmysession', '/mysession' ], ++ [ '//mysession', '/mysession' ], ++ [ '/foo//bar', '/foo/bar' ], ++ ++ # Dot segments ++ [ '/./mysession', '/mysession' ], ++ [ '/foo/../mysession', '/mysession' ], ++ [ '/a/b/./c/../d', '/a/b/d' ], ++ [ '/..', '/' ], ++ [ '/../foo', '/foo' ], ++ [ '/a/b/', '/a/b/' ], ++ [ '/a/b/..', '/a/' ], ++ [ '/a/b/.', '/a/b/' ], ++ ++ # Query string is not decoded (rules can contain GET parameters) ++ [ '/index.php?logout=1', '/index.php?logout=1' ], ++ [ '/foo?url=%2Fbar%20baz', '/foo?url=%2Fbar%20baz' ], ++ [ '/f%6Fo?x=%2F', '/foo?x=%2F' ], ++ ++ # Absolute-URI form: applications only see the path ++ [ 'http://app.example.com/my%73ession?a=1', '/mysession?a=1' ], ++ [ 'http://app.example.com', '/' ], ++ [ 'http://app.example.com?a=1', '/?a=1' ], ++ ++ # An empty path is "/" ++ [ '', '/' ], ++ [ '?a=1', '/?a=1' ], ++ [ undef, '/' ], ++ ++ # Invalid escape sequences and '+' are kept as is ++ [ '/100%', '/100%' ], ++ [ '/a+b', '/a+b' ], ++ ++ # Non absolute paths are not normalized ++ [ '*', '*' ], ++); ++ ++foreach my $t (@tests) { ++ is( $h->canonicalUri( $t->[0] ), ++ $t->[1], ++ 'canonicalUri(' . ( defined $t->[0] ? $t->[0] : 'undef' ) . ')' ); ++} ++ ++done_testing( scalar @tests + 1 ); # +1 for use_ok +--- a/lemonldap-ng-handler/t/lmConf-1.json ++++ b/lemonldap-ng-handler/t/lmConf-1.json +@@ -47,6 +47,7 @@ + "^/test-uri2": "varIsInUri($ENV{REQUEST_URI}, '/test-uri2/', $uid)", + "^/test-restricted_uri": "varIsInUri($ENV{REQUEST_URI}, '/test-restricted_uri/', \"$uid/\", 1)", + "^/skipif": "$ENV{REQUEST_URI} =~ /zz/ ? skip : 1", ++ "^/alwaysskip": "skip", + "^/fortimelords": "inGroup('timelords')", + "^/fordaleks": "inGroup('daleks')", + "^/logout": "logout_sso", +--- a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Viewer.pm ++++ b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Viewer.pm +@@ -169,7 +169,9 @@ + $self->SUPER::getKey( $req, @args ); + } + else { +- if ( $req->{env}->{REQUEST_URI} =~ m%/view/(?:latest|\d+/\w+)$% ) { ++ # Compare the routed path (and not REQUEST_URI, which also contains ++ # the query string: it could satisfy the whitelist from there) ++ if ( $req->path =~ m%^/view/(?:latest|\d+/\w+)$% ) { + $self->logger->debug(" $req->{env}->{REQUEST_URI} -> URI allowed"); + $self->SUPER::getKey( $req, @args ); + } +--- a/lemonldap-ng-manager/t/71-viewer-without-diff.t ++++ b/lemonldap-ng-manager/t/71-viewer-without-diff.t +@@ -83,6 +83,19 @@ + or print STDERR Dumper($res); + count(2); + ++# The whitelist applies to the routed path: neither an extra path segment ++# nor a query string ending with an allowed URI may open the browser gate ++$res = $client2->jsonResponse('/view/2/globalStorageOptions/x'); ++ok( $res->{value} eq '_Hidden_', 'Deep path is NOT allowed' ) ++ or print STDERR Dumper($res); ++count(1); ++ ++$res = $client2->jsonResponse( '/view/2/globalStorageOptions/x', ++ 'y=/view/latest' ); ++ok( $res->{value} eq '_Hidden_', 'Query string does not open the gate' ) ++ or print STDERR Dumper($res); ++count(1); ++ + # Remove new conf + `rm -rf t/conf/lmConf-2.json`; + +--- a/lemonldap-ng-portal/MANIFEST ++++ b/lemonldap-ng-portal/MANIFEST +@@ -997,6 +997,8 @@ + t/92-Required-Auth-Methods.t + t/92-Required-Issuer-Methods.t + t/92-Required-UserDB-Methods.t ++t/93-locationRules-encoded-urls.t ++t/95-soap-isAuthorizedURI.t + t/99-Bad-logLevel.t + t/99-Dont-load-Dumper.t + t/99-pod.t +--- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Menu.pm ++++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Menu.pm +@@ -383,8 +383,8 @@ + || "auto"; + $apphash->{$key}->{options}->{uri} =~ URIRE; + my ( $vhost, $appuri ) = ( $3, $5 ); +- $vhost = $self->p->HANDLER->resolveAlias($vhost); +- $appuri ||= '/'; ++ $vhost = $self->p->HANDLER->resolveAlias($vhost); ++ $appuri = $self->p->HANDLER->canonicalUri($appuri); + + # Remove if display is "no" or "off" + delete $apphash->{$key} and next if ( $appdisplay =~ /^(no|off)$/ ); +--- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Process.pm ++++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Process.pm +@@ -186,7 +186,7 @@ + if ( $u->scheme =~ /^https?$/ ) { + $proto = $u->scheme; + $vhost = $u->host if $u->can("host"); +- $appuri = $u->path_query; ++ $appuri = $self->HANDLER->canonicalUri( $u->path_query ); + } + } + +--- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/CheckUser.pm ++++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/CheckUser.pm +@@ -516,6 +516,8 @@ + my ( $vhost, $appuri ) = $uri =~ m@^https?://([^/#]*)(.*)@; + my $exist = 0; + ++ $appuri = $self->p->HANDLER->canonicalUri($appuri); ++ + $vhost =~ s/:\d+$//; + foreach my $vh ( keys %{ $self->conf->{locationRules} } ) { + if ( $vh eq $vhost ) { +--- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/RESTServer.pm ++++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/RESTServer.pm +@@ -455,7 +455,7 @@ + my ( $host, $uri ); + if ( $req->urldc =~ URIRE ) { + ( $host, $uri ) = ( $3 . ( $4 ? ":$4" : '' ), $5 ); +- $uri ||= '/'; ++ $uri = $self->p->HANDLER->canonicalUri($uri); + return $self->p->sendError( $req, "Bad URL $req->{urldc}", 400 ) + unless ($host); + } +--- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/SOAPServer.pm ++++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/SOAPServer.pm +@@ -465,7 +465,8 @@ + die 'uri is required' unless ($url); + my ( $host, $uri ); + if ( $url =~ URIRE ) { +- ( $host, $uri ) = ( $1, $2 ); ++ ( $host, $uri ) = ( $3 . ( $4 ? ":$4" : '' ), $5 ); ++ $uri = $self->p->HANDLER->canonicalUri($uri); + } + else { + die 'Bad uri'; +--- a/lemonldap-ng-portal/t/35-My-session.t ++++ b/lemonldap-ng-portal/t/35-My-session.t +@@ -2,6 +2,8 @@ + use Test::More; + use strict; + use IO::String; ++use MIME::Base64; ++use URI::Escape; + + BEGIN { + require 't/test-lib.pm'; +@@ -93,6 +95,45 @@ + ); + count(5); + ++# authorizationfor: the URL is percent-decoded and normalized by the web ++# server before the request is routed to the application, so rules must be ++# tested against the same canonical value, else they can be bypassed with an ++# encoded URL (#3723) ++sub authorizationfor { ++ my ($url) = @_; ++ ok( ++ my $res = $client->_get( ++ '/mysession', ++ query => 'authorizationfor=' ++ . uri_escape( encode_base64( $url, '' ) ), ++ cookie => "lemonldap=$id" ++ ), ++ "Check for $url" ++ ); ++ count(1); ++ expectOK($res); ++ return JSON::from_json( $res->[2]->[0] ); ++} ++ ++foreach my $url ( ++ 'http://test1.example.com/deny', # no encoding ++ 'http://test1.example.com/%64eny', # "d" encoded ++ 'http://test1.example.com/den%79', # "y" encoded ++ 'http://test1.example.com/./deny', # dot segment ++ 'http://test1.example.com/foo/../deny', # dot segment ++ 'http://test1.example.com//deny', # duplicate slash ++ 'http://test1.example.com/%2Fdeny', # encoded slash ++ ) ++{ ++ is( authorizationfor($url)->{result}, 0, " $url is refused" ); ++ count(1); ++} ++ ++# Decoded as /denY: the rule ^/deny doesn't apply ++is( authorizationfor('http://test1.example.com/den%59')->{result}, ++ 1, ' http://test1.example.com/den%59 is granted' ); ++count(1); ++ + # Test logout + $client->logout($id); + +--- /dev/null ++++ b/lemonldap-ng-portal/t/93-locationRules-encoded-urls.t +@@ -0,0 +1,77 @@ ++use warnings; ++use Test::More; ++use strict; ++use IO::String; ++ ++require 't/test-lib.pm'; ++ ++my $res; ++ ++my $client = LLNG::Manager::Test->new( { ++ ini => { ++ logLevel => 'error', ++ authentication => 'Demo', ++ userDB => 'Same', ++ restSessionServer => 1, ++ locationRules => { ++ 'auth.example.com' => { ++ '^/mysession' => 'deny', ++ default => 'accept', ++ }, ++ }, ++ } ++ } ++); ++ ++my $id = $client->login('dwho'); ++ ++# The URL is percent-decoded and normalized by the web server before the ++# request is routed to the application: with Nginx (FastCGI), REQUEST_URI ++# contains for example /my%73ession while PATH_INFO (used to route the ++# request inside the portal) is /mysession. Rules must be tested against the ++# same value, else they can be bypassed with an encoded URL. ++foreach my $uri ( ++ '/mysession', # no encoding ++ '/my%73ession', # "s" encoded ++ '/mysess%69on', # "i" encoded ++ '/%6Dysession', # "m" encoded ++ '/./mysession', # dot segment ++ '/foo/../mysession', # dot segment ++ '/foo//../mysession', # duplicate slash ++ '//mysession', # duplicate slash ++ '/%2Fmysession', # encoded slash ++ ) ++{ ++ ok( ++ $res = $client->_get( ++ '/mysession', ++ cookie => "lemonldap=$id", ++ accept => 'application/json', ++ query => 'whoami=1', ++ custom => { REQUEST_URI => "$uri?whoami=1" }, ++ ), ++ "Auth query to $uri" ++ ); ++ count(1); ++ expectForbidden($res); ++} ++ ++# Negative control: this URL is decoded as /mysessCon, the rule must not be ++# applied (the router answers 400 because such route doesn't exist, the ++# important point is that it is not a 403 from the rule) ++ok( ++ $res = $client->_get( ++ '/mysessCon', ++ cookie => "lemonldap=$id", ++ accept => 'application/json', ++ custom => { REQUEST_URI => '/mysess%43on' }, ++ ), ++ 'Auth query to /mysess%43on' ++); ++count(1); ++ok( $res->[0] != 403, ' Rule is not applied on /mysessCon' ) ++ or explain( $res->[0], 'not 403' ); ++count(1); ++ ++clean_sessions(); ++done_testing( count() ); +--- /dev/null ++++ b/lemonldap-ng-portal/t/95-soap-isAuthorizedURI.t +@@ -0,0 +1,107 @@ ++use warnings; ++use Test::More; ++use strict; ++use IO::String; ++use LWP::UserAgent; ++use LWP::Protocol::PSGI; ++ ++BEGIN { ++ require 't/test-lib.pm'; ++} ++ ++my $maintests = 16; ++my $client; ++ ++# Redefine LWP methods for tests ++LWP::Protocol::PSGI->register( ++ sub { ++ my $req = Plack::Request->new(@_); ++ ok( $req->uri =~ m#http://auth.example.com(.*)#, ' @ SOAP REQUEST @' ); ++ my $url = $1; ++ my $res; ++ my $s = $req->content; ++ ok( ++ $res = $client->_post( ++ $url, ++ IO::String->new($s), ++ length => length($s), ++ type => $req->header('Content-Type'), ++ custom => { ++ HTTP_SOAPACTION => $req->header('Soapaction'), ++ }, ++ ), ++ ' Execute request' ++ ); ++ expectOK($res); ++ ok( getHeader( $res, 'Content-Type' ) =~ m#^(?:text|application)/xml#, ++ ' Content is XML' ) ++ or explain( $res->[1], 'Content-Type => application/xml' ); ++ pass(' @ END OF SOAP REQUEST @'); ++ count(4); ++ return $res; ++ } ++); ++ ++SKIP: { ++ eval 'use SOAP::Lite'; ++ if ($@) { ++ skip 'SOAP::Lite not found', $maintests; ++ } ++ ++ $client = LLNG::Manager::Test->new( { ++ ini => { ++ logLevel => 'error', ++ authentication => 'Demo', ++ userDB => 'Same', ++ soapSessionServer => 1, ++ locationRules => { ++ 'auth.example.com' => { default => 'accept' }, ++ 'test1.example.com' => { ++ '^/deny' => 'deny', ++ default => 'accept', ++ }, ++ }, ++ } ++ } ++ ); ++ ++ my $id = $client->login('dwho'); ++ ++ my $soap; ++ ok( ++ $soap = SOAP::Lite->new( proxy => 'http://auth.example.com/sessions' ), ++ 'SOAP client' ++ ); ++ ++ # The URL is percent-decoded and normalized by the web server before the ++ # request is routed to the application: rules must be tested against the ++ # same canonical value, else they can be bypassed with an encoded URL. ++ foreach my $url ( ++ 'http://test1.example.com/deny', # no encoding ++ 'http://test1.example.com/%64eny', # "d" encoded ++ 'http://test1.example.com/den%79', # "y" encoded ++ 'http://test1.example.com/./deny', # dot segment ++ 'http://test1.example.com/foo/../deny', # dot segment ++ 'http://test1.example.com//deny', # duplicate slash ++ 'http://test1.example.com/%2Fdeny', # encoded slash ++ ) ++ { ++ $soap->default_ns('urn:Lemonldap/NG/Common/PSGI/SOAPService'); ++ my $call; ++ ok( $call = $soap->call( 'isAuthorizedURI', $id, $url ), ++ "SOAP call for $url" ); ++ my $res = $call->result(); ++ ok( !$res, "Authorization refused for $url" ); ++ } ++ ++ # Negative control: this URL is decoded as /denY, the rule ^/deny must not ++ # apply, so authorization must be granted. ++ $soap->default_ns('urn:Lemonldap/NG/Common/PSGI/SOAPService'); ++ my $res = $soap->call( 'isAuthorizedURI', $id, ++ 'http://test1.example.com/den%59' )->result(); ++ ok( $res, 'Authorization granted for http://test1.example.com/den%59' ); ++} ++ ++count($maintests); ++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-08-08 21:08:26.000000000 +0000 +++ lemonldap-ng-2.21.2+ds/debian/patches/series 2026-09-24 14:56:00.000000000 +0000 @@ -11,3 +11,9 @@ workaround-nginx-issue.patch CVE-2026-19349.patch CVE-2026-12804.patch +3701-password-reveal-surrounding-form.patch +3701-match-all-password-forms.patch +3722-force-scalar-context-for-params.patch +3726-pass-vhost-to-getLevel.patch +CVE-2026-95811.patch +CVE-2026-92288-CVE-2026-92289.patch