Skip to content

✨ Feature / SafeXml — single hardened xmerl_scan entry point#29

Open
docJerem wants to merge 2 commits into
mainfrom
refactor/safe-xml-wrapper
Open

✨ Feature / SafeXml — single hardened xmerl_scan entry point#29
docJerem wants to merge 2 commits into
mainfrom
refactor/safe-xml-wrapper

Conversation

@docJerem
Copy link
Copy Markdown
Owner

@docJerem docJerem commented May 19, 2026

Part of #32 — first PR of the SAML XML parsing surface hardening initiative.

Summary

  • New module ExSaml.Core.Xml.SafeXml — single entry point for parsing untrusted SAML/XML via :xmerl_scan. Always forces allow_entities: false, namespace_conformant: true, quiet: true; callers cannot weaken these options.

  • Tuple-only API. SafeXml.scan/1 returns {:ok, xml()} | {:error, :invalid_xml} — no raising variant. Errors are values, composable in with/case pipelines.

  • Tuple cascade through callers. Binding.decode_response/2 (public), Sp.decrypt_assertion/2 (private) and Helper.decode_saml_payload/2 (private) all propagate the tuple. The previous rescue in Helper is replaced by a case; the error reason is now an atom ({:invalid_response, :invalid_xml}) instead of a stringified inspect(error).

  • Configurable error hook for SIEM / OCSF pipelines:

    config :ex_saml, :safe_xml,
      on_error: &MyApp.SIEM.report_xml_error/1

    The arity-1 handler receives %{reason: :invalid_xml, kind: :error | :exit | :throw, payload: …}. Any exception from the handler is silently caught — it can never mask the original parse failure or alter control flow.

  • De-duplication of the four pre-existing :xmerl_scan.string call sites (Metadata.parse/1, Binding.decode_response/2 × 2, Sp.decrypt_assertion/2). The binary→charlist conversion (the source of the 🐛 Fix / UTF-8 characters in SAMLResponse rejected by xmerl_scan #22 UTF-8 fix that had to patch four files) is centralised in SafeXml.scan/1 — future encoding changes are now a one-file diff.

Why this PR

This is PR 1 of #32 (SAML XML parsing surface hardening). The umbrella issue lays out the full four-PR plan; the short version is that the original mix security.check_release task drafted in #15 relied on regex source-scanning, which was silently contournable. The root cause was upstream: :xmerl_scan was called in four different files with slightly different options. With a single hardened call site, a future audit check (PR 2 of #32) becomes incontournable: any reference to :xmerl_scan.* outside safe_xml.ex is a violation.

Behaviour notes

  • Preserved: Metadata.parse/1 still returns {:error, invalid_xml_violation()} on any parse failure or non-element root.
  • Changed (deliberate): Binding.decode_response/2 return shape goes from xml() to {:ok, xml()} | {:error, :invalid_xml}. Public spec change. Single intra-lib caller (Helper.decode_saml_payload/2) updated; tests updated.
  • Changed (deliberate): Helper.decode_saml_payload/2 error shape {:invalid_response, …} now carries an atom reason instead of an interpolated inspect(error) string. No caller pattern-matches on it (verified via grep).
  • Changed (deliberate): Binding.decode_response/2 now passes quiet: true to :xmerl_scan (only Metadata.parse/1 did before). Malformed-payload xmerl warning logs are now silent; error semantics unchanged.

Test plan

  • mix compile --warnings-as-errors clean
  • mix format --check-formatted clean
  • mix credo --strict — no issues
  • mix sobelow --config --skip — scan complete, no findings
  • mix test211 tests, 0 failures, including:
    • 7 SafeXml.scan/1 cases (well-formed, binary/charlist parity, UTF-8, malformed, empty, XXE external entity, internal entity)
    • 5 :on_error hook cases (called once per failure, not called on success, swallows handler errors, no-op when unconfigured, no-op when value is not an arity-1 function)
    • 7 updated Binding.decode_response/2 callers in binding_test.exs (deflate + non-deflate roundtrip + full UTF-8 regression matrix)
  • grep -rn ':xmerl_scan.string' lib/ — only invocation lives at safe_xml.ex:59
  • grep -rn 'scan!' lib/ test/ — no remaining callers (no raising API surface)

Introduce a single hardening surface for parsing untrusted SAML/XML.
`SafeXml.scan/1` and `scan!/1` are now the only call sites of
`:xmerl_scan.string` in `lib/`; they always pass `allow_entities: false`,
`namespace_conformant: true`, and `quiet: true`. Callers cannot weaken
these options.

The binary→charlist conversion is centralised in `SafeXml.scan/1` too,
which removes the per-site duplication that #22 (UTF-8 fix) had to patch
in four files — future encoding changes are now a one-file diff.

Replace the four ad-hoc `:xmerl_scan.string(...)` invocations:

  * `ExSaml.Core.Binding.decode_response/2` (DEFLATE + non-DEFLATE paths)
  * `ExSaml.Core.Sp.decrypt_assertion/2`
  * `ExSaml.Metadata.parse/1`

Behaviour preserved:

  * `Binding` / `Sp` callers still receive a raise on malformed input
    (via `scan!/1`) — the surrounding `extract_assertion/3` and friends
    keep their existing `rescue`/`catch` wrappers.
  * `Metadata.parse/1` still returns `{:error, invalid_xml_violation()}`
    on any parse failure or non-element root.

Behaviour change worth noting:

  * `Binding.decode_response/2` now suppresses xmerl warnings on
    malformed payloads (`quiet: true`). Previously they were logged.

Test coverage:

  * New `ExSaml.Core.Xml.SafeXmlTest` exercises well-formed XML,
    binary vs. charlist parity, UTF-8 content, malformed input, empty
    input, XXE (external entity), internal entity expansion, and the
    raising variant.

Sets up steps 2–4 of the plan discussed on #15 (AST-based check,
config invariants for app-side concerns, CI wiring of
`mix security.check_release`).
@docJerem docJerem changed the title ♻️ Refactor / Centralise xmerl_scan behind ExSaml.Core.Xml.SafeXml ✨ Feature / SafeXml — single hardened xmerl_scan entry point May 19, 2026
Drop the raising variant. `SafeXml.scan/1` is now the single entry point
and always returns `{:ok, xml()} | {:error, :invalid_xml}` — more
composable in `with`/`case` chains, safer to plug into pipelines.

