Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ local saml = resty_saml.new(opts)
| `sp_issuer` | string | None | SP name to access IdP. |
| `idp_uri` | string | None | URI of IdP. |
| `idp_cert` | string | None | IdP Certificate, used to verify saml response. |
| `idp_issuers` | array of strings | None | Issuers accepted on a login response; every assertion it carries has to name one. Unset accepts any issuer the `idp_cert` signs for. |

Copy link
Copy Markdown

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, but idp_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:389 still documents doc_issuer as "Get the text of the issuer node", which stopped being what it does in this PR.

Copy link
Copy Markdown
Contributor Author

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:389 describing doc_issuer as the text of the issuer node stopped being true in this PR.

| `login_callback_uri` | string | None | redirect uri used to callback the SP from IdP after login. |
| `logout_uri` | string | None | logout uri to trigger logout. |
| `logout_callback_uri` | string | None | redirect uri used to callback the SP from IdP after logout. |
Expand Down
37 changes: 37 additions & 0 deletions lua/resty/saml.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allowed gets no type or shape check, so a misconfigured idp_issuers either 500s or denies everyone with a diagnostic that points at the wrong thing. All four measured on this branch:

  • ngx.null, which is what a JSON null decodes to when the config arrives from a plugin → 500, saml.lua:279: bad argument #1 to 'ipairs' (table expected, got userdata)
  • a bare string instead of a one-element list → 500, table expected, got string
  • { ["https://idp.example.com"] = true } → silent 401 for everyone
  • a list with a hole in it → ipairs stops there, so anything after it is unreachable → silent 401

The first two take down every ACS callback, and the error names ipairs rather than the option that was set wrong. A type(allowed) ~= "table" guard here, or normalising the list into a set once in new(), covers all four.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 new() rather than here: reject a value that is neither nil nor a list, and normalise the list into a set once, so the per-request path stays a lookup and the four shapes you measured all fail at construction with the option named. A bad shape denies rather than accepts, since an allow-list nobody can read is not one to ignore.

if expected == issuer then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 unexpected issuer in response from IdP: with the real value on the following lines, which reads as an empty issuer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking it. NameIDType derives from xs:string, whose whitespace facet is preserve, so libxml2 is right to hand back the text verbatim and the comparison is the wrong place to be strict about it. Trimming both sides.

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)

Expand Down Expand Up @@ -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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This gate only runs on the callback. login() at saml.lua:196 returns the stored identity — including issuer = sess:get("issuer") — without checking it against idp_issuers again.

That is the incident this option exists for: an operator finds a rogue issuer the shared idp_cert signs for and adds the allow-list to shut it out, but every session established before that change keeps working until it expires, and cookie sessions have no server-side store to evict. Checking the stored issuer against the list on the resume path would close it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 secret invalidates all of them at once, which is the blunt version of the eviction being asked for.

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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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:

[error] ... unexpected issuer in response from IdP: https://evil.example.com
2026/01/01 00:00:00 [error] FORGED LOG LINE injected by the issuer, client: 127.0.0.1, ...

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 doc_issuers returns comes from signature-covered content: a Response signed whole, or an assertion the signature names with the rest dropped. Putting a newline in it means getting the configured IdP to sign an assertion whose own Issuer contains one, which is available only in the shared or intermediate issued certificate case, the same narrow scenario idp_issuers exists for.

The stronger vector is already on main and needs no key at all. saml.lua:295 logs args.RelayState on a state mismatch, straight from the query string, unauthenticated and unsigned. saml.lua:443 and the two lines after it log name_id and session_index the same way.

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
Expand Down
5 changes: 4 additions & 1 deletion src/binding.c
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ static char* ERRORS[] = {
"document does not validate against schema",
"invalid signature algorithm",
"signature does not match",
"signature does not cover the message",
};

