Security guide

SAML is a security protocol whose entire value depends on getting a handful of checks exactly right. pygamlastan is a thin binding: the cryptography and the 32-check validation suite live upstream in gamlastan / bergshamra / kryptering. This guide describes the security properties the binding preserves, the safe entry points, and the footguns the API deliberately leaves reachable so that you can integrate without recreating a classic SAML CVE.

Read this before wiring pygamlastan into an authentication flow.

Signature trust is the whole game

A SAML Response is only meaningful if its signature was cryptographically verified against a trusted IdP key and that verification is bound to the exact assertion you then trust. Every real-world SAML break — signature exclusion, signature wrapping (XSW), comment splicing — is a failure of that binding, not of the underlying crypto.

pygamlastan gives you two ways to process a response. One is safe by construction; the other trusts you to do the binding yourself.

process_response_verified — the safe entry point (use this)

pygamlastan.profiles.process_response_verified() takes the raw response XML and a pygamlastan.crypto.SamlVerifier built from the trusted IdP certificate. It performs the XML-DSig verification internally, over the exact bytes it then parses and validates, and feeds only the cryptographically verified reference IDs into the validation suite. There is no way for the “verified” set to drift from the bytes you actually trust.

from pygamlastan import crypto, security, profiles

verifier = crypto.SamlVerifier.from_cert(idp_cert_pem)   # trust anchor + verify key

result = profiles.process_response_verified(
    response_xml,                       # raw bytes as received at the ACS
    verifier,
    security.SecurityConfig(),          # production defaults
    sp_entity_id="https://sp.example.org/sp",
    acs_url="https://sp.example.org/acs",
    expected_idp_entity_id="https://idp.example.org",
    expected_request_id="_req1",
    replay_cache=security.InMemoryReplayCache(),
)
identity = result.name_id

Prefer this entry point in every SP integration. It is the one the examples/django-sp app uses.

process_response / validate_response — trust is caller-supplied

pygamlastan.profiles.process_response() and pygamlastan.security.validate_response() take a parsed Response plus two caller-supplied trust inputs:

  • response_signature_verified — a plain bool, and

  • verified_signed_ids — the element IDs you claim were cryptographically verified.

These functions do not verify any signature themselves. They decide whether the signed-assertion / signed-response requirements are satisfied entirely from what you pass in.

Danger

If you pass response_signature_verified=True or a hand-built verified_signed_ids without actually verifying, or you verify the wrong document, every signature-dependent check passes vacuously — a full authentication bypass / assertion forgery. This is exactly the class of bug behind real SAML CVEs.

If you must use the lower-level path (e.g. you verify with your own crypto), the verified_signed_ids you pass must come from a real verification of the same bytes:

verify_results = verifier.verify_all_enveloped(response_xml)   # real XML-DSig checks
if not verify_results or any(not result for result in verify_results):
    raise ValueError("SAML response signature verification failed")
verified_signed_ids = [
    signed_id
    for verify_result in verify_results
    for signed_id in verify_result.signed_reference_ids()
]
parsed = xml.parse_response(response_xml)                     # same bytes
result = profiles.process_response(
    parsed, security.SecurityConfig(), sp_entity_id, acs_url, idp_entity_id,
    verified_signed_ids=verified_signed_ids,               # from the verifier, not hand-built
    replay_cache=security.InMemoryReplayCache(),
)

When in doubt, use process_response_verified and let the binding do the binding.

Verifier policy knobs

pygamlastan.crypto.SamlVerifier exposes the 0.7 XML-DSig hardening knobs from gamlastan. The defaults are the SAML-safe values; the example below sets them explicitly so an audit can see the intended policy:

from pygamlastan import crypto

verifier = crypto.SamlVerifier.from_cert(idp_cert_pem)

# Keep X.509 NotBefore/NotAfter enforcement enabled.
verifier.set_skip_time_checks(False)

# Trust only keys/certificates you configured, never attacker-supplied KeyInfo.
verifier.set_trusted_keys_only(True)

# Enforce XML Signature Wrapping reference-position checks.
verifier.set_strict_verification(True)

# Reject HMAC truncation below CVE-2009-0217's 160-bit floor.
verifier.set_hmac_min_out_len(160)

# Require every signed reference digest to be checked locally.
verifier.set_require_reference_digests(True)

# Do not let raw inline KeyValue/DEREncodedKeyValue bypass trust anchors.
verifier.set_allow_raw_inline_keyinfo_with_trust_anchors(False)

