Imported from smotanacom/netget (
src/server/saml_sp/AGENTS.md). Install upstream withnpx skills add smotanacom/netget --skill saml_sp. Copyright stays with the author.
saml_sp — SAML 2.0 Service Provider simulator
Serves the SP side of the SAML 2.0 Web Browser SSO profile over hyper HTTP/1.1 and asks the
model what to do with each request. DevelopmentState::Experimental, group Authentication,
keywords saml sp / saml service provider / service provider / sp / saml-sp. Feature
saml-sp = ["urlencoding"].
Read this first: NetGet verifies nothing. The SAMLResponse body is handed to the model as
text and the model decides who the user is. No XML signature is checked — there is no key here
to check one against — and neither are the issuer, the audience restriction, NotBefore /
NotOnOrAfter, or assertion-ID replay. A forged or expired assertion is accepted exactly as
readily as a genuine one. The session cookie is the bare user id, with no server-side session
store.
The description() used to read "validates SAML assertions" and llm_control listed
"assertion validation". Both now say what actually happens; do not let the claim back in. If
you want validation behaviour, it has to be spelled out in the instruction — "reject anything
whose <saml:Issuer> is not https://myidp.example.com" — and even then the model is reading
XML, not verifying cryptography.
Files
| File | Contents |
|---|---|
mod.rs |
SamlSpServer::spawn_with_llm_actions, handle_saml_sp_request, build_safe_response |
actions.rs |
SamlSpProtocol (Protocol + Server), four sync actions, build_authn_post_form, build_authn_redirect, escape_html, cookie_value, SAML_SP_REQUEST_EVENT |
No startup_params: get_startup_parameters() is the empty default, so StartupParams rejects
any key a caller passes.
One event, four actions
saml_sp_request fires for every request, carrying method, path, query, headers,
body and client_ip. There is no routing — /login, /acs, /AssertionConsumerService,
/metadata and anything else all arrive the same way and the model decides from path.
| Action | Produces |
|---|---|
send_authn_request |
200 text/html — redirect page or auto-submitting form carrying the AuthnRequest to the IDP |
process_assertion |
200 text/html welcome page + Set-Cookie: session_id=… |
send_metadata |
200 application/samlmetadata+xml |
send_error_response |
model-chosen status (default 403), an HTML error page |
The event's .with_actions(...) holds all four real definitions. That list — not
get_sync_actions() — is what call_llm advertises to the model.
send_authn_request takes binding: HTTP-POST builds an auto-submitting form,
anything else (default HTTP-Redirect) builds a meta-refresh page whose URL carries
SAMLRequest and RelayState as percent-encoded query parameters.
Base64 is ours, not the model's
The model supplies plain AuthnRequest XML; build_authn_post_form /
build_authn_redirect base64-encode it. Inbound, the raw SAMLResponse=… body is passed
through as text and the model decodes it. Do not add an action parameter that asks the model
for base64.
HTML escaping and the session cookie
escape_html covers & < > " ' and is applied to user_id, the rendered attributes, the
error_message, idp_sso_url, relay_state and the assembled redirect URL. Every one of
those is model output derived from an untrusted request — most directly user_id, which the
model lifts out of an assertion an attacker supplied. Unescaped, a " broke out of the
surrounding attribute and injected markup into pages the browser renders (and, for the POST
binding, auto-submits).
cookie_value percent-encodes the user id before it goes into Set-Cookie. Raw, a ; let a
crafted user id append cookie attributes and CR/LF let it split the response header.
Nothing here may panic
build_safe_response is the only place a Response is built: an out-of-range status becomes
500 and a header hyper rejects is dropped with a warning.
send_error_response documents status_code as a model-supplied parameter and
process_assertion puts a model-supplied user id into Set-Cookie; the old code did
Response::builder().status(status as u16)…body(..).unwrap(), so status_code: 1000 — or a
header value containing CR/LF — panicked inside the connection task instead of answering.
Local copy of http_common::handler::build_safe_response, which the saml-sp feature cannot
reach because http_common is gated on feature = "http".
Hostile input
Two bounds, both covered by tests/server/saml_sp/hardening_test.rs. For an SP the
"affirmative default" class is the vulnerability rather than a cosmetic problem: a 2xx is the
only thing a browser reads as a completed sign-in.
MAX_REQUEST_BYTES= 256 KiB./acstakes an anonymous POST and the body reaches the model verbatim as prompt text, so the previous unboundedreq.collect()let one request grow the process without limit and drive an LLM call with megabytes of attacker-chosen prompt. Over the limit is413and a refusal — never a truncated body, which would arrive at the model as a well-formed request whose assertion happened to end early.status_ornarrows a model-supplied status withu16::try_from.65736 as u16is200.send_error_response— the model's only way to refuse an assertion — therefore arrived as the status that admits the user. Bothmod.rsand the executor now refuse the wrap; the executor additionally pinssend_error_responseto 400–599.
After hyper flushes a body-limit refusal, the connection half-closes its write side and
uses src/server/accept_bounded.rs's drain_after_response to discard at most 2 MiB of the
remaining upload for at most two seconds in an 8 KiB buffer. This keeps an upload already
in flight from resetting the socket and replacing the 413 with ECONNRESET. The drain
starts only after the HTTP connection completes; a model call or a parked manual handler
remains outside these deadlines. The existing hardening test checks the 413 without a
model call, and the shared response_drain_tests cover both discard bounds.
No XML is parsed here. The SAMLResponse is passed to the model as text and NetGet never
builds a tree, so the entity-expansion (billion-laughs) and unbounded-nesting classes do not
arise on this path — the absence of a parser is, on this one axis, the safe choice. It is also
why no signature can be checked: see the warning at the top of this file. (A non-UTF-8 body is
reported to the model as <N bytes of non-UTF-8 data…>; it used to be base64-encoded into the
event, which the project rule forbids and which no model can decode.)
Storage
None, per the project rule. There is no session table: process_assertion sets a cookie and
nothing on the server remembers it, so a later request carrying that cookie is just another
request the model must judge. If a scenario needs session continuity, the model keeps it in
server memory or the instruction states a rule.
Not implemented
XML signature verification, certificate/trust management, SingleLogout, artifact binding, encrypted assertions, replay protection, persistent sessions, and TLS.
Request-only. metadata() declares .request_only(…): "SAML is HTTP request/response; a
server cannot send a peer anything unprompted". The dashboard's [ send message ] on a peer is
disabled and shows that reason, and MCP send_to_peer refuses with it.
Examples
Start a SAML Service Provider on port 8081.
On /login send an AuthnRequest to the IDP at http://localhost:8080/sso via HTTP-Redirect.
On /acs read the SAMLResponse, and only if its issuer is http://localhost:8080 and the
assertion has not expired, start a session for the NameID; otherwise reject with 403.
On /metadata return an EntityDescriptor with ACS at http://localhost:8081/acs.
Deterministic equivalent — no LLM call per request:
"event_handlers": [{"event_pattern": "saml_sp_request", "handler": {"type": "static",
"actions": [{"type": "process_assertion", "user_id": "testuser",
"attributes": {"email": "test@example.com", "role": "user"}}]}}]
Note what that static handler means: it accepts every assertion unconditionally. That is fine for testing an IDP and is exactly what a honeypot wants; it is not authentication.
Failing closed
Three outcomes on the request path are deliberately distinct, and each is tagged in the log so
an operator can tell them apart with a grep for decision=:
| Situation | decision= |
Wire |
|---|---|---|
The model answered, choosing a 4xx (send_error_response) |
model_reject |
the model's status and page |
| The model answered normally | model_answer |
the model's status and page |
| The model produced no usable action output | fail_closed_no_answer |
500, WireFailure::Unavailable category text |
The LLM call returned Err |
fail_closed_llm_error |
503 + Retry-After: 5 when overloaded, else 500; category text |
Two properties hold on both fail-closed rows and must keep holding: never a 2xx (a 2xx is
the only thing a browser reads as a completed sign-in) and never a Set-Cookie.
The status used to default to 200, so a model that answered with only a common/memory action
— or whose protocol result was an ActionResult::Multiple wrapping the real Output, or whose
output bytes were not JSON — produced an empty 200 OK. There is no default any more: a
response is emitted only if a usable Output was actually parsed, and Multiple is flattened
so a wrapped Output is not dropped.
The body on both fail-closed rows is WireFailure::prefixed_text(), a &'static str. Never
interpolate the error — it names the backend URL, the model and netget's own retry machinery,
and the peer here is an untrusted browser. The full error goes to tracing and the status
stream. tests/wire_failure_test.rs fails the build if the leaked idioms reappear.
Tests
tests/server/saml_sp/ exists and is declared in tests/server/mod.rs; see
tests/server/saml_sp/AGENTS.md for the strategy, the call budget and the known gaps.
./cargo-isolated.sh test --no-default-features --features saml-sp --test server -- \
--test-threads=100 saml_sp
Pairs naturally with saml_idp on another port: point the IDP's acs_url at this server's
/acs.
References
SAML 2.0 Core, SAML 2.0 Web Browser SSO Profile, SAML 2.0 Bindings (OASIS).
Connection bounds
Before September 2026 this server accepted without limit and bounded no read in time, so a peer
that connected and said nothing held a socket, a task and an AppState entry forever,
pre-authentication, and a hundred of them was a free denial of service on a server that would
happily accept a hundred more. It now declares both halves; the constants and the argument for
each live beside them in src/server/saml_sp/mod.rs.
| Bound | Value | Why this number |
|---|---|---|
FIRST_BYTE_READ_TIMEOUT |
30s | HTTP is client-speaks-first, so a peer that has completed the handshake and sent no byte has asked nothing and negotiated nothing — the state carries no protocol yet, which is why this number is the same across netget's HTTP family. Apache's mod_reqtimeout gives the request header 20s and nginx's client_header_timeout 60s. Enforced with TcpStream::peek before the socket reaches hyper, so the request line is still there afterwards. |
IDLE_BETWEEN_REQUESTS_TIMEOUT |
60s | These endpoints are reached two ways and the two pull in opposite directions: a browser does one redirect round and never comes back on that connection (Apache's KeepAliveTimeout of 5s is tuned for exactly that), while a relying party's back-channel client — token exchange, introspection, a JWKS or metadata fetch — reuses a pooled connection (nginx's 75s is tuned for that). 60s is comfortably past any pooled back-channel round trip and does not let a browser that navigated away hold a slot for minutes. Nothing here streams or long-polls. The five-minute numbers these specifications do quote are not this number: an assertion's NotOnOrAfter and an id_token's exp bound how long a credential may be presented, not how long a socket may be silent. |
MAX_CONNECTIONS |
256 | The shared default. Each admitted connection may buffer one body of up to 256 KiB, well inside the ~1 GiB ceiling netget's HTTP family is held to; a protocol declares a smaller number only when its per-connection cost is larger. Refusal: HTTP/1.1 503 Service Unavailable with Retry-After, written straight onto the socket — the peer has sent no request line for hyper to answer — and logged decision=fail_closed_connection_cap. Fixed bytes, so nothing derived from an error can reach the wire. |
There is no NetGet client that points at this server: src/client/saml/ plays the SP role
itself and targets an IdP, so this bound has no peer of ours to strand — the fourth exemption
in PROTOCOL_QUALITY.md's three-state test.
The deadline covers the read and nothing else. hyper owns every read once serve_connection
starts, and it keeps polling the connection for more input while a request is being answered —
so a deadline on those reads would be wrong here, not merely awkward. The idle bound is a
watchdog over ConnectionActivity instead, which reports a connection with work in flight as not
idle at all. The model round-trip, and an event a manual rule parked for a human
(src/state/intercepts.rs, 300s by default), are therefore outside every deadline by
construction: an answer that takes minutes can never close the connection it is an answer for.
That is the .connectionless() lesson in the project AGENTS.md read in reverse — TFTP evicted
live transfers because "idle" was measured wrongly.
hyper's own header_read_timeout is not this bound. Its 30-second default is inert unless
http1::Builder::timer is also set, which nothing here does: hyper downgrades a defaulted
duration to None when no timer is present and applies no deadline at all. That is why the
peek is not redundant.
tests/server/saml_sp/connection_bounds_test.rs drives all three from the wire, with three
sockets on one server whose only rule is * → manual: a silent peer must be closed after the
first-byte bound, a peer that sends a request line and then stalls (slowloris) after the idle
bound, and a peer whose request is parked for a human must not be closed at all. The shared
driver and the removal-verification notes are in tests/helpers/http_bounds.rs.
tests/tcp_server_bounds_ratchet_test.rs fails the build if either bound disappears.
