diff --git a/classes/form/selectidp_buttons.php b/classes/form/selectidp_buttons.php
index 54cf33108..4294dc358 100644
--- a/classes/form/selectidp_buttons.php
+++ b/classes/form/selectidp_buttons.php
@@ -82,7 +82,12 @@ public function definition() {
* @return string
*/
private function get_idpbutton($idpentityid, $idpname, $logourl, $rememberedidp = false) {
- $logo = !is_null($logourl) ? "
" : "";
+ $logo = '';
+ $logourl = clean_param($logourl, PARAM_URL);
+ if (!is_null($logourl)) {
+ $logo = "
";
+ }
+ $idpname = clean_param($idpname, PARAM_TEXT);
$extraclasses = $rememberedidp ? "rememberedidp" : "";
return <<
diff --git a/idp/sso.php b/idp/sso.php
index fcd00ca9d..6f3c25fa1 100644
--- a/idp/sso.php
+++ b/idp/sso.php
@@ -53,9 +53,14 @@
// Get data from input request.
$id = $xpath->evaluate('normalize-space(/*/@ID)');
-$destination = htmlspecialchars($xpath->evaluate('normalize-space(/*/@AssertionConsumerServiceURL)'));
+$destinationraw = $xpath->evaluate('normalize-space(/*/@AssertionConsumerServiceURL)');
$sp = $xpath->evaluate('normalize-space(/*/*[local-name() = "Issuer"])');
+// Validate ID.
+if (!auth_saml2_is_valid_saml_id($id)) {
+ throw new saml2_exception('invalid_id_error', get_string('invalid_id_error', 'auth_saml2', $id));
+}
+
// Confirm we know about this SP.
$knownsps = [];
foreach (explode(PHP_EOL, $saml2auth->config->moodleidpsplist) as $ksp) {
@@ -70,6 +75,20 @@
throw new saml2_exception('unknown_sp_error', get_string('moodleidpsplist_error', 'auth_saml2', $sp));
}
+// Validate ACS.
+$destinationhost = parse_url($destinationraw, PHP_URL_HOST);
+$sphost = parse_url($sp, PHP_URL_HOST);
+
+if (empty($destinationhost)) {
+ throw new saml2_exception('unknown_sp_error', get_string('moodleidpsplist_error', 'auth_saml2', $sp));
+}
+
+if ($saml2auth->config->acsmatchissuer && strcasecmp($destinationhost, $sphost) !== 0) {
+ throw new saml2_exception('acs_mismatch_error', get_string('acs_mismatch_error', 'auth_saml2'));
+}
+
+$destination = htmlspecialchars($destinationraw);
+
// Get time in UTC.
$datetime = new DateTime();
$datetime->setTimezone(new DatetimeZone('UTC'));
diff --git a/lang/en/auth_saml2.php b/lang/en/auth_saml2.php
index f0974abc5..bfabedb45 100644
--- a/lang/en/auth_saml2.php
+++ b/lang/en/auth_saml2.php
@@ -22,6 +22,9 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
+$string['acs_mismatch_error'] = 'ACS URL does not match issuer';
+$string['acsmatchissuer'] = 'Match ACS with issuer';
+$string['acsmatchissuer_help'] = 'If enabled, the Assertion Consumer Service (ACS) URL must match the issuer host. If you know that they do/should, enable this option to improve login security.';
$string['allowcreate'] = 'Allow create';
$string['allowcreate_help'] = 'Allow creation of IdP users on demand';
$string['alterlogout'] = 'Alternative Logout URL';
@@ -129,6 +132,7 @@
$string['idpname_help'] = 'eg myUNI - this is detected from the metadata and will show on the dual login page (if enabled)';
$string['idpnamedefault'] = 'Login via SAML2';
$string['idpnamedefault_varaible'] = 'Login via SAML2 ({$a})';
+$string['invalid_id_error'] = 'Invalid SAML ID';
$string['localityname'] = 'Locality';
$string['locked'] = 'Locked';
$string['logdir'] = 'Log Directory';
diff --git a/locallib.php b/locallib.php
index 448c08cac..83dc73613 100644
--- a/locallib.php
+++ b/locallib.php
@@ -519,3 +519,14 @@ function auth_saml2_admin_nav($title, $url) {
$PAGE->set_heading(get_string('pluginname', 'auth_saml2') . ': ' . $title);
$PAGE->set_title(get_string('pluginname', 'auth_saml2') . ': ' . $title);
}
+
+/**
+ * Validate a SAML protocol ID (xs:ID / NCName syntax) to prevent XML injection.
+ *
+ * @param string $id
+ * @return bool
+ */
+function auth_saml2_is_valid_saml_id(string $id): bool {
+ // NCName: starts with a letter or underscore, followed by letters, digits, '-', '_' or '.'.
+ return (bool) preg_match('/^[A-Za-z_][A-Za-z0-9_.-]*$/', $id) && strlen($id) <= 256;
+}
diff --git a/settings.php b/settings.php
index 865ea42b5..7645bdb29 100644
--- a/settings.php
+++ b/settings.php
@@ -222,6 +222,14 @@
$acssetting->set_updatedcallback('auth_saml2_update_sp_metadata');
$settings->add($acssetting);
+ $settings->add(new admin_setting_configselect(
+ 'auth_saml2/acsmatchissuer',
+ get_string('acsmatchissuer', 'auth_saml2'),
+ get_string('acsmatchissuer_help', 'auth_saml2'),
+ 0,
+ $yesno,
+ ));
+
$settings->add(new admin_setting_configselect(
'auth_saml2/allowcreate',
get_string('allowcreate', 'auth_saml2'),
diff --git a/version.php b/version.php
index 7d0ddb0ce..92e90bf3d 100644
--- a/version.php
+++ b/version.php
@@ -24,8 +24,8 @@
defined('MOODLE_INTERNAL') || die();
-$plugin->version = 2026040202; // The current plugin version (Date: YYYYMMDDXX).
-$plugin->release = 2026040202; // Match release exactly to version.
+$plugin->version = 2026040203; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->release = 2026040203; // Match release exactly to version.
$plugin->requires = 2025040400; // Requires Moodle 5.0
$plugin->component = 'auth_saml2'; // Full name of the plugin (used for diagnostics).
$plugin->maturity = MATURITY_STABLE;