Skip to content
Tomi Belan edited this page Mar 7, 2025 · 1 revision

Resources

https://github.com/fmfi-svt/saml-shibboleth-guide

https://github.com/fmfi-svt/andrvotr

https://github.com/SAML-Toolkits/python3-saml

SP Software Comparison

My ranking: pysaml2 < python3-saml < Shibboleth SP (mod_shib) < mod_auth_mellon.

Both Shibboleth SP (mod_shib) and mod_auth_mellon look robust. I slightly prefer mod_auth_mellon because it uses standard Apache configuration instead of XML, doesn't need a separate daemon service, and always gives you all SAML attributes. Shibboleth silently discards attributes not defined in attribute-map.xml (e.g. Andrvotr Authority Tokens). On the other hand, Shibboleth can be configured with a logout callback to also destroy app-specific session cookies, a feature I haven't found in mod_auth_mellon.

However, I'm thinking about maybe potentially replacing Apache with another server one day. I'd like to avoid increasing our dependence on it. So I decided to look for a pure Python library instead of an Apache module.

In my opinion, pysaml2 has many drawbacks: poor documentation (only config format, not Python API), messy code, overly complex examples, too many dependencies, and usage of xmlsec as a subprocess binary. python3-saml can be made to work, and its documentation and implementation are good enough. But it has not been entirely smooth sailing either -- see the many comments in votrfront/login.py. In retrospect it is quite possible that using an Apache module would've been the better choice.

Certificates

Generated with:

openssl req -new -x509 -days 3652 -newkey rsa:3072 -noenc -subj /CN=votr.uniba.sk -out sp.crt -keyout sp.key

To inspect the certificate:

openssl x509 -in /var/www/votr-beta/var/saml/certs/sp.crt -text -noout
  • The subject field is ignored in practice.
  • The X509 extensions are ignored in practice. The above command uses defaults from v3_ca in /etc/ssl/openssl.cnf, which are questionable (e.g. CA:TRUE) but functional.
  • The RSA key size of 3072 bits was adopted from mellon_create_metadata and shib-keygen. It might not be strictly necessary.
  • Reviewing the code and comparing the outputs of mellon_create_metadata and shib-keygen has confirmed that there are no major differences except for which X509 extensions are generated.

See you in ten years!

Logout

This section details the saml_logout() function implementation in votrfront/login.py. Given its low importance in the grand scheme of things, this felt like too much detail to write in a code comment, so it's here on the wiki instead.

saml_logout() is the handler function for /saml_logout, which is registered in our SAML metadata as the SingleLogoutService address. It is requested in two distinct scenarios:

Externally Initiated Logout

When another entity (the IdP or another SP) initiates logout, the IdP will redirect to /saml_logout?SAMLRequest=..., ordering us to delete our session.

As I understand it, there are two ways to work out which session we're supposed to terminate:

  1. The "proper" approach is to read the <NameID> element in the logout request, and maintain an index of all active sessions mapping from NameID to Votr sessid.
  2. The "lazy" approach is to simply read the session cookie from the incoming HTTP request.

Votr currently uses the "lazy" approach. It is easier to implement, but it has some disadvantages:

  • It can break with SameSite cookies. (Described in more detail below.)
  • It is vulnerable to "logout CSRF" attacks (visiting a malicious site can log you out from Votr). The malicious site simply needs to send a valid recent IdP-signed logout request originally intended for another user. Logout CSRF is unpleasant but generally not a huge deal.

We handle the request as follows:

  1. Validate the request. If it's invalid, do nothing.
    • This is generally a good first step, but as explained earlier, it does not fully defend against "logout CSRF" in our "lazy" implementation.
  2. Request /ais/logout.do to terminate the AIS session.
    Notes:
    • It's not a necessary step, Votr only does it to be polite and free up AIS server resources.
    • Any redirects returned by /ais/logout.do are ignored, to avoid starting another recursive IdP logout within this IdP logout.
    • In an ideal world, this request would be completely unnecessary, because the IdP would take care of destroying the AIS session by sending it another SAML logout request. But: 1. AIS uses an old version of WSO2 IS which does not forward logout requests properly. 2. The browser doesn't know the AIS session cookie, only Votr has it, so it would only work if AIS+WSO2 implemented the "proper" NameID-based approach. So sending the request can be meaningful, for now.
  3. Delete our session file and session cookie.
  4. Redirect back to the IdP with a successful SAML logout response.

Votr-Initiated Logout

When a user clicks the logout button in Votr, the browser sends a POST request to /logout. It calls /ais/logout.do, deletes our session file/cookie, and if SAML is used, calls auth.logout() to generate a SAML logout request and redirects to the IdP. The IdP terminates its own session and commands other services (if any) to delete theirs.

The IdP eventually navigates to /saml_logout?SAMLResponse=... to inform us of the logout status. Usually a good strategy would be to validate the SAML logout response, check its success status, and redirect to /. However, Shibboleth IdP loads it in a hidden iframe. The user remains on the IdP logout status page with no option to return to Votr. I found this quite surprising, but the SAML specification only requires that the SAML logout response is sent, not how or where.

This means the request is largely vestigial. We do validate the SAML logout response and check its success status, but only for perfunctory logging. Afterwards we just respond with a plain message. Redirecting to / would fail the X-Frame-Options and Sec-Fetch-Dest checks because of the iframe, and the user won't see our response anyway. Our session was already deleted during /logout, because it's more reliable to do it sooner.

Additional Logout Notes

  • The python3-saml function auth.process_slo() is a bit weird. It takes an optional delete_session_cb callback argument, but it is always called exactly once. I think this might be because the library was ported from other programming languages with different idioms. Our saml_logout() calls auth.process_slo() without delete_session_cb, and deletes the session later.
  • saml_logout() does not check Sec-Fetch-... header values. The SAML spec doesn't prescribe whether SingleLogoutService should be requested with an iframe, or top-level navigation, or AJAX, etc. In practice Shibboleth IdP always loads it with Sec-Fetch-Dest: iframe, Sec-Fetch-Mode: navigate, Sec-Fetch-Site: same-site (assuming same second-level domain). But let's pretend to support other IdPs at least in theory.
  • Our webserver config adds X-Frame-Options: DENY to all responses except /saml_logout.

Replay Attacks

The python3-saml library handles most of the security logic and message validation for SAML, but some checks are left to the library user's responsibility because they require persistent storage.

Check #1 is to save the SAML authentication request ID in a cookie (or cookie-based server side session) and verify that the incoming SAML response has the correct InResponseTo value. This is not very well explained in the python3-saml README, but the demo code has a mostly complete example showing how to do it.

The purpose of checking InResponseTo is to prevent "login CSRF" attacks. "Login CSRF" is a somewhat strange attack because the attacker's goal is not to gain access to the victim's account, but to sneakily force the victim to use the attacker's account. This can be a problem on some sites (e.g. in e-shops if the confused victim saves their credit card number in the account). In our case it's probably harmless or low impact, but I implemented the check just in case.

Check #2 is to keep a persistent list of recently seen IDs and verify that each incoming SAML message has a unique never-before-seen ID. This is described and recommended in the python3-saml README, which claims it is needed to avoid "replay attacks".

The SAML specification similarly says: "The service provider MUST ensure that bearer assertions are not replayed, by maintaining the set of used ID values [...]."

I might be tempting fate, but: to be frank, I think this check is completely pointless. I don't think SAML replay attacks are real. I spent a lot of time researching this, but I haven't seen any realistic attack scenario which would allow a third party to intercept or recover a SAML message (assuming HTTPS is used). There are some threat models where it is possible (e.g. TLS vulnerability, full OS compromise of SP/IdP/victim), but in those cases, the attacker has so much power that they can use much simpler attacks, and checking for replayed IDs does not provide any extra protection.

As an additional point of comparison, the closest analogue to SAML "replay attacks" in the OAuth/OIDC world is the "authorization code injection attack". This one is real. The OAuth threat model assumes that attackers may be able to read the authorization response (see (A3) in RFC 9700), and tries to defend against this at the protocol level, because there are documented cases of this happening. Notable examples are leaking the URL which contains the authorization code in the referrer header (in cases where the SP is tricked to redirect to the attacker, or load an attacker's subresource on the page), and issues on mobile systems with multiple native apps registering on the same URL. However, all these attacks only leak the URL. As far as I can tell, there is no attack that leaks the cookie headers or the POST body. Therefore, I posit these attacks do not apply to SAML with POST binding.

SameSite Cookies

The SameSite cookie attribute can sometimes interfere with SAML login/logout flows. If the ACS endpoint tries to read a previously set SP cookie for CSRF reasons, SameSite=Full could be a problem. As for logout, Shibboleth IdP uses iframes so even SameSite=Lax might be insufficient.

In our case the production SP runs on votr.uniba.sk and IdP on idp.uniba.sk. They count as "same site" (eTLD) according to the Public Suffix List. So the SameSite attribute doesn't cause any problems with SAML. (But conversely it also doesn't offer much protection. Any site on *.uniba.sk could attempt a CSRF attack. Hence we also utilize classic CSRF tokens and fetch metadata headers.)

If the situation changes for any reason, the best fix for us is probably to explicitly set SameSite=None on our session cookie. As far as I know, this is not penalized by browsers at the moment. Even if browsers hypothetically start limiting the lifetime of such cookies, it wouldn't be an issue for our short-lived sessions.

Clone this wiki locally