From fbf8bf84dbe3bed33a179a668e2c3d5d109d469a Mon Sep 17 00:00:00 2001 From: Kamil Tekiela Date: Sun, 5 Apr 2026 18:43:57 +0100 Subject: [PATCH 1/3] Implement mysqli::quote_string method (#20729) --- NEWS | 3 + UPGRADING | 4 + ext/mysqli/mysqli.stub.php | 7 ++ ext/mysqli/mysqli_api.c | 25 +++++- ext/mysqli/mysqli_arginfo.h | 11 ++- .../tests/mysqli_class_mysqli_interface.phpt | 1 + ext/mysqli/tests/mysqli_quote_string.phpt | 86 +++++++++++++++++++ 7 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 ext/mysqli/tests/mysqli_quote_string.phpt diff --git a/NEWS b/NEWS index 1759f6e2ee765..11a0dbb34a077 100644 --- a/NEWS +++ b/NEWS @@ -64,6 +64,9 @@ PHP NEWS . Fixed bug GH-21223; mb_guess_encoding no longer crashes when passed huge list of candidate encodings (with 200,000+ entries). (Jordi Kroon) +- Opcache: + . Added mysqli_quote_string() and mysqli::quote_string(). (Kamil Tekiela) + - Opcache: . Fixed bug GH-20051 (apache2 shutdowns when restart is requested during preloading). (Arnaud, welcomycozyhom) diff --git a/UPGRADING b/UPGRADING index 467387a9ea3c9..18f34eca1984f 100644 --- a/UPGRADING +++ b/UPGRADING @@ -151,6 +151,10 @@ PHP 8.6 UPGRADE NOTES . `grapheme_strrev()` returns strrev for grapheme cluster unit. RFC: https://wiki.php.net/rfc/grapheme_strrev +- mysqli: + . Added `mysqli::quote_string()` and `mysqli_quote_string()`. + RFC: https://wiki.php.net/rfc/mysqli_quote_string + - Standard: . `clamp()` returns the given value if in range, else return the nearest bound. diff --git a/ext/mysqli/mysqli.stub.php b/ext/mysqli/mysqli.stub.php index 7ca2a20ca1b77..cc6b8c57404a0 100644 --- a/ext/mysqli/mysqli.stub.php +++ b/ext/mysqli/mysqli.stub.php @@ -906,6 +906,11 @@ public function real_connect( */ public function real_escape_string(string $string): string {} + /** + * @alias mysqli_quote_string + */ + public function quote_string(string $string): string {} + /** * @tentative-return-type * @alias mysqli_reap_async_query @@ -1547,6 +1552,8 @@ function mysqli_real_escape_string(mysqli $mysql, string $string): string {} /** @alias mysqli_real_escape_string */ function mysqli_escape_string(mysqli $mysql, string $string): string {} +function mysqli_quote_string(mysqli $mysql, string $string): string {} + function mysqli_real_query(mysqli $mysql, string $query): bool {} /** @refcount 1 */ diff --git a/ext/mysqli/mysqli_api.c b/ext/mysqli/mysqli_api.c index 9473f4a06c135..259fc128237e4 100644 --- a/ext/mysqli/mysqli_api.c +++ b/ext/mysqli/mysqli_api.c @@ -1198,7 +1198,7 @@ PHP_FUNCTION(mysqli_options) zend_argument_value_error(ERROR_ARG_POS(2), "must be MYSQLI_INIT_COMMAND, MYSQLI_SET_CHARSET_NAME, MYSQLI_SERVER_PUBLIC_KEY, or one of the MYSQLI_OPT_* constants"); RETURN_THROWS(); } - + if (expected_type != Z_TYPE_P(mysql_value)) { switch (expected_type) { case IS_STRING: @@ -1363,6 +1363,29 @@ PHP_FUNCTION(mysqli_real_escape_string) { RETURN_NEW_STR(newstr); } +PHP_FUNCTION(mysqli_quote_string) { + MY_MYSQL *mysql; + zval *mysql_link = NULL; + char *escapestr; + size_t escapestr_len; + zend_string *newstr; + + if (zend_parse_method_parameters(ZEND_NUM_ARGS(), getThis(), "Os", &mysql_link, mysqli_link_class_entry, &escapestr, &escapestr_len) == FAILURE) { + RETURN_THROWS(); + } + MYSQLI_FETCH_RESOURCE_CONN(mysql, mysql_link, MYSQLI_STATUS_VALID); + + newstr = zend_string_safe_alloc(2, escapestr_len, 2, 0); + char *out = ZSTR_VAL(newstr); + *out++ = '\''; + out += mysql_real_escape_string(mysql->mysql, out, escapestr, escapestr_len); + *out++ = '\''; + *out = '\0'; + newstr = zend_string_truncate(newstr, out - ZSTR_VAL(newstr), 0); + + RETURN_NEW_STR(newstr); +} + /* {{{ Undo actions from current transaction */ PHP_FUNCTION(mysqli_rollback) { diff --git a/ext/mysqli/mysqli_arginfo.h b/ext/mysqli/mysqli_arginfo.h index 4439908e55dcf..0121f36f3cfdc 100644 --- a/ext/mysqli/mysqli_arginfo.h +++ b/ext/mysqli/mysqli_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit mysqli.stub.php instead. - * Stub hash: c0af8c627a063fbd2c8b76c63b5e70c8ddd1e4f9 */ + * Stub hash: dc804bc50cd0a0e14dafc0e03564d5699d641db0 */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_MASK_EX(arginfo_mysqli_affected_rows, 0, 1, MAY_BE_LONG|MAY_BE_STRING) ZEND_ARG_OBJ_INFO(0, mysql, mysqli, 0) @@ -252,6 +252,8 @@ ZEND_END_ARG_INFO() #define arginfo_mysqli_escape_string arginfo_mysqli_real_escape_string +#define arginfo_mysqli_quote_string arginfo_mysqli_real_escape_string + #define arginfo_mysqli_real_query arginfo_mysqli_multi_query ZEND_BEGIN_ARG_WITH_RETURN_OBJ_TYPE_MASK_EX(arginfo_mysqli_reap_async_query, 0, 1, mysqli_result, MAY_BE_BOOL) @@ -527,6 +529,10 @@ ZEND_BEGIN_ARG_WITH_TENTATIVE_RETURN_TYPE_INFO_EX(arginfo_class_mysqli_real_esca ZEND_ARG_TYPE_INFO(0, string, IS_STRING, 0) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_mysqli_quote_string, 0, 1, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, string, IS_STRING, 0) +ZEND_END_ARG_INFO() + ZEND_BEGIN_ARG_WITH_TENTATIVE_RETURN_OBJ_TYPE_MASK_EX(arginfo_class_mysqli_reap_async_query, 0, 0, mysqli_result, MAY_BE_BOOL) ZEND_END_ARG_INFO() @@ -767,6 +773,7 @@ ZEND_FUNCTION(mysqli_report); ZEND_FUNCTION(mysqli_query); ZEND_FUNCTION(mysqli_real_connect); ZEND_FUNCTION(mysqli_real_escape_string); +ZEND_FUNCTION(mysqli_quote_string); ZEND_FUNCTION(mysqli_real_query); ZEND_FUNCTION(mysqli_reap_async_query); ZEND_FUNCTION(mysqli_release_savepoint); @@ -883,6 +890,7 @@ static const zend_function_entry ext_functions[] = { ZEND_FE(mysqli_real_connect, arginfo_mysqli_real_connect) ZEND_FE(mysqli_real_escape_string, arginfo_mysqli_real_escape_string) ZEND_RAW_FENTRY("mysqli_escape_string", zif_mysqli_real_escape_string, arginfo_mysqli_escape_string, 0, NULL, NULL) + ZEND_FE(mysqli_quote_string, arginfo_mysqli_quote_string) ZEND_FE(mysqli_real_query, arginfo_mysqli_real_query) ZEND_FE(mysqli_reap_async_query, arginfo_mysqli_reap_async_query) ZEND_FE(mysqli_release_savepoint, arginfo_mysqli_release_savepoint) @@ -957,6 +965,7 @@ static const zend_function_entry class_mysqli_methods[] = { ZEND_RAW_FENTRY("query", zif_mysqli_query, arginfo_class_mysqli_query, ZEND_ACC_PUBLIC, NULL, NULL) ZEND_RAW_FENTRY("real_connect", zif_mysqli_real_connect, arginfo_class_mysqli_real_connect, ZEND_ACC_PUBLIC, NULL, NULL) ZEND_RAW_FENTRY("real_escape_string", zif_mysqli_real_escape_string, arginfo_class_mysqli_real_escape_string, ZEND_ACC_PUBLIC, NULL, NULL) + ZEND_RAW_FENTRY("quote_string", zif_mysqli_quote_string, arginfo_class_mysqli_quote_string, ZEND_ACC_PUBLIC, NULL, NULL) ZEND_RAW_FENTRY("reap_async_query", zif_mysqli_reap_async_query, arginfo_class_mysqli_reap_async_query, ZEND_ACC_PUBLIC, NULL, NULL) ZEND_RAW_FENTRY("escape_string", zif_mysqli_real_escape_string, arginfo_class_mysqli_escape_string, ZEND_ACC_PUBLIC, NULL, NULL) ZEND_RAW_FENTRY("real_query", zif_mysqli_real_query, arginfo_class_mysqli_real_query, ZEND_ACC_PUBLIC, NULL, NULL) diff --git a/ext/mysqli/tests/mysqli_class_mysqli_interface.phpt b/ext/mysqli/tests/mysqli_class_mysqli_interface.phpt index bada1d85a5cef..ccac6710edd1d 100644 --- a/ext/mysqli/tests/mysqli_class_mysqli_interface.phpt +++ b/ext/mysqli/tests/mysqli_class_mysqli_interface.phpt @@ -43,6 +43,7 @@ require_once 'skipifconnectfailure.inc'; 'ping' => true, 'prepare' => true, 'query' => true, + 'quote_string' => true, 'real_connect' => true, 'real_escape_string' => true, 'real_query' => true, diff --git a/ext/mysqli/tests/mysqli_quote_string.phpt b/ext/mysqli/tests/mysqli_quote_string.phpt new file mode 100644 index 0000000000000..b4a25aa3996f7 --- /dev/null +++ b/ext/mysqli/tests/mysqli_quote_string.phpt @@ -0,0 +1,86 @@ +--TEST-- +mysqli_quote_string() +--EXTENSIONS-- +mysqli +--SKIPIF-- + +--FILE-- +query("SELECT $escaped AS test"); +$value = $result->fetch_column(); +echo $value . "\n"; + +$escaped = mysqli_quote_string($link, '" OR 1=1 -- foo'); +echo $escaped . "\n"; +$result = $link->query("SELECT $escaped AS test"); +$value = $result->fetch_column(); +echo $value . "\n"; + +$escaped = mysqli_quote_string($link, "\n"); +if ($escaped !== "'\\n'") { + printf("[001] Expected '\\n', got %s\n", $escaped); +} + +$escaped = mysqli_quote_string($link, "\r"); +if ($escaped !== "'\\r'") { + printf("[002] Expected '\\r', got %s\n", $escaped); +} + +$escaped = mysqli_quote_string($link, "foo" . chr(0) . "bar"); +if ($escaped !== "'foo\\0bar'") { + printf("[003] Expected 'foo\\0bar', got %s\n", $escaped); +} + +echo "=====================\n"; + +// Test that the SQL injection is impossible with NO_BACKSLASH_ESCAPES mode +$link->query('SET @@sql_mode="NO_BACKSLASH_ESCAPES"'); + +echo $link->quote_string('\\') . "\n"; +echo $link->quote_string('"') . "\n"; +echo $link->quote_string("'") . "\n"; + +$escaped = $link->quote_string("\' \ \""); +echo $escaped . "\n"; +$result = $link->query("SELECT $escaped AS test"); +$value = $result->fetch_column(); +echo $value . "\n"; + +$escaped = $link->quote_string('" OR 1=1 -- foo'); +echo $escaped . "\n"; +$result = $link->query("SELECT $escaped AS test"); +$value = $result->fetch_column(); +echo $value . "\n"; + +echo "done!"; +?> +--EXPECT-- +'\\' +'\"' +'\'' +'\\\' \\ \"' +\' \ " +'\" OR 1=1 -- foo' +" OR 1=1 -- foo +===================== +'\' +'"' +'''' +'\'' \ "' +\' \ " +'" OR 1=1 -- foo' +" OR 1=1 -- foo +done! From ef8b1b8bb5127efdd31176e4d53f70d91976f07f Mon Sep 17 00:00:00 2001 From: Kamil Tekiela Date: Sun, 5 Apr 2026 18:46:09 +0100 Subject: [PATCH 2/3] Fix wrong heading --- NEWS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NEWS b/NEWS index 11a0dbb34a077..f22f5b47017e7 100644 --- a/NEWS +++ b/NEWS @@ -64,7 +64,7 @@ PHP NEWS . Fixed bug GH-21223; mb_guess_encoding no longer crashes when passed huge list of candidate encodings (with 200,000+ entries). (Jordi Kroon) -- Opcache: +- Mysqli: . Added mysqli_quote_string() and mysqli::quote_string(). (Kamil Tekiela) - Opcache: From b685fb2a94cfa82f1dc26039ae2329a6914ee296 Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Sun, 5 Apr 2026 21:00:14 +0200 Subject: [PATCH 3/3] More general assert() gcov solution Fully disable assert() when compiling with gcov instead of overriding individual macros. --- Zend/zend_API.h | 8 +------- Zend/zend_portability.h | 5 ++++- build/Makefile.gcov | 1 - 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/Zend/zend_API.h b/Zend/zend_API.h index 1ec5813678a45..e56ded4e8f1b5 100644 --- a/Zend/zend_API.h +++ b/Zend/zend_API.h @@ -1055,13 +1055,7 @@ static zend_always_inline bool zend_char_has_nul_byte(const char *s, size_t know #define RETURN_ZVAL(zv, copy, dtor) do { RETVAL_ZVAL(zv, copy, dtor); return; } while (0) #define RETURN_FALSE do { RETVAL_FALSE; return; } while (0) #define RETURN_TRUE do { RETVAL_TRUE; return; } while (0) - -#ifndef HAVE_GCOV -# define RETURN_THROWS() do { ZEND_ASSERT(EG(exception)); (void) return_value; return; } while (0) -#else -/* Drop ZEND_ASSERT() to avoid untested branch warning in gcov. */ -# define RETURN_THROWS() do { (void) return_value; return; } while (0) -#endif +#define RETURN_THROWS() do { ZEND_ASSERT(EG(exception)); (void) return_value; return; } while (0) #define HASH_OF(p) (Z_TYPE_P(p)==IS_ARRAY ? Z_ARRVAL_P(p) : ((Z_TYPE_P(p)==IS_OBJECT ? Z_OBJ_HT_P(p)->get_properties(Z_OBJ_P(p)) : NULL))) diff --git a/Zend/zend_portability.h b/Zend/zend_portability.h index c8a6dfa871b5a..b218886c2da5c 100644 --- a/Zend/zend_portability.h +++ b/Zend/zend_portability.h @@ -106,7 +106,10 @@ # define ZEND_ASSUME(c) #endif -#if ZEND_DEBUG +#ifdef HAVE_GCOV +/* Disable assert() when compiling with gcov to avoid untested branch warning. */ +# define ZEND_ASSERT(c) ((void)sizeof(c)) +#elif ZEND_DEBUG # define ZEND_ASSERT(c) assert(c) #else # define ZEND_ASSERT(c) ZEND_ASSUME(c) diff --git a/build/Makefile.gcov b/build/Makefile.gcov index d0dde7c120c8c..a1ae17a373eab 100644 --- a/build/Makefile.gcov +++ b/build/Makefile.gcov @@ -31,7 +31,6 @@ GCOVR_EXCLUDES = \ GCOVR_EXCLUDE_LINES_BY_PATTERNS = \ '.*\b(ZEND_PARSE_PARAMETERS_(START|END|NONE)|Z_PARAM_).*' \ '\s*EMPTY_SWITCH_DEFAULT_CASE\(\)(;)?\s*' \ - '\s*ZEND_ASSERT\(.*\);\s*' \ '\s*ZEND_UNREACHABLE\(\);\s*' lcov: lcov-html