-
Notifications
You must be signed in to change notification settings - Fork 2
fix: accept OneTimeUse, and let the replay record honour it #53
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5624c19
8bf6364
bd5226e
b162b7b
a01ffb8
c769806
b8728ad
a2e67be
3884513
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -59,6 +59,7 @@ typedef struct { | |
| xmlChar* id; | ||
| xmlChar* issuer; | ||
| int has_conditions; | ||
| int one_time_use; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding a field here is worth pairing with a one-line Makefile fix: neither object rule lists Verified with This PR escapes by luck:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed (3884513): both object rules list |
||
| xmlChar* not_before; | ||
| xmlChar* not_on_or_after; | ||
| xmlChar* unknown_condition; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -396,18 +396,19 @@ static size_t count_assertion_el(xmlNode* parent, const char* name) { | |
| } | ||
|
|
||
|
|
||
| // Conditions this SP can actually satisfy. SAML Core 2.5.1 makes an assertion | ||
| // Conditions this SP understands. SAML Core 2.5.1 makes an assertion | ||
| // carrying any other one Indeterminate rather than valid, so everything else is | ||
| // reported for the caller to refuse. | ||
| // | ||
| // ProxyRestriction is here because it binds an IdP issuing on behalf of another | ||
| // IdP and asks nothing of the SP consuming the assertion. OneTimeUse is not, | ||
| // because honouring it means remembering which assertions have been spent, and | ||
| // Core 2.5.1.5 tells a party that cannot keep that record to treat the | ||
| // assertion as invalid. | ||
| // ProxyRestriction binds an IdP issuing on behalf of another IdP and asks | ||
| // nothing of the SP consuming the assertion. OneTimeUse is always valid by | ||
| // Core 2.5.1.5, a condition on use rather than on validity: it asks the SP to | ||
| // keep a record of the assertions it has spent, which the caller has or has | ||
| // not, so it is reported as a flag. | ||
|
Comment on lines
+403
to
+407
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "which the caller has or has not" is the load-bearing assumption, and in the shipping product the caller cannot have it. Both An operator who smuggles This is not a regression against any shipped version, since #42's refusal postdates 0.2.5. But a companion PR exposing
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed on the facts, and it is the follow-up: api7/api7-ee-3-gateway#2177 covers exposing As you note, 0.2.5 already accepts |
||
| static int is_known_condition(xmlNode* node) { | ||
| return is_assertion_el(node, "AudienceRestriction") || | ||
| is_assertion_el(node, "ProxyRestriction"); | ||
| is_assertion_el(node, "ProxyRestriction") || | ||
| is_assertion_el(node, "OneTimeUse"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Matching on element name only leaves the other schema-valid encoding of the same condition refused: Verified: an assertion whose only condition is So the PR body's premise — "there is no configuration that gets past the refusal" — remains true for this encoding after the fix.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pre-existing and deliberate: #42 refuses the |
||
| } | ||
|
|
||
|
|
||
|
|
@@ -510,14 +511,18 @@ static int read_assertion(xmlDoc* doc, xmlNode* node, saml_assertion_t* a) { | |
| } | ||
|
|
||
| for (xmlNode* child = conditions->children; child != NULL; child = child->next) { | ||
| if (child->type == XML_ELEMENT_NODE && !is_known_condition(child)) { | ||
| if (is_assertion_el(child, "OneTimeUse")) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This sits before the Verified through So the field does not mean "this assertion carries OneTimeUse", it means "it carries OneTimeUse and no unrecognised condition preceded it". Harmless only because
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed (c769806). The scan no longer stops at the first unknown condition; the first unknown name is still what is reported. TEST 51 reads the flag in both orders. |
||
| a->one_time_use = 1; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Setting the flag on every occurrence means a Verified: A PR whose subject is spec conformance for this element leaving the element's own cardinality rule unenforced seems worth a second look, especially since the body cites Keycloak's broker ("only checks there is at most one") as the peer behaviour being matched.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
| if (child->type == XML_ELEMENT_NODE && !is_known_condition(child) && | ||
| a->unknown_condition == NULL) { | ||
| // the caller refuses the assertion on this name, so losing it would | ||
| // let the condition through rather than fail the read | ||
| // let the condition through rather than fail the read. The scan goes | ||
| // on so what else the assertion carries is read whatever the order | ||
| a->unknown_condition = xmlStrdup(child->name); | ||
| if (a->unknown_condition == NULL) { | ||
| return -1; | ||
| } | ||
| break; | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -574,9 +574,10 @@ offers no subject confirmation this SP can satisfy | |
| ngx.say(login_with("plain", saml_response({ | ||
| conditions = conditions({ body = "<saml:ProxyRestriction Count=\"1\"/>" }), | ||
| }))) | ||
| -- OneTimeUse asks this SP to remember which assertions it has spent | ||
| -- OneTimeUse is always valid (Core 2.5.1.5); with no replay_dict it | ||
| -- asks for a record this SP does not keep, which is said, not refused | ||
| ngx.say(login_with("plain", saml_response({ | ||
| conditions = conditions({ body = "<saml:OneTimeUse/>" }), | ||
| id = "single", conditions = conditions({ body = "<saml:OneTimeUse/>" }), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This block lost its only behavioural assertion about OneTimeUse and gained no replacement, so the weakening the PR ships has no coverage. The deleted As it stands, a change that started refusing OneTimeUse again on the no-dict path breaks no test, and neither does a regression in the other direction.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TEST 13's first |
||
| }))) | ||
| -- and a condition it has never heard of asks who knows what | ||
| ngx.say(login_with("plain", saml_response({ | ||
|
|
@@ -589,10 +590,10 @@ offers no subject confirmation this SP can satisfy | |
| } | ||
| --- response_body | ||
| 302 / | ||
| 401 nil | ||
| 302 / | ||
| 401 nil | ||
| --- error_log eval | ||
| [qr/carries a condition this SP cannot satisfy: OneTimeUse/, | ||
| [qr/\[warn\] .* assertion single carries OneTimeUse, which this SP cannot enforce without replay_dict/, | ||
| qr/carries a condition this SP cannot satisfy: Condition/] | ||
|
|
||
|
|
||
|
|
@@ -631,7 +632,7 @@ response from IdP is addressed to http://evil.example.com/acs | |
| content_by_lua_block { | ||
| local xml = sign_doc(response( | ||
| assertion({ id = "a1", conditions = conditions({ not_on_or_after = "2026-07-21T00:00:00Z", | ||
| body = audience("sp") }) }) .. | ||
| body = audience("sp") .. "<saml:OneTimeUse/>" }) }) .. | ||
| assertion({ id = "a2", name_id = "second@example.com", | ||
| confirmations = confirmation({ recipient = ACS }) }))) | ||
| local doc, err = parse(xml) | ||
|
|
@@ -641,14 +642,15 @@ response from IdP is addressed to http://evil.example.com/acs | |
| ngx.say(a.id, " conditions=", tostring(a.has_conditions), | ||
| " expires=", tostring(a.not_on_or_after), | ||
| " audiences=", #a.audience_restrictions, | ||
| " confirmations=", #a.subject_confirmations) | ||
| " confirmations=", #a.subject_confirmations, | ||
| " one_time_use=", tostring(a.one_time_use)) | ||
| end | ||
| ngx.say("destination: ", tostring(saml.doc_destination(doc))) | ||
| } | ||
| } | ||
| --- response_body | ||
| a1 conditions=true expires=2026-07-21T00:00:00Z audiences=1 confirmations=0 | ||
| a2 conditions=false expires=nil audiences=0 confirmations=1 | ||
| a1 conditions=true expires=2026-07-21T00:00:00Z audiences=1 confirmations=0 one_time_use=true | ||
| a2 conditions=false expires=nil audiences=0 confirmations=1 one_time_use=false | ||
| destination: nil | ||
|
|
||
|
|
||
|
|
@@ -1386,3 +1388,135 @@ earlier: true | |
| --- response_body | ||
| 302 / | ||
| dated one decides: true | ||
|
|
||
|
|
||
|
|
||
| === TEST 48: with a record, an OneTimeUse assertion is treated like any other | ||
| --- config | ||
| location /t { | ||
| content_by_lua_block { | ||
| ngx.shared.saml_replay:flush_all() | ||
| local xml = saml_response({ | ||
| id = "stamped", | ||
| conditions = conditions({ not_on_or_after = at(600), body = "<saml:OneTimeUse/>" }), | ||
| }) | ||
| ngx.say(login_with("replay", xml)) | ||
| -- remembered until acceptance ends plus clock_skew, as any other | ||
| local ttl = ngx.shared.saml_replay:ttl(replay_key("stamped")) | ||
| ngx.say("recorded: ", ttl > 650 and ttl <= 660) | ||
| ngx.say(login_with("replay", xml)) | ||
| } | ||
| } | ||
| --- response_body | ||
| 302 / | ||
| recorded: true | ||
| 401 nil | ||
| --- error_log | ||
| assertion stamped has been presented already | ||
| --- no_error_log | ||
| [crit] | ||
| [alert] | ||
| [emerg] | ||
| OneTimeUse | ||
|
|
||
|
|
||
|
|
||
| === TEST 49: a full dict says when the untracked login asked for single use | ||
| --- config | ||
| location /t { | ||
| content_by_lua_block { | ||
| local dict = ngx.shared.saml_replay_full | ||
| dict:flush_all() | ||
| dict:flush_expired() | ||
| local filler = string.rep("x", 256) | ||
| local i, ok = 0, true | ||
| while ok do | ||
| ok = dict:safe_set("filler-" .. i, filler, 600) | ||
| if ok then i = i + 1 end | ||
| if i > 5000 then break end | ||
| end | ||
| local j = 0 | ||
| while dict:safe_add("small-" .. j, true, 600) do | ||
| j = j + 1 | ||
| if j > 5000 then break end | ||
| end | ||
| ngx.say(login_with("replay_full", saml_response({ | ||
| id = "untracked-stamped", | ||
| conditions = conditions({ body = "<saml:OneTimeUse/>" }), | ||
| }))) | ||
| } | ||
| } | ||
| --- response_body | ||
| 302 / | ||
| --- error_log | ||
| in saml_replay_full: no memory, this login is not covered by replay tracking though it carries OneTimeUse | ||
|
|
||
|
|
||
|
|
||
| === TEST 50: an OneTimeUse assertion that outlives its record says so | ||
| --- config | ||
| location /t { | ||
| content_by_lua_block { | ||
| ngx.shared.saml_replay:flush_all() | ||
| -- nothing bounds it, so the record falls back to replay_ttl | ||
| ngx.say(login_with("replay", saml_response({ | ||
| id = "stamped-unbounded", | ||
| conditions = conditions({ body = "<saml:OneTimeUse/>" }), | ||
| }))) | ||
| -- valid for years, so the record is capped at a day | ||
| ngx.say(login_with("replay", saml_response({ | ||
| id = "stamped-forever", | ||
| conditions = conditions({ not_on_or_after = "9999-12-31T23:59:59Z", | ||
| body = "<saml:OneTimeUse/>" }), | ||
| }))) | ||
| } | ||
| } | ||
| --- response_body | ||
| 302 / | ||
| 302 / | ||
| --- error_log eval | ||
| [qr/\[warn\] .* assertion stamped-unbounded carries OneTimeUse but stays acceptable past its record, which lapses in 600 seconds/, | ||
| qr/\[warn\] .* assertion stamped-forever carries OneTimeUse but stays acceptable past its record, which lapses in 86400 seconds/] | ||
|
|
||
|
|
||
|
|
||
| === TEST 51: OneTimeUse is read wherever it sits among the conditions | ||
| --- config | ||
| location /t { | ||
| content_by_lua_block { | ||
| local unknown = '<saml:Condition xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ' .. | ||
| 'xsi:type="saml:AudienceRestrictionType"><saml:Audience>sp</saml:Audience></saml:Condition>' | ||
| for _, body in ipairs({ "<saml:OneTimeUse/>" .. unknown, unknown .. "<saml:OneTimeUse/>" }) do | ||
| local doc, err = parse(sign_doc(response(assertion({ | ||
| id = "ordered", conditions = conditions({ body = body }), | ||
| })))) | ||
| if err then ngx.say("err: ", err) return end | ||
| local a = saml.doc_assertions(doc)[1] | ||
| ngx.say("one_time_use=", tostring(a.one_time_use), | ||
| " unknown_condition=", tostring(a.unknown_condition)) | ||
| end | ||
| } | ||
| } | ||
| --- response_body | ||
| one_time_use=true unknown_condition=Condition | ||
| one_time_use=true unknown_condition=Condition | ||
|
|
||
|
|
||
|
|
||
| === TEST 52: without a record, an OneTimeUse assertion is accepted again | ||
| --- config | ||
| location /t { | ||
| content_by_lua_block { | ||
| local xml = saml_response({ | ||
| id = "stamped-untracked", | ||
| conditions = conditions({ not_on_or_after = at(600), body = "<saml:OneTimeUse/>" }), | ||
| }) | ||
| ngx.say(login_with("plain", xml)) | ||
| ngx.say(login_with("plain", xml)) | ||
| } | ||
| } | ||
| --- response_body | ||
| 302 / | ||
| 302 / | ||
| --- error_log eval | ||
| qr/\[warn\] .* assertion stamped-untracked carries OneTimeUse, which this SP cannot enforce without replay_dict/ | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This line is the only thing standing between the old refusal and silent acceptance, and it fails in both directions.
Invisible by default. nginx's documented default is
error_log logs/error.log error;, and OpenResty does not raise it, so an embedder that never sets a level discards this entirely — the login changes from refused to accepted-and-replayable with no notice at all. The suite only sees it becauset/assertion-conditions.t:3callslog_level('info'). README:148 presents the warning as the delivery mechanism without saying what level is required to see it.Unbounded when it is visible. It sits behind only a session lookup and a RelayState comparison against the caller's own session, with no once-per-worker latch. Verified: a OneTimeUse assertion restricted to another audience returns
401 niland still writes the warning, so one captured signed assertion replayed in aGET /->POST /acsloop writes WARN lines indefinitely for logins that never succeed. And the population this PR unblocks — IdPs that stamp OneTimeUse on every assertion — gets one line per login forever, with no way to silence it.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The warning is a courtesy, not the safety line; silent acceptance is what Core 2.5.1.5 prescribes and what Spring, Shibboleth SP and Keycloak do.
Level: both consumers default
error_log_leveltowarn(apisix/cli/config.lua, EEconfig-default.yaml), so it is visible where it matters. The README now says it is logged atwarn(b162b7b).Volume: a refused attempt on that loop already writes
response from IdP rejected: ...at ERR, so the vector exists today one level up; this adds a line to it. One line per accepted login is the correct signal for a deployment whose IdP asks for single use and which has not configured it, and a once-per-worker latch would hide a persistent state after the first hit. Left as is.