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:
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 (®ex,
+ pattern,
+ REG_EXTENDED | REG_NOSUB));
+ is_valid = (0 ==
+ regexec (®ex,
+ email,
+ 0,
+ NULL,
+ 0));
+ regfree (®ex);
+ 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 (®ex,
+ pattern,
+ REG_EXTENDED | REG_NOSUB | REG_ICASE));
+ ret = regexec (®ex,
+ phone, 0,
+ NULL, 0);
+ regfree (®ex);
+ 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;
+ }
+}