The unsafe directions are guarded: disabling trusted-key-only mode, non-strict verification, reference-digest enforcement, X.509 time checks, or the HMAC minimum raises unless the matching unsafe_* argument is explicit. Enabling raw inline KeyInfo with configured trust anchors is guarded the same way.

For a document with more than one signature, use pygamlastan.crypto.SamlVerifier.verify_all_enveloped() to inspect every signature:

results = verifier.verify_all_enveloped(response_xml)
if not results or any(not result for result in results):
    raise ValueError("SAML response signature verification failed")
signed_ids = [
    signed_id
    for result in results
    for signed_id in result.signed_reference_ids()
]

process_response_verified already does this internally. You only need this manual collection when you deliberately use the lower-level process_response / validate_response APIs.

XML input hardening (XXE, billion-laughs, deep nesting)

Every parse entry point in pygamlastan handles attacker-controlled XML, so they all go through gamlastan’s hardened parse_secure path — there is no API that exposes the raw parser, so this cannot be bypassed from Python. Two defenses are applied before any SAML-level processing:

  • DTD / ``<!DOCTYPE>`` rejection. Any document carrying a DTD is rejected with pygamlastan.SamlXmlError. Legitimate SAML never uses a DTD, so this categorically removes XXE / external-entity / entity-smuggling: no external entity is resolved and no internal entity is expanded into a parsed SAML tree.

  • Fail-closed resource limits (uppsala 0.9): element nesting depth (128), entity-expansion byte budget (1 MiB), and entity nesting depth (256). These bound billion-laughs / quadratic-blowup amplification and deep-nesting stack exhaustion. Exceeding a limit raises pygamlastan.SamlXmlError.

This protection covers pygamlastan.xml (responses, requests, assertions, logout messages), pygamlastan.metadata (remote/published metadata), and the internal parse inside process_response_verified. See Input hardening (XXE and resource limits) for a code example.

Authentication freshness (authn_instant)

SAML distinguishes when a response was generated (IssueInstant) from when the principal actually authenticated (AuthnStatement/@AuthnInstant). When an IdP reuses an existing SSO session instead of re-prompting, the authentication happened earlier than the response.

pygamlastan.profiles.create_response() (and create_unsolicited_response) keep these separate:

  • now — the issue instant (defaults to the current wall clock).

  • authn_instant — the real authentication time (defaults to now).

# Reused SSO session: report the real login time, not "now".
resp = profiles.create_response(options, name_id, authn_instant=user_last_login)

# Fresh login: omit authn_instant; both instants collapse to now.
resp = profiles.create_response(options, name_id)

Warning

Collapsing both instants to “now” for a reused session over-reports authentication freshness to SPs that enforce it via ForceAuthn, RequestedAuthnContext, or a max-age policy — a freshness-spoofing weakness. Pass the true authn_instant (e.g. Django’s user.last_login) whenever a session may be reused. examples/django-idp does this.

Replay protection and persistent-NameID safety

  • Replay cache. A replay cache rejects an assertion ID that has already been seen. It is required by defaultprocess_response / process_response_verified refuse to run without one rather than silently skipping replay protection. The in-memory cache is single-process; back it with a shared store (database/Redis) for multi-worker deployments. The Python adapter fails closed: if your check_and_insert raises, the ID is treated as a replay. See Validation and replay protection.

  • Persistent-ID store. When a response carries a persistent NameID, a persistent-ID store is required so NameID reassignment (one identifier re-pointed at a different subject) is detected. This adapter also fails closed: a Python-side error is treated as a conflict.

  • The unsafe_no_replay_cache / unsafe_no_persistent_id_store flags exist only to make the requirement explicit and opt-out-able in tests. The unsafe_ prefix is a deliberate signpost: do not set them in production.

Encrypted assertions

The generic process_response cannot decrypt or prove the provenance of an EncryptedAssertion, so it rejects require_encrypted_assertions and refuses opaque encrypted-only responses rather than pretending to validate them. Decrypt first (see pygamlastan.crypto) and validate the decrypted assertion, or use a profile path that handles decryption.

Untrusted metadata: display fields and URLs

Metadata is attacker-influenced input. SAML metadata comes from federation aggregates and MDQ servers, and a single entry is controlled by whoever operates that SP or IdP - not by you. pygamlastan parses metadata; it does not sanitize the human-facing strings and URLs inside it for safe display. The parser rejects DTDs and bounds resource use (see the XML-hardening section above), but the values it returns are copied verbatim from the document.

This matters most for the UiInfo / UiLogo data (mdui:UIInfo) an IdP reads to show an SP’s name and logo on a consent screen, via EntityDescriptor.ui_info().

Important

Treat every UiInfo / UiLogo string as untrusted, attacker-controlled data. Two concrete risks:

  • Stored XSS. display_names, descriptions, and keywords values are raw text from the SP’s metadata. If you render them into HTML without output-encoding them, a hostile SP can inject script into your consent page. Always HTML-escape these before display (most template engines do this by default - do not bypass it with “safe”/”raw” markers here).

  • Dangerous URL schemes. information_urls, privacy_statement_urls, and UiLogo.url are not scheme-checked. A value may be javascript:... or a hostile data: URI. Before emitting one as an href or <img src>, validate it against an explicit allowlist - typically https: only (and data: only if you intentionally inline images).

from urllib.parse import urlparse

def safe_logo_url(logo):
    # Allow only https logos; reject javascript:/data:/everything else.
    if urlparse(logo.url).scheme == "https":
        return logo.url
    return None  # fall back to a default icon

ui = sp_metadata.ui_info("sp")
if ui and ui.display_names:
    # `name` must still be HTML-escaped by your template on render.
    name = ui.display_names[0][1]

The same “parsed, not vetted” rule applies to the other metadata accessors: entity_categories(), entity_attribute_values(), supported_algorithms(), and registration_authority are signals from the SP’s metadata. Use them to make decisions (which attributes to release, which algorithm to use), but if you ever echo them into a UI or log, output-encode them too. The trustworthiness of any of these depends entirely on having fetched the metadata over signature-verified MDQ or from a vetted local file - an unsigned metadata feed lets an attacker set all of it.

Note

Attribute-release decisions key on metadata too: EntityDescriptor.requested_attributes() and the entity-category URIs drive what ReleasePolicy.filter() releases. A hostile or spoofed SP entry could request more than it should, so the release policy is your privacy boundary - see Identity Provider integration.

Footguns the API leaves reachable (and why)

These exist for tests, examples, and advanced integrators. Each is named so it is obvious in a code review.

SecurityConfig.permissive()

Relaxes signature and other requirements. Constructing it emits a UserWarning. Never use it in production — it exists so examples and tests can run without real signatures. Use SecurityConfig (production defaults) or SecurityConfig.strict() instead.

The now parameter

process_response* and validate_response accept a now override for deterministic tests. In production, omit it so the real wall clock drives the validity-window checks — a caller-pinned now could keep an expired assertion inside its window.

verified_signed_ids / response_signature_verified

The trust-coupling inputs described above. Only ever populate verified_signed_ids from a real SamlVerifier result over the same bytes — or avoid them entirely by using process_response_verified.

Verifier downgrade methods

Methods such as set_trusted_keys_only(False), set_strict_verification(False), set_require_reference_digests(False), set_allow_raw_inline_keyinfo_with_trust_anchors(True), set_hmac_min_out_len(0), and set_skip_time_checks(True) are reachable for legacy interop and negative tests. Each unsafe direction requires an explicit unsafe_* argument and emits a warning. Do not use them in production SAML flows.

Checklist for a production SP

  1. Process responses with process_response_verified() and a SamlVerifier built from the trusted IdP certificate (from signature-verified MDQ or a vetted local file).

  2. Use SecurityConfig defaults (or strict()); never permissive().

  3. Pass a shared, fail-closed replay cache; pass a persistent-ID store whenever persistent NameIDs are in use.

  4. Do not pass now; do not set any unsafe_* flag.

  5. Let the binding parse — never hand SAML XML to a third-party parser that does not reject DTDs and bound entity expansion.

Checklist for a production IdP

  1. Sign assertions/responses with your real key (file or PKCS#11/HSM — see Signing, verification, and encryption).

  2. Pass the true authn_instant (e.g. user.last_login) whenever a browser session may be reused, so freshness is reported honestly.

  3. Verify inbound AuthnRequest signatures if your threat model requires them.

  4. Apply attribute-release and NameID policy appropriate to your privacy requirements (see Identity Provider integration).

  5. Fetch SP metadata over signature-verified MDQ or from a vetted local file.

  6. When showing an SP’s mdui:UIInfo on a consent screen, HTML-escape its display names/descriptions and allowlist logo/URL schemes to https: - the values are attacker-controlled (see Untrusted metadata: display fields and URLs).