char* saml_binding_error_msg(saml_binding_status_t status) {
Expand Down Expand Up @@ -303,7 +304,9 @@ saml_binding_status_t saml_binding_post_verify(xmlSecKeysMngr* mngr, xmlDoc* doc
if (res < 0) {
return SAML_XMLSEC_ERROR;
} else if (res == 0) {
confine_identity_to_signature(doc);
if (!bind_identity_to_signature(doc)) {
return SAML_UNSIGNED_IDENTITY;
}
return SAML_OK;
} else {
return SAML_INVALID_SIGNATURE;
Expand Down
30 changes: 30 additions & 0 deletions src/lua_saml.c
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,35 @@ static int doc_issuer(lua_State* L) {
}


/***
Get the issuer of every assertion whose content the document's readers consume
@function doc_issuers
@tparam xmlDoc* doc
@treturn table issuers
*/
static int doc_issuers(lua_State* L) {
lua_settop(L, 1);
xmlDoc* doc = doc_check(L, 1);
lua_pop(L, 1);

xmlChar** issuers;
size_t issuers_len;
if (saml_doc_issuers(doc, &issuers, &issuers_len) < 0) {
lua_pushnil(L);
return 1;
}

lua_newtable(L);
for (size_t i = 0; i < issuers_len; i++) {
lua_pushinteger(L, i + 1);
lua_pushstring(L, issuers[i] == NULL ? "" : (char*)issuers[i]);
lua_settable(L, -3);
}
saml_issuers_free(issuers, issuers_len);
return 1;
}


/***
Get the value of the StatusCode[Value] attribute in the document
@function doc_status_code
Expand Down Expand Up @@ -1160,6 +1189,7 @@ static const struct luaL_Reg saml_funcs[] = {
{"doc_root_name", doc_root_name},
{"doc_id", doc_id},
{"doc_issuer", doc_issuer},
{"doc_issuers", doc_issuers},
{"doc_name_id", doc_name_id},
{"doc_status_code", doc_status_code},
{"doc_session_index", doc_session_index},
Expand Down
3 changes: 3 additions & 0 deletions src/saml.h
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ typedef enum {
SAML_INVALID_DOC,
SAML_INVALID_SIG_ALG,
SAML_INVALID_SIGNATURE,
SAML_UNSIGNED_IDENTITY,
} saml_binding_status_t;

char* saml_binding_error_msg(saml_binding_status_t status);
Expand All @@ -78,6 +79,8 @@ void saml_shutdown();

int saml_doc_validate(xmlDoc* doc);
xmlChar* saml_doc_issuer(xmlDoc* doc);
int saml_doc_issuers(xmlDoc* doc, xmlChar*** issuers, size_t* issuers_len);
void saml_issuers_free(xmlChar** issuers, size_t issuers_len);
xmlChar* saml_doc_name_id(xmlDoc* doc);
xmlChar* saml_doc_status_code(xmlDoc* doc);
xmlChar* saml_doc_session_index(xmlDoc* doc);
Expand Down
34 changes: 19 additions & 15 deletions src/sig.c
Original file line number Diff line number Diff line change
Expand Up @@ -339,28 +339,31 @@ static int signature_covers(xmlDoc* doc, xmlNode* sig, xmlNode* node) {
}


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;
}


// Leave the document with nothing a reader can read that the verified signature
// does not cover, and say whether that was possible at all.
//
// Identity is read from /samlp:Response/saml:Assertion, i.e. only from an
// assertion that is a direct child of the verified root message. saml_verify_doc
// checks one Signature but not that it covers the assertion a reader will pick,
// so remove every top-level assertion that signature leaves out. A signature
// over the whole message covers all of them. The removed nodes are siblings, so
// assertion that is a direct child of the root message. saml_verify_doc checks
// one Signature but not that it covers the assertion a reader will pick, so
// remove every top-level assertion that signature leaves out. A signature over
// the whole message covers all of them. The removed nodes are siblings, so
// freeing one never dangles another.
static void confine_identity_to_signature(xmlDoc* doc) {
//
// A message that carries no assertion has nothing to confine this way, and
// samlp:Extensions takes elements of any other namespace, so a signed assertion
// parked there satisfies saml_verify_doc while the message around it stays the
// sender's to write. Such a message is only trustworthy signed whole.
static int bind_identity_to_signature(xmlDoc* doc) {
xmlNode* root = xmlDocGetRootElement(doc);
if (root == NULL) {
return;
return 0;
}
xmlNode* sig = xmlSecFindNode(root, xmlSecNodeSignature, xmlSecDSigNs);
if (sig != NULL && signature_covers(doc, sig, root)) {
return;
return 1;
}
if (xmlStrEqual(root->name, (const xmlChar*)"Response") != 1) {
return 0;
}
xmlNode* child = root->children;
while (child != NULL) {
Expand All @@ -371,4 +374,5 @@ static void confine_identity_to_signature(xmlDoc* doc) {
}
child = next;
}
return 1;
}
156 changes: 130 additions & 26 deletions src/xml.c
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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. confine_identity_to_signature has exactly one call site, binding.c:306 inside saml_binding_post_verify. The redirect path (saml_binding_redirect_parse / saml_binding_redirect_verify) never prunes, and login_callback accepts GET.

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 saml_doc_issuer/saml_doc_issuers from anywhere other than post_verify, silently loses the property the comment claims.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

bind_identity_to_signature, as it is called after 3939263, still has the single call site in saml_binding_post_verify, and the redirect path is safe because the query-string signature covers the whole message rather than because anything pruned it. Writing both down, and that these accessors assume a document that came through one of the two verify paths, so narrowing either one is visibly what breaks the property.

// 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, this is real. Fixed in 041bb56.

doc_attrs collects from every top-level assertion and doc_name_id takes the first one carrying a subject, so matching a single issuer left a gap: a response signed as a whole could pair an allow-listed first assertion with a second one from an issuer nobody approved, and its attributes would land in the session.

New saml_doc_issuers returns the issuer of every top-level assertion (the message's own for anything that carries none), and the callback now requires all of them to be allow-listed, naming the offending one when it refuses. An assertion with no Issuer is invalid SAML and is listed as an empty string, which no configured issuer matches. doc_issuer still returns the first, which is what the session stores.

t/login-callback.t TEST 5 covers it, TEST 6 the case where the allow-list names both, and t/signed-response.t TEST 21 the accessor. 81 subtests pass; with the single-issuer check restored, TEST 5 is the only thing that fails.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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. login_callback stores sess:set("issuer", saml.doc_issuer(doc)) (saml.lua:330 / 364), which is now the assertion's Issuer, while logout_callback reads the LogoutRequest's own Issuer at saml.lua:436 and compares the two at saml.lua:443.

Any deployment where the Response and Assertion Issuers legitimately differ — a brokering IdP passing an upstream assertion through — starts logging issuer different: on every logout after upgrading, with no config change on their side.

Related: that comparison only warns and then destroys the session anyway, so idp_issuers is enforced on exactly one of the two paths where the message is attacker-supplied.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 saml_doc_issuers has the same branch at line 100.

A LogoutRequest whose only <ds:Signature> sits inside an IdP-signed assertion parked in <samlp:Extensions> passes saml_binding_post_verify: confine_identity_to_signature only sweeps direct-child assertions, so the one in Extensions survives and nothing is removed. Building this branch and running that document through binding_post_parse gives

root=LogoutRequest issuer=https://attacker.example.com name_id=signed@example.com issuers=https://attacker.example.com

The LogoutRequest's own <saml:NameID> was victim@example.comdoc_name_id returned the one from the Extensions assertion instead, because it uses the recursive xmlSecFindNode. So on this branch neither accessor is anchored to signed content.

It doesn't reach idp_issuers today, since only login_callback calls issuers_allowed and the root there is a Response. But the PR makes doc_issuer a trust-bearing accessor and logout_callback already reads it (saml.lua:436), so extending the pin to the logout path — which the framing here invites — would be gating on text the attacker typed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For cross-reference: #36 already records the Extensions assertion shape for doc_name_id, and #34 the unanchored XPaths behind it. The part that's new here is that this PR asserts the invariant in the comment above and makes doc_issuer/doc_issuers trust-bearing on the same unverified branch, so the two now have to be fixed together rather than separately.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 issuer=https://attacker.example.com name_id=attacker@example.com. Fixed in 3939263, which closes #36 as well.

The assumption in that comment is now enforced rather than asserted. bind_identity_to_signature returns whether the document was left with nothing a reader can reach that the signature does not cover, and saml_binding_post_verify refuses when it was not:

  • a Response keeps the existing sweep, since its assertions are what the readers read;
  • any other root carries no assertion to confine, so the signature has to cover the message itself, else SAML_UNSIGNED_IDENTITY ("signature does not cover the message").

That kills the Extensions shape at verification rather than at each accessor, so doc_issuer, doc_issuers and doc_name_id are all anchored by the same rule. On top of it, doc_name_id and doc_session_index now take a LogoutRequest's NameID and SessionIndex from the message itself, which is where the schema puts them, instead of the first one anywhere in the document. Belt and braces once the root has to be signed, but it is what #36 asked for and it folds the duplicated child walks into one ns_child helper.

One behaviour change beyond the logout path: TEST 14's ArtifactResponse, whose only signature sits on a nested assertion, is refused now rather than read as an empty identity. Its expectation moved accordingly.

TESTs 22 and 23 cover both halves. 90 subtests pass; rebuilt against the previous commit's src/ they fail with exactly your output, along with TEST 14.

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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
*issuers_len = i;
return 0;
}


Expand All @@ -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;
}
Expand All @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading