diff --git a/README.md b/README.md index 107ee53..297ffb0 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,9 @@ local saml = resty_saml.new(opts) | `logout_redirect_uri` | string | None | redirect uri after sucessful logout. | | `sp_cert` | string | None | SP Certificate, used to sign the saml request. | | `sp_private_key` | string | None | SP private key. | +| `sp_acs_url` | string | built from the request | Absolute URL of this SP's assertion consumer service. It is announced to the IdP, every `SubjectConfirmationData/@Recipient` has to name it, and a `Destination` has to name it on a response carrying one. Unset, it is assembled from the request's scheme and host, which is only as trustworthy as whatever sits in front: set it wherever the ingress does not normalise `Forwarded` and `X-Forwarded-*`, or terminates TLS without setting `X-Forwarded-Proto`. | +| `sp_audiences` | array of strings | `{ sp_issuer }` | Audiences this SP answers to. An assertion carrying an `AudienceRestriction` has to name one of them; an assertion carrying none is unrestricted. | +| `clock_skew` | number | `60` | Seconds of clock difference tolerated against the IdP when weighing `NotBefore` and `NotOnOrAfter`. | #### saml:authenticate() diff --git a/lua/resty/saml.lua b/lua/resty/saml.lua index 8f7469a..df68e6a 100644 --- a/lua/resty/saml.lua +++ b/lua/resty/saml.lua @@ -126,6 +126,15 @@ local function saml_get_redirect_uri(path) return scheme .. "://" .. host .. path end +-- The endpoint the IdP delivers the response to. A configured value wins over +-- the one assembled from request headers, which the requester can steer, and it +-- is what an SP behind a proxy that rewrites neither scheme nor host needs. +-- The same value is announced to the IdP and enforced on the way back, so the +-- two cannot drift. +local function sp_acs_url(opts) + return opts.sp_acs_url or saml_get_redirect_uri(opts.login_callback_uri) +end + local function interp(s, tab) return s:gsub('($%b{})', function(w) local key = w:sub(3, -2) @@ -151,7 +160,7 @@ local AUTHN_REQUEST = [[ local function authn_request(opts) return interp(AUTHN_REQUEST, { - acs_url = saml_get_redirect_uri(opts.login_callback_uri), + acs_url = sp_acs_url(opts), destination = opts.idp_uri, issue_instant = os.date("!%Y-%m-%dT%TZ"), issuer = opts.sp_issuer, @@ -225,9 +234,28 @@ local function login(self, opts) return ngx.redirect(opts.idp_uri .. "?" .. query_str) end +-- Days since 1970-01-01 for a civil date. os.time reads its table as local +-- time, which would shift every SAML timestamp by the machine's offset. +local function days_from_civil(year, month, day) + if month <= 2 then + year = year - 1 + end + local era = math.floor(year / 400) + local year_of_era = year - era * 400 + local day_of_year = math.floor((153 * ((month + 9) % 12) + 2) / 5) + day - 1 + local day_of_era = year_of_era * 365 + math.floor(year_of_era / 4) + - math.floor(year_of_era / 100) + day_of_year + return era * 146097 + day_of_era - 719468 +end + local function parse_iso8601_utc_time(str) -- NOTE: We accept only 'Z' for timezone. - local year_s, month_s, day_s, hour_s, min_s, sec_s = str:match('(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d):(%d%d).*Z') + -- Anchored at both ends, so a year the four-digit field cannot hold is + -- refused rather than read from part way in: xs:dateTime allows a leading + -- minus for BCE, and an unanchored match starts after it and turns 9999 BCE + -- into 9999 CE. A fractional second is read and truncated towards the past. + local year_s, month_s, day_s, hour_s, min_s, sec_s = + str:match('^(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d):(%d%d)%.?%d*Z$') if year_s == nil then return nil, 'invalid UTC time pattern unmatch' end @@ -255,7 +283,147 @@ local function parse_iso8601_utc_time(str) if sec < 0 or 59 < sec then return nil, 'invalid sec in UTC time' end - return os.time{year=year, month=month, day=day, hour=hour, min=min, sec=sec} + return days_from_civil(year, month, day) * 86400 + hour * 3600 + min * 60 + sec +end + + +-- Values lifted out of the IdP's document end up in the error log, which is +-- read a line at a time. XML folds a literal newline inside an attribute to a +-- space, but a character reference survives that, and the Response wrapper is +-- not covered by the signature, so its Destination is whatever the sender +-- typed. Escape rather than trust any of it to stay on one line. +-- Every value read out of a SAML message goes through here on its way to a log, +-- whether or not a signature covers it and whichever message it came from. The +-- rule is the value's origin, not its type: an attribute the schema constrains +-- today is one schema revision away from carrying anything. +local function loggable(value) + return (tostring(value):gsub("%c", function(c) + return string.format("\\x%02X", c:byte()) + end)) +end + + +-- A signature says the message came from the IdP. It does not say the assertion +-- is still good, that it was issued for this SP, or that it may be presented +-- here. Those live in the assertion's own Conditions and SubjectConfirmation, +-- and are checked below. +-- +-- A constraint the IdP left out is not invented: an IdP that sends no +-- AudienceRestriction keeps working. One the IdP did send is enforced, which is +-- what stops an assertion minted for another SP in the same federation. +local DEFAULT_CLOCK_SKEW = 60 + +local function time_bounds_ok(not_before, not_on_or_after, now, skew) + local opens, closes, err + + if not_before then + opens, err = parse_iso8601_utc_time(not_before) + if not opens then + return false, "carries an unreadable NotBefore " .. not_before .. ": " .. err + end + end + + if not_on_or_after then + closes, err = parse_iso8601_utc_time(not_on_or_after) + if not closes then + return false, "carries an unreadable NotOnOrAfter " .. not_on_or_after .. ": " .. err + end + end + + -- A window that opens after it closes is empty on every clock, so the skew + -- allowance has no say in it: without this, each end on its own looks + -- acceptable and an inversion of up to twice the allowance passes. Strictly + -- later rather than not earlier, since a fractional second is truncated away + -- and two instants inside one second read as equal. + if opens and closes and opens > closes then + return false, "opens at " .. not_before .. " and closes at " .. not_on_or_after + end + + if opens and now + skew < opens then + return false, "is not valid before " .. not_before + end + + if closes and now - skew >= closes then + return false, "is not valid on or after " .. not_on_or_after + end + + return true +end + + +local function audience_accepted(accepted, audiences) + for _, audience in ipairs(audiences) do + for _, expected in ipairs(accepted) do + if expected == audience then + return true + end + end + end + return false +end + + +-- The assertion may be presented to whoever the Recipient names, for as long as +-- the confirmation data allows. Several confirmations can be offered and any one +-- of them being satisfiable is enough. +local function confirmation_ok(confirmation, acs_url, now, skew) + -- Recipient is the only thing a confirmation says about where the assertion + -- may be presented, so it has to be there. An absent one, an empty + -- SubjectConfirmationData, and one carrying nothing but conditions that + -- happen to hold all say the same nothing, and any of them would otherwise + -- answer in place of a sibling that binds the assertion somewhere else. + if confirmation.recipient ~= acs_url then + return false + end + return (time_bounds_ok(confirmation.not_before, confirmation.not_on_or_after, now, skew)) +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, acs_url, now) + local skew = opts.clock_skew or DEFAULT_CLOCK_SKEW + local accepted = opts.sp_audiences or { opts.sp_issuer } + + for _, assertion in ipairs(assertions) do + local where = "assertion " .. tostring(assertion.id) .. " " + + -- SAML Core 2.5.1: a condition the SP cannot satisfy leaves the + -- assertion Indeterminate, which is not a licence to use it + if assertion.unknown_condition then + return false, where .. "carries a condition this SP cannot satisfy: " .. + assertion.unknown_condition + 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 + end + + -- each AudienceRestriction narrows the audience separately, so this SP + -- has to be named in all of them + for _, restriction in ipairs(assertion.audience_restrictions) do + if not audience_accepted(accepted, restriction) then + return false, where .. "is restricted to " .. table.concat(restriction, ", ") + end + end + + local confirmations = assertion.subject_confirmations + if #confirmations > 0 then + local satisfiable = false + for _, confirmation in ipairs(confirmations) do + if confirmation_ok(confirmation, acs_url, now, skew) then + satisfiable = true + break + end + end + if not satisfiable then + return false, where .. "offers no subject confirmation this SP can satisfy" + end + end + end + + return true end -- An Issuer is a string in the XML schema, so libxml2 hands back the element @@ -345,13 +513,38 @@ local function login_callback(self, opts) local status_code = saml.doc_status_code(doc) if status_code ~= saml.STATUS_SUCCESS then - ngx.log(ngx.ERR, "IdP returned non-success status: ", status_code) + ngx.log(ngx.ERR, "IdP returned non-success status: ", loggable(status_code)) ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) end local state = args.RelayState if state ~= saml_state then - ngx.log(ngx.ERR, "state different: args.state=", state, ", state=", saml_state) + ngx.log(ngx.ERR, "state different: args.state=", loggable(state), ", state=", saml_state) + ngx.exit(ngx.HTTP_UNAUTHORIZED) + end + + local acs_url = sp_acs_url(opts) + + local destination, destination_err = saml.doc_destination(doc) + if destination_err then + ngx.log(ngx.ERR, "could not read the destination of the response from IdP: ", + destination_err) + ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) + end + if destination and destination ~= acs_url then + ngx.log(ngx.ERR, "response from IdP is addressed to ", loggable(destination)) + ngx.exit(ngx.HTTP_UNAUTHORIZED) + end + + local assertions = saml.doc_assertions(doc) + if not assertions then + ngx.log(ngx.ERR, "could not read the assertions in response from IdP") + ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) + end + + local acceptable, reason = assertions_acceptable(opts, assertions, acs_url, ngx.time()) + if not acceptable then + ngx.log(ngx.ERR, "response from IdP rejected: ", loggable(reason)) ngx.exit(ngx.HTTP_UNAUTHORIZED) end @@ -362,7 +555,7 @@ local function login_callback(self, opts) 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)) + ngx.log(ngx.ERR, "unexpected issuer in response from IdP: ", loggable(unexpected)) ngx.exit(ngx.HTTP_UNAUTHORIZED) end @@ -377,11 +570,15 @@ local function login_callback(self, opts) local expires if session_expires then expires, err = parse_iso8601_utc_time(session_expires) - ngx.log(ngx.INFO, "login callback: session_expires=", os.date("%Y-%m-%d %T %z", expires)) if err then - ngx.say(err) + -- ngx.say would commit the response, leaving ngx.exit unable to set + -- a status and the caller a 200 carrying this string + ngx.log(ngx.ERR, "unreadable SessionNotOnOrAfter ", loggable(session_expires), + " in response from IdP: ", err) ngx.exit(500) end + ngx.log(ngx.INFO, "login callback: session_expires=", + os.date("!%Y-%m-%d %TZ", expires)) end @@ -397,7 +594,7 @@ local function login_callback(self, opts) sess:set("request_uri", nil) sess:save() - ngx.log(ngx.INFO, "login finish: name_id=", name_id) + ngx.log(ngx.INFO, "login finish: name_id=", loggable(name_id)) return ngx.redirect(request_uri) end @@ -469,20 +666,20 @@ local function logout_callback(self, opts) local saved_issuer = sess:get("issuer") if issuer ~= saved_issuer then - ngx.log(ngx.WARN, "issuer different: issuer=", issuer, + ngx.log(ngx.WARN, "issuer different: issuer=", loggable(issuer), ", data.issuer=", saved_issuer) end local saved_name_id = sess:get("name_id") if name_id ~= saved_name_id then - ngx.log(ngx.WARN, "name_id different: name_id=", name_id, + ngx.log(ngx.WARN, "name_id different: name_id=", loggable(name_id), ", data.name_id=", saved_name_id) end local saved_session_index = sess:get("session_index") if session_index ~= saved_session_index then ngx.log(ngx.WARN, "session_index different: session_index=", - session_index, ", data.session_index=", saved_session_index) + loggable(session_index), ", data.session_index=", saved_session_index) end sess:destroy() @@ -502,7 +699,7 @@ local function logout_callback(self, opts) else local status_code = saml.doc_status_code(doc) if status_code ~= saml.STATUS_SUCCESS then - ngx.log(ngx.ERR, "IdP returned non-success status: ", status_code) + ngx.log(ngx.ERR, "IdP returned non-success status: ", loggable(status_code)) ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) end diff --git a/src/binding.c b/src/binding.c index 0f77e6e..b8fa16d 100644 --- a/src/binding.c +++ b/src/binding.c @@ -44,12 +44,24 @@ static char* ERRORS[] = { "invalid signature algorithm", "signature does not match", "signature does not cover the message", + "document carries a document type declaration", }; char* saml_binding_error_msg(saml_binding_status_t status) { return ERRORS[status - SAML_ZLIB_ERROR]; } + +// Canonicalisation drops the document type declaration before anything is +// hashed, so no signature covers one and none is disturbed by one being added. +// Its ATTLIST defaults are still answered to every reader that asks a node for +// an attribute, without ever being written onto the node, so a prologue nobody +// signed supplies attributes the signed content never carried. SAML has no use +// for a DTD, so a message carrying one is refused rather than read. +static int doc_has_dtd(xmlDoc* doc) { + return doc->intSubset != NULL || doc->extSubset != NULL; +} + static void redirect_concat_args(char* saml_type, char* content, char* sig_alg, char* relay_state, str_t* query) { char* content_uri = saml_uri_encode(content); char* sig_alg_uri = saml_uri_encode(sig_alg); @@ -177,6 +189,10 @@ saml_binding_status_t saml_binding_redirect_parse(char* content, char* sig_alg, return SAML_INVALID_XML; } + if (doc_has_dtd(*doc)) { + return SAML_HAS_DTD; + } + if (!saml_doc_validate(*doc)) { return SAML_INVALID_DOC; } @@ -291,6 +307,10 @@ saml_binding_status_t saml_binding_post_parse(char* content, xmlDoc** doc) { return SAML_INVALID_XML; } + if (doc_has_dtd(*doc)) { + return SAML_HAS_DTD; + } + if (!saml_doc_validate(*doc)) { return SAML_INVALID_DOC; } diff --git a/src/lua_saml.c b/src/lua_saml.c index e65a1d2..5286321 100644 --- a/src/lua_saml.c +++ b/src/lua_saml.c @@ -548,6 +548,137 @@ static int doc_attrs(lua_State* L) { } +/*** +Get the Destination attribute of the root message +@function doc_destination +@tparam xmlDoc* doc +@treturn ?string destination +@treturn ?string error +*/ +static int doc_destination(lua_State* L) { + lua_settop(L, 1); + xmlDoc* doc = doc_check(L, 1); + lua_pop(L, 1); + + // nil for an absent Destination and nil for one that could not be read would + // be the same answer to the caller, and it skips the check on the first + xmlChar* destination; + if (saml_doc_destination(doc, &destination) < 0) { + lua_pushnil(L); + lua_pushstring(L, "could not read Destination"); + return 2; + } + + if (destination == NULL) { + lua_pushnil(L); + } else { + lua_pushstring(L, (char*)destination); + xmlFree(destination); + } + lua_pushnil(L); + return 2; +} + + +// An absent attribute is left absent rather than pushed as an empty string, so +// that the caller can tell "the IdP said nothing" from "the IdP said nothing +// useful". +static void set_str_field(lua_State* L, const char* name, const xmlChar* value) { + if (value == NULL) { + return; + } + lua_pushstring(L, name); + lua_pushstring(L, (const char*)value); + lua_settable(L, -3); +} + + +static void set_bool_field(lua_State* L, const char* name, int value) { + lua_pushstring(L, name); + lua_pushboolean(L, value); + lua_settable(L, -3); +} + + +static void push_audience_restrictions(lua_State* L, saml_assertion_t* a) { + lua_pushstring(L, "audience_restrictions"); + lua_newtable(L); + for (size_t i = 0; i < a->audience_restrictions_len; i++) { + saml_audience_restriction_t* restriction = a->audience_restrictions + i; + lua_pushinteger(L, i + 1); + lua_newtable(L); + // a dense index, so an audience with no text leaves no hole for ipairs to + // stop at and shorten the list the assertion declared + int n = 0; + for (size_t j = 0; j < restriction->audiences_len; j++) { + if (restriction->audiences[j] == NULL) { + continue; + } + lua_pushinteger(L, ++n); + lua_pushstring(L, (char*)restriction->audiences[j]); + lua_settable(L, -3); + } + lua_settable(L, -3); + } + lua_settable(L, -3); +} + + +static void push_subject_confirmations(lua_State* L, saml_assertion_t* a) { + lua_pushstring(L, "subject_confirmations"); + lua_newtable(L); + for (size_t i = 0; i < a->confirmations_len; i++) { + saml_subject_confirmation_t* confirmation = a->confirmations + i; + lua_pushinteger(L, i + 1); + lua_newtable(L); + set_str_field(L, "method", confirmation->method); + set_str_field(L, "recipient", confirmation->recipient); + set_str_field(L, "not_before", confirmation->not_before); + set_str_field(L, "not_on_or_after", confirmation->not_on_or_after); + set_str_field(L, "in_response_to", confirmation->in_response_to); + lua_settable(L, -3); + } + lua_settable(L, -3); +} + + +/*** +Get the constraints each top-level assertion of the document attaches to itself +@function doc_assertions +@tparam xmlDoc* doc +@treturn table assertions +*/ +static int doc_assertions(lua_State* L) { + lua_settop(L, 1); + xmlDoc* doc = doc_check(L, 1); + lua_pop(L, 1); + + saml_assertion_t* assertions; + size_t assertions_len; + if (saml_doc_assertions(doc, &assertions, &assertions_len) < 0) { + lua_pushnil(L); + return 1; + } + + lua_newtable(L); + for (size_t i = 0; i < assertions_len; i++) { + saml_assertion_t* a = assertions + i; + lua_pushinteger(L, i + 1); + lua_newtable(L); + set_str_field(L, "id", a->id); + set_bool_field(L, "has_conditions", a->has_conditions); + set_str_field(L, "not_before", a->not_before); + set_str_field(L, "not_on_or_after", a->not_on_or_after); + set_str_field(L, "unknown_condition", a->unknown_condition); + push_audience_restrictions(L, a); + push_subject_confirmations(L, a); + lua_settable(L, -3); + } + saml_assertions_free(assertions, assertions_len); + return 1; +} + + static int get_key_format(lua_State* L, int narg) { #if (LUA_VERSION_NUM > 502) int format = (int)luaL_checkinteger(L, narg); @@ -1195,6 +1326,8 @@ static const struct luaL_Reg saml_funcs[] = { {"doc_session_index", doc_session_index}, {"doc_session_expires", doc_session_expires}, {"doc_attrs", doc_attrs}, + {"doc_assertions", doc_assertions}, + {"doc_destination", doc_destination}, {"key_read_memory", key_read_memory}, {"key_read_file", key_read_file}, diff --git a/src/saml.h b/src/saml.h index d70aa3a..f324fb0 100644 --- a/src/saml.h +++ b/src/saml.h @@ -42,6 +42,31 @@ typedef struct { int num_values; } saml_attr_t; +typedef struct { + xmlChar** audiences; + size_t audiences_len; +} saml_audience_restriction_t; + +typedef struct { + xmlChar* method; + xmlChar* recipient; + xmlChar* not_before; + xmlChar* not_on_or_after; + xmlChar* in_response_to; +} saml_subject_confirmation_t; + +typedef struct { + xmlChar* id; + int has_conditions; + xmlChar* not_before; + xmlChar* not_on_or_after; + xmlChar* unknown_condition; + saml_audience_restriction_t* audience_restrictions; + size_t audience_restrictions_len; + saml_subject_confirmation_t* confirmations; + size_t confirmations_len; +} saml_assertion_t; + typedef enum { SAML_ZLIB_ERROR = -2, SAML_XMLSEC_ERROR, @@ -58,6 +83,7 @@ typedef enum { SAML_INVALID_SIG_ALG, SAML_INVALID_SIGNATURE, SAML_UNSIGNED_IDENTITY, + SAML_HAS_DTD, } saml_binding_status_t; char* saml_binding_error_msg(saml_binding_status_t status); @@ -87,6 +113,9 @@ xmlChar* saml_doc_session_index(xmlDoc* doc); xmlChar* saml_doc_session_expires(xmlDoc* doc); int saml_doc_attrs(xmlDoc* doc, saml_attr_t** attrs, size_t* attrs_len); void saml_attrs_free(saml_attr_t* attrs, size_t attrs_len); +int saml_doc_assertions(xmlDoc* doc, saml_assertion_t** assertions, size_t* assertions_len); +int saml_doc_destination(xmlDoc* doc, xmlChar** destination); +void saml_assertions_free(saml_assertion_t* assertions, size_t assertions_len); xmlSecTransformCtx* saml_sign_binary(xmlSecKey* key, xmlSecTransformId transform_id, unsigned char* data, size_t data_len); int saml_verify_binary(xmlSecKey* cert, xmlSecTransformId transform_id, unsigned char* data, size_t data_len, unsigned char* sig, size_t sig_len); diff --git a/src/xml.c b/src/xml.c index 381980e..15f975b 100644 --- a/src/xml.c +++ b/src/xml.c @@ -351,3 +351,272 @@ void saml_attrs_free(saml_attr_t* attrs, size_t attrs_len) { } free(attrs); } + + +// An absent attribute and one whose value could not be copied both come back +// NULL from xmlGetNoNsProp, and every caller here reads NULL as "the IdP said +// nothing". For a bound or an endpoint that is a constraint quietly dropped, so +// tell the two apart and let the read fail rather than the check. +static int read_attr(xmlNode* node, const char* name, xmlChar** out) { + *out = xmlGetNoNsProp(node, (const xmlChar*)name); + if (*out == NULL && xmlHasNsProp(node, (const xmlChar*)name, NULL) != NULL) { + return -1; + } + return 0; +} + + +// A direct child element of node named name in the assertion namespace. +static int is_assertion_el(xmlNode* node, const char* name) { + return node->type == XML_ELEMENT_NODE && + xmlStrEqual(node->name, (const xmlChar*)name) == 1 && + node->ns != NULL && + xmlStrEqual(node->ns->href, (const xmlChar*)SAML_XMLNS_ASSERTION) == 1; +} + + +static xmlNode* assertion_child(xmlNode* node, const char* name) { + for (xmlNode* child = node->children; child != NULL; child = child->next) { + if (is_assertion_el(child, name)) { + return child; + } + } + return NULL; +} + + +static size_t count_assertion_el(xmlNode* parent, const char* name) { + size_t n = 0; + for (xmlNode* child = parent->children; child != NULL; child = child->next) { + if (is_assertion_el(child, name)) { + n++; + } + } + return n; +} + + +// Conditions this SP can actually satisfy. 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. +static int is_known_condition(xmlNode* node) { + return is_assertion_el(node, "AudienceRestriction") || + is_assertion_el(node, "ProxyRestriction"); +} + + +// Each AudienceRestriction is a separate restriction and the assertion applies +// only where all of them do, so they are kept apart rather than flattened. +static int read_audience_restrictions(xmlDoc* doc, xmlNode* conditions, saml_assertion_t* a) { + size_t count = count_assertion_el(conditions, "AudienceRestriction"); + if (count == 0) { + return 0; + } + + a->audience_restrictions = calloc(count, sizeof(saml_audience_restriction_t)); + if (a->audience_restrictions == NULL) { + return -1; + } + a->audience_restrictions_len = count; + + size_t i = 0; + for (xmlNode* node = conditions->children; node != NULL; node = node->next) { + if (!is_assertion_el(node, "AudienceRestriction")) { + continue; + } + + saml_audience_restriction_t* restriction = a->audience_restrictions + i++; + size_t audiences = count_assertion_el(node, "Audience"); + if (audiences == 0) { + continue; + } + + restriction->audiences = calloc(audiences, sizeof(xmlChar*)); + if (restriction->audiences == NULL) { + return -1; + } + restriction->audiences_len = audiences; + + size_t j = 0; + for (xmlNode* child = node->children; child != NULL; child = child->next) { + if (is_assertion_el(child, "Audience")) { + restriction->audiences[j++] = xmlNodeListGetString(doc, child->children, 1); + } + } + } + return 0; +} + + +static int read_subject_confirmations(xmlNode* subject, saml_assertion_t* a) { + size_t count = count_assertion_el(subject, "SubjectConfirmation"); + if (count == 0) { + return 0; + } + + a->confirmations = calloc(count, sizeof(saml_subject_confirmation_t)); + if (a->confirmations == NULL) { + return -1; + } + a->confirmations_len = count; + + size_t i = 0; + for (xmlNode* node = subject->children; node != NULL; node = node->next) { + if (!is_assertion_el(node, "SubjectConfirmation")) { + continue; + } + + saml_subject_confirmation_t* confirmation = a->confirmations + i++; + if (read_attr(node, "Method", &confirmation->method) < 0) { + return -1; + } + + xmlNode* data = assertion_child(node, "SubjectConfirmationData"); + if (data == NULL) { + continue; + } + if (read_attr(data, "Recipient", &confirmation->recipient) < 0 || + read_attr(data, "NotBefore", &confirmation->not_before) < 0 || + read_attr(data, "NotOnOrAfter", &confirmation->not_on_or_after) < 0 || + read_attr(data, "InResponseTo", &confirmation->in_response_to) < 0) { + return -1; + } + } + return 0; +} + + +static int read_assertion(xmlDoc* doc, xmlNode* node, saml_assertion_t* a) { + if (read_attr(node, "ID", &a->id) < 0) { + return -1; + } + + xmlNode* conditions = assertion_child(node, "Conditions"); + if (conditions != NULL) { + a->has_conditions = 1; + if (read_attr(conditions, "NotBefore", &a->not_before) < 0 || + read_attr(conditions, "NotOnOrAfter", &a->not_on_or_after) < 0) { + return -1; + } + + 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 + // let the condition through rather than fail the read + a->unknown_condition = xmlStrdup(child->name); + if (a->unknown_condition == NULL) { + return -1; + } + break; + } + } + + if (read_audience_restrictions(doc, conditions, a) < 0) { + return -1; + } + } + + xmlNode* subject = assertion_child(node, "Subject"); + if (subject != NULL && read_subject_confirmations(subject, a) < 0) { + return -1; + } + return 0; +} + + +// The constraints every top-level assertion of a Response attaches to itself: +// the validity window, the audiences it is restricted to, and the subject +// confirmations that say where and until when it may be presented. They are +// reported per assertion because they belong to one assertion rather than to +// the document, and a reader consumes several. Messages carrying no assertion +// report none. +int saml_doc_assertions(xmlDoc* doc, saml_assertion_t** assertions, size_t* assertions_len) { + *assertions = NULL; + *assertions_len = 0; + + xmlNode* root = xmlDocGetRootElement(doc); + if (root == NULL || xmlStrEqual(root->name, (const xmlChar*)"Response") != 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; + } + + saml_assertion_t* list = calloc(count, sizeof(saml_assertion_t)); + if (list == NULL) { + return -1; + } + + size_t i = 0; + for (xmlNode* child = root->children; child != NULL; child = child->next) { + if (!is_saml_assertion(child)) { + continue; + } + if (read_assertion(doc, child, list + i++) < 0) { + saml_assertions_free(list, count); + return -1; + } + } + + *assertions = list; + *assertions_len = count; + return 0; +} + + +// The Destination of the root message, through an out parameter so that a value +// which could not be read is distinguishable from one that is absent. The caller +// skips the check on absent, which is the wrong answer for the other. +int saml_doc_destination(xmlDoc* doc, xmlChar** destination) { + *destination = NULL; + + xmlNode* root = xmlDocGetRootElement(doc); + if (root == NULL) { + return 0; + } + return read_attr(root, "Destination", destination); +} + + +void saml_assertions_free(saml_assertion_t* assertions, size_t assertions_len) { + for (size_t i = 0; i < assertions_len; i++) { + saml_assertion_t* a = assertions + i; + xmlFree(a->id); + xmlFree(a->not_before); + xmlFree(a->not_on_or_after); + xmlFree(a->unknown_condition); + + for (size_t j = 0; j < a->audience_restrictions_len; j++) { + saml_audience_restriction_t* restriction = a->audience_restrictions + j; + for (size_t k = 0; k < restriction->audiences_len; k++) { + xmlFree(restriction->audiences[k]); + } + free(restriction->audiences); + } + free(a->audience_restrictions); + + for (size_t j = 0; j < a->confirmations_len; j++) { + saml_subject_confirmation_t* confirmation = a->confirmations + j; + xmlFree(confirmation->method); + xmlFree(confirmation->recipient); + xmlFree(confirmation->not_before); + xmlFree(confirmation->not_on_or_after); + xmlFree(confirmation->in_response_to); + } + free(a->confirmations); + } + free(assertions); +} diff --git a/t/assertion-conditions.t b/t/assertion-conditions.t new file mode 100644 index 0000000..4d48cae --- /dev/null +++ b/t/assertion-conditions.t @@ -0,0 +1,850 @@ +use Test::Nginx::Socket::Lua; + +log_level('info'); +no_long_string(); +repeat_each(1); +no_shuffle(); +plan 'no_plan'; + +my $pwd = `pwd`; +chomp $pwd; + +add_block_preprocessor(sub { + my ($block) = @_; + + if ((!defined $block->error_log) && (!defined $block->no_error_log)) { + $block->set_value("no_error_log", "[error]"); + } + + if (!defined $block->request) { + $block->set_value("request", "GET /t"); + } + + my $main_config = $block->main_config // <<_EOC_; + env SAML_DATA_DIR=./; +_EOC_ + + $block->set_value("main_config", $main_config); + + my $http_config = $block->http_config // <<_EOC_; + lua_package_path '$pwd/lua/?.lua;$pwd/deps/share/lua/5.1/?.lua;$pwd/t/?.lua;;'; + lua_package_cpath '$pwd/?.so;$pwd/deps/lib/lua/5.1/?.so;;'; + + init_by_lua_block { + saml = require "saml" + local err = saml.init({ debug = true, data_dir = os.getenv("SAML_DATA_DIR") }) + if err then assert(nil, err) end + + SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success" + IDP = "https://idp.example.com" + ACS = "http://127.0.0.1:1984/acs" + BEARER = "urn:oasis:names:tc:SAML:2.0:cm:bearer" + + KEY_PEM = [[-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDYYOJFazEru+eF +1bGFzH8xuC2clcWjnpIvXf5Jrseg7gfMh0nMM83OddLWB2Er+RWmVj361qaQR35p +JHGm3hFw20b2S+zBPxA6LCrHJ7vD/kOKEiDKxU3Ls5QK9+fTHFXIbpDtGAuISmmc +eWNaTZPIMdxPlpKYIyNJIUc2RxSREjsGlsrWWEtsroMjxpaHNNupadRUmkHXvZsC +EAsi3penjfZxG6v9R22tBwJxgj/ceXZwtTQJ7tuNtthv+kWP6/Q9owHW3uGL8Bin +46GRqAfHSGC64No+NwETF5iuephkIggtbvrlazTdPwu8Ddl8l4I1QfYmNxKPxnzJ +7pDwvBeRAgMBAAECggEAFkMTjKZcav48cg/cIaK6VGx5XuKm8LBcJHz0cHLHzbYn +vcKOlHChBFSpgkVEmWBZeqFlY5Upkm8Uoa8y9ULkQvsAiE8j9vbszbtlFFPxdNcI +bmBymMIngKWDfgRnCNiht8suZIJkj1tulb+EehJAuehtXQ/mGbqFwxymJb627jzk +MJ5bDsaVeBNu4gBQAp0USzreMO3AN9YxXmcJapZ5Bdc8avQzhzWRxNNJxtp6Uw56 +cviuDxg7OJCaEHhUBFiDVu4O2HmrS/XdYUAwFcRO1hY/JfcaJ3DOHOl6y5eoRHwC +kMb8DhT/qECJ9rWc+APdUqiY1ag0Kq9BcRxkEGlcMQKBgQD32hzAPpuwW9Z0M9qd +x70PPkrJD8jgIprC92DHpHfztiZ2ctH3WxupH7UtZfI8tSVzh7WhWPPtrQ01ZcFh +ZPsFN74c7pWtW+JSm0pvDCQQG5qX9eJLna8GeI6f3hpM+u8pXr6p2ZQJGnjlGZfc +VNfJhvqCVH7hiG9fdAavsH1dKQKBgQDffeUD7x8I3ARbiZqDgANA9HqJi1ffhqFZ +xTWKLtr8NCPS8X+DvFrUDlGhBoDY7IGZhDhmBcb8/v7Kke3GT0/mff8GFsj9TUqh +fgzDxj5I/9HEjBKgpAG1J4B87QYZueLriMfX5Ff2wmCeqCwF4ftfjZVU9izyIa7B +hKYubQBMKQKBgQDslAk1h41cfYzqRkS6rllMH42K9cIsD1viFfcPGXJV8twr29WH +YjO470clGlZqlA43hKZeaGYNzEz7VzGLIbRpepfBTgsY+sfBSfF2pgQWTAL4Yf+r +ZcwXRSP+fSZlrHB08LbVsZWYSuhy5kcKTQHcnzanCLhD1tNYLYvkT3aaYQKBgQDK +c3nMuYUMenn8DceJTaIk6hJCnJZqZsOs1UdtuIooona9NITFag+BPsNVMdXwKzYv +QaXxTVR3g+p8x/pzhQ8lBYfKFUPWqXhsmAmqIt/zMsHr4NNS756YYoMzJ2c6ULgt +ksctW60PW/84WbEfVxll8pSO1T3bzQVISghbz+PQGQKBgQCEptD2bKHhF8RzRyfC +QXydnF7O6GEK3au3OKPb6BsLwJpTP2Wc1feTcg/lzCS5eUhNMxPv+4Ua7SLiF4li +vnI8SyPV2nGlsjna9maSkBq01YrLEMsPPSqw01Nf4W5jtUgk+jbZt9K3SrvTGzpJ +/2lpqvTIUUQTrTJNL6GZUBY1/Q== +-----END PRIVATE KEY-----]] + + CERT_PEM = [[-----BEGIN CERTIFICATE----- +MIIDFTCCAf2gAwIBAgIUC9GZCQFhxDfguRhTjIcG/LxOZMQwDQYJKoZIhvcNAQEL +BQAwGjEYMBYGA1UEAwwPaWRwLmV4YW1wbGUuY29tMB4XDTI2MDgxMDExMDkwNFoX +DTM2MDgwNzExMDkwNFowGjEYMBYGA1UEAwwPaWRwLmV4YW1wbGUuY29tMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2GDiRWsxK7vnhdWxhcx/MbgtnJXF +o56SL13+Sa7HoO4HzIdJzDPNznXS1gdhK/kVplY9+tamkEd+aSRxpt4RcNtG9kvs +wT8QOiwqxye7w/5DihIgysVNy7OUCvfn0xxVyG6Q7RgLiEppnHljWk2TyDHcT5aS +mCMjSSFHNkcUkRI7BpbK1lhLbK6DI8aWhzTbqWnUVJpB172bAhALIt6Xp432cRur +/UdtrQcCcYI/3Hl2cLU0Ce7bjbbYb/pFj+v0PaMB1t7hi/AYp+OhkagHx0hguuDa +PjcBExeYrnqYZCIILW765Ws03T8LvA3ZfJeCNUH2JjcSj8Z8ye6Q8LwXkQIDAQAB +o1MwUTAdBgNVHQ4EFgQUlbLjSTfPYYltgF5anYLJxHTRS/owHwYDVR0jBBgwFoAU +lbLjSTfPYYltgF5anYLJxHTRS/owDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B +AQsFAAOCAQEAjCv57yzpZMReoVJaZor6NGd5kcf8DfI2LLWJ4MGXzq/6kZLYy+Op +M1CxHA2wnxFmqcVmEra0zi2H2PkbM9p3oPK3upPdrL/ke2dIChP1yokaQoW9f2bY +K2INu9LIVuSD8hOUHDXPiH4Smt91V0GfrFHcxysfm97Y+TC+84grwcFE3JiRgfF+ +WYG9w8xaCTTorUKUGum8/5beRd8qNCxVnh4Ke5vaRaUj28MbqLSQp1dvm0cqe+4d +kna+UpbWKQOQ8uAAtFIH+bX2uh8NbCBfATfwEMYzAffGKkmRkkoQHNv0Uf5uIduu +GnHKA3uj9HpsS6fAxHNPPvWxRjO67Xj8Yw== +-----END CERTIFICATE-----]] + + -- one SP per configuration under test, picked by request header + OPTS = { + plain = {}, + skew = { clock_skew = 300 }, + audiences = { sp_audiences = { "https://sp.example.com/metadata" } }, + acs = { sp_acs_url = "http://127.0.0.1:1984/acs" }, + } + SPS = {} + + function sp(name) + if SPS[name] == nil then + local opts = { + sp_issuer = "sp", + idp_uri = "http://127.0.0.1:1984/idp", + login_callback_uri = "/acs", + logout_uri = "/logout", + logout_callback_uri = "/sls", + logout_redirect_uri = "/logout_ok", + sp_cert = CERT_PEM, + sp_private_key = KEY_PEM, + idp_cert = CERT_PEM, + secret = "very-secret-key-that-is-32-byte!", + } + for k, v in pairs(OPTS[name]) do opts[k] = v end + SPS[name] = require("resty.saml").new(opts) + end + return SPS[name] + end + + function sign_doc(xml) + local key = assert(saml.key_read_memory(KEY_PEM, saml.KeyDataFormatPem)) + saml.key_add_cert_memory(key, CERT_PEM, saml.KeyDataFormatCertPem) + local transform = saml.find_transform_by_href( + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256") + local out = assert(saml.sign_xml(key, transform, xml, + { id_attr = "ID", insert_after = { saml.XMLNS_ASSERTION, "Issuer" } })) + return (out:gsub("<%?xml.-%?>%s*", "")) + end + + -- an IdP timestamp this many seconds away from now + function at(offset) + return os.date("!%Y-%m-%dT%TZ", ngx.time() + offset) + end + + function attr(name, value) + if value == nil then return "" end + return string.format(' %s="%s"', name, value) + end + + function audience(...) + local out = {} + for _, name in ipairs({...}) do + out[#out + 1] = "" .. name .. "" + end + return "" .. table.concat(out) .. "" + end + + function conditions(spec) + spec = spec or {} + return string.format('%s', + attr("NotBefore", spec.not_before), attr("NotOnOrAfter", spec.not_on_or_after), + spec.body or "") + end + + function confirmation(spec) + spec = spec or {} + local data = "" + if spec.data ~= false then + data = string.format('', + attr("Recipient", spec.recipient), attr("NotBefore", spec.not_before), + attr("NotOnOrAfter", spec.not_on_or_after)) + end + return string.format('%s', + spec.method or BEARER, data) + end + + function authn_statement(session_expires) + if session_expires == nil then + return "" + end + return string.format('' .. + '' .. + 'urn:oasis:names:tc:SAML:2.0:ac:classes:Password' .. + '', + attr("SessionNotOnOrAfter", session_expires)) + end + + -- Subject, then Conditions, then the statements, the order the schema + -- prescribes + function assertion(spec) + spec = spec or {} + return string.format('' .. + '%s' .. + '%s%s%s%s', + spec.id or "a1", IDP, spec.name_id or "signed\@example.com", + spec.confirmations or "", spec.conditions or "", + authn_statement(spec.session_expires)) + end + + function response(body, destination) + return string.format('%s' .. + '%s', + attr("Destination", destination), IDP, SUCCESS, body) + end + + -- only the assertion is signed, the shape an IdP sends by default + function saml_response(spec, destination) + return response(sign_doc(assertion(spec)), destination) + end + + function callback_headers(name, cookie, extra) + local headers = { + ["X-Test-SP"] = name, + ["Cookie"] = cookie:match("^[^;]+"), + ["Content-Type"] = "application/x-www-form-urlencoded", + } + for k, v in pairs(extra or {}) do headers[k] = v end + return headers + end + + -- start a login, then hand the crafted response back to the callback + -- with the session and RelayState that login handed out + function login_with(name, xml, extra) + local httpc = require("resty.http").new() + local base = "http://127.0.0.1:1984" + local headers = { ["X-Test-SP"] = name } + + local res, err = httpc:request_uri(base .. "/", { headers = headers }) + if not res then return "login request: " .. err end + local cookie = res.headers["Set-Cookie"] + if type(cookie) == "table" then cookie = cookie[1] end + local state = res.headers["Location"]:match("RelayState=([^&]+)") + + res, err = httpc:request_uri(base .. "/acs", { + method = "POST", + body = "SAMLResponse=" .. ngx.escape_uri(saml.base64_encode(xml)) .. + "&RelayState=" .. state, + headers = callback_headers(name, cookie, extra), + }) + if not res then return "callback request: " .. err end + return res.status .. " " .. tostring(res.headers["Location"]) + end + + -- log in, then ask the app again carrying the session the callback + -- handed out: a live session answers 200, an expired one starts over + function session_after_login(name, xml) + local httpc = require("resty.http").new() + local base = "http://127.0.0.1:1984" + local headers = { ["X-Test-SP"] = name } + + local res = assert(httpc:request_uri(base .. "/", { headers = headers })) + local cookie = res.headers["Set-Cookie"] + if type(cookie) == "table" then cookie = cookie[1] end + local state = res.headers["Location"]:match("RelayState=([^&]+)") + + res = assert(httpc:request_uri(base .. "/acs", { + method = "POST", + body = "SAMLResponse=" .. ngx.escape_uri(saml.base64_encode(xml)) .. + "&RelayState=" .. state, + headers = callback_headers(name, cookie), + })) + if res.status ~= 302 then return "callback: " .. res.status end + + local rotated = res.headers["Set-Cookie"] + if type(rotated) == "table" then rotated = rotated[1] end + if rotated then cookie = rotated end + + res = assert(httpc:request_uri(base .. "/", { + headers = { ["X-Test-SP"] = name, ["Cookie"] = cookie:match("^[^;]+") }, + })) + return res.status + end + + function parse(xml) + local key = assert(saml.key_read_memory(CERT_PEM, saml.KeyDataFormatCertPem)) + local mngr = assert(saml.create_keys_manager({ key })) + saml.key_add_ca_memory(mngr, CERT_PEM) + return saml.binding_post_parse(saml.base64_encode(xml), function(_) return mngr end) + end + } + + server { + listen 1984; + + location / { + access_by_lua_block { + sp(ngx.var.http_x_test_sp or "plain"):authenticate() + } + + content_by_lua_block { + ngx.exit(200) + } + } + } +_EOC_ + + $block->set_value("http_config", $http_config); +}); + +run_tests(); + +__DATA__ + +=== TEST 1: an assertion inside its validity window is accepted +--- config + location /t { + content_by_lua_block { + ngx.say(login_with("plain", saml_response({ + conditions = conditions({ not_before = at(-60), not_on_or_after = at(600) }), + }))) + } + } +--- response_body +302 / + + + +=== TEST 2: an expired assertion is refused however it is replayed +--- config + location /t { + content_by_lua_block { + ngx.say(login_with("plain", saml_response({ + conditions = conditions({ not_before = at(-7200), not_on_or_after = at(-3600) }), + }))) + } + } +--- response_body +401 nil +--- error_log +is not valid on or after + + + +=== TEST 3: an assertion whose window has not opened is refused +--- config + location /t { + content_by_lua_block { + ngx.say(login_with("plain", saml_response({ + conditions = conditions({ not_before = at(3600), not_on_or_after = at(7200) }), + }))) + } + } +--- response_body +401 nil +--- error_log +is not valid before + + + +=== TEST 4: the clock skew allowance covers a small difference with the IdP +--- config + location /t { + content_by_lua_block { + local spec = { conditions = conditions({ not_on_or_after = at(-120) }) } + ngx.say(login_with("plain", saml_response(spec))) + ngx.say(login_with("skew", saml_response(spec))) + } + } +--- response_body +401 nil +302 / +--- error_log +is not valid on or after + + + +=== TEST 5: an assertion restricted to another SP is refused +--- config + location /t { + content_by_lua_block { + -- what an IdP serving a federation mints for a different SP + ngx.say(login_with("plain", saml_response({ + conditions = conditions({ body = audience("https://other.example.com") }), + }))) + } + } +--- response_body +401 nil +--- error_log +is restricted to https://other.example.com + + + +=== TEST 6: an assertion restricted to this SP is accepted +--- config + location /t { + content_by_lua_block { + ngx.say(login_with("plain", saml_response({ + conditions = conditions({ body = audience("https://other.example.com", "sp") }), + }))) + } + } +--- response_body +302 / + + + +=== TEST 7: sp_audiences names the audience the IdP was configured with +--- config + location /t { + content_by_lua_block { + local spec = { + conditions = conditions({ body = audience("https://sp.example.com/metadata") }), + } + ngx.say(login_with("plain", saml_response(spec))) + ngx.say(login_with("audiences", saml_response(spec))) + } + } +--- response_body +401 nil +302 / +--- error_log +is restricted to https://sp.example.com/metadata + + + +=== TEST 8: each AudienceRestriction narrows the audience on its own +--- config + location /t { + content_by_lua_block { + -- named in the first restriction, left out of the second + ngx.say(login_with("plain", saml_response({ + conditions = conditions({ + body = audience("sp") .. audience("https://other.example.com"), + }), + }))) + } + } +--- response_body +401 nil +--- error_log +is restricted to https://other.example.com + + + +=== TEST 9: a confirmation addressed to another endpoint is refused +--- config + location /t { + content_by_lua_block { + ngx.say(login_with("plain", saml_response({ + confirmations = confirmation({ recipient = "http://evil.example.com/acs" }), + }))) + } + } +--- response_body +401 nil +--- error_log +offers no subject confirmation this SP can satisfy + + + +=== TEST 10: a confirmation addressed here and still open is accepted +--- config + location /t { + content_by_lua_block { + ngx.say(login_with("plain", saml_response({ + confirmations = confirmation({ recipient = ACS, not_on_or_after = at(300) }), + }))) + } + } +--- response_body +302 / + + + +=== TEST 11: a confirmation that has run out is refused +--- config + location /t { + content_by_lua_block { + ngx.say(login_with("plain", saml_response({ + confirmations = confirmation({ recipient = ACS, not_on_or_after = at(-3600) }), + }))) + } + } +--- response_body +401 nil +--- error_log +offers no subject confirmation this SP can satisfy + + + +=== TEST 12: one satisfiable confirmation among several is enough +--- config + location /t { + content_by_lua_block { + ngx.say(login_with("plain", saml_response({ + confirmations = confirmation({ recipient = "http://evil.example.com/acs" }) .. + confirmation({ recipient = ACS, not_on_or_after = at(300) }), + }))) + } + } +--- response_body +302 / + + + +=== TEST 13: a condition this SP cannot satisfy leaves the assertion indeterminate +--- config + location /t { + content_by_lua_block { + -- ProxyRestriction binds the IdP, not this SP, so it is satisfied + ngx.say(login_with("plain", saml_response({ + conditions = conditions({ body = "" }), + }))) + -- OneTimeUse asks this SP to remember which assertions it has spent + ngx.say(login_with("plain", saml_response({ + conditions = conditions({ body = "" }), + }))) + -- and a condition it has never heard of asks who knows what + ngx.say(login_with("plain", saml_response({ + conditions = conditions({ + body = 'sp', + }), + }))) + } + } +--- response_body +302 / +401 nil +401 nil +--- error_log eval +[qr/carries a condition this SP cannot satisfy: OneTimeUse/, +qr/carries a condition this SP cannot satisfy: Condition/] + + + +=== TEST 14: a response addressed to another endpoint is refused +--- config + location /t { + content_by_lua_block { + ngx.say(login_with("plain", saml_response({}, "http://evil.example.com/acs"))) + ngx.say(login_with("plain", saml_response({}, ACS))) + } + } +--- response_body +401 nil +302 / +--- error_log +response from IdP is addressed to http://evil.example.com/acs + + + +=== TEST 15: an assertion carrying no constraints is still accepted +--- config + location /t { + content_by_lua_block { + ngx.say(login_with("plain", saml_response({}))) + } + } +--- response_body +302 / + + + +=== TEST 16: the constraints are reported per assertion, not pooled +--- config + location /t { + content_by_lua_block { + local xml = sign_doc(response( + assertion({ id = "a1", conditions = conditions({ not_on_or_after = "2026-07-21T00:00:00Z", + body = audience("sp") }) }) .. + assertion({ id = "a2", name_id = "second@example.com", + confirmations = confirmation({ recipient = ACS }) }))) + local doc, err = parse(xml) + if err then ngx.say("err: ", err) return end + + for _, a in ipairs(saml.doc_assertions(doc)) do + ngx.say(a.id, " conditions=", tostring(a.has_conditions), + " expires=", tostring(a.not_on_or_after), + " audiences=", #a.audience_restrictions, + " confirmations=", #a.subject_confirmations) + end + ngx.say("destination: ", tostring(saml.doc_destination(doc))) + } + } +--- response_body +a1 conditions=true expires=2026-07-21T00:00:00Z audiences=1 confirmations=0 +a2 conditions=false expires=nil audiences=0 confirmations=1 +destination: nil + + + +=== TEST 17: a UTC timestamp is read as UTC whatever the machine's timezone is +--- config + location /t { + content_by_lua_block { + -- an assertion good for another hour, with the worker fourteen + -- hours ahead of UTC: read as local time it would already have run + -- out + ngx.say(login_with("plain", saml_response({ + conditions = conditions({ not_before = at(-60), not_on_or_after = at(3600) }), + }))) + } + } +--- main_config +env SAML_DATA_DIR=./; +env TZ=XXX-14; +--- response_body +302 / + + + +=== TEST 18: a configured ACS URL settles what the endpoint checks compare against +--- config + location /t { + content_by_lua_block { + local elsewhere = saml_response({ + confirmations = confirmation({ recipient = "https://sp.example.com/acs" }), + }) + local here = saml_response({ confirmations = confirmation({ recipient = ACS }) }) + local forged = { + ["X-Forwarded-Proto"] = "https", + ["X-Forwarded-Host"] = "sp.example.com", + } + + -- assembled from the request, the endpoint moves with the headers + ngx.say(login_with("plain", elsewhere, forged)) + -- configured, it stays where the deployment put it + ngx.say(login_with("acs", elsewhere, forged)) + -- and headers that disagree cannot refuse an assertion that names it + ngx.say(login_with("acs", here, forged)) + + -- the same value settles Destination, which is read on a response + -- carrying no confirmation to weigh + local addressed = saml_response({}, "https://sp.example.com/acs") + ngx.say(login_with("plain", addressed, forged)) + ngx.say(login_with("acs", addressed, forged)) + } + } +--- response_body +302 / +401 nil +302 / +302 / +401 nil +--- error_log eval +[qr/offers no subject confirmation this SP can satisfy/, +qr{addressed to https://sp\.example\.com/acs}] + + + +=== TEST 19: an audience with no text leaves the rest of its restriction readable +--- config + location /t { + content_by_lua_block { + ngx.say(login_with("plain", saml_response({ + conditions = conditions({ body = "" .. + "sp" .. + "" }), + }))) + } + } +--- response_body +302 / + + + +=== TEST 20: a confirmation that states nothing confirms nothing +--- config + location /t { + content_by_lua_block { + local elsewhere = confirmation({ recipient = "https://evil.example.com/acs" }) + + -- no SubjectConfirmationData at all + ngx.say(login_with("plain", saml_response({ + confirmations = confirmation({ data = false }), + }))) + -- the element written out with nothing in it, which the schema + -- allows since every attribute on it is optional + ngx.say(login_with("plain", saml_response({ + confirmations = confirmation({}), + }))) + -- and neither spelling may answer in place of a sibling that binds + -- the assertion somewhere else + ngx.say(login_with("plain", saml_response({ + confirmations = elsewhere .. confirmation({ data = false }), + }))) + ngx.say(login_with("plain", saml_response({ + confirmations = elsewhere .. confirmation({}), + }))) + -- nor may one carrying only a condition that happens to hold + ngx.say(login_with("plain", saml_response({ + confirmations = elsewhere .. confirmation({ not_before = at(-600) }), + }))) + } + } +--- response_body +401 nil +401 nil +401 nil +401 nil +401 nil +--- error_log +offers no subject confirmation this SP can satisfy + + + +=== TEST 21: session lifetime follows the IdP's clock, not the machine's +--- config + location /t { + content_by_lua_block { + -- ten minutes of session left, with the worker fourteen hours + -- ahead of UTC: read as local time it would already be spent + ngx.say(session_after_login("plain", saml_response({ session_expires = at(600) }))) + -- and ten minutes past, which is spent either way + ngx.say(session_after_login("plain", saml_response({ session_expires = at(-600) }))) + } + } +--- main_config +env SAML_DATA_DIR=./; +env TZ=XXX-14; +--- response_body +200 +302 + + + +=== TEST 22: a newline smuggled into the response cannot split a log line +--- config + location /t { + content_by_lua_block { + -- Destination rides the unsigned wrapper, so it is the sender's to + -- write, and a character reference is not folded to a space the way + -- a literal newline would be + ngx.say(login_with("plain", saml_response({}, "https://x WARNING-forged-entry"))) + } + } +--- response_body +401 nil +--- error_log +addressed to https://x\x0AWARNING-forged-entry + + + +=== TEST 23: a year the parser cannot hold is refused, not read from part way in +--- config + location /t { + content_by_lua_block { + -- 9999 BCE, which the schema accepts: unanchored, the match starts + -- after the minus and reads a far-future 9999 CE out of an + -- already-expired bound + ngx.say(login_with("plain", saml_response({ + conditions = conditions({ not_on_or_after = "-9999-01-01T00:00:00Z" }), + }))) + -- five digits, where the match can start one character in + ngx.say(login_with("plain", saml_response({ + conditions = conditions({ not_on_or_after = "20260-01-01T00:00:00Z" }), + }))) + -- a fractional second is still read + ngx.say(login_with("plain", saml_response({ + conditions = conditions({ not_on_or_after = (at(600):gsub("Z", ".500Z")) }), + }))) + } + } +--- response_body +401 nil +401 nil +302 / +--- error_log +carries an unreadable NotOnOrAfter -9999-01-01T00:00:00Z + + + +=== TEST 24: an unreadable session expiry is an error, not a 200 carrying one +--- config + location /t { + content_by_lua_block { + local httpc = require("resty.http").new() + local base = "http://127.0.0.1:1984" + + local res = assert(httpc:request_uri(base .. "/", { headers = { ["X-Test-SP"] = "plain" } })) + local cookie = res.headers["Set-Cookie"] + if type(cookie) == "table" then cookie = cookie[1] end + local state = res.headers["Location"]:match("RelayState=([^&]+)") + + -- schema-valid xs:dateTime this parser will not read: a numeric + -- offset where SAML requires Z + local xml = saml_response({ session_expires = "2030-01-01T00:00:00+00:00" }) + res = assert(httpc:request_uri(base .. "/acs", { + method = "POST", + body = "SAMLResponse=" .. ngx.escape_uri(saml.base64_encode(xml)) .. + "&RelayState=" .. state, + headers = callback_headers("plain", cookie), + })) + -- the status the branch always meant to send, and the parse + -- error kept out of the body it used to be written into + ngx.say(res.status, " leaks=", + tostring(((res.body or ""):find("UTC time", 1, true)) ~= nil)) + } + } +--- response_body +500 leaks=false +--- error_log +unreadable SessionNotOnOrAfter 2030-01-01T00:00:00+00:00 + + + +=== TEST 25: a window that opens after it closes is empty on every clock +--- config + location /t { + content_by_lua_block { + -- inverted by less than twice the skew allowance, so each end taken + -- on its own still looks acceptable + ngx.say(login_with("plain", saml_response({ + conditions = conditions({ not_before = at(30), not_on_or_after = at(-30) }), + }))) + -- the same shape on a subject confirmation, which shares the check + ngx.say(login_with("plain", saml_response({ + confirmations = confirmation({ recipient = ACS, + not_before = at(30), not_on_or_after = at(-30) }), + }))) + -- both ends inside one second still reads as open, since the + -- fractional part is truncated away before they are compared + ngx.say(login_with("plain", saml_response({ + conditions = conditions({ not_before = at(0), not_on_or_after = at(0) }), + }))) + } + } +--- response_body +401 nil +401 nil +302 / +--- error_log eval +[qr/opens at .* and closes at /, +qr/offers no subject confirmation this SP can satisfy/] + + + +=== TEST 26: a document type declaration is refused, whatever it declares +--- config + location /t { + content_by_lua_block { + -- an ATTLIST default answers every reader that asks the node for an + -- attribute, without ever being written onto the node, and + -- canonicalisation drops the prologue before anything is hashed. So + -- this invents a Recipient inside signed content while leaving every + -- signature intact, whichever scope it covers + local doctype = ']>' + local body = assertion({ confirmations = confirmation({}) }) + + ngx.say(login_with("plain", doctype .. response(sign_doc(body)))) + ngx.say(login_with("plain", doctype .. sign_doc(response(body)))) + -- and the same documents without the prologue, which carry no + -- Recipient of their own + ngx.say(login_with("plain", response(sign_doc(body)))) + } + } +--- response_body +400 nil +400 nil +401 nil +--- error_log eval +[qr/parse post from IdP: document carries a document type declaration/, +qr/offers no subject confirmation this SP can satisfy/]