Skip to content

Commit 47dec5c

Browse files
internal: Automated internal backport: CVE-2026-40165.sec.patch to authentik-2025.12 (#22275)
Automated internal backport of patch CVE-2026-40165.sec.patch to authentik-2025.12 Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
1 parent 63dbcd3 commit 47dec5c

3 files changed

Lines changed: 75 additions & 20 deletions

File tree

authentik/sources/saml/processors/response.py

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -231,12 +231,12 @@ def _handle_name_id_transient(self) -> SourceFlowManager:
231231
user has an attribute that refers to our Source for cleanup. The user is also deleted
232232
on logout and periodically."""
233233
# Create a temporary User
234-
name_id = self._get_name_id()
234+
name_id_el, name_id = self._get_name_id()
235235
expiry = mktime(
236236
(now() + timedelta_from_string(self._source.temporary_user_delete_after)).timetuple()
237237
)
238238
user: User = User.objects.create(
239-
username=name_id.text,
239+
username=name_id,
240240
attributes={
241241
USER_ATTRIBUTE_GENERATED: True,
242242
USER_ATTRIBUTE_SOURCES: [
@@ -247,20 +247,18 @@ def _handle_name_id_transient(self) -> SourceFlowManager:
247247
},
248248
path=self._source.get_user_path(),
249249
)
250-
LOGGER.debug("Created temporary user for NameID Transient", username=name_id.text)
250+
LOGGER.debug("Created temporary user for NameID Transient", username=name_id)
251251
user.set_unusable_password()
252252
user.save()
253-
UserSAMLSourceConnection.objects.create(
254-
source=self._source, user=user, identifier=name_id.text
255-
)
253+
UserSAMLSourceConnection.objects.create(source=self._source, user=user, identifier=name_id)
256254
return SAMLSourceFlowManager(
257255
source=self._source,
258256
request=self._http_request,
259-
identifier=str(name_id.text),
257+
identifier=str(name_id),
260258
user_info={
261259
"root": self._root,
262260
"assertion": self.get_assertion(),
263-
"name_id": name_id,
261+
"name_id": name_id_el,
264262
},
265263
policy_context={},
266264
)
@@ -271,7 +269,7 @@ def get_assertion(self) -> _Element | None:
271269
return self._assertion
272270
return self._root.find(f"{{{NS_SAML_ASSERTION}}}Assertion")
273271

274-
def _get_name_id(self) -> _Element:
272+
def _get_name_id(self) -> tuple[_Element, str]:
275273
"""Get NameID Element"""
276274
assertion = self.get_assertion()
277275
if assertion is None:
@@ -282,12 +280,11 @@ def _get_name_id(self) -> _Element:
282280
name_id = subject.find(f"{{{NS_SAML_ASSERTION}}}NameID")
283281
if name_id is None:
284282
raise ValueError("NameID element not found")
285-
return name_id
283+
return name_id, "".join(name_id.itertext())
286284

287285
def _get_name_id_filter(self) -> dict[str, str]:
288286
"""Returns the subject's NameID as a Filter for the `User`"""
289-
name_id_el = self._get_name_id()
290-
name_id = name_id_el.text
287+
name_id_el, name_id = self._get_name_id()
291288
if not name_id:
292289
raise UnsupportedNameIDFormat("Subject's NameID is empty.")
293290
_format = name_id_el.attrib["Format"]
@@ -308,26 +305,26 @@ def _get_name_id_filter(self) -> dict[str, str]:
308305

309306
def prepare_flow_manager(self) -> SourceFlowManager:
310307
"""Prepare flow plan depending on whether or not the user exists"""
311-
name_id = self._get_name_id()
308+
name_id_el, name_id = self._get_name_id()
312309
# Sanity check, show a warning if NameIDPolicy doesn't match what we go
313-
if self._source.name_id_policy != name_id.attrib["Format"]:
310+
if self._source.name_id_policy != name_id_el.attrib["Format"]:
314311
LOGGER.warning(
315312
"NameID from IdP doesn't match our policy",
316313
expected=self._source.name_id_policy,
317-
got=name_id.attrib["Format"],
314+
got=name_id_el.attrib["Format"],
318315
)
319316
# transient NameIDs are handled separately as they don't have to go through flows.
320-
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT:
317+
if name_id_el.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT:
321318
return self._handle_name_id_transient()
322319

323320
return SAMLSourceFlowManager(
324321
source=self._source,
325322
request=self._http_request,
326-
identifier=str(name_id.text),
323+
identifier=name_id,
327324
user_info={
328325
"root": self._root,
329326
"assertion": self.get_assertion(),
330-
"name_id": name_id,
327+
"name_id": name_id_el,
331328
},
332329
policy_context={
333330
"saml_response": etree.tostring(self._root),

authentik/sources/saml/tests/test_response.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,33 @@ def test_verification_assertion_duplicate(self):
193193

194194
parser = ResponseProcessor(self.source, request)
195195
parser.parse()
196-
self.assertNotEqual(parser._get_name_id().text, "bad")
197-
self.assertEqual(parser._get_name_id().text, "_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7")
196+
self.assertNotEqual(parser._get_name_id()[1], "bad")
197+
self.assertEqual(parser._get_name_id()[1], "_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7")
198+
199+
@freeze_time("2022-10-14T14:15:00")
200+
def test_name_id_comment(self):
201+
"""Test comment in name ID"""
202+
fixture = load_fixture("fixtures/response_signed_assertion_dup.xml")
203+
fixture = fixture.replace(
204+
"_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7",
205+
"_ce3d2948b4cf20146dee0a0b3dd6f<!--x-->69b6cf86f62d7",
206+
)
207+
key = load_fixture("fixtures/signature_cert.pem")
208+
kp = CertificateKeyPair.objects.create(
209+
name=generate_id(),
210+
certificate_data=key,
211+
)
212+
self.source.verification_kp = kp
213+
self.source.signed_assertion = True
214+
self.source.signed_response = False
215+
request = self.factory.post(
216+
"/",
217+
data={"SAMLResponse": b64encode(fixture.encode()).decode()},
218+
)
219+
220+
parser = ResponseProcessor(self.source, request)
221+
parser.parse()
222+
self.assertEqual(parser._get_name_id()[1], "_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7")
198223

199224
@freeze_time("2014-07-17T01:02:18Z")
200225
def test_verification_response(self):
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# CVE-2026-40165
2+
3+
_Reported by [@kodareef5](https://github.com/kodareef5), [@Android-Login-Analysis](https://github.com/Android-Login-Analysis), and [@AyushParkara](https://github.com/AyushParkara)_
4+
5+
## SAML Source Fails to Validate Assertion Conditions
6+
7+
### Summary
8+
9+
Due to how authentik used to extract the NameID value from a SAML assertion, it was possible for an attacker to trick authentik into only seeing a part of the NameID value, potentially allowing an attacker to get access to other accounts.
10+
11+
### Patches
12+
13+
authentik 2025.12.5 and 2026.2.3 fix this issue, for other versions the workaround below can be used.
14+
15+
### Impact
16+
17+
This issue can be exploited given an authentik instance with a SAML Source, where the attacker has an account on the SAML Source and the ability to modify their NameID value (commonly username or E-mail), and XML Signing is enabled. The attacker can modify the SAML assertion given to authentik by injecting a comment within the NameID value, which effectively truncates the NameID value to the snippet before the comment, and give the attack access to any user account.
18+
19+
### Workarounds
20+
21+
Create a SAML Source property mapping with the following expression and add it to all SAML Sources:
22+
23+
```python
24+
if name_id.text != "".join(name_id.itertext()):
25+
raise ValueError("Mismatched NameID")
26+
return {}
27+
```
28+
29+
### For more information
30+
31+
If you have any questions or comments about this advisory:
32+
33+
- Email us at [security@goauthentik.io](mailto:security@goauthentik.io).

0 commit comments

Comments
 (0)