-
Notifications
You must be signed in to change notification settings - Fork 2
fix: read the issuer from signed content, and let it be pinned #41
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
0cbf079
92c511c
041bb56
7bbea1e
3939263
18d7678
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 |
|---|---|---|
|
|
@@ -258,6 +258,65 @@ local function parse_iso8601_utc_time(str) | |
| return os.time{year=year, month=month, day=day, hour=hour, min=min, sec=sec} | ||
| end | ||
|
|
||
| -- An Issuer is a string in the XML schema, so libxml2 hands back the element | ||
| -- text as written, indentation included. Compare what the two sides mean. | ||
| local function trim(s) | ||
| return (s:gsub("^%s*(.-)%s*$", "%1")) | ||
| end | ||
|
|
||
| -- Read idp_issuers once, into a set. A shape the callback cannot walk is a | ||
| -- configuration mistake, and finding out at construction names the option, | ||
| -- where finding out per request is a 500 or a blanket refusal that blames the | ||
| -- IdP. An empty list stays legal and means what it says: nobody is expected. | ||
| local function issuer_set(issuers) | ||
| if issuers == nil then | ||
| return nil | ||
| end | ||
|
|
||
| local invalid = "idp_issuers must be a list of strings" | ||
| if type(issuers) ~= "table" then | ||
| error(invalid, 3) | ||
| end | ||
|
|
||
| local set, count = {}, 0 | ||
| for i, issuer in pairs(issuers) do | ||
| if type(i) ~= "number" or i % 1 ~= 0 or i < 1 or type(issuer) ~= "string" then | ||
| error(invalid, 3) | ||
| end | ||
| set[trim(issuer)] = true | ||
| count = count + 1 | ||
| end | ||
| -- a gap would leave the entries past it unreachable to ipairs | ||
| if count ~= #issuers then | ||
| error(invalid, 3) | ||
| end | ||
| return set | ||
| end | ||
|
|
||
| -- A valid signature says the message came from the configured key. It does not | ||
| -- say which IdP that key speaks for, so pin the issuer when the caller names | ||
| -- the ones it expects. No list keeps the previous behaviour; a list nothing | ||
| -- matches, an empty one included, admits nobody. | ||
| -- | ||
| -- Every assertion is weighed, not just the one the issuer is taken from: a | ||
| -- response may legitimately carry several, and attributes are read from all of | ||
| -- them. A response whose issuers cannot be read vouches for nobody. Returns | ||
| -- what to name in the log alongside a refusal. | ||
| local function issuers_allowed(allowed, issuers) | ||
| if allowed == nil then | ||
| return true | ||
| end | ||
| if type(issuers) ~= "table" or #issuers == 0 then | ||
| return false, "none readable" | ||
| end | ||
| for _, issuer in ipairs(issuers) do | ||
| if not allowed[trim(issuer)] then | ||
| return false, issuer | ||
|
Comment on lines
+312
to
+314
|
||
| end | ||
| end | ||
| return true | ||
| end | ||
|
|
||
| local function login_callback(self, opts) | ||
| local sess = session.start(self.session_config) | ||
|
|
||
|
|
@@ -301,6 +360,12 @@ local function login_callback(self, opts) | |
| local name_id = saml.doc_name_id(doc) | ||
| local session_index = saml.doc_session_index(doc) | ||
|
|
||
| local allowed, unexpected = issuers_allowed(self.idp_issuers, saml.doc_issuers(doc)) | ||
| if not allowed then | ||
| ngx.log(ngx.ERR, "unexpected issuer in response from IdP: ", tostring(unexpected)) | ||
|
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 rejected Issuer goes into the error log unescaped, and on this branch it is attacker-controlled by construction — reaching here means the signature checked out but the issuer is not on the list. A newline in it forges log lines: Confirmed on this branch. Unauthenticated endpoint, so it is repeatable at will. Escaping the value, or logging a fixed message plus a sanitised form, 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. The injection is real; "attacker-controlled by construction" is not. Reaching that line means the document passed verification, and after this PR every value The stronger vector is already on main and needs no key at all. saml.lua:295 logs So this is not a property of the line the PR adds, and escaping only that one leaves the easier vector in place. Filing it as an issue over all the sites in the file, which is also the only way it gets a test that means anything.
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. Thanks for #47, and the correction on my "by construction" was fair. One thing worth knowing before these land, since it is invisible from either PR on its own: this line stays unescaped after merging with #42, and nothing flags it. #42 adds Separately, both PRs append one entry to the tail of |
||
| ngx.exit(ngx.HTTP_UNAUTHORIZED) | ||
| end | ||
|
|
||
| -- a success response the signature leaves without a readable assertion | ||
| -- carries no identity, so there is nobody to authenticate as | ||
| if not name_id then | ||
|
|
@@ -494,6 +559,7 @@ function _M.new(opts) | |
| obj.key_mngr_from_doc = function(doc) return obj.idp_cert_manager end | ||
| 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) | ||
| local cookie_secure, cookie_same_site | ||
| if opts.auth_protocol_binding_method == "HTTP-POST" then | ||
| cookie_secure = true | ||
|
|
||
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.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: api7/lua-resty-saml
Length of output: 7586
🏁 Script executed:
Repository: api7/lua-resty-saml
Length of output: 7571
🏁 Script executed:
Repository: api7/lua-resty-saml
Length of output: 1753
🏁 Script executed:
Repository: api7/lua-resty-saml
Length of output: 503
🌐 Web query:
LuaJIT 2.1 table length operator sparse table implementation __len # table border behavior💡 Result:
In LuaJIT 2.1, the behavior of the length operator (#) is designed to find a border in a table, consistent with standard Lua definitions [1][2][3]. A border is defined as any non-negative integer index n such that t[n] is non-nil and t[n+1] is nil (or 0 if t[4] is nil) [5][6]. Implementation Details: The internal implementation, primarily located in
lj_tab.cvia thelj_tab_lenfunction, uses a high-performance approach to identify this border [4][1][2]: 1. Fast Path: The algorithm first examines the array part of the table [4]. It checks the last element of the array; if it is nil, it performs a binary search within the array part to find the transition from non-nil to nil [4][2]. 2. Slow Path: If the array part does not provide a definitive boundary (or if the hash part must be considered), the function invokestab_len_slow[4]. This involves a "widening search" to find an upper bound for the table's indices, followed by a binary search to pinpoint the boundary [4]. 3. Metamethods: If a table has alenmetamethod, the length operator will invoke that metamethod instead of performing the standard lookup [5][6]. Sparse Tables and Undefined Behavior: The length operator is only reliably defined for "sequences"—tables where all positive integer keys from 1 to n are present and non-nil [1][2][5]. For sparse tables (tables with "holes"), the result of#tis considered implementation-defined and can return any of the table's valid borders [1][2][3]. Because LuaJIT's internal binary search depends on the specific state of the array and hash parts, which can change based on how the table was populated, relying on#tfor non-sequence tables is discouraged and can lead to inconsistent, non-deterministic behavior [1][2][7]. Top results: [4] https://github.com/LuaJIT/LuaJIT/blob/v2.1/src/lj_tab.c [1] https://percona.community/blog/2020/04/29/the-anatomy-of-luajit-tables-and-whats-special-about-them/ [2] https://habr.com/en/companies/vk/articles/500960/ [3] LuaJIT/LuaJIT#527 [5] https://www.lua.org/manual/5.5/manual.htmlCitations:
Reject sparse issuer tables using the maximum index
The
#issuersoperator is implementation-dependent for sparse tables. Do not use it to validate list density. Track the maximum validated numeric index and reject the table whencount ~= max_index.🤖 Prompt for AI Agents