diff --git a/README.markdown b/README.markdown index 38dbd5bc..abceb7e8 100644 --- a/README.markdown +++ b/README.markdown @@ -27,6 +27,7 @@ php-memcached 3.x: * Requires libmemcached 1.x or higher. * Optionally supports igbinary 2.0 or higher. * Optionally supports msgpack 2.0 or higher. +* Optionally supports phpser 0.9.0 or higher (PHP 8.2 or higher). php-memcached 2.x: * Supports PHP 5.2 - 5.6. @@ -50,3 +51,32 @@ representation that is interoperable with msgpack implementations for other languages. When installing php-memcached from source code, the msgpack module must be installed first so that php-memcached can access its C header files. Load both modules in your `php.ini` at runtime to begin using msgpack. + +[phpser](https://github.com/iliaal/phpser) is a binary serializer for PHP +data structures that can sign what it writes. Build php-memcached with +`--enable-memcached-phpser` after installing phpser, and load phpser before +memcached in your `php.ini` (`extension=phpser` above `extension=memcached`). +Select it with `Memcached::OPT_SERIALIZER => Memcached::SERIALIZER_PHPSER` or +`memcached.serializer = "phpser"`. + +Without `phpser.key`, phpser behaves like igbinary: arrays, objects, and null +go through phpser, while strings, integers, floats, and booleans are stored +natively. + +When `phpser.key` is set to a key of at least 32 bytes, values are HMAC-signed +for the `memcached` context and verified before decoding, so a client that can +write to the memcached servers cannot make the reader instantiate objects. +In this mode: + +* Strings, booleans, null, arrays, and objects are stored as signed phpser + values. Integers and floats stay native, so `increment()` and `decrement()` + keep working. +* Reads accept only signed phpser values that verify, plus native integers and + floats. Any other item, including values written by other serializers, plain + strings, and items created by `increment()` with an initial value, reads back + as `false` with a warning, like a value that fails to unserialize. +* `append()` and `prepend()` are refused with a warning, because they would + break the signature. + +A non-empty `phpser.key` shorter than 32 bytes also enables this mode, and +then every phpser write and read fails. diff --git a/composer.json b/composer.json index 3356c89d..d9822ae3 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,8 @@ }, "suggest": { "ext-igbinary": "igbinary is a faster and more compact binary serializer for PHP data structures.", - "ext-msgpack": "msgpack is a faster and more compact data structure representation that is interoperable with msgpack implementations for other languages." + "ext-msgpack": "msgpack is a faster and more compact data structure representation that is interoperable with msgpack implementations for other languages.", + "ext-phpser": "phpser is a binary serializer for PHP data structures that can sign values with phpser.key." }, "php-ext": { "extension-name": "memcached", @@ -30,6 +31,10 @@ "name": "enable-memcached-igbinary", "description": "Enable memcached igbinary serializer support" }, + { + "name": "enable-memcached-phpser", + "description": "Enable memcached phpser serializer support" + }, { "name": "enable-memcached-json", "description": "Enable memcached json serializer support" diff --git a/config.m4 b/config.m4 index 0e4ef8cf..d711e889 100644 --- a/config.m4 +++ b/config.m4 @@ -12,6 +12,9 @@ PHP_ARG_ENABLE(memcached-session, whether to enable memcached session handler su PHP_ARG_ENABLE(memcached-igbinary, whether to enable memcached igbinary serializer support, [ --enable-memcached-igbinary Enable memcached igbinary serializer support], no, no) +PHP_ARG_ENABLE(memcached-phpser, whether to enable memcached phpser serializer support, +[ --enable-memcached-phpser Enable memcached phpser serializer support], no, no) + PHP_ARG_ENABLE(memcached-json, whether to enable memcached json serializer support, [ --enable-memcached-json Enable memcached json serializer support], no, no) @@ -160,6 +163,31 @@ if test "$PHP_MEMCACHED" != "no"; then fi fi + if test "$PHP_MEMCACHED_PHPSER" != "no"; then + AC_MSG_CHECKING([for phpser includes]) + phpser_inc_path="" + + if test -f "$abs_srcdir/include/php/ext/phpser/php_phpser_api.h"; then + phpser_inc_path="$abs_srcdir/include/php" + elif test -f "$abs_srcdir/ext/phpser/php_phpser_api.h"; then + phpser_inc_path="$abs_srcdir" + elif test -f "$phpincludedir/ext/phpser/php_phpser_api.h"; then + phpser_inc_path="$phpincludedir" + else + for i in php php4 php5 php6; do + if test -f "$prefix/include/$i/ext/phpser/php_phpser_api.h"; then + phpser_inc_path="$prefix/include/$i" + fi + done + fi + + if test "$phpser_inc_path" = ""; then + AC_MSG_ERROR([Cannot find php_phpser_api.h]) + else + AC_MSG_RESULT([$phpser_inc_path]) + fi + fi + if test "$PHP_MEMCACHED_MSGPACK" != "no"; then AC_MSG_CHECKING([for msgpack includes]) msgpack_inc_path="" @@ -215,6 +243,20 @@ if test "$PHP_MEMCACHED" != "no"; then AC_MSG_RESULT([disabled]) fi + AC_MSG_CHECKING([for memcached phpser support]) + if test "$PHP_MEMCACHED_PHPSER" != "no"; then + AC_MSG_RESULT([enabled]) + AC_DEFINE(HAVE_MEMCACHED_PHPSER,1,[Whether memcache phpser serializer is enabled]) + PHPSER_INCLUDES="-I$phpser_inc_path" + ifdef([PHP_ADD_EXTENSION_DEP], + [ + PHP_ADD_EXTENSION_DEP(memcached, phpser) + ]) + else + PHPSER_INCLUDES="" + AC_MSG_RESULT([disabled]) + fi + AC_MSG_CHECKING([for memcached msgpack support]) if test "$PHP_MEMCACHED_MSGPACK" != "no"; then AC_MSG_RESULT([enabled]) @@ -405,7 +447,7 @@ if test "$PHP_MEMCACHED" != "no"; then export PKG_CONFIG_PATH="$ORIG_PKG_CONFIG_PATH" PHP_SUBST(MEMCACHED_SHARED_LIBADD) - PHP_NEW_EXTENSION(memcached, $PHP_MEMCACHED_FILES, $ext_shared,,$SESSION_INCLUDES $IGBINARY_INCLUDES $LIBEVENT_INCLUDES $MSGPACK_INCLUDES) + PHP_NEW_EXTENSION(memcached, $PHP_MEMCACHED_FILES, $ext_shared,,$SESSION_INCLUDES $IGBINARY_INCLUDES $PHPSER_INCLUDES $LIBEVENT_INCLUDES $MSGPACK_INCLUDES) if test "ac_cv_have_fastlz" != "yes"; then PHP_ADD_BUILD_DIR($ext_builddir/fastlz, 1) fi diff --git a/config.w32 b/config.w32 index 76d4ee3c..e1a3ee6c 100644 --- a/config.w32 +++ b/config.w32 @@ -4,6 +4,7 @@ ARG_ENABLE('memcached', 'libmemcached extension', 'no'); ARG_ENABLE('memcached-session', 'whether to enable memcached session handler support', 'no'); ARG_ENABLE('memcached-igbinary', 'whether to enable memcached igbinary serializer support', 'no'); +ARG_ENABLE('memcached-phpser', 'whether to enable memcached phpser serializer support', 'no'); ARG_ENABLE('memcached-json', 'whether to enable memcached json serializer support', 'no'); ARG_ENABLE('memcached-msgpack', 'whether to enable memcached msgpack serializer support', 'no'); @@ -36,6 +37,17 @@ if (PHP_MEMCACHED == "yes") { ERROR("memcached: header 'igbinary.h' not found"); } } + if (PHP_MEMCACHED_PHPSER != "no"){ + AC_DEFINE("HAVE_MEMCACHED_PHPSER",1); + ADD_EXTENSION_DEP("memcached", "phpser", true); + if (!CHECK_HEADER_ADD_INCLUDE("php_phpser_api.h", "CFLAGS_MEMCACHED", configure_module_dirname + "\\..\\phpser")) { + ERROR("memcached: header 'php_phpser_api.h' not found"); + } + // php_phpser_api.h selects dllimport only when COMPILE_DL_PHPSER is defined. + if (typeof(PHP_PHPSER_SHARED) != "undefined" && PHP_PHPSER_SHARED) { + ADD_FLAG("CFLAGS_MEMCACHED", "/D COMPILE_DL_PHPSER"); + } + } if (PHP_MEMCACHED_MSGPACK != "no"){ AC_DEFINE("HAVE_MEMCACHED_MSGPACK",1); ADD_EXTENSION_DEP("memcached", "msgpack", true); diff --git a/memcached-api.php b/memcached-api.php index 8ab03d4c..ade8b952 100644 --- a/memcached-api.php +++ b/memcached-api.php @@ -96,6 +96,8 @@ class Memcached { const HAVE_MSGPACK; + const HAVE_PHPSER; + const HAVE_ENCODING; /** @@ -127,6 +129,8 @@ class Memcached { const SERIALIZER_MSGPACK; + const SERIALIZER_PHPSER; + /** * Compression types */ diff --git a/memcached.ini b/memcached.ini index 5decf399..d26f5ca3 100644 --- a/memcached.ini +++ b/memcached.ini @@ -112,7 +112,7 @@ ;memcached.compression_threshold = 2000 ; Set the default serializer for new memcached objects. -; valid values are: php, igbinary, json, json_array, msgpack +; valid values are: php, igbinary, json, json_array, msgpack, phpser ; ; json - standard php JSON encoding. This serializer ; is fast and compact but only works on UTF-8 @@ -122,6 +122,8 @@ ; php - the standard php serializer ; igbinary - a binary serializer ; msgpack - a cross-language binary serializer +; phpser - a binary serializer; with phpser.key set, values are signed and +; only signed values, integers, and floats are read back ; ; The default is igbinary if available, then msgpack if available, then php otherwise. ;memcached.serializer = "igbinary" diff --git a/package.xml b/package.xml index 524a51e1..65856a91 100644 --- a/package.xml +++ b/package.xml @@ -142,6 +142,15 @@ http://pear.php.net/dtd/package-2.0.xsd"> + + + + + + + + + @@ -206,6 +215,10 @@ http://pear.php.net/dtd/package-2.0.xsd"> msgpack 2.0 + + phpser + 0.9.0 + memcached @@ -216,6 +229,7 @@ http://pear.php.net/dtd/package-2.0.xsd"> + diff --git a/php_memcached.c b/php_memcached.c index 47ed2d33..5dc3dcd2 100644 --- a/php_memcached.c +++ b/php_memcached.c @@ -59,6 +59,16 @@ # include "ext/msgpack/php_msgpack.h" #endif +#ifdef HAVE_MEMCACHED_PHPSER +# include "ext/standard/php_versioning.h" +#ifdef PHP_WIN32 +# include "php_phpser_api.h" +#else +# include "ext/phpser/php_phpser_api.h" +#endif +# define MEMC_PHPSER_CONTEXT "memcached" +#endif + # include "ext/spl/spl_exceptions.h" static int le_memc; @@ -109,6 +119,7 @@ static int php_memc_list_entry(void) { #define MEMC_VAL_IS_IGBINARY 5 #define MEMC_VAL_IS_JSON 6 #define MEMC_VAL_IS_MSGPACK 7 +#define MEMC_VAL_IS_PHPSER 8 #define MEMC_VAL_COMPRESSED (1<<0) #define MEMC_VAL_COMPRESSION_ZLIB (1<<1) @@ -336,6 +347,10 @@ static PHP_INI_MH(OnUpdateSerializer) } else if (!strcmp(ZSTR_VAL(new_value), "msgpack")) { MEMC_G(serializer_type) = SERIALIZER_MSGPACK; #endif // msgpack +#ifdef HAVE_MEMCACHED_PHPSER + } else if (!strcmp(ZSTR_VAL(new_value), "phpser")) { + MEMC_G(serializer_type) = SERIALIZER_PHPSER; +#endif // phpser } else { return FAILURE; } @@ -902,6 +917,31 @@ zend_bool s_invoke_cache_callback(zval *zobject, zend_fcall_info *fci, zend_fcal Wrapper for setting from zval ****************************************/ +#ifdef HAVE_MEMCACHED_PHPSER +/* phpser has no API that reports whether signing is active, so apply its own + * rule: a non-empty phpser.key means values must verify. A key shorter than + * phpser accepts also counts, because phpser then rejects every value. */ +static +zend_bool s_phpser_key_set(void) +{ + zend_ini_entry *entry = zend_hash_str_find_ptr(EG(ini_directives), "phpser.key", sizeof("phpser.key") - 1); + + return entry && entry->value && ZSTR_LEN(entry->value) > 0; +} +#endif + +/* With signed phpser, only verified phpser items and native integers and + * floats are read back; every other value type is written through phpser. */ +static +zend_bool s_phpser_keyed(php_memc_serializer_type serializer) +{ +#ifdef HAVE_MEMCACHED_PHPSER + return serializer == SERIALIZER_PHPSER && s_phpser_key_set(); +#else + return 0; +#endif +} + static zend_bool s_compress_value (php_memc_compression_type compression_type, zend_long compression_level, zend_string **payload_in, uint32_t *flags) { @@ -1026,6 +1066,25 @@ zend_bool s_serialize_value (php_memc_serializer_type serializer, zval *value, s break; #endif + /* + phpser serialization + */ +#ifdef HAVE_MEMCACHED_PHPSER + case SERIALIZER_PHPSER: + { + zend_string *encoded; + + /* phpser raises a warning or an exception on failure */ + if (phpser_api_serialize(value, MEMC_PHPSER_CONTEXT, sizeof(MEMC_PHPSER_CONTEXT) - 1, &encoded) != PHPSER_API_OK) { + return 0; + } + smart_str_append(buf, encoded); + zend_string_release(encoded); + MEMC_VAL_SET_TYPE(*flags, MEMC_VAL_IS_PHPSER); + } + break; +#endif + /* JSON serialization */ @@ -1079,16 +1138,33 @@ zend_bool s_serialize_value (php_memc_serializer_type serializer, zval *value, s return 1; } +static +zend_string *s_serialize_payload(php_memc_serializer_type serializer, zval *value, uint32_t *flags) +{ + smart_str buffer = {0}; + + if (!s_serialize_value (serializer, value, &buffer, flags)) { + smart_str_free(&buffer); + return NULL; + } + return buffer.s; +} + static zend_string *s_zval_to_payload(php_memc_object_t *intern, zval *value, uint32_t *flags) { zend_string *payload; php_memc_user_data_t *memc_user_data = memcached_get_user_data(intern->memc); zend_bool should_compress = memc_user_data->compression_enabled; + zend_bool phpser_keyed = s_phpser_keyed(memc_user_data->serializer); switch (Z_TYPE_P(value)) { case IS_STRING: + if (phpser_keyed) { + payload = s_serialize_payload(memc_user_data->serializer, value, flags); + break; + } payload = zval_get_string(value); MEMC_VAL_SET_TYPE(*flags, MEMC_VAL_IS_STRING); break; @@ -1116,30 +1192,34 @@ zend_string *s_zval_to_payload(php_memc_object_t *intern, zval *value, uint32_t break; case IS_TRUE: + if (phpser_keyed) { + payload = s_serialize_payload(memc_user_data->serializer, value, flags); + break; + } payload = zend_string_init ("1", 1, 0); MEMC_VAL_SET_TYPE(*flags, MEMC_VAL_IS_BOOL); should_compress = 0; break; case IS_FALSE: + if (phpser_keyed) { + payload = s_serialize_payload(memc_user_data->serializer, value, flags); + break; + } payload = zend_string_alloc (0, 0); MEMC_VAL_SET_TYPE(*flags, MEMC_VAL_IS_BOOL); should_compress = 0; break; default: - { - smart_str buffer = {0}; - - if (!s_serialize_value (memc_user_data->serializer, value, &buffer, flags)) { - smart_str_free(&buffer); - return NULL; - } - payload = buffer.s; - } + payload = s_serialize_payload(memc_user_data->serializer, value, flags); break; } + if (!payload) { + return NULL; + } + /* turn off compression for values below the threshold */ if (ZSTR_LEN(payload) == 0 || ZSTR_LEN(payload) < MEMC_G(compression_threshold)) { should_compress = 0; @@ -2150,6 +2230,14 @@ static void php_memc_store_impl(INTERNAL_FUNCTION_PARAMETERS, int op, zend_bool } } + if (s_phpser_keyed(memc_user_data->serializer)) { + /* Appending to a signed value breaks its tag, so it would read back as false */ + if (op == MEMC_OP_APPEND || op == MEMC_OP_PREPEND) { + php_error_docref(NULL, E_WARNING, "cannot append/prepend with the phpser serializer when phpser.key is set"); + RETURN_NULL(); + } + } + if (!s_memc_write_zval (intern, op, server_key, key, value, expiration)) { RETURN_FALSE; } @@ -3187,6 +3275,12 @@ int php_memc_set_option(php_memc_object_t *intern, long option, zval *value) if (lval == SERIALIZER_MSGPACK) { memc_user_data->serializer = SERIALIZER_MSGPACK; } else +#endif + /* phpser serializer */ +#ifdef HAVE_MEMCACHED_PHPSER + if (lval == SERIALIZER_PHPSER) { + memc_user_data->serializer = SERIALIZER_PHPSER; + } else #endif /* php serializer */ if (lval == SERIALIZER_PHP) { @@ -3827,6 +3921,27 @@ zend_bool s_unserialize_value (memcached_st *memc, int val_type, zend_string *pa #endif break; + case MEMC_VAL_IS_PHPSER: +#ifdef HAVE_MEMCACHED_PHPSER + { + int rc = phpser_api_unserialize(ZSTR_VAL(payload), ZSTR_LEN(payload), MEMC_PHPSER_CONTEXT, sizeof(MEMC_PHPSER_CONTEXT) - 1, return_value); + + if (rc != PHPSER_API_OK) { + ZVAL_FALSE(return_value); + /* With phpser.key set, phpser itself reports every failure a forged value can cause */ + if (rc == PHPSER_API_ERR && !EG(exception) && !s_phpser_key_set()) { + php_error_docref(NULL, E_WARNING, "could not unserialize value with phpser"); + } + return 0; + } + } +#else + ZVAL_FALSE(return_value); + php_error_docref(NULL, E_WARNING, "could not unserialize value, no phpser support"); + return 0; +#endif + break; + case MEMC_VAL_IS_JSON: #ifdef HAVE_JSON_API { @@ -3861,6 +3976,7 @@ zend_bool s_memcached_result_to_zval(memcached_st *memc, memcached_result_st *re size_t payload_len; uint32_t flags; zend_bool retval = 1; + php_memc_user_data_t *memc_user_data = memcached_get_user_data(memc); payload = memcached_result_value(result); payload_len = memcached_result_length(result); @@ -3871,6 +3987,22 @@ zend_bool s_memcached_result_to_zval(memcached_st *memc, memcached_result_st *re return 0; } + /* The type comes from the stored flags, which whoever wrote the item chose, + * so the configured serializer alone does not keep other decoders out. */ + if (s_phpser_keyed(memc_user_data->serializer)) { + switch (MEMC_VAL_GET_TYPE(flags)) { + case MEMC_VAL_IS_PHPSER: + case MEMC_VAL_IS_LONG: + case MEMC_VAL_IS_DOUBLE: + break; + + default: + php_error_docref(NULL, E_WARNING, "could not unserialize value: phpser.key is set and the value is not signed phpser"); + ZVAL_FALSE(return_value); + return 0; + } + } + if (MEMC_VAL_HAS_FLAG(flags, MEMC_VAL_COMPRESSED)) { data = s_decompress_value (payload, payload_len, flags); if (!data) { @@ -3915,6 +4047,7 @@ zend_bool s_memcached_result_to_zval(memcached_st *memc, memcached_result_st *re case MEMC_VAL_IS_IGBINARY: case MEMC_VAL_IS_JSON: case MEMC_VAL_IS_MSGPACK: + case MEMC_VAL_IS_PHPSER: retval = s_unserialize_value (memc, MEMC_VAL_GET_TYPE(flags), data, return_value); break; @@ -4056,6 +4189,9 @@ static const zend_module_dep memcached_deps[] = { #endif #ifdef HAVE_MEMCACHED_MSGPACK ZEND_MOD_REQUIRED("msgpack") +#endif +#ifdef HAVE_MEMCACHED_PHPSER + ZEND_MOD_REQUIRED("phpser") #endif ZEND_MOD_REQUIRED("spl") ZEND_MOD_END @@ -4187,6 +4323,15 @@ static void php_memc_register_constants(INIT_FUNC_ARGS) REGISTER_MEMC_CLASS_CONST_BOOL(HAVE_MSGPACK, 0); #endif + /* + * Indicate whether phpser serializer is available + */ +#ifdef HAVE_MEMCACHED_PHPSER + REGISTER_MEMC_CLASS_CONST_BOOL(HAVE_PHPSER, 1); +#else + REGISTER_MEMC_CLASS_CONST_BOOL(HAVE_PHPSER, 0); +#endif + /* * Indicate whether set_encoding_key is available */ @@ -4334,6 +4479,7 @@ static void php_memc_register_constants(INIT_FUNC_ARGS) REGISTER_MEMC_CLASS_CONST_LONG(SERIALIZER_JSON, SERIALIZER_JSON); REGISTER_MEMC_CLASS_CONST_LONG(SERIALIZER_JSON_ARRAY, SERIALIZER_JSON_ARRAY); REGISTER_MEMC_CLASS_CONST_LONG(SERIALIZER_MSGPACK, SERIALIZER_MSGPACK); + REGISTER_MEMC_CLASS_CONST_LONG(SERIALIZER_PHPSER, SERIALIZER_PHPSER); /* * Compression types @@ -4398,11 +4544,43 @@ static void php_memc_register_constants(INIT_FUNC_ARGS) } /* }}} */ +#ifdef HAVE_MEMCACHED_PHPSER +/* phpser_api_version() does not exist before phpser 0.9.0, so check the + * module version before calling into the API. */ +static +int php_memc_check_phpser(void) +{ + const char *version = zend_get_module_version("phpser"); + + if (version == NULL || php_version_compare(version, "0.9.0") < 0) { + php_error_docref(NULL, E_CORE_WARNING, + "Memcached::SERIALIZER_PHPSER requires phpser 0.9.0 or later, found %s", + version ? version : "none"); + return FAILURE; + } + + if (phpser_api_version() != PHPSER_API_VERSION) { + php_error_docref(NULL, E_CORE_WARNING, + "Memcached::SERIALIZER_PHPSER requires phpser API version %d, found %u", + PHPSER_API_VERSION, phpser_api_version()); + return FAILURE; + } + + return SUCCESS; +} +#endif + /* {{{ PHP_MINIT_FUNCTION */ PHP_MINIT_FUNCTION(memcached) { zend_class_entry ce; +#ifdef HAVE_MEMCACHED_PHPSER + if (php_memc_check_phpser() == FAILURE) { + return FAILURE; + } +#endif + memcpy(&memcached_object_handlers, zend_get_std_object_handlers(), sizeof(zend_object_handlers)); memcached_object_handlers.offset = offsetof(php_memc_object_t, zo); memcached_object_handlers.clone_obj = NULL; @@ -4506,6 +4684,12 @@ PHP_MINFO_FUNCTION(memcached) php_info_print_table_row(2, "msgpack support", "no"); #endif +#ifdef HAVE_MEMCACHED_PHPSER + php_info_print_table_row(2, "phpser support", "yes"); +#else + php_info_print_table_row(2, "phpser support", "no"); +#endif + #ifdef HAVE_ZSTD_H php_info_print_table_row(2, "zstd support", "yes"); #else diff --git a/php_memcached_private.h b/php_memcached_private.h index b7d2a5e1..8c2975e5 100644 --- a/php_memcached_private.h +++ b/php_memcached_private.h @@ -97,7 +97,8 @@ typedef enum { SERIALIZER_IGBINARY = 2, SERIALIZER_JSON = 3, SERIALIZER_JSON_ARRAY = 4, - SERIALIZER_MSGPACK = 5 + SERIALIZER_MSGPACK = 5, + SERIALIZER_PHPSER = 6 } php_memc_serializer_type; typedef enum { diff --git a/tests/phpser.inc b/tests/phpser.inc new file mode 100644 index 00000000..43f5fbb4 --- /dev/null +++ b/tests/phpser.inc @@ -0,0 +1,83 @@ +cmd = $data['cmd'] ?? 'none'; + } + + public function __destruct() { + self::$events[] = "destruct"; + } +} + +/* Writes an item with caller-chosen flags, as anyone with write access to + * the memcached server can. */ +function phpser_raw_set($key, $flags, $data) +{ + $fp = fsockopen(MEMC_SERVER_HOST, MEMC_SERVER_PORT, $errno, $errstr, 2); + fwrite($fp, "set $key $flags 0 " . strlen($data) . "\r\n" . $data . "\r\n"); + $reply = trim(fgets($fp)); + fclose($fp); + if ($reply !== "STORED") { + echo "raw set of $key failed: $reply\n"; + } +} + +/* Returns [flags, data] for a key, read without any client-side decoding. */ +function phpser_raw_get($key) +{ + $fp = fsockopen(MEMC_SERVER_HOST, MEMC_SERVER_PORT, $errno, $errstr, 2); + fwrite($fp, "get $key\r\n"); + $header = trim(fgets($fp)); + if ($header === "END") { + fclose($fp); + return null; + } + list(, , $flags, $len) = explode(' ', $header); + $data = ''; + while (strlen($data) < $len) { + $data .= fread($fp, $len - strlen($data)); + } + fclose($fp); + return [(int) $flags, $data]; +} + +function phpser_raw_type($key) +{ + $item = phpser_raw_get($key); + return $item === null ? null : ($item[0] & 0xf); +} + +/* Signs a phpser frame the way phpser does for phpser.key = $key. */ +function phpser_forge_signed($value, $key, $context = 'memcached') +{ + $frame = phpser_serialize($value); + $subkey = hash_hmac('sha256', "phpser:" . $context . ":v1\0", $key, true); + return $frame . hash_hmac('sha256', $frame, $subkey, true); +} + +function phpser_gadget_serialized() +{ + return 'O:12:"PhpserGadget":1:{s:3:"cmd";s:6:"rm -rf";}'; +} diff --git a/tests/phpser_keyed.phpt b/tests/phpser_keyed.phpt new file mode 100644 index 00000000..f3fa8be3 --- /dev/null +++ b/tests/phpser_keyed.phpt @@ -0,0 +1,106 @@ +--TEST-- +Memcached phpser serializer with phpser.key: stored types, counters, append/prepend +--SKIPIF-- + +--INI-- +phpser.key=memcached-phpser-test-key-0123456789 +--FILE-- + Memcached::SERIALIZER_PHPSER, + Memcached::OPT_COMPRESSION => false, +)); + +echo "-- stored types\n"; +$values = array( + 'str' => 'hello', + 'empty' => '', + 'true' => true, + 'false' => false, + 'null' => null, + 'arr' => array(1, 'two' => 2), + 'obj' => (object) array('a' => 1), + 'long' => 42, + 'dbl' => 1.5, +); +foreach ($values as $key => $value) { + $m->set($key, $value); + $back = $m->get($key); + printf("%-5s type=%d same=%s\n", $key, phpser_raw_type($key), + var_export(is_object($value) ? $back == $value : $back === $value, true)); +} + +echo "-- signed bytes match phpser_serialize_keyed()\n"; +var_dump(phpser_raw_get('str')[1] === phpser_serialize_keyed('hello', 'memcached')); +var_dump(phpser_raw_get('str')[1] === phpser_forge_signed('hello', PHPSER_TEST_KEY)); + +echo "-- counters stay native\n"; +var_dump($m->set('counter', 10)); +var_dump($m->increment('counter', 5)); +var_dump($m->decrement('counter', 2)); +var_dump($m->get('counter')); +var_dump(phpser_raw_type('counter')); + +echo "-- append/prepend are refused\n"; +$m->set('ap', 'abc'); +var_dump($m->append('ap', 'def')); +var_dump($m->prepend('ap', 'xyz')); +var_dump($m->appendByKey('', 'ap', 'def')); +var_dump($m->get('ap')); + +echo "-- user flags are kept and do not affect the type\n"; +$m->setOption(Memcached::OPT_USER_FLAGS, 0xBEEF); +$m->set('uf', array('x')); +$r = $m->get('uf', null, Memcached::GET_EXTENDED); +var_dump($r['value'] === array('x'), $r['flags'] === 0xBEEF, phpser_raw_type('uf')); + +echo "-- cas writes sign too\n"; +$r = $m->get('str', null, Memcached::GET_EXTENDED); +var_dump($m->cas($r['cas'], 'str', 'replaced')); +var_dump($m->get('str'), phpser_raw_type('str')); +?> +--EXPECTF-- +-- stored types +str type=8 same=true +empty type=8 same=true +true type=8 same=true +false type=8 same=true +null type=8 same=true +arr type=8 same=true +obj type=8 same=true +long type=1 same=true +dbl type=2 same=true +-- signed bytes match phpser_serialize_keyed() +bool(true) +bool(true) +-- counters stay native +bool(true) +int(15) +int(13) +int(13) +int(1) +-- append/prepend are refused + +Warning: Memcached::append(): cannot append/prepend with the phpser serializer when phpser.key is set in %s on line %d +NULL + +Warning: Memcached::prepend(): cannot append/prepend with the phpser serializer when phpser.key is set in %s on line %d +NULL + +Warning: Memcached::appendByKey(): cannot append/prepend with the phpser serializer when phpser.key is set in %s on line %d +NULL +string(3) "abc" +-- user flags are kept and do not affect the type +bool(true) +bool(true) +int(8) +-- cas writes sign too +bool(true) +string(8) "replaced" +int(8) diff --git a/tests/phpser_keyed_attack.phpt b/tests/phpser_keyed_attack.phpt new file mode 100644 index 00000000..f8ed881c --- /dev/null +++ b/tests/phpser_keyed_attack.phpt @@ -0,0 +1,110 @@ +--TEST-- +Memcached phpser serializer with phpser.key rejects items it did not sign +--SKIPIF-- + +--INI-- +phpser.key=memcached-phpser-test-key-0123456789 +--FILE-- + Memcached::SERIALIZER_PHPSER)); +$php = memc_create_instance(MEMC_SERVER_HOST, MEMC_SERVER_PORT, array(Memcached::OPT_SERIALIZER => Memcached::SERIALIZER_PHP)); + +$gadget = new PhpserGadget(); +$gadget->cmd = 'rm -rf'; +$gadget_phpser = phpser_serialize($gadget); +$gadget_igbinary = function_exists('igbinary_serialize') ? igbinary_serialize($gadget) : "\x00\x00\x00\x02\x17\x0cPhpserGadget"; +unset($gadget); + +$zlib = function ($data) { + return pack('V', strlen($data)) . gzcompress($data); +}; + +$signed = phpser_serialize_keyed(array('k' => str_repeat('v', 50)), 'memcached'); +$tampered = $signed; +$tampered[10] = chr(ord($tampered[10]) ^ 1); + +$items = array( + 'ser_gadget' => array(MEMC_TEST_IS_SERIALIZED, phpser_gadget_serialized()), + 'ser_gadget_zlib' => array(MEMC_TEST_IS_SERIALIZED | MEMC_TEST_COMPRESSED_ZLIB, $zlib(phpser_gadget_serialized())), + 'igbinary' => array(MEMC_TEST_IS_IGBINARY, $gadget_igbinary), + 'string' => array(MEMC_TEST_IS_STRING, 'attacker text'), + 'bool' => array(MEMC_TEST_IS_BOOL, '1'), + 'json' => array(MEMC_TEST_IS_JSON, '{"a":1}'), + 'msgpack' => array(MEMC_TEST_IS_MSGPACK, "\x81\xa1a\x01"), + 'unknown_type' => array(9, 'x'), + 'phpser_unsigned' => array(MEMC_TEST_IS_PHPSER, $gadget_phpser), + 'phpser_unsigned_zlib' => array(MEMC_TEST_IS_PHPSER | MEMC_TEST_COMPRESSED_ZLIB, $zlib($gadget_phpser)), + 'phpser_wrongkey' => array(MEMC_TEST_IS_PHPSER, phpser_forge_signed(array('evil'), str_repeat('z', 32))), + 'phpser_ctx' => array(MEMC_TEST_IS_PHPSER, phpser_forge_signed(array('evil'), PHPSER_TEST_KEY, 'redis')), + 'phpser_tampered' => array(MEMC_TEST_IS_PHPSER, $tampered), + 'phpser_truncated' => array(MEMC_TEST_IS_PHPSER, substr($signed, 0, -1)), + 'phpser_empty' => array(MEMC_TEST_IS_PHPSER, ''), + 'good' => array(MEMC_TEST_IS_PHPSER, phpser_forge_signed(array('ok'), PHPSER_TEST_KEY)), + 'good_zlib' => array(MEMC_TEST_IS_PHPSER | MEMC_TEST_COMPRESSED_ZLIB, $zlib($signed)), + 'native_long' => array(MEMC_TEST_IS_LONG, '7'), + 'native_double' => array(MEMC_TEST_IS_DOUBLE, '2.5'), +); + +foreach ($items as $key => $item) { + phpser_raw_set($key, $item[0], $item[1]); +} + +/* Other serializers write into the same pool */ +$php->set('php_client_array', array('from' => 'php serializer')); +$php->set('php_client_string', 'plain string'); +$php->set('php_client_bool', true); +foreach (array('php_client_array', 'php_client_string', 'php_client_bool') as $key) { + $items[$key] = null; +} + +PhpserGadget::$events = array(); + +$warnings = array(); +set_error_handler(function ($no, $str) use (&$warnings) { + $warnings[] = preg_replace('/^Memcached::\w+\(\): /', '', $str); + return true; +}); + +foreach (array_keys($items) as $key) { + $warnings = array(); + $value = $m->get($key); + printf("%-20s %s rc=%d warnings=%d%s\n", $key, + is_array($value) ? 'array(' . count($value) . ')' : var_export($value, true), + $m->getResultCode(), count($warnings), $warnings ? ' ' . $warnings[0] : ''); +} + +restore_error_handler(); +echo "gadget events: ", json_encode(PhpserGadget::$events), "\n"; +var_dump(Memcached::RES_SOME_ERRORS); +?> +--EXPECT-- +ser_gadget false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +ser_gadget_zlib false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +igbinary false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +string false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +bool false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +json false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +msgpack false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +unknown_type false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +phpser_unsigned false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded +phpser_unsigned_zlib false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded +phpser_wrongkey false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded +phpser_ctx false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded +phpser_tampered false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded +phpser_truncated false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded +phpser_empty false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded +good array(1) rc=0 warnings=0 +good_zlib array(1) rc=0 warnings=0 +native_long 7 rc=0 warnings=0 +native_double 2.5 rc=0 warnings=0 +php_client_array false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +php_client_string false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +php_client_bool false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +gadget events: [] +int(19) diff --git a/tests/phpser_keyed_incr_initial.phpt b/tests/phpser_keyed_incr_initial.phpt new file mode 100644 index 00000000..0592ae34 --- /dev/null +++ b/tests/phpser_keyed_incr_initial.phpt @@ -0,0 +1,37 @@ +--TEST-- +Memcached phpser serializer with phpser.key: items created by increment() with an initial value carry no type +--SKIPIF-- + +--INI-- +phpser.key=memcached-phpser-test-key-0123456789 +--FILE-- + Memcached::SERIALIZER_PHPSER, + Memcached::OPT_BINARY_PROTOCOL => true, +)); + +/* The server creates the item with flags 0, the string type, so get() + * refuses it; increment() and decrement() keep working on it. */ +var_dump($m->increment('c', 1, 10)); +var_dump($m->increment('c', 5)); +var_dump(phpser_raw_type('c')); +var_dump(@$m->get('c')); + +$m->set('c2', 10); +var_dump($m->increment('c2', 1, 99)); +var_dump($m->get('c2')); +?> +--EXPECT-- +int(10) +int(15) +int(0) +bool(false) +int(11) +int(11) diff --git a/tests/phpser_keyed_multi.phpt b/tests/phpser_keyed_multi.phpt new file mode 100644 index 00000000..16ba935e --- /dev/null +++ b/tests/phpser_keyed_multi.phpt @@ -0,0 +1,99 @@ +--TEST-- +Memcached phpser serializer with phpser.key gates getMulti, getDelayed, fetch, byKey, and callbacks +--SKIPIF-- + +--INI-- +phpser.key=memcached-phpser-test-key-0123456789 +--FILE-- + Memcached::SERIALIZER_PHPSER)); + +$m->set('good1', array('one')); +$m->set('good2', 'two'); +$m->set('num', 3); +phpser_raw_set('evil_ser', MEMC_TEST_IS_SERIALIZED, phpser_gadget_serialized()); +phpser_raw_set('evil_str', MEMC_TEST_IS_STRING, 'forged'); +phpser_raw_set('evil_unsigned', MEMC_TEST_IS_PHPSER, phpser_serialize(array('forged'))); +PhpserGadget::$events = array(); + +$keys = array('good1', 'evil_ser', 'good2', 'evil_str', 'num', 'evil_unsigned'); + +/* One warning per rejected item, and the rejected values must not reach + * any caller, whatever the entry point. */ +$warnings = 0; +set_error_handler(function () use (&$warnings) { $warnings++; return true; }); + +function show($label, $value, $m, &$warnings) { + echo $label, ": ", json_encode($value), " rc=", $m->getResultCode(), " warnings=", $warnings, "\n"; + $warnings = 0; +} + +show('getMulti', $m->getMulti($keys), $m, $warnings); +show('getMultiByKey', $m->getMultiByKey('srv', $keys), $m, $warnings); + +$ext = $m->getMulti($keys, Memcached::GET_EXTENDED); +show('getMulti extended keys', array_keys($ext), $m, $warnings); + +show('getByKey evil', $m->getByKey('srv', 'evil_ser'), $m, $warnings); +show('getByKey good', $m->getByKey('srv', 'good1'), $m, $warnings); +show('get extended evil', $m->get('evil_str', null, Memcached::GET_EXTENDED), $m, $warnings); + +/* A decode failure makes fetch() return false and drops the next item, + * and makes fetchAll() return false; that is the existing contract for any + * value that does not unserialize. */ +$m->getDelayed($keys); +$seq = array(); +for ($i = 0; $i < 5; $i++) { + $r = $m->fetch(); + $seq[] = ($r ? $r['key'] . '=' . json_encode($r['value']) : 'false') . ' rc=' . $m->getResultCode(); +} +show('getDelayed+fetch', $seq, $m, $warnings); + +$m->getDelayed($keys, true); +show('getDelayed(cas)+fetchAll', $m->fetchAll(), $m, $warnings); + +$cb = array(); +$m->getDelayedByKey('srv', $keys, false, function ($memc, $r) use (&$cb) { + $cb[$r['key']] = $r['value']; +}); +ksort($cb); +show('getDelayedByKey callback', $cb, $m, $warnings); + +$called = 0; +$v = $m->get('evil_ser', function ($memc, $key, &$value) use (&$called) { + $called++; + $value = 'from callback'; + return true; +}); +show('read-through on rejected item', array($v, $called), $m, $warnings); + +$v = $m->get('missing', function ($memc, $key, &$value) { + $value = 'from callback'; + return true; +}); +show('read-through on miss', $v, $m, $warnings); +show('read-through value stored signed', array($m->get('missing'), phpser_raw_type('missing')), $m, $warnings); + +restore_error_handler(); +echo "gadget events: ", json_encode(PhpserGadget::$events), "\n"; +?> +--EXPECT-- +getMulti: {"good1":["one"],"good2":"two","num":3} rc=19 warnings=3 +getMultiByKey: {"good1":["one"],"good2":"two","num":3} rc=19 warnings=3 +getMulti extended keys: ["good1","good2","num"] rc=19 warnings=3 +getByKey evil: false rc=19 warnings=1 +getByKey good: ["one"] rc=0 warnings=0 +get extended evil: false rc=19 warnings=1 +getDelayed+fetch: ["good1=[\"one\"] rc=0","false rc=19","false rc=19","false rc=19","false rc=16"] rc=16 warnings=3 +getDelayed(cas)+fetchAll: false rc=19 warnings=3 +getDelayedByKey callback: {"good1":["one"],"good2":"two","num":3} rc=19 warnings=3 +read-through on rejected item: [false,0] rc=19 warnings=1 +read-through on miss: "from callback" rc=0 warnings=0 +read-through value stored signed: ["from callback",8] rc=0 warnings=0 +gadget events: [] diff --git a/tests/phpser_short_key.phpt b/tests/phpser_short_key.phpt new file mode 100644 index 00000000..87150724 --- /dev/null +++ b/tests/phpser_short_key.phpt @@ -0,0 +1,62 @@ +--TEST-- +Memcached phpser serializer rejects values when phpser.key is too short +--SKIPIF-- + +--INI-- +phpser.key=too-short +--FILE-- + Memcached::SERIALIZER_PHPSER)); + +$warnings = array(); +set_error_handler(function ($no, $str) use (&$warnings) { + $warnings[] = preg_replace('/^Memcached::\w+\(\): /', '', $str); + return true; +}); + +function show($label, $value, $m, &$warnings) { + echo $label, ": ", var_export($value, true), " rc=", $m->getResultCode(), " ", json_encode($warnings), "\n"; + $warnings = array(); +} + +show('set string', $m->set('s', 'value'), $m, $warnings); +show('set array', $m->set('a', array(1)), $m, $warnings); +show('set bool', $m->set('b', true), $m, $warnings); +show('set long', $m->set('l', 5), $m, $warnings); +show('get long', $m->get('l'), $m, $warnings); +show('increment', $m->increment('l'), $m, $warnings); + +phpser_raw_set('unsigned', MEMC_TEST_IS_PHPSER, phpser_serialize(array('forged'))); +phpser_raw_set('signed_short', MEMC_TEST_IS_PHPSER, phpser_forge_signed(array('forged'), 'too-short')); +phpser_raw_set('ser', MEMC_TEST_IS_SERIALIZED, phpser_gadget_serialized()); +phpser_raw_set('str', MEMC_TEST_IS_STRING, 'forged'); +PhpserGadget::$events = array(); + +show('get unsigned', $m->get('unsigned'), $m, $warnings); +show('get signed with short key', $m->get('signed_short'), $m, $warnings); +show('get serialized', $m->get('ser'), $m, $warnings); +show('get string', $m->get('str'), $m, $warnings); + +restore_error_handler(); +echo "gadget events: ", json_encode(PhpserGadget::$events), "\n"; +var_dump(Memcached::RES_PAYLOAD_FAILURE); +?> +--EXPECT-- +set string: false rc=-1001 ["phpser.key must be at least 32 bytes; keyed value rejected"] +set array: false rc=-1001 ["phpser.key must be at least 32 bytes; keyed value rejected"] +set bool: false rc=-1001 ["phpser.key must be at least 32 bytes; keyed value rejected"] +set long: true rc=0 [] +get long: 5 rc=0 [] +increment: 6 rc=0 [] +get unsigned: false rc=19 ["phpser.key must be at least 32 bytes; keyed value rejected"] +get signed with short key: false rc=19 ["phpser.key must be at least 32 bytes; keyed value rejected"] +get serialized: false rc=19 ["could not unserialize value: phpser.key is set and the value is not signed phpser"] +get string: false rc=19 ["could not unserialize value: phpser.key is set and the value is not signed phpser"] +gadget events: [] +int(-1001) diff --git a/tests/phpser_unkeyed.phpt b/tests/phpser_unkeyed.phpt new file mode 100644 index 00000000..b3a15c7b --- /dev/null +++ b/tests/phpser_unkeyed.phpt @@ -0,0 +1,71 @@ +--TEST-- +Memcached phpser serializer without phpser.key: INI selection, native scalars, foreign types unchanged +--SKIPIF-- + +--INI-- +memcached.serializer=phpser +--FILE-- + false)); +var_dump($m->getOption(Memcached::OPT_SERIALIZER) === Memcached::SERIALIZER_PHPSER); + +foreach (array('str' => 'hello', 'true' => true, 'long' => 7, 'dbl' => 0.5, 'arr' => array(1), 'obj' => (object) array('a' => 1)) as $key => $value) { + $m->set($key, $value); + echo $key, " type=", phpser_raw_type($key), "\n"; +} +var_dump(phpser_raw_get('arr')[1] === phpser_serialize(array(1))); + +echo "-- append works on native strings\n"; +var_dump($m->append('str', ' world'), $m->get('str')); + +echo "-- foreign types still decode by their stored flags\n"; +phpser_raw_set('ser', MEMC_TEST_IS_SERIALIZED, serialize(array('php' => 1))); +var_dump($m->get('ser')); + +echo "-- a corrupt phpser item reads as false\n"; +phpser_raw_set('bad', MEMC_TEST_IS_PHPSER, 'not a phpser frame'); +var_dump($m->get('bad'), $m->getResultCode()); + +echo "-- a signed item has a trailing tag that plain phpser rejects\n"; +phpser_raw_set('signed', MEMC_TEST_IS_PHPSER, phpser_forge_signed(array('x'), PHPSER_TEST_KEY)); +var_dump($m->get('signed')); + +$m->setOption(Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_PHP); +var_dump($m->get('arr')); +?> +--EXPECTF-- +bool(true) +str type=0 +true type=3 +long type=1 +dbl type=2 +arr type=8 +obj type=8 +bool(true) +-- append works on native strings +bool(true) +string(11) "hello world" +-- foreign types still decode by their stored flags +array(1) { + ["php"]=> + int(1) +} +-- a corrupt phpser item reads as false + +Warning: Memcached::get(): could not unserialize value with phpser in %s on line %d +bool(false) +int(19) +-- a signed item has a trailing tag that plain phpser rejects + +Warning: Memcached::get(): could not unserialize value with phpser in %s on line %d +bool(false) +array(1) { + [0]=> + int(1) +} diff --git a/tests/types_phpser.phpt b/tests/types_phpser.phpt new file mode 100644 index 00000000..1db18387 --- /dev/null +++ b/tests/types_phpser.phpt @@ -0,0 +1,24 @@ +--TEST-- +Memcached store & fetch type and value correctness using phpser serializer +--SKIPIF-- + +--FILE-- + +--EXPECT-- +TEST DONE +TEST DONE diff --git a/tests/types_phpser_keyed.phpt b/tests/types_phpser_keyed.phpt new file mode 100644 index 00000000..e097c7df --- /dev/null +++ b/tests/types_phpser_keyed.phpt @@ -0,0 +1,26 @@ +--TEST-- +Memcached store & fetch type and value correctness using phpser serializer, phpser.key set +--SKIPIF-- + +--INI-- +phpser.key=memcached-phpser-test-key-0123456789 +--FILE-- + +--EXPECT-- +TEST DONE +TEST DONE