merchant

Merchant backend to process payments, run by merchants
Log | Files | Refs | Submodules | README | LICENSE

commit bdcb27bda1f614e07e4aa3cc23604cafafbef94d
parent 0f7de2887b1acb3d6cce8332c95f5fbf2ed39074
Author: Christian Grothoff <grothoff@gnunet.org>
Date:   Thu, 29 Jan 2026 15:17:42 +0900

total overkill fix for #10937: email and phone number validation and normalization in merchant backend

Diffstat:
Msrc/backend/taler-merchant-httpd_private-patch-instances-ID.c | 37+++++++++++++++++++++++++++++++++----
Msrc/backend/taler-merchant-httpd_private-post-instances.c | 43++++++++++++++++++++++++++++++++++++++++---
Msrc/include/taler_merchant_util.h | 26++++++++++++++++++++++++++
Msrc/util/Makefile.am | 9++++++++-
Msrc/util/validators.c | 245++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
5 files changed, 351 insertions(+), 9 deletions(-)

diff --git a/src/backend/taler-merchant-httpd_private-patch-instances-ID.c b/src/backend/taler-merchant-httpd_private-patch-instances-ID.c @@ -69,6 +69,7 @@ patch_instances_ID (struct TMH_MerchantInstance *mi, const char *name; struct TMH_WireMethod *wm_head = NULL; struct TMH_WireMethod *wm_tail = NULL; + const char *iphone = NULL; bool no_transfer_delay; bool no_pay_delay; bool no_refund_delay; @@ -85,7 +86,7 @@ patch_instances_ID (struct TMH_MerchantInstance *mi, NULL), GNUNET_JSON_spec_mark_optional ( GNUNET_JSON_spec_string ("phone_number", - (const char **) &is.phone), + &iphone), NULL), GNUNET_JSON_spec_mark_optional ( GNUNET_JSON_spec_string ("logo", @@ -196,7 +197,31 @@ patch_instances_ID (struct TMH_MerchantInstance *mi, TALER_EC_GENERIC_PARAMETER_MALFORMED, "default_wire_transfer_delay"); } - + if (NULL != iphone) + { + is.phone = TALER_MERCHANT_phone_validate_normalize (iphone, + false); + if (NULL == is.phone) + { + GNUNET_break_op (0); + GNUNET_JSON_parse_free (spec); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "phone_number"); + } + } + if ( (NULL != is.email) && + (! TALER_MERCHANT_email_valid (is.email)) ) + { + GNUNET_break_op (0); + GNUNET_JSON_parse_free (spec); + GNUNET_free (is.phone); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "email"); + } if ( (NULL != is.phone) && (NULL != mi->settings.phone) && (0 == strcmp (mi->settings.phone, @@ -218,6 +243,7 @@ patch_instances_ID (struct TMH_MerchantInstance *mi, { GNUNET_break_op (0); GNUNET_JSON_parse_free (spec); + GNUNET_free (is.phone); return TALER_MHD_reply_with_error ( connection, MHD_HTTP_BAD_REQUEST, @@ -230,6 +256,7 @@ patch_instances_ID (struct TMH_MerchantInstance *mi, { GNUNET_break_op (0); GNUNET_JSON_parse_free (spec); + GNUNET_free (is.phone); return TALER_MHD_reply_with_error ( connection, MHD_HTTP_BAD_REQUEST, @@ -307,6 +334,7 @@ patch_instances_ID (struct TMH_MerchantInstance *mi, if (GNUNET_OK != ret) { GNUNET_JSON_parse_free (spec); + GNUNET_free (is.phone); return (GNUNET_NO == ret) ? MHD_YES : MHD_NO; @@ -332,6 +360,7 @@ patch_instances_ID (struct TMH_MerchantInstance *mi, "PATCH /instances")) { GNUNET_JSON_parse_free (spec); + GNUNET_free (is.phone); return TALER_MHD_reply_with_error (connection, MHD_HTTP_INTERNAL_SERVER_ERROR, TALER_EC_GENERIC_DB_START_FAILED, @@ -404,8 +433,8 @@ giveup: mi->settings.name = GNUNET_strdup (name); if (NULL != is.email) mi->settings.email = GNUNET_strdup (is.email); - if (NULL != is.phone) - mi->settings.phone = GNUNET_strdup (is.phone); + mi->settings.phone = is.phone; + is.phone = NULL; if (NULL != is.website) mi->settings.website = GNUNET_strdup (is.website); if (NULL != is.logo) diff --git a/src/backend/taler-merchant-httpd_private-post-instances.c b/src/backend/taler-merchant-httpd_private-post-instances.c @@ -65,6 +65,7 @@ post_instances (const struct TMH_RequestHandler *rh, struct TMH_WireMethod *wm_head = NULL; struct TMH_WireMethod *wm_tail = NULL; const json_t *jauth; + const char *iphone = NULL; bool no_pay_delay; bool no_refund_delay; bool no_transfer_delay; @@ -80,7 +81,7 @@ post_instances (const struct TMH_RequestHandler *rh, NULL), GNUNET_JSON_spec_mark_optional ( GNUNET_JSON_spec_string ("phone_number", - (const char **) &is.phone), + &iphone), NULL), GNUNET_JSON_spec_mark_optional ( GNUNET_JSON_spec_string ("website", @@ -165,6 +166,31 @@ post_instances (const struct TMH_RequestHandler *rh, TALER_EC_GENERIC_PARAMETER_MALFORMED, "default_wire_transfer_delay"); } + if (NULL != iphone) + { + is.phone = TALER_MERCHANT_phone_validate_normalize (iphone, + false); + if (NULL == is.phone) + { + GNUNET_break_op (0); + GNUNET_JSON_parse_free (spec); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "phone_number"); + } + } + if ( (NULL != is.email) && + (! TALER_MERCHANT_email_valid (is.email)) ) + { + GNUNET_break_op (0); + GNUNET_JSON_parse_free (spec); + GNUNET_free (is.phone); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "email"); + } { enum GNUNET_GenericReturnValue ret; @@ -174,6 +200,7 @@ post_instances (const struct TMH_RequestHandler *rh, &auth_password); if (GNUNET_OK != ret) { + GNUNET_free (is.phone); GNUNET_JSON_parse_free (spec); return (GNUNET_NO == ret) ? MHD_YES : MHD_NO; } @@ -201,6 +228,7 @@ post_instances (const struct TMH_RequestHandler *rh, if (! id_wellformed) { GNUNET_JSON_parse_free (spec); + GNUNET_free (is.phone); return TALER_MHD_reply_with_error (connection, MHD_HTTP_BAD_REQUEST, TALER_EC_GENERIC_PARAMETER_MALFORMED, @@ -212,6 +240,7 @@ post_instances (const struct TMH_RequestHandler *rh, { GNUNET_break_op (0); GNUNET_JSON_parse_free (spec); + GNUNET_free (is.phone); return TALER_MHD_reply_with_error (connection, MHD_HTTP_BAD_REQUEST, TALER_EC_GENERIC_PARAMETER_MALFORMED, @@ -222,6 +251,7 @@ post_instances (const struct TMH_RequestHandler *rh, { GNUNET_break_op (0); GNUNET_JSON_parse_free (spec); + GNUNET_free (is.phone); return TALER_MHD_reply_with_error (connection, MHD_HTTP_BAD_REQUEST, TALER_EC_GENERIC_PARAMETER_MALFORMED, @@ -233,6 +263,7 @@ post_instances (const struct TMH_RequestHandler *rh, { GNUNET_break_op (0); GNUNET_JSON_parse_free (spec); + GNUNET_free (is.phone); return TALER_MHD_reply_with_error (connection, MHD_HTTP_BAD_REQUEST, TALER_EC_GENERIC_PARAMETER_MALFORMED, @@ -249,6 +280,7 @@ post_instances (const struct TMH_RequestHandler *rh, if (mi->deleted) { GNUNET_JSON_parse_free (spec); + GNUNET_free (is.phone); return TALER_MHD_reply_with_error (connection, MHD_HTTP_CONFLICT, TALER_EC_MERCHANT_PRIVATE_POST_INSTANCES_PURGE_REQUIRED, @@ -295,6 +327,7 @@ post_instances (const struct TMH_RequestHandler *rh, is.default_refund_delay)) ) { GNUNET_JSON_parse_free (spec); + GNUNET_free (is.phone); return TALER_MHD_reply_static (connection, MHD_HTTP_NO_CONTENT, NULL, @@ -302,6 +335,7 @@ post_instances (const struct TMH_RequestHandler *rh, 0); } GNUNET_JSON_parse_free (spec); + GNUNET_free (is.phone); return TALER_MHD_reply_with_error (connection, MHD_HTTP_CONFLICT, TALER_EC_MERCHANT_PRIVATE_POST_INSTANCES_ALREADY_EXISTS, @@ -319,6 +353,7 @@ post_instances (const struct TMH_RequestHandler *rh, { GNUNET_break_op (0); GNUNET_JSON_parse_free (spec); + GNUNET_free (is.phone); /* does nothing... */ return TALER_MHD_reply_with_error (connection, MHD_HTTP_BAD_REQUEST, TALER_EC_GENERIC_PARAMETER_MISSING, @@ -330,6 +365,7 @@ post_instances (const struct TMH_RequestHandler *rh, { GNUNET_break_op (0); GNUNET_JSON_parse_free (spec); + GNUNET_free (is.phone); return TALER_MHD_reply_with_error (connection, MHD_HTTP_BAD_REQUEST, TALER_EC_GENERIC_PARAMETER_MISSING, @@ -375,6 +411,7 @@ post_instances (const struct TMH_RequestHandler *rh, if (GNUNET_OK != ret) { GNUNET_JSON_parse_free (spec); + GNUNET_free (is.phone); return (GNUNET_NO == ret) ? MHD_YES : MHD_NO; @@ -414,8 +451,8 @@ post_instances (const struct TMH_RequestHandler *rh, mi->settings.name = GNUNET_strdup (is.name); if (NULL != is.email) mi->settings.email = GNUNET_strdup (is.email); - if (NULL != is.phone) - mi->settings.phone = GNUNET_strdup (is.phone); + mi->settings.phone = is.phone; + is.phone = NULL; if (NULL != is.website) mi->settings.website = GNUNET_strdup (is.website); if (NULL != is.logo) diff --git a/src/include/taler_merchant_util.h b/src/include/taler_merchant_util.h @@ -142,6 +142,32 @@ TALER_MERCHANT_image_data_url_valid (const char *image_data_url); /** + * Check if @a email is a valid email address. + * + * FIXME: move to libgnunetutil? + * + * @param email string to check + * @return true if @a email is a valid e-mail address + */ +bool +TALER_MERCHANT_email_valid (const char *email); + + +/** + * Check if @a email is a valid international phone number + * with "+CC" prefix. + * + * @param phone phone number to check + * @param allow_letters to allow "A-Z" letters representing digits + * @return normalized phone number if @a phone is valid, + * NULL if @a phone is not a phone number + */ +char * +TALER_MERCHANT_phone_validate_normalize (const char *phone, + bool allow_letters); + + +/** * Channel used to transmit MFA authorization request. */ enum TALER_MERCHANT_MFA_Channel diff --git a/src/util/Makefile.am b/src/util/Makefile.am @@ -19,7 +19,8 @@ bin_PROGRAMS = \ AM_TESTS_ENVIRONMENT=export TALER_PREFIX=$${TALER_PREFIX:-@libdir@};export PATH=$${TALER_PREFIX:-@prefix@}/bin:$$PATH; check_PROGRAMS = \ - test_contract + test_contract \ + test_validators TESTS = \ $(check_PROGRAMS) @@ -63,3 +64,9 @@ test_contract_SOURCES = \ test_contract_LDADD = \ -lgnunetutil \ libtalermerchantutil.la + +test_validators_SOURCES = \ + test_contract.c +test_validators_LDADD = \ + -lgnunetutil \ + libtalermerchantutil.la diff --git a/src/util/validators.c b/src/util/validators.c @@ -23,7 +23,7 @@ #include <gnunet/gnunet_db_lib.h> #include <taler/taler_json_lib.h> #include "taler_merchant_util.h" - +#include <regex.h> bool TALER_MERCHANT_image_data_url_valid (const char *image_data_url) @@ -51,3 +51,246 @@ TALER_MERCHANT_image_data_url_valid (const char *image_data_url) } return true; } + + +bool +TALER_MERCHANT_email_valid (const char *email) +{ + regex_t regex; + bool is_valid; + + if ('\0' == email[0]) + return false; + + /* Maximum email length per RFC 5321 */ + if (strlen (email) > 254) + return false; + + /* + * Email regex pattern supporting: + * + * Local part (before @): + * - Dot-atom: alphanumeric, dots, hyphens, underscores + * (no leading/trailing dots, no consecutive dots) + * - Quoted-string: quoted text with escaped chars inside + * + * Domain part (after @): + * - Domain labels: alphanumeric and hyphens + * (no leading/trailing hyphens per label) + * - IP literals: [IPv4] or [IPv6:...] + * + * Pattern breakdown: + * Local part: + * ([a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+ + * (\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*) + * = dot-atom (atext chars, dots allowed between parts) + * + * |"([^"\\]|\\.)*" + * = quoted-string (anything in quotes with escaping) + * + * Domain part: + * ([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])? + * (\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*) + * = domain labels (63 chars max, hyphens in middle) + * + * |\[([0-9]{1,3}\.){3}[0-9]{1,3}\] + * = IPv4 literal + * + * |\[IPv6:[0-9a-fA-F:]+\] + * = IPv6 literal + */ + const char *pattern = + "^(" + /* Local part: dot-atom-text or quoted-string */ + "([a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(\\.)?)*[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+" + "|" + "\"([^\"\\\\]|\\\\.)*\"" + ")" + "@" + "(" + /* Domain: domain labels (with at least one dot) or IP literal */ + "([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+)" + "|" + "\\[((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}" + "([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\\]" + "|" + "\\[IPv6:[0-9a-fA-F:]*[0-9a-fA-F]\\]" + ")$"; + + GNUNET_assert (0 == + regcomp (&regex, + pattern, + REG_EXTENDED | REG_NOSUB)); + is_valid = (0 == + regexec (&regex, + email, + 0, + NULL, + 0)); + regfree (&regex); + return is_valid; +} + + +char * +TALER_MERCHANT_phone_validate_normalize (const char *phone, + bool allow_letters) +{ + if ('\0' == phone[0]) + return NULL; + + /* Maximum phone length (reasonable practical limit) */ + if (strlen (phone) > 30) + return NULL; + + { + regex_t regex; + int ret; + + /* + * Phone number regex pattern with +CC prefix requirement: + * + * Supports: + * - Country codes (1-3 digits after +) + * - Variable length national numbers + * - Spaces, hyphens, and dots as separators + * - Parentheses for area codes + * - Optional extension notation (x, ext, extension) + * - Optional letters representing digits (2-9) if allow_letters is true + * + * Examples: + * +1-202-555-0173 + * +33 1 42 68 53 00 + * +44.20.7946.0958 + * +1 (202) 555-0173 + * +886 2 2345 6789 + * +1-800-CALL-NOW (if allow_letters is true) + * +49-30-12345678x123 + * + * Pattern breakdown: + * ^\+[0-9]{1,3} + * = Plus sign followed by 1-3 digit country code + * + * [-. ]? + * = Optional separator after country code + * + * (\([0-9]{1,4}\)[-. ]?)? + * = Optional parenthesized area code with separator + * + * [0-9A-Z] + * = Start with digit or letter + * + * ([-. ]?[0-9A-Z])* + * = Digit/letter groups with optional separators + * + * ([ ]?(x|ext|extension)[ ]?[0-9]{1,6})? + * = Optional extension + * + * $ + * = End of string + */ + const char *pattern_digits = + "^\\+[0-9]{1,3}" /* Plus and country code (1-3 digits) */ + "[-. ]?" /* Optional single separator */ + "(" /* Optional area code group */ + "\\([0-9]{1,4}\\)" /* Area code in parens */ + "[-. ]?" /* Optional separator after parens */ + ")?" + "[0-9]" /* Start national number with digit */ + "(" /* National number: alternating digits and separators */ + "[-. ]?[0-9]" /* Separator optionally followed by digit */ + ")*" + "([ ]?(x|ext|extension)[ ]?[0-9]{1,6})?" /* Optional extension */ + "$"; + + const char *pattern_with_letters = + "^\\+[0-9]{1,3}" /* Plus and country code (1-3 digits) */ + "[-. ]?" /* Optional single separator */ + "(" /* Optional area code group */ + "\\([0-9]{1,4}\\)" /* Area code in parens */ + "[-. ]?" /* Optional separator after parens */ + ")?" + "[0-9A-Z]" /* Start national number with digit or letter */ + "(" /* National number: alternating digits/letters and separators */ + "[-. ]?[0-9A-Z]" /* Separator optionally followed by digit or letter */ + ")*" + "([ ]?(x|ext|extension)[ ]?[0-9]{1,6})?" /* Optional extension */ + "$"; + + const char *pattern = allow_letters + ? pattern_with_letters + : pattern_digits; + + GNUNET_assert (0 == + regcomp (&regex, + pattern, + REG_EXTENDED | REG_NOSUB | REG_ICASE)); + ret = regexec (&regex, + phone, 0, + NULL, 0); + regfree (&regex); + if (0 != ret) + return NULL; /* invalid number */ + } + + /* Phone is valid - normalize it */ + { + char *normalized; + char *out; + + normalized = GNUNET_malloc (strlen (phone) + 1); + out = normalized; + *out++ = '+'; /* Start with plus sign */ + + for (const char *in = phone; + '\0' != *in; + in++) + { + if (isdigit ((unsigned char) *in)) + { + /* Copy digit as-is */ + *out++ = *in; + } + else if (allow_letters && isalpha ((unsigned char) *in)) + { + /* Convert letter to corresponding digit (A-Z maps to 2-9) */ + char upper = toupper ((unsigned char) *in); + /* T9 keypad mapping: + * 2: ABC + * 3: DEF + * 4: GHI + * 5: JKL + * 6: MNO + * 7: PQRS + * 8: TUV + * 9: WXYZ + */ + char digit; + + if (upper >= 'A' && upper <= 'C') + digit = '2'; + else if (upper >= 'D' && upper <= 'F') + digit = '3'; + else if (upper >= 'G' && upper <= 'I') + digit = '4'; + else if (upper >= 'J' && upper <= 'L') + digit = '5'; + else if (upper >= 'M' && upper <= 'O') + digit = '6'; + else if (upper >= 'P' && upper <= 'S') + digit = '7'; + else if (upper >= 'T' && upper <= 'V') + digit = '8'; + else if (upper >= 'W' && upper <= 'Z') + digit = '9'; + else + digit = '0'; /* Fallback (shouldn't happen) */ + *out++ = digit; + } + /* Skip separators, parentheses, and spaces */ + /* Skip 'x', 'ext', 'extension' keywords and their extension digits */ + } + *out = '\0'; /* redundant, but helps analyzers... */ + return normalized; + } +}