-
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
base: main
Are you sure you want to change the base?
Changes from all commits
0cbf079
92c511c
041bb56
7bbea1e
3939263
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,37 @@ local function parse_iso8601_utc_time(str) | |
| return os.time{year=year, month=month, day=day, hour=hour, min=min, sec=sec} | ||
| 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 | ||
| local ok = false | ||
| for _, expected in ipairs(allowed) do | ||
|
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 first two take down every ACS callback, and the error names
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. Taking this. A misconfigured option should not 500 every ACS callback, and "unexpected issuer" is the wrong thing to log when the fault is the config. Doing it in |
||
| if expected == issuer 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. Exact compare with no trim. libxml2 keeps the element text verbatim, so a pretty-printed <saml:Issuer>
https://idp.example.com
</saml:Issuer>yields a Lua string with the whitespace attached, which never equals the configured value — every login 401s (measured on this branch). It is unusually hard to diagnose because the value is logged unescaped: the operator sees
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. Taking it. For the record on severity: every login fails, immediately and for everyone, so it surfaces the moment the option is configured rather than sitting there silently. The unreadable log line is the part that makes it expensive, and that is covered by the escaping thread. |
||
| ok = true | ||
| break | ||
| end | ||
| end | ||
| if not ok then | ||
| return false, issuer | ||
| end | ||
| end | ||
| return true | ||
| end | ||
|
|
||
| local function login_callback(self, opts) | ||
| local sess = session.start(self.session_config) | ||
|
|
||
|
|
@@ -301,6 +332,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(opts.idp_issuers, saml.doc_issuers(doc)) | ||
|
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 gate only runs on the callback. That is the incident this option exists for: an operator finds a rogue issuer the shared
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 incident is the right one to name, but the fix as described logs out every session created before the option existed, because those carry no stored issuer and nil cannot be told apart from an issuer that is no longer allowed. Allowing nil through fails open and gives the incident back. There is also a remedy today: cookie sessions have no server-side store, but rotating Worth doing with the upgrade case thought through rather than added here. Filing it. |
||
| 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. |
||
| 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,20 +25,137 @@ static xmlXPathObject* eval_xpath(xmlDoc* doc, xmlXPathCompExpr* xpath) { | |
| } | ||
|
|
||
|
|
||
| static int is_saml_assertion(xmlNode* node) { | ||
| return node->type == XML_ELEMENT_NODE && | ||
| xmlStrEqual(node->name, (const xmlChar*)"Assertion") == 1 && | ||
| node->ns != NULL && | ||
| xmlStrEqual(node->ns->href, (const xmlChar*)SAML_XMLNS_ASSERTION) == 1; | ||
| } | ||
|
|
||
|
|
||
| // The direct child of node named name in namespace ns, or NULL. Only direct | ||
| // children: an element the message itself declares is not the same as one a | ||
| // document-wide search happens to reach first. | ||
| static xmlNode* ns_child(xmlNode* node, const xmlChar* name, const char* ns) { | ||
| for (xmlNode* child = node->children; child != NULL; child = child->next) { | ||
| if (child->type == XML_ELEMENT_NODE && | ||
| xmlStrEqual(child->name, name) == 1 && | ||
| child->ns != NULL && | ||
| xmlStrEqual(child->ns->href, (const xmlChar*)ns) == 1) { | ||
| return child; | ||
| } | ||
| } | ||
| return NULL; | ||
| } | ||
|
|
||
|
|
||
| // The text of node's own Issuer child, or NULL. Issuer is in the assertion | ||
| // namespace wherever it appears, so a look-alike in another one is not it. | ||
| static xmlChar* issuer_of(xmlDoc* doc, xmlNode* node) { | ||
| xmlNode* issuer = ns_child(node, (const xmlChar*)"Issuer", SAML_XMLNS_ASSERTION); | ||
| return issuer == NULL ? NULL : xmlNodeListGetString(doc, issuer->children, 1); | ||
| } | ||
|
|
||
|
|
||
| // A Response's issuer is read from its assertion, the element the identity | ||
|
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 invariant this rests on — "every top-level assertion still in the document is one the signature covers" — only holds for one of the two bindings. It happens to be safe there because the query-string signature covers the whole message, but that's an unstated dependency. Worth naming it here: narrowing the redirect signature, or calling
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. Fair, the comment names one of the two mechanisms and the redirect path relies on the other.
|
||
| // itself comes from. The Response's own Issuer sits outside an assertion-level | ||
| // signature and can be rewritten without breaking it, while every top-level | ||
| // assertion still in the document is one the signature covers. A message that | ||
| // carries no assertion is only accepted signed whole, so its own Issuer is the | ||
| // one to read. | ||
| xmlChar* saml_doc_issuer(xmlDoc* doc) { | ||
| xmlNode* node = xmlDocGetRootElement(doc); | ||
| if (node == NULL) { | ||
| xmlNode* root = xmlDocGetRootElement(doc); | ||
| if (root == NULL) { | ||
| return NULL; | ||
| } | ||
|
|
||
| node = node->children; | ||
| while (node != NULL) { | ||
| if (xmlStrEqual(node->name, (xmlChar*)"Issuer") == 1) { | ||
| return xmlNodeListGetString(doc, node->children, 1); | ||
| if (xmlStrEqual(root->name, (const xmlChar*)"Response") == 1) { | ||
| for (xmlNode* child = root->children; child != NULL; child = child->next) { | ||
| if (is_saml_assertion(child)) { | ||
| return issuer_of(doc, child); | ||
|
Comment on lines
+74
to
+75
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. Good catch, this is real. Fixed in 041bb56.
New
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. Changing what this returns for a Response also changes a comparison that isn't in the diff. Any deployment where the Response and Assertion Issuers legitimately differ — a brokering IdP passing an upstream assertion through — starts logging Related: that comparison only warns and then destroys the session anyway, so
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. Intentional, and the alternative is the vulnerability. Keeping the Response's Issuer is what #33 is about, so a brokering deployment cannot get the old value back without giving the attacker the rewritable one. The comparison at saml.lua:443 warns and then destroys the session regardless of the outcome, so what changes for a brokering IdP is log noise, not behaviour. Going into the release notes. On which of the two is the right one to store: the assertion issuer is the authority that authenticated the user, which is what #40 asks to pin, and it is what pysaml2, python3-saml and Shibboleth validate. The scope point is fair and has just become actionable: before 3939263 a LogoutRequest could carry its identity in an unsigned message, so pinning its issuer would have gated on attacker-typed text. Now that such a message has to be signed whole, the value is covered and the pin can be extended there. Filing that separately rather than widening this PR. |
||
| } | ||
| } | ||
| node = node->next; | ||
| return NULL; | ||
| } | ||
| return NULL; | ||
|
|
||
| return issuer_of(doc, root); | ||
|
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 branch returns the root's own Issuer with nothing checking that a signature covers the root, which is the opposite of what the comment above it says ("Other messages carry no assertion and are signed whole"). The new A LogoutRequest whose only The LogoutRequest's own It doesn't reach 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. For cross-reference: #36 already records 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. You are right, and the reproduction matches: on the previous commit that document gives The assumption in that comment is now enforced rather than asserted.
That kills the One behaviour change beyond the logout path: TEST 14's TESTs 22 and 23 cover both halves. 90 subtests pass; rebuilt against the previous commit's The redirect binding is untouched, since it signs the encoded query string and never reaches this path. |
||
| } | ||
|
|
||
|
|
||
| void saml_issuers_free(xmlChar** issuers, size_t issuers_len) { | ||
| for (size_t i = 0; i < issuers_len; i++) { | ||
| xmlFree(issuers[i]); | ||
| } | ||
| free(issuers); | ||
| } | ||
|
|
||
|
|
||
| // Every issuer the message attributes content to: one per top-level assertion | ||
| // of a Response, or its own for a message that carries none and is therefore | ||
| // only accepted signed whole. A caller matching | ||
| // the issuer against a policy has to weigh all of them, because doc_attrs reads | ||
| // every top-level assertion and doc_name_id the first one carrying a subject. | ||
| // An assertion with no Issuer is invalid SAML; it is listed as an empty string, | ||
| // which no configured issuer matches. | ||
| int saml_doc_issuers(xmlDoc* doc, xmlChar*** issuers, size_t* issuers_len) { | ||
| *issuers = NULL; | ||
| *issuers_len = 0; | ||
|
|
||
| xmlNode* root = xmlDocGetRootElement(doc); | ||
| if (root == NULL) { | ||
| return 0; | ||
| } | ||
|
|
||
| if (xmlStrEqual(root->name, (const xmlChar*)"Response") != 1) { | ||
| xmlChar* issuer = issuer_of(doc, root); | ||
| if (issuer == NULL) { | ||
| return 0; | ||
| } | ||
| *issuers = malloc(sizeof(xmlChar*)); | ||
| if (*issuers == NULL) { | ||
| xmlFree(issuer); | ||
| return -1; | ||
| } | ||
| (*issuers)[0] = issuer; | ||
| *issuers_len = 1; | ||
| return 0; | ||
| } | ||
|
|
||
| size_t count = 0; | ||
| for (xmlNode* child = root->children; child != NULL; child = child->next) { | ||
| if (is_saml_assertion(child)) { | ||
| count++; | ||
| } | ||
| } | ||
| if (count == 0) { | ||
| return 0; | ||
| } | ||
|
|
||
| *issuers = malloc(count * sizeof(xmlChar*)); | ||
| if (*issuers == NULL) { | ||
| return -1; | ||
| } | ||
|
|
||
| size_t i = 0; | ||
| for (xmlNode* child = root->children; child != NULL && i < count; child = child->next) { | ||
| if (!is_saml_assertion(child)) { | ||
| continue; | ||
| } | ||
| xmlChar* issuer = issuer_of(doc, child); | ||
| if (issuer == NULL) { | ||
| issuer = xmlStrdup((const xmlChar*)""); | ||
| } | ||
| if (issuer == NULL) { | ||
| // a short list would read as fewer assertions to vouch for than the | ||
| // document holds, so report the failure rather than an incomplete answer | ||
| saml_issuers_free(*issuers, i); | ||
| *issuers = NULL; | ||
| return -1; | ||
| } | ||
| (*issuers)[i++] = issuer; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| *issuers_len = i; | ||
| return 0; | ||
| } | ||
|
|
||
|
|
||
|
|
@@ -49,7 +166,8 @@ xmlChar* saml_doc_name_id(xmlDoc* doc) { | |
| } | ||
|
|
||
| if (xmlStrEqual(node->name, (xmlChar*)"LogoutRequest") == 1) { | ||
| node = xmlSecFindNode(node, (xmlChar*)"NameID", (xmlChar*)SAML_XMLNS_ASSERTION); | ||
| // the subject the request names, which the schema puts directly under it | ||
| node = ns_child(node, (const xmlChar*)"NameID", SAML_XMLNS_ASSERTION); | ||
| if (node == NULL) { | ||
| return NULL; | ||
| } | ||
|
|
@@ -76,20 +194,6 @@ xmlChar* saml_doc_name_id(xmlDoc* doc) { | |
| } | ||
|
|
||
|
|
||
| // The direct child of node named name in the protocol namespace, or NULL. | ||
| static xmlNode* protocol_child(xmlNode* node, const xmlChar* name) { | ||
| for (xmlNode* child = node->children; child != NULL; child = child->next) { | ||
| if (child->type == XML_ELEMENT_NODE && | ||
| xmlStrEqual(child->name, name) == 1 && | ||
| child->ns != NULL && | ||
| xmlStrEqual(child->ns->href, (const xmlChar*)SAML_XMLNS_PROTOCOL) == 1) { | ||
| return child; | ||
| } | ||
| } | ||
| return NULL; | ||
| } | ||
|
|
||
|
|
||
| xmlChar* saml_doc_status_code(xmlDoc* doc) { | ||
| // Read the top-level message's status directly, not a document-wide match: | ||
| // a nested Response (for example inside saml:Advice) can precede the root | ||
|
|
@@ -98,11 +202,11 @@ xmlChar* saml_doc_status_code(xmlDoc* doc) { | |
| if (root == NULL) { | ||
| return NULL; | ||
| } | ||
| xmlNode* status = protocol_child(root, (const xmlChar*)"Status"); | ||
| xmlNode* status = ns_child(root, (const xmlChar*)"Status", SAML_XMLNS_PROTOCOL); | ||
| if (status == NULL) { | ||
| return NULL; | ||
| } | ||
| xmlNode* code = protocol_child(status, (const xmlChar*)"StatusCode"); | ||
| xmlNode* code = ns_child(status, (const xmlChar*)"StatusCode", SAML_XMLNS_PROTOCOL); | ||
| if (code == NULL) { | ||
| return NULL; | ||
| } | ||
|
|
@@ -144,7 +248,7 @@ xmlChar* saml_doc_session_index(xmlDoc* doc) { | |
| } | ||
|
|
||
| if (xmlStrEqual(node->name, (xmlChar*)"LogoutRequest") == 1) { | ||
| node = xmlSecFindNode(node, (xmlChar*)"SessionIndex", (xmlChar*)SAML_XMLNS_PROTOCOL); | ||
| node = ns_child(node, (const xmlChar*)"SessionIndex", SAML_XMLNS_PROTOCOL); | ||
| if (node == NULL) { | ||
| return NULL; | ||
| } | ||
|
|
||
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 row still doesn't convey.
The default column says
None, butidp_issuers = {}denies everybody (measured: 401). From a caller's side a JSON[]and an unset field are indistinguishable here, so it's worth stating that an empty list is not the same as no list — the code comment says it, the table doesn't.Also
src/lua_saml.c:389still documentsdoc_issueras "Get the text of the issuer node", which stopped being what it does in this PR.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.
Taking both. The empty list behaviour belongs in the table, not only in a comment nobody configuring the plugin reads, and
lua_saml.c:389describingdoc_issueras the text of the issuer node stopped being true in this PR.