-
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
fe7774e
9fcf54f
c3d5bc5
672ccd7
2c483e5
193da86
44e5034
b935eaf
8dd5b26
21e0d9a
57bb15f
6de2bf8
603b046
30f4b17
ca97738
e734364
e8f1dad
1668ce8
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 |
|---|---|---|
|
|
@@ -68,6 +68,10 @@ local saml = resty_saml.new(opts) | |
|
|
||
| `opts` is a table of below items: | ||
|
|
||
| `new` keeps `opts` by reference and reads it for the SP's whole life: hand the | ||
| table over and do not mutate it afterwards. An embedder whose configuration table | ||
| is shared or reused passes a copy (`core.table.deepcopy(conf)` in APISIX). | ||
|
|
||
| | key | type | default value | Description | | ||
| | ----------- | ----------- | ----------- | ----------- | | ||
| | `sp_issuer` | string | None | SP name to access IdP. | | ||
|
|
@@ -128,25 +132,33 @@ long as that assertion could still be used. A response normally carries one, so | |
| taking ten logins a second against an IdP issuing ten-minute assertions holds around | ||
| six thousand entries at once: `1m` is too small for that and a busy deployment wants | ||
| more. A zone with no room leaves that assertion untracked and logs an error naming | ||
| the assertion and the zone, rather than evicting an entry that is still protecting | ||
| somebody else. A response carrying several assertions can end up partly tracked, | ||
| the assertion and the zone, saying too when that assertion carried `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 sentence enumerates what the full-dict error names as a closed list — "naming the assertion and the zone, saying too when that assertion carried That matters more here than it usually would, because an operator following this paragraph to build a log filter writes a pattern for Minor, same paragraphs: the rewritten sentences were spliced in without re-flowing. 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. One more place the section did not keep step, and this one has an established convention to follow. The The file already has the shape for exactly this: |
||
| rather than evicting an entry that is still protecting somebody else. A response carrying several assertions can end up partly tracked, | ||
| which is the safe direction: a later replay still collides on whichever of them was | ||
| recorded. | ||
|
|
||
| **The record is bounded even where acceptance is not.** An assertion with no usable | ||
| expiry is remembered for `replay_ttl` and accepted for good, so it is refusable only | ||
| inside that window; one the IdP made valid beyond a day is remembered for the day | ||
| and accepted again past it. Both need an IdP far outside shipped defaults, where | ||
| and accepted again past it. Where either happens to an assertion carrying | ||
| `<saml:OneTimeUse/>`, the login says so at `warn` level, since the single use its | ||
| IdP asked for ends with the record. Both need an IdP far outside shipped defaults, where | ||
| the delivery window is minutes and the assertion window at most an hour, and the | ||
| alternative is a record nothing reclaims. The limit an operator can move is | ||
| `replay_ttl`; the day cap is fixed. | ||
|
|
||
| **Two things it deliberately does not do.** An assertion carrying `<saml:OneTimeUse/>` | ||
| is still refused outright, so an IdP asking for exactly this protection cannot log in | ||
| even with the option on; that is tracked separately and the two do not meet yet. And | ||
| re-submitting a response that already logged in is refused, which is what a browser | ||
| does when it loses the redirect that ends a login. Returning to the application starts | ||
| a fresh login, and the IdP will not ask for a password again. | ||
| **This is what `<saml:OneTimeUse/>` asks for.** An IdP stamps that condition on an | ||
|
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 paragraph takes one half of Core 2.5.1.5 and states it as the whole. Worth settling explicitly, because it is the PR's central premise. 2.5.1.5 has two halves. The one quoted here is that the condition is always valid and asks the SP to keep a record. The other is what it asks the SP not to do: it forbids retaining "assertions, or the information they contain in some other form, for reuse", and makes observing OneTimeUse a MUST for any implementation that does retain. This module retains exactly that, in a session. That is the case 2.5.1.5 names as its motivation: an IdP stamps OneTimeUse on an assertion carrying group membership, the user is removed from the group a minute later, and this SP keeps authorizing them from the session cache — with Flagging the overlap honestly: the PR body argues the opposite reading, that this SP does not retain assertions for future use. The session cache is the thing I would want that sentence to address directly, since "the information they contain in some other form" is what a session is. Either the README should say the retention bound is the session's, not the record's, or the reading should be argued rather than assumed. |
||
| assertion to ask the SP to keep exactly this record. SAML Core 2.5.1.5 makes the | ||
| condition always valid, a condition on use rather than on validity, so the login goes | ||
| through with or without the option. With it, the assertion is single-use within the | ||
| bounds above, the zone with no room included. Without it, the login is accepted and a line at `warn` level names `replay_dict`, | ||
|
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. Two things in this paragraph, both from b935eaf. "the assertion is single-use within the bounds above, the zone with no room included" — the absolute construction "X included" extends the domain of the preceding predicate (compare "it works in all weather, snow included"), so the first-pass reading is "single-use even when the zone has no room". The code does the opposite: at :635-639 a failed Second, README:137-141 says "one the IdP made valid beyond a day is remembered for the day and accepted again past it. Where either happens ... the login says so at Minor: both edited lines are un-rewrapped at 132 and 127 characters; every other line in README.md:124-156 is 73-88. |
||
| so an IdP that asks for this is the signal to set it; a deployment logging at `error` | ||
| or above does not see it. | ||
|
|
||
| **One thing it deliberately does not do.** Re-submitting a response that already logged | ||
| in is refused, which is what a browser does when it loses the redirect that ends a | ||
| login. Returning to the application starts a fresh login, and the IdP will not ask for | ||
| a password again. | ||
|
|
||
| #### Seeding the worker | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -325,7 +325,8 @@ local DEFAULT_REPLAY_TTL = 600 | |
|
|
||
| -- and how long any assertion is remembered at most, whatever it claims. An | ||
| -- assertion valid for years would pin a slot the dict never reclaims, and | ||
| -- nobody is still trying to complete that login a day later. | ||
| -- nobody is still trying to complete that login a day later. It bounds the | ||
| -- IdP's window, never replay_ttl: that one is the operator's own choice | ||
| local MAX_REPLAY_TTL = 86400 | ||
|
|
||
| local function time_bounds_ok(not_before, not_on_or_after, now, skew) | ||
|
|
@@ -405,7 +406,7 @@ end | |
|
|
||
| -- Every top-level assertion the verified signature left in the document is one | ||
| -- the readers draw identity from, so every one of them has to hold up. | ||
| local function assertions_acceptable(opts, assertions, expected, now) | ||
| local function assertions_acceptable(opts, assertions, expected, now, replay_dict) | ||
| local skew = opts.clock_skew or DEFAULT_CLOCK_SKEW | ||
|
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.
The same value slips through the Both close with the positive form: |
||
| local accepted = opts.sp_audiences or { opts.sp_issuer } | ||
|
|
||
|
|
@@ -419,6 +420,17 @@ local function assertions_acceptable(opts, assertions, expected, now) | |
| assertion.unknown_condition | ||
| end | ||
|
|
||
| -- Core 2.5.1.5: OneTimeUse is always valid, and asks the SP to keep a | ||
| -- record of the assertions it has spent. replay_dict is that record; | ||
| -- without it the IdP's request goes unmet, and the operator is told | ||
| -- what to configure rather than the user refused. The handle is the | ||
| -- one the last gate enforces on, so the two cannot disagree | ||
| if assertion.one_time_use and not replay_dict then | ||
|
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. Of the four gate-halves across the two OneTimeUse warns, this Paired mutations on an isolated harness, baseline 259/259 PASS. Rewriting this to Under the surviving mutation, every login through any dictless SP logs One line closes it: add |
||
| ngx.log(ngx.WARN, "assertion ", loggable(assertion.id), | ||
|
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 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 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
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. 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 Volume: a refused attempt on that loop already writes 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. Pushback 2 verified and withdrawn for the two named consumers — What I did not raise last time, and is the sharper point: this warning fires before the SP decides whether to refuse the login at all. It sits at :427, ahead of Your volume argument was that a refused attempt already writes an ERR one level up, so the vector exists. It does, but that one is the SP reporting its own refusal; this one advertises a configuration change that would not have helped.
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. The dict-side warn moved because it asserted a record that did not exist. This one is true whenever it fires: the SP cannot enforce the condition without 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. Now measured rather than argued, and b935eaf makes it a documentation contradiction as well. An SP with no README:150, as edited this round, states the opposite: "Without it, the login is accepted and a line at And |
||
| " from ", loggable(assertion.issuer), | ||
|
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. Three things about the issuer now being named, all verified.
The The four new patterns pin that some issuer is named, not that it is this assertion's. Replacing |
||
| " carries OneTimeUse, which this SP cannot enforce without replay_dict") | ||
| end | ||
|
|
||
| local ok, err = time_bounds_ok(assertion.not_before, assertion.not_on_or_after, now, skew) | ||
| if not ok then | ||
| return false, where .. err | ||
|
|
@@ -579,9 +591,10 @@ end | |
| -- protecting somebody else's login, which is what add would do on its own: the | ||
| -- entry it takes belongs to another user, the login it stops protecting is | ||
| -- theirs, and the warning is reported against whoever needed the space. | ||
| local function spend_assertions(dict, opts, assertions, expected, now) | ||
| local function spend_assertions(dict, dict_name, opts, assertions, expected, now) | ||
|
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. The two options this function re-reads live per request are the two
Separately, and independent of any mutation: the
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. The live-read half is answered by the handover contract the README now states (ca97738): new() keeps the table, hand it over and do not mutate it; a shared table is passed as a copy at the call site. The string-typed replay_ttl and the unguarded clock_skew both need a mutation that contract forbids. The upper-bound half was real and is fixed the other way around (e734364): rather than refusing the value at new(), the day cap now binds only the IdP-derived window, and an explicit replay_ttl is taken as given, past a day included. That makes the README's sentence literally true, nothing is silently halved any more, and the cap keeps doing the one job its comment claims, distrusting the assertion's window. The operator pinning their own dict's slots for a week is their own sized-for-it choice, already validated a number at construction. TEST 54 pins a 172800 value recorded in full. |
||
| local skew = opts.clock_skew or DEFAULT_CLOCK_SKEW | ||
| local spent = {} | ||
| local warned | ||
|
|
||
| for _, assertion in ipairs(assertions) do | ||
| if not assertion.id then | ||
|
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 is the one failure path that returns without rolling back Latent rather than live, and I checked rather than assumed: an assertion with no ID inside a Response-level signature is rejected at parse with It is worth closing anyway because if it ever fires it fires wrong. The
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. Declined, on the reachability you established yourself: Core's schema requires the ID and validation runs before any read, so no compliant or non-compliant input reaches this branch today. A rollback in code that cannot run is correctness with no observable behaviour, and this PR's diff stays on behaviour that exists. If the validation invariant ever moves, whoever moves it owns re-weighing this branch, and this thread documents what to fix. |
||
|
|
@@ -592,17 +605,27 @@ local function spend_assertions(dict, opts, assertions, expected, now) | |
| local usable_until = last_moment_usable(assertion, expected) | ||
| if usable_until then | ||
| ttl = usable_until + skew - now | ||
| end | ||
| if ttl < 1 then | ||
| ttl = 1 | ||
| elseif ttl > MAX_REPLAY_TTL then | ||
| ttl = MAX_REPLAY_TTL | ||
| if ttl < 1 then | ||
| ttl = 1 | ||
| elseif ttl > MAX_REPLAY_TTL then | ||
| ttl = MAX_REPLAY_TTL | ||
| end | ||
| end | ||
|
|
||
| -- the record is bounded where acceptance is not, so past it the | ||
| -- assertion is accepted again. An IdP that asked for single use is | ||
| -- told, since it is the IdP's window that made the record fall short. | ||
| -- Weighed before the clamp: a window of exactly the cap is covered | ||
| local outlives = usable_until == nil or usable_until + skew - now > MAX_REPLAY_TTL | ||
|
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.
Reproduced with a control. Assertion carrying The divergence is TEST 50 pins the message text only for |
||
|
|
||
| local key = replay_key(opts, assertion) | ||
| local added, add_err = dict:safe_add(key, true, ttl) | ||
| if added then | ||
| spent[#spent + 1] = key | ||
| if assertion.one_time_use and outlives then | ||
| warned = warned or {} | ||
| warned[#warned + 1] = { id = assertion.id, issuer = assertion.issuer, ttl = ttl } | ||
|
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. The buffer this commit introduced is pinned by nothing. Two mutations, both green at 259/259: The accumulation one is the one that can bite: nothing in the suite drives two OneTimeUse assertions that both outlive their records through one response, so "one line per assertion" is asserted nowhere. TEST 42 already builds a two-assertion response and would only need both stamped and unbounded plus a Related, and the reason I would not spend much on the laziness: |
||
| end | ||
| elseif add_err == "exists" then | ||
| -- this response authenticates nobody, so the assertions already | ||
| -- taken from it are handed back rather than left spent | ||
|
|
@@ -611,12 +634,23 @@ local function spend_assertions(dict, opts, assertions, expected, now) | |
| end | ||
| return false, "assertion " .. assertion.id .. " has been presented already" | ||
| else | ||
| ngx.log(ngx.ERR, "could not remember assertion ", loggable(assertion.id), " in ", | ||
| opts.replay_dict, ": ", add_err, | ||
| ", this login is not covered by replay tracking") | ||
| ngx.log(ngx.ERR, "could not remember assertion ", loggable(assertion.id), | ||
|
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 is a consequence of the narrowing I asked for, and I think 21e0d9a over-corrected — worth a second look rather than leaving as is. The old tail said "this login is not covered by replay tracking". I objected because it claims something about the whole login that one assertion's failed add cannot establish. The replacement, "this assertion is not tracked", is correct — but it dropped the only statement of outcome at ERR level. Nothing in this line now says the request was served. That matters precisely for the reader this line exists for. A 32k zone fills in production; an on-call operator logging at The suite still carries the old contract, which is the tell: TEST 40 at t:1186 is titled "a full dict leaves the login working and says so", pinning a message that no longer says it. Both properties fit in one line — something like |
||
| " from ", loggable(assertion.issuer), " in ", dict_name, ": ", add_err, | ||
| ", this assertion is not tracked", | ||
|
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 PR now states one concept three ways within ~220 lines, and this site is the odd one out in a way that will cost later. The no-dict case gets a dedicated They share neither wording ( Worth noting the severity ordering runs backwards against the three: the weakest case (not configured) gets its own line, and the strongest (configured, and the record was not written) gets a suffix. |
||
| assertion.one_time_use and " though it carries OneTimeUse" or "") | ||
|
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. The negative direction of this suffix is untested: a mutant that appends it unconditionally passes all 246 tests. Verified by replacing So a regression that tells every zone-exhausted login its IdP asked for single use — when it did not — ships with green CI. One line closes it:
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. Taken (672ccd7): TEST 40 asserts |
||
| end | ||
| end | ||
|
|
||
| -- said only once every record stands: sooner would describe a record the | ||
| -- add may yet refuse, or one the rollback above takes back. The facts are | ||
| -- what was kept, so the line is built only where the level prints it | ||
|
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. I owe you a correction here, and this comment is where it landed. The justification I gave for buffering facts instead of the built string — "so nothing is built where the level discards it" — is wrong, and the comment now states it as an invariant the code does not hold. Lua evaluates Instrumented: with the flush counting Measured like-for-like on this runtime (300k iterations, three runs within 2%): None of that matters in absolute terms — once per login, against an RSA verify in the same request. What matters is the comment, since it is the whole stated reason the machinery exists and the next reader will believe it. The buffering still earns its place on the other ground you gave it, which is true and load-bearing: nothing is said until every record stands. I would keep the structure and cut the second clause, or replace it with the ordering reason. 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. Following my own correction with a second one, since a later pass found the other clause is wrong too — and this one is the author's, not mine. "sooner would describe a record the add may yet refuse" is false for the record being described. The append sits at :625-628, inside What is true is the clause after it: "or one the rollback above takes back". A later sibling colliding at :629 deletes the keys already spent, and deferring is what keeps the warn from describing a record that gets retracted. That is a real and sufficient reason for the machinery. So of the three-line comment: clause one is false, clause two is true and load-bearing, clause three is the one I supplied and it is false. Reducing it to the rollback reason alone leaves it correct and still explains why the buffer exists. |
||
| if warned then | ||
| for _, w in ipairs(warned) do | ||
| ngx.log(ngx.WARN, "assertion ", loggable(w.id), " from ", loggable(w.issuer), | ||
| " carries OneTimeUse but stays acceptable past its record, which lapses in ", | ||
| w.ttl, " seconds") | ||
| end | ||
| end | ||
| return true | ||
| end | ||
|
|
||
|
|
@@ -705,7 +739,8 @@ local function login_callback(self, opts) | |
| end | ||
|
|
||
| local now = ngx.time() | ||
| local acceptable, reason = assertions_acceptable(opts, assertions, expected, now) | ||
| local acceptable, reason = assertions_acceptable(opts, assertions, expected, now, | ||
| self.replay_dict) | ||
| if not acceptable then | ||
| ngx.log(ngx.ERR, "response from IdP rejected: ", loggable(reason)) | ||
| ngx.exit(ngx.HTTP_UNAUTHORIZED) | ||
|
|
@@ -748,8 +783,8 @@ local function login_callback(self, opts) | |
| -- the last gate: everything that can still refuse this login has run, so | ||
| -- the assertion is spent only where it actually authenticates somebody | ||
| if self.replay_dict then | ||
| local unused, used_reason = spend_assertions(self.replay_dict, opts, assertions, | ||
| expected, now) | ||
| local unused, used_reason = spend_assertions(self.replay_dict, self.replay_dict_name, | ||
| opts, assertions, expected, now) | ||
| if not unused then | ||
| ngx.log(ngx.ERR, "response from IdP rejected: ", loggable(used_reason)) | ||
| ngx.exit(ngx.HTTP_UNAUTHORIZED) | ||
|
|
@@ -944,6 +979,9 @@ function _M.new(opts) | |
| if obj.replay_dict == nil then | ||
| error("no lua_shared_dict named " .. opts.replay_dict, 2) | ||
| end | ||
|
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 comment claims more than the change delivers, and it is one of the two places a maintainer will look for the rule. "kept beside the handle, so what the ERR names is the zone written to, whatever happens to the caller's table afterwards" — two problems. Two supporting details. The zone now has three representations on one object: And the per-field mechanism this extends has already failed once in this same function:
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. Comment narrowed in ca97738: it now says why the name is kept at all, the shdict handle has no name accessor, and promises nothing about the caller's table; the object-level claim lives in the README's new handover contract instead. The third representation, opts.replay_dict, is stable under that contract, and the snapshot stays as shipped rather than growing a per-field scheme. The dead auth_protocol_binding_method snapshot is pre-PR code and stays out of this diff. |
||
| -- the handle carries no name accessor, so the name it was resolved | ||
| -- from rides beside it for the diagnostics | ||
| obj.replay_dict_name = opts.replay_dict | ||
|
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 line freezes the dict name three lines above a guard whose own comment calls Verified by probe. This is live rather than hypothetical. The only consumer, The altitude is what I would change rather than adding a second snapshot. Unrelated defect in the same one-line function, found while probing it:
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. The probes are sound and the class is real in theory; what it lacks is an occupant. The one live host treats plugin conf as immutable by convention: a config change builds a new table with a new version, which misses the lrucache and constructs a fresh SP, so nothing rewrites a handed-over table in place, and schema defaults are injected before new() ever sees it. openid-connect clones for the reverse reason, to protect the shared conf from the plugin's own writes. So rather than copying inside the library for a scenario with no occurrence, the boundary is now the documented contract (ca97738): new() keeps the table by reference, hand it over and do not mutate it afterwards; an embedder whose table is shared or reused passes a copy at the call site, where core.table.deepcopy already exists. A host that mutates mid-flight is outside the contract the README now states, the same footing every resty library stands on. 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. The trim asymmetry got wider with 57bb15f, in a way worth folding into whatever fix this thread lands on. All three log lines this PR adds now print That makes three consumers of one value with two normalizations. Applying |
||
| -- it is half the key, and tostring would turn a missing one into the | ||
| -- literal nil that two deployments would then share | ||
| if type(opts.sp_issuer) ~= "string" then | ||
|
|
||
| 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 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. Confirmed fixed and it is load-bearing: The identical class survives one level up, if you want to close it properly while you are here. Minor, opposite 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. Confirmed and the small half is in (8dd5b26): the objects depend on the Makefile, and 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. The Makefile-and-archives half is in and I verified both: Variables still do not trigger anything, and they are the way this build is actually configured. Measured on the built tree: The default |
||
| 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 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. The #54 deferral is defensible on fail-closed grounds and I am not asking for it here. What the note does not say is that the asymmetry is created by this PR rather than inherited. Before this PR both spellings of the same condition — The message degradation is new too. Schema validation runs first and Worth one sentence in the deferral note saying the split is new, so #54 is not read as purely pre-existing.
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. 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. OneTimeUse is now named twice in this file with nothing linking the two: here, so it does not land in Delete or misspell one and the failure is silent in opposite directions — drop it here and every OneTimeUse assertion 401s as an unknown condition; misspell it at :515 and A single |
||
| } | ||
|
|
||
|
|
||
|
|
@@ -509,6 +510,10 @@ static int read_assertion(xmlDoc* doc, xmlNode* node, saml_assertion_t* a) { | |
| return -1; | ||
| } | ||
|
|
||
| // answered on its own, so the refusal scan below owes it nothing and | ||
| // reads the same whatever the order of the conditions | ||
| a->one_time_use = assertion_child(conditions, "OneTimeUse") != NULL; | ||
|
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. The shape is much better — the flag reads on its own line and the refusal scan is back to its pre-PR form. The negative-control gap I raised last round is not closed, though, and one of the surviving mutations is not mitigated by anything. Four mutations, four distinct binaries (md5 verified different each time), clean isolated baseline 259/259 PASS before each: (a) namespace-blind name match, (b) namespace-optional, accepting (a)-(c) are mitigated in practice: (d) is not mitigated. Separately confirmed and worth recording: TEST 51 does pin order-independence — re-merging the read into the loop with the |
||
|
|
||
| for (xmlNode* child = conditions->children; child != NULL; child = child->next) { | ||
| if (child->type == XML_ELEMENT_NODE && !is_known_condition(child)) { | ||
| // the caller refuses the assertion on this name, so losing it would | ||
|
|
||
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.
Two things this line newly broke, both measured with real runs rather than
-n.The archives are a prerequisite of the link but not of the compiles, and the compiles are what consume
-Ixmlsec1-$(XMLSEC_VER)/include/. Withxmlsec1-1.2.28/moved aside and the objects removed — the fresh-clone condition —make saml.so OPENSSL_DIR=...goes straight for the objects:gcc -c ... -Ixmlsec1-1.2.28/include/ ... -o lua_saml.o src/lua_saml.c→src/lua_saml.c:9:10: fatal error: xmlsec/xmlsec.h: No such file or directory→make: *** [Makefile:44: lua_saml.o] Error 1. The wget/tar/configure recipe never runs.make buildstill works only becausebuild: $(XMLSEC1_STATIC_LIBS) saml.sohappens to list the archives first, so the goal now looks self-bootstrapping while it is not.saml.o lua_saml.o: | $(XMLSEC1_STATIC_LIBS)fixes it and themake -jordering with it.make -B saml.soregressed into a double re-download.$(XMLSEC1_STATIC_LIBS)is two targets sharing one recipe, and GNU make expandsA B: ; recipeinto two independent rules carrying that recipe. Making it a prerequisite ofsaml.soexposes that on this goal:make -n -B saml.soat head emits thewgettwice; the same probe againstgit show 2c483e5:Makefileemits it zero times. Under-jtwo wgets write the same tarball, twotar zxfextract over each other, and two./configure; makerun in the same directory — executing it for real hitautomake-1.15: command not found, because tar restores the 2019 mtimes and trips maintainer-mode regeneration, leaving a tree needingmake clean. Before this commitmake -B saml.sowas the safe way to force a relink. GNU Make >= 4.3 has grouped targets —$(XMLSEC1_STATIC_LIBS) &:— or one stamp file both archives depend on.