-
Notifications
You must be signed in to change notification settings - Fork 2
fix: let an assertion be presented only once #50
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
Changes from all commits
b640cdb
a9fa958
19e96e0
8144136
d59bd4a
53bf327
90671a1
c2edc13
e37f8e0
42fd9b8
9ea4cf5
8d4cba9
9d59d5a
02f214f
ffae70a
41bd566
0494f32
c5d206b
975d2f1
c308609
d48a9a5
30788cd
04c236a
1b92b9d
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 |
|---|---|---|
|
|
@@ -320,6 +320,14 @@ end | |
| -- what stops an assertion minted for another SP in the same federation. | ||
| local DEFAULT_CLOCK_SKEW = 60 | ||
|
|
||
| -- how long an assertion that sets no expiry of its own is remembered | ||
| 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. | ||
| local MAX_REPLAY_TTL = 86400 | ||
|
|
||
| local function time_bounds_ok(not_before, not_on_or_after, now, skew) | ||
| local opens, closes, err | ||
|
|
||
|
|
@@ -501,6 +509,118 @@ local function issuers_allowed(allowed, issuers) | |
| return true | ||
| end | ||
|
|
||
| -- The last moment the checks above would still admit the assertion. They | ||
| -- combine as an AND: the Conditions window has to hold, and one confirmation | ||
| -- has to be satisfiable, so acceptance ends at whichever gives out first, the | ||
| -- Conditions close or the last confirmation still standing. Profile 4.1.4.2 | ||
| -- puts a bearer assertion's expiry on its confirmation, so a Conditions | ||
| -- carrying nothing but an audience is the profile-minimal shape rather than an | ||
| -- odd one. Nil when nothing bounds acceptance, which replay_ttl stands in for. | ||
| -- | ||
| -- Only confirmations that could ever confirm at this SP have a say, the same | ||
| -- ones confirmation_ok weighs, minus the clock: one naming another Recipient | ||
| -- or another request can never keep the assertion alive here, and one whose | ||
| -- close this parser will not take, a legal xs:dateTime carrying an offset | ||
| -- rather than Z, is unsatisfiable in the same way. Reading those as | ||
| -- contributing nothing rather than as unbounded matters in both directions, | ||
| -- since a confirmation naming no close never gives out: one satisfiable such | ||
| -- confirmation means the confirmations impose no limit at all, where the | ||
| -- earlier reading let a shorter sibling shrink the record below what an | ||
| -- absent sibling would have left it. | ||
| local function last_moment_usable(assertion, expected) | ||
| local notes_close | ||
| local unbounded = #assertion.subject_confirmations == 0 | ||
| for _, confirmation in ipairs(assertion.subject_confirmations) do | ||
| local confirms_here = confirmation.recipient == expected.acs_url and | ||
| (confirmation.in_response_to == nil or | ||
| confirmation.in_response_to == expected.request_id) | ||
| if confirms_here then | ||
| if confirmation.not_on_or_after == nil then | ||
| unbounded = true | ||
| else | ||
| local at = parse_iso8601_utc_time(confirmation.not_on_or_after) | ||
| if at and (notes_close == nil or at > notes_close) then | ||
| notes_close = at | ||
| end | ||
| end | ||
| end | ||
|
Comment on lines
+533
to
+546
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, in 30788cd, with one extension. The rule is as you state it: the confirmation limit comes from the satisfiable alternatives, one of them naming no NotOnOrAfter means the confirmations impose no limit, and it combines with the Conditions close by the earlier of the two, falling to replay_ttl when nothing bounds acceptance. "Satisfiable" is taken literally: a confirmation naming another Recipient or another request has no say, since it can never keep the assertion alive here, so a dateless confirmation addressed elsewhere does not unbound the record either. Your example alone had not moved me, since both rules lapse against unbounded acceptance and differ only in slots. What did is the sibling shape: Conditions with no close, one confirmation closing in a minute beside one naming no close. The old rule remembered it for that minute, less than the replay_ttl an absent sibling would have produced, against acceptance that never ends. Also surveyed how the field handles this before settling it (Shibboleth, pac4j, Sustainsys, ITfoxtec, SimpleSAMLphp, the OneLogin family, node-saml, Spring, Keycloak): every library that derives a record lifetime from the assertion reads a single attribute and requires it to exist, so the mixed shape cannot arise for them; Shibboleth instead uses a fixed freshness window off IssueInstant. Accepting a dateless confirmation while keeping a record is territory none of them enter, so the rule is spelled out here rather than borrowed. TESTs 45 to 47 pin the three edges, and each fails alone when its half of the rule is reverted. |
||
| end | ||
| if unbounded then | ||
| notes_close = nil | ||
| end | ||
|
|
||
| local conditions_close | ||
| if assertion.not_on_or_after then | ||
| conditions_close = parse_iso8601_utc_time(assertion.not_on_or_after) | ||
| end | ||
|
|
||
| if conditions_close and notes_close then | ||
| return math.min(conditions_close, notes_close) | ||
| end | ||
| return conditions_close or notes_close | ||
| end | ||
|
|
||
|
|
||
| -- An ID is unique only within the IdP that minted it, and idp_issuers takes a | ||
| -- list, so the two travel together. The SP name keeps instances sharing one | ||
| -- dict apart. | ||
| local function replay_key(opts, assertion) | ||
| return opts.sp_issuer .. "|" .. (assertion.issuer or "") .. "|" .. assertion.id | ||
| end | ||
|
|
||
|
|
||
| -- A bearer assertion is good for one login. Nothing above stops the same one | ||
|
jarvis9443 marked this conversation as resolved.
|
||
| -- being presented again inside its validity window, so its ID is remembered for | ||
| -- as long as it could still be used and a second presentation is refused. | ||
| -- | ||
| -- Called at the last gate rather than beside the checks, so a login the rest of | ||
| -- the callback still refuses leaves the assertion unspent. A dict with no room | ||
| -- leaves this assertion untracked rather than evicting one that is still | ||
| -- 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 skew = opts.clock_skew or DEFAULT_CLOCK_SKEW | ||
| local spent = {} | ||
|
|
||
| for _, assertion in ipairs(assertions) do | ||
| if not assertion.id then | ||
| return false, "an assertion without an ID cannot be tracked" | ||
| end | ||
|
|
||
| local ttl = opts.replay_ttl or DEFAULT_REPLAY_TTL | ||
| local usable_until = last_moment_usable(assertion, expected) | ||
| if usable_until then | ||
| ttl = usable_until + skew - now | ||
| end | ||
| if ttl < 1 then | ||
|
jarvis9443 marked this conversation as resolved.
|
||
| ttl = 1 | ||
| elseif ttl > MAX_REPLAY_TTL then | ||
| ttl = MAX_REPLAY_TTL | ||
|
Comment on lines
+598
to
+599
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 divergence is real and it is the documented trade rather than an oversight, so both suggested changes are declined and the README now carries the residue explicitly (04c236a). Rejecting validity windows beyond the retention limit refuses logins to punish another party's configuration, which this PR has declined three times already on the same grounds; a retain-through-the-full-window mode just re-enables the pinned slot the cap was built against, behind a knob nobody reads until an incident. The cap exists because an entry with an eight-thousand-year expiry is a slot the dict never reclaims, and enough of those evict records still protecting somebody. Proportion, for the record: reaching the cap needs an IdP issuing assertions valid beyond a day. Shipped defaults put the delivery window at minutes everywhere and the assertion window at minutes to an hour (Shibboleth and Keycloak 5m, ADFS and Entra ~60m), so the gap opens only behind an administrator overriding defaults by two orders of magnitude, and for every real IdP the record outlives the assertion. It is the same residue class as replay_ttl for a dateless assertion: memory is bounded, acceptance is not, and the README states both in one place now. |
||
| end | ||
|
|
||
| local key = replay_key(opts, assertion) | ||
| local added, add_err = dict:safe_add(key, true, ttl) | ||
| if added then | ||
| spent[#spent + 1] = key | ||
| elseif add_err == "exists" then | ||
| -- this response authenticates nobody, so the assertions already | ||
| -- taken from it are handed back rather than left spent | ||
| for _, taken in ipairs(spent) do | ||
| dict:delete(taken) | ||
| 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") | ||
| end | ||
| end | ||
|
|
||
| return true | ||
| end | ||
|
|
||
|
|
||
| local function login_callback(self, opts) | ||
| local sess = session.start(self.session_config) | ||
|
|
||
|
|
@@ -584,7 +704,8 @@ local function login_callback(self, opts) | |
| ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) | ||
| end | ||
|
|
||
| local acceptable, reason = assertions_acceptable(opts, assertions, expected, ngx.time()) | ||
| local now = ngx.time() | ||
| local acceptable, reason = assertions_acceptable(opts, assertions, expected, now) | ||
| if not acceptable then | ||
| ngx.log(ngx.ERR, "response from IdP rejected: ", loggable(reason)) | ||
| ngx.exit(ngx.HTTP_UNAUTHORIZED) | ||
|
|
@@ -624,6 +745,17 @@ local function login_callback(self, opts) | |
| end | ||
|
|
||
|
|
||
| -- 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) | ||
| if not unused then | ||
| ngx.log(ngx.ERR, "response from IdP rejected: ", loggable(used_reason)) | ||
| ngx.exit(ngx.HTTP_UNAUTHORIZED) | ||
| end | ||
| end | ||
|
|
||
| sess:set("authenticated", true) | ||
| sess:set("name_id", name_id) | ||
| sess:set("session_index", session_index) | ||
|
|
@@ -800,6 +932,30 @@ function _M.new(opts) | |
| obj.idp_cert_func = function(doc) return idp_cert end | ||
| obj.auth_protocol_binding_method = opts.auth_protocol_binding_method | ||
| obj.idp_issuers = issuer_set(opts.idp_issuers) | ||
| -- read once, and raised rather than returned so a mistyped name names | ||
| -- itself. A message built as an argument to assert is built on every | ||
| -- successful call too, and a non-string one fails on the concatenation | ||
| -- rather than on the option. | ||
| if opts.replay_dict ~= nil then | ||
| if type(opts.replay_dict) ~= "string" then | ||
| error("replay_dict must be the name of a lua_shared_dict", 2) | ||
| end | ||
| obj.replay_dict = ngx.shared[opts.replay_dict] | ||
| if obj.replay_dict == nil then | ||
| error("no lua_shared_dict named " .. opts.replay_dict, 2) | ||
| end | ||
| -- 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 | ||
| error("sp_issuer must be a string to track assertions", 2) | ||
| end | ||
| -- zero means never expire to lua_shared_dict, and a number arriving | ||
| -- from YAML or the environment as text compares against nothing | ||
| if opts.replay_ttl ~= nil and | ||
| (type(opts.replay_ttl) ~= "number" or opts.replay_ttl < 1) then | ||
| error("replay_ttl must be a positive number of seconds", 2) | ||
| end | ||
| end | ||
| local cookie_secure, cookie_same_site | ||
| if opts.auth_protocol_binding_method == "HTTP-POST" then | ||
| cookie_secure = true | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.