From 1ab4400f057e7e2f736463880fbf77f835a1ae4e Mon Sep 17 00:00:00 2001 From: Roman Janota Date: Wed, 5 Aug 2026 13:53:13 +0200 Subject: [PATCH] session UPDATE add LDAP CRL download support Restrict allowed CURL protocols to HTTP(S) and, if supported by libcurl, LDAP(S) to prevent SSRF via malicious CRL distribution point URIs. Add native LDAP CRL download support by fetching the LDIF response via CURL and parsing it to extract the base64-encoded CRL. The LDIF parser handles curl's tab-indented output, folded continuation lines and CRLF/LF line endings. Only known CRL attribute names (certificateRevocationList, authorityRevocationList, deltaRevocationList, optionally with ;binary per RFC 4523) are accepted, instead of any binary attribute. URL scheme dispatch is case-insensitive per RFC 3986. Fix nc_base64_decode_wrap (OpenSSL) to strip base64 padding from EVP_DecodeBlock's return value, matching the documented contract and the mbedTLS backend. Fixes #604 --- src/session.c | 315 +++++++++++++++++++++++++++++++++++++++++- src/session_openssl.c | 23 ++- 2 files changed, 333 insertions(+), 5 deletions(-) diff --git a/src/session.c b/src/session.c index 72dcce75..7530cbc5 100644 --- a/src/session.c +++ b/src/session.c @@ -1966,6 +1966,61 @@ nc_session_seen_uris_free(char **seen_uris, int seen_count) free(seen_uris); } +/** + * @brief Restrict allowed CURL protocols to prevent SSRF via malicious CRL DP URIs. + * + * Only HTTP(S) and, if supported by the linked libcurl, LDAP(S) are allowed. + * + * @param[in] handle CURL handle to configure. + * @return 0 on success, 1 on failure. + */ +static int +nc_session_curl_set_protocols(CURL *handle) +{ + curl_version_info_data *ver; + int i, have_ldap = 0, have_ldaps = 0; + +#if CURL_AT_LEAST_VERSION(7, 85, 0) + char proto_str[32]; /* "http,https,ldap,ldaps" + NUL */ +#else + long protocols = CURLPROTO_HTTP | CURLPROTO_HTTPS; +#endif + + ver = curl_version_info(CURLVERSION_NOW); + if (ver && ver->protocols) { + for (i = 0; ver->protocols[i]; i++) { + if (!strcmp(ver->protocols[i], "ldap")) { + have_ldap = 1; + } else if (!strcmp(ver->protocols[i], "ldaps")) { + have_ldaps = 1; + } + } + } + +#if CURL_AT_LEAST_VERSION(7, 85, 0) + /* curl 7.85.0+ uses a string-based option to avoid deprecation */ + snprintf(proto_str, sizeof proto_str, "http,https%s%s", + have_ldap ? ",ldap" : "", have_ldaps ? ",ldaps" : ""); + if (curl_easy_setopt(handle, CURLOPT_PROTOCOLS_STR, proto_str)) { + ERR(NULL, "Setting CURLOPT_PROTOCOLS_STR failed."); + return 1; + } +#else + if (have_ldap) { + protocols |= CURLPROTO_LDAP; + } + if (have_ldaps) { + protocols |= CURLPROTO_LDAPS; + } + if (curl_easy_setopt(handle, CURLOPT_PROTOCOLS, protocols)) { + ERR(NULL, "Setting CURLOPT_PROTOCOLS failed."); + return 1; + } +#endif + + return 0; +} + /** * @brief Initialize CURL handle for downloading CRL. * @@ -1998,6 +2053,11 @@ nc_session_curl_init(CURL **handle, struct nc_curl_data *data) return 1; } + /* restrict allowed protocols to prevent SSRF via malicious CRL DP URIs */ + if (nc_session_curl_set_protocols(*handle)) { + return 1; + } + if (curl_easy_setopt(*handle, CURLOPT_WRITEFUNCTION, nc_session_curl_cb)) { ERR(NULL, "Setting curl callback failed."); return 1; @@ -2011,6 +2071,252 @@ nc_session_curl_init(CURL **handle, struct nc_curl_data *data) return 0; } +/** + * @brief Advance past the end of the current LDIF line. + * + * @param[in] line_end Pointer to the end of the line content (after stripping CR). + * @param[in] data_end End of the whole LDIF buffer. + * @return Pointer to the start of the next line, or data_end if at the end. + */ +static char * +nc_session_ldif_next_line(char *line_end, char *data_end) +{ + char *next; + + if (line_end >= data_end) { + return data_end; + } + + /* skip the CR of a CRLF pair */ + if (*line_end == '\r') { + next = line_end + 2; + } else { + next = line_end + 1; + } + + if (next > data_end) { + next = data_end; + } + + return next; +} + +/** + * @brief Check if an LDIF attribute name is a known CRL attribute. + * + * Accepts certificateRevocationList, authorityRevocationList, and + * deltaRevocationList, optionally followed by a transfer option such + * as ';binary' (RFC 4523). + * + * @param[in] name Attribute name (not null-terminated). + * @param[in] len Length of the attribute name. + * @return 1 if it is a CRL attribute, 0 otherwise. + */ +static int +nc_session_ldif_is_crl_attr(const char *name, uint32_t len) +{ + static const char *crl_attrs[] = {"certificateRevocationList", "authorityRevocationList", "deltaRevocationList"}; + uint32_t i, attr_len; + + for (i = 0; i < (sizeof crl_attrs) / (sizeof crl_attrs[0]); i++) { + attr_len = (uint32_t)strlen(crl_attrs[i]); + /* the name must match exactly or be followed by ';' (transfer option) */ + if ((len == attr_len) || ((len > attr_len) && (name[attr_len] == ';'))) { + if (!strncasecmp(name, crl_attrs[i], attr_len)) { + return 1; + } + } + } + + return 0; +} + +/** + * @brief Extract a binary CRL from an LDIF response. + * + * The LDIF data is not null-terminated, so we scan it as a byte buffer. + * We look for an attribute line carrying a known CRL attribute (see + * nc_session_ldif_is_crl_attr) with the '::' binary value marker, collect + * its base64 value (including folded continuation lines starting with a + * space), and decode it to raw DER. + * + * @param[in] ldif LDIF response data. + * @param[out] crl_data Extracted raw CRL data. + * @param[out] crl_size Size of the extracted CRL data. + * @return 0 on success, 1 on failure. + */ +static int +nc_session_ldif_extract_crl(struct nc_curl_data *ldif, unsigned char **crl_data, uint32_t *crl_size) +{ + int ret, rc = 0; + char *line_start, *line_end, *b64_start, *attr_colon, *tmp; + char *b64_buf = NULL; + uint32_t b64_len = 0, b64_cap = 0, line_len; + char *pos, *data_end; + + *crl_data = NULL; + *crl_size = 0; + + if (!ldif->data || !ldif->size) { + return 1; + } + + data_end = (char *)ldif->data + ldif->size; + pos = (char *)ldif->data; + + while (pos < data_end) { + /* find end of current line */ + line_start = pos; + line_end = memchr(pos, '\n', data_end - pos); + if (!line_end) { + line_end = data_end; + } else { + /* strip trailing \r */ + if ((line_end > line_start) && (line_end[-1] == '\r')) { + line_end--; + } + } + line_len = (uint32_t)(line_end - line_start); + + /* strip leading tabs (curl's LDIF output indents attribute lines) */ + while ((line_len > 0) && (line_start[0] == '\t')) { + line_start++; + line_len--; + } + + /* advance pos past the newline for the next iteration */ + pos = nc_session_ldif_next_line(line_end, data_end); + + /* skip empty lines and comments */ + if ((line_len == 0) || (line_start[0] == '#')) { + continue; + } + + /* skip continuation lines (start with space) */ + if (line_start[0] == ' ') { + continue; + } + + /* look for '::' which marks a binary attribute value */ + attr_colon = memchr(line_start, ':', line_len); + if (attr_colon && (attr_colon + 1 < line_end) && (attr_colon[1] == ':')) { + /* check the attribute name is a known CRL attribute */ + if (!nc_session_ldif_is_crl_attr(line_start, (uint32_t)(attr_colon - line_start))) { + continue; + } + + /* found the CRL attribute; b64 starts after ":: " */ + b64_start = attr_colon + 2; + if ((b64_start < line_end) && (b64_start[0] == ' ')) { + b64_start++; + } + + /* collect this first chunk */ + b64_len = (uint32_t)(line_end - b64_start); + b64_cap = b64_len + 1; + b64_buf = malloc(b64_cap); + NC_CHECK_ERRMEM_GOTO(!b64_buf, rc = 1, cleanup); + memcpy(b64_buf, b64_start, b64_len); + b64_buf[b64_len] = '\0'; + + /* now collect continuation lines (folded, start with space) */ + while (pos < data_end) { + if ((pos[0] != ' ') && (pos[0] != '\t')) { + break; + } + + line_start = pos; + line_end = memchr(pos, '\n', data_end - pos); + if (!line_end) { + line_end = data_end; + } else { + if ((line_end > line_start) && (line_end[-1] == '\r')) { + line_end--; + } + } + line_len = (uint32_t)(line_end - line_start); + + /* advance pos */ + pos = nc_session_ldif_next_line(line_end, data_end); + + /* skip leading whitespace (the fold marker) */ + while ((line_len > 0) && ((line_start[0] == ' ') || (line_start[0] == '\t'))) { + line_start++; + line_len--; + } + + if (b64_len + line_len + 1 > b64_cap) { + b64_cap = b64_len + line_len + 1; + tmp = realloc(b64_buf, b64_cap); + NC_CHECK_ERRMEM_GOTO(!tmp, rc = 1, cleanup); + b64_buf = tmp; + } + memcpy(b64_buf + b64_len, line_start, line_len); + b64_len += line_len; + b64_buf[b64_len] = '\0'; + } + + break; + } + } + + if (!b64_buf) { + ERR(NULL, "No binary CRL attribute found in LDIF response."); + rc = 1; + goto cleanup; + } + + /* decode base64 to raw DER */ + ret = nc_base64_decode_wrap(b64_buf, crl_data); + if (ret == -1) { + ERR(NULL, "Decoding base64 CRL data from LDIF failed."); + rc = 1; + goto cleanup; + } + + /* nc_base64_decode_wrap returns the decoded length */ + *crl_size = (uint32_t)ret; + +cleanup: + free(b64_buf); + return rc; +} + +/** + * @brief Fetch a CRL from an LDAP URL via CURL and parse the LDIF response. + * + * @param[in] handle CURL handle. + * @param[in] url LDAP URL to fetch the CRL from. + * @param[in,out] data Stores the downloaded LDIF, then the decoded raw CRL. + * @return 0 on success, 1 on failure. + */ +static int +nc_session_ldap_fetch(CURL *handle, const char *url, struct nc_curl_data *data) +{ + int ret; + unsigned char *crl_data = NULL; + uint32_t crl_size = 0; + + /* fetch via curl, which handles the LDAP protocol natively */ + ret = nc_session_curl_fetch(handle, url); + if (ret) { + return ret; + } + + /* parse the LDIF response and extract the base64-encoded CRL */ + ret = nc_session_ldif_extract_crl(data, &crl_data, &crl_size); + if (ret) { + return ret; + } + + /* replace the LDIF data with the decoded raw CRL */ + free(data->data); + data->data = crl_data; + data->size = crl_size; + + return 0; +} + /** * @brief Download CRLs for certificates in the peer's chain and merge them into crl_store. * @@ -2066,9 +2372,16 @@ nc_session_tls_crl_fetch_for_peer_chain(void *peer_chain, void *cert_store, void } VRB(NULL, "Downloading CRL from \"%s\".", uris[j]); - ret = nc_session_curl_fetch(handle, uris[j]); + if (!strncasecmp(uris[j], "ldap://", 7) || !strncasecmp(uris[j], "ldaps://", 8)) { + ret = nc_session_ldap_fetch(handle, uris[j], &downloaded); + } else { + ret = nc_session_curl_fetch(handle, uris[j]); + } if (ret) { WRN(NULL, "Failed to fetch CRL from \"%s\".", uris[j]); + free(downloaded.data); + downloaded.data = NULL; + downloaded.size = 0; continue; } diff --git a/src/session_openssl.c b/src/session_openssl.c index 77f49607..8a7c8650 100644 --- a/src/session_openssl.c +++ b/src/session_openssl.c @@ -1114,18 +1114,33 @@ nc_tls_is_der_subpubkey_wrap(unsigned char *der, long len) int nc_base64_decode_wrap(const char *base64, unsigned char **bin) { - int ret; + int ret, pad = 0; + uint32_t len; + + len = (uint32_t)strlen(base64); - *bin = malloc((strlen(base64) / 4) * 3); + *bin = malloc((len / 4) * 3); NC_CHECK_ERRMEM_RET(!*bin, -1); - ret = EVP_DecodeBlock(*bin, (const unsigned char *)base64, strlen(base64)); + ret = EVP_DecodeBlock(*bin, (const unsigned char *)base64, len); if (ret == -1) { ERR(NULL, "Base64 decoding failed (%s).", ERR_reason_error_string(ERR_get_error())); free(*bin); *bin = NULL; + return -1; } - return ret; + + /* EVP_DecodeBlock returns a length rounded up to a multiple of 3 and does + * not strip base64 padding, so subtract trailing '=' chars for the real + * decoded length, matching mbedtls_base64_decode behaviour */ + if ((len >= 1) && (base64[len - 1] == '=')) { + pad++; + } + if ((len >= 2) && (base64[len - 2] == '=')) { + pad++; + } + + return ret - pad; } int