Cascade the tuple through the callers so the error stays a value end to
end (no rescue at site, no hidden raise to catch):

  * `Binding.decode_response/2` public spec is now
    `{:ok, xml()} | {:error, :invalid_xml}` (was `xml()`).
  * `Sp.decrypt_assertion/2` now returns
    `{:ok, xml()} | {:error, :invalid_xml | :decrypt_failed}`;
    `extract_assertion/3` consumes it via `with`.
  * `Helper.decode_saml_payload/2` replaces its `rescue` with a `case`
    on the tuple; the error term changes from a stringified
    `inspect(error)` to the atom reason (`{:invalid_response, reason}`)
    — no caller pattern-matches on the previous shape.

Add a configurable error hook for SIEM / OCSF pipelines:

    config :ex_saml, :safe_xml,
      on_error: &MyApp.SIEM.report_xml_error/1

The arity-1 handler receives a context map
`%{reason: :invalid_xml, kind: :error | :exit | :throw, payload: …}`.
Any exception from the handler is swallowed — the handler can never
mask the original parse failure or alter control flow.

Tests updated:

  * `SafeXmlTest` drops the `scan!/1` cases, adds five `:on_error`
    cases (called once per failure, not called on success, swallows
    handler errors, no-op when unconfigured, no-op when value is not
    an arity-1 function).
  * `BindingTest` patterns the `{:ok, _}` shape across the 7 call
    sites (roundtrip + UTF-8 regression suite).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant