This is an automated email from the ASF dual-hosted git repository.
wwbmmm pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/brpc.git
The following commit(s) were added to refs/heads/master by this push:
new 84457631 Bound total array allocation of one redis reply by
-redis_max_allocation_size (#3560)
84457631 is described below
commit 8445763165f3a1b381c54eb6fb1c1bc4f26ebb8e
Author: Weibing Wang <[email protected]>
AuthorDate: Wed Sep 23 10:07:20 2026 +0800
Bound total array allocation of one redis reply by
-redis_max_allocation_size (#3560)
* Bound total array allocation of one redis reply by
-redis_max_allocation_size
The redis reply parser capped each array allocation individually and the
nesting depth separately, but nothing bounded the two together: nesting a
maximally-sized array inside another committed depth * cap bytes from a
tiny reply. Thread a per-reply byte budget through
RedisReply::ConsumePartialIOBuf, save it in the root array when the
parsing suspends and restore it on resume so that withholding data cannot
reset it.
Also stop pre-resizing RedisCommandParser::_args from a declared RESP
array count; grow it as the arguments actually arrive so that a ~12-byte
incomplete command no longer commits up to the cap per connection.
* Initialize used_bytes in RedisReply::Reset()
Arrays built by user code (e.g. SetArray) never went through the reply
parser, so the new used_bytes field could hold an indeterminate value
that CopyFromDifferentArena would read. Initialize it in Reset(), which
every construction path goes through.
---------
Co-authored-by: brpc-oncall <brpc-oncall@localhost>
---
src/brpc/redis_command.cpp | 9 ++-
src/brpc/redis_reply.cpp | 46 ++++++++++++++--
src/brpc/redis_reply.h | 8 ++-
test/brpc_redis_unittest.cpp | 128 +++++++++++++++++++++++++++++++++++++++++++
4 files changed, 184 insertions(+), 7 deletions(-)
diff --git a/src/brpc/redis_command.cpp b/src/brpc/redis_command.cpp
index a833cb54..62b9bd1c 100644
--- a/src/brpc/redis_command.cpp
+++ b/src/brpc/redis_command.cpp
@@ -565,7 +565,10 @@ RedisCommandConsumeState
RedisCommandParser::ConsumeImpl(butil::IOBuf& buf,
_parsing_array = true;
_length = value;
_index = 0;
- _args.resize(value);
+ // NOTE: don't resize _args to `value' here. The count is just a
+ // declaration and the arguments may never arrive; growing _args
+ // lazily avoids committing up to -redis_max_allocation_size of a
+ // connection from a ~12-byte incomplete command.
return CONSUME_STATE_CONTINUE;
}
if (_index >= _length) {
@@ -599,6 +602,10 @@ RedisCommandConsumeState
RedisCommandParser::ConsumeImpl(butil::IOBuf& buf,
}
buf.cutn(d, len);
d[len] = '\0';
+ if (_args.size() <= (size_t)_index) {
+ // Grow _args as the arguments arrive instead of upfront.
+ _args.resize(_index + 1);
+ }
_args[_index].set(d, len);
if (_index == 0) {
// convert it to lowercase when it is command name
diff --git a/src/brpc/redis_reply.cpp b/src/brpc/redis_reply.cpp
index e93b9282..ee77b3e6 100644
--- a/src/brpc/redis_reply.cpp
+++ b/src/brpc/redis_reply.cpp
@@ -96,21 +96,41 @@ bool RedisReply::SerializeTo(butil::IOBufAppender*
appender) {
}
ParseError RedisReply::ConsumePartialIOBuf(butil::IOBuf& buf) {
- return ConsumePartialIOBuf(buf, 0);
+ // `used_bytes' accumulates the arena bytes charged to
+ // -redis_max_allocation_size for this reply. It is shared by the whole
+ // reply tree so that nested arrays draw from one budget instead of each
+ // getting a full one (which multiplied the cap by nesting depth). When
+ // the parsing suspends, the count is saved in the root array and restored
+ // on resume, otherwise a peer could reset the budget by withholding data.
+ uint32_t used_bytes = 0;
+ ParseError err = ConsumePartialIOBuf(buf, 0, &used_bytes);
+ if (err != PARSE_OK && _type == REDIS_REPLY_ARRAY &&
+ _data.array.last_index >= 0) {
+ // The parsing suspends on sub replies, remember the budget for resume.
+ _data.array.used_bytes = used_bytes;
+ }
+ return err;
}
-ParseError RedisReply::ConsumePartialIOBuf(butil::IOBuf& buf, int depth) {
+ParseError RedisReply::ConsumePartialIOBuf(butil::IOBuf& buf, int depth,
+ uint32_t* used_bytes) {
if (depth > FLAGS_redis_max_reply_depth) {
LOG(ERROR) << "redis reply exceeds max depth! max="
<< FLAGS_redis_max_reply_depth << ", actually=" << depth;
return PARSE_ERROR_ABSOLUTELY_WRONG;
}
if (_type == REDIS_REPLY_ARRAY && _data.array.last_index >= 0) {
+ if (depth == 0) {
+ // Resuming the root array of a suspended reply: restore the
+ // budget charged by previous calls.
+ *used_bytes = _data.array.used_bytes;
+ }
// The parsing was suspended while parsing sub replies,
// continue the parsing.
RedisReply* subs = (RedisReply*)_data.array.replies;
for (int i = _data.array.last_index; i < _length; ++i) {
- ParseError err = subs[i].ConsumePartialIOBuf(buf, depth + 1);
+ ParseError err = subs[i].ConsumePartialIOBuf(buf, depth + 1,
+ used_bytes);
if (err != PARSE_OK) {
return err;
}
@@ -268,12 +288,26 @@ ParseError RedisReply::ConsumePartialIOBuf(butil::IOBuf&
buf, int depth) {
<< max_count << ", actually=" << count;
return PARSE_ERROR_ABSOLUTELY_WRONG;
}
+ // Charge the allocation to the per-reply budget so that nested
+ // arrays cannot multiply -redis_max_allocation_size by depth.
+ const size_t need = sizeof(RedisReply) * count;
+ if (FLAGS_redis_max_allocation_size > 0 &&
+ (uint64_t)*used_bytes + need >
+ (uint64_t)FLAGS_redis_max_allocation_size) {
+ LOG(ERROR) << "accumulated array allocation exceeds max "
+ "allocation size! max="
+ << FLAGS_redis_max_allocation_size
+ << ", already=" << *used_bytes
+ << ", requesting=" << need;
+ return PARSE_ERROR_ABSOLUTELY_WRONG;
+ }
// FIXME(gejun): Call allocate_aligned instead.
- RedisReply* subs =
(RedisReply*)_arena->allocate(sizeof(RedisReply) * count);
+ RedisReply* subs = (RedisReply*)_arena->allocate(need);
if (subs == nullptr) {
LOG(FATAL) << "Fail to allocate RedisReply[" << count << "]";
return PARSE_ERROR_ABSOLUTELY_WRONG;
}
+ *used_bytes += need;
for (int64_t i = 0; i < count; ++i) {
new (&subs[i]) RedisReply(_arena);
}
@@ -286,7 +320,8 @@ ParseError RedisReply::ConsumePartialIOBuf(butil::IOBuf&
buf, int depth) {
// be continued in next calls by tracking _data.array.last_index.
_data.array.last_index = 0;
for (int64_t i = 0; i < count; ++i) {
- ParseError err = subs[i].ConsumePartialIOBuf(buf, depth + 1);
+ ParseError err = subs[i].ConsumePartialIOBuf(buf, depth + 1,
+ used_bytes);
if (err != PARSE_OK) {
return err;
}
@@ -404,6 +439,7 @@ void RedisReply::CopyFromDifferentArena(const RedisReply&
other) {
new (&subs[i]) RedisReply(_arena);
}
_data.array.last_index = other._data.array.last_index;
+ _data.array.used_bytes = other._data.array.used_bytes;
if (_data.array.last_index > 0) {
// incomplete state
for (int i = 0; i < _data.array.last_index; ++i) {
diff --git a/src/brpc/redis_reply.h b/src/brpc/redis_reply.h
index 4efb5b71..725e184e 100644
--- a/src/brpc/redis_reply.h
+++ b/src/brpc/redis_reply.h
@@ -146,7 +146,8 @@ private:
// by calling CopyFrom[Different|Same]Arena.
DISALLOW_COPY_AND_ASSIGN(RedisReply);
- ParseError ConsumePartialIOBuf(butil::IOBuf& buf, int depth);
+ ParseError ConsumePartialIOBuf(butil::IOBuf& buf, int depth,
+ uint32_t* used_bytes);
void FormatStringImpl(const char* fmt, va_list args, RedisReplyType type);
void SetStringImpl(const butil::StringPiece& str, RedisReplyType type);
@@ -158,6 +159,10 @@ private:
const char* long_str;
struct {
int32_t last_index; // >= 0 if previous parsing suspends on
replies.
+ // Arena bytes charged to -redis_max_allocation_size by the reply
+ // tree so far. Only read/written on the root array of a reply
+ // whose parsing was suspended, to keep the budget across resumes.
+ uint32_t used_bytes;
RedisReply* replies;
} array;
uint64_t padding[2]; // For swapping, must cover all bytes.
@@ -176,6 +181,7 @@ inline void RedisReply::Reset() {
_type = REDIS_REPLY_NIL;
_length = 0;
_data.array.last_index = -1;
+ _data.array.used_bytes = 0;
_data.array.replies = nullptr;
// _arena should not be reset because further memory allocation needs it.
}
diff --git a/test/brpc_redis_unittest.cpp b/test/brpc_redis_unittest.cpp
index 724b5e77..b1c57067 100644
--- a/test/brpc_redis_unittest.cpp
+++ b/test/brpc_redis_unittest.cpp
@@ -116,6 +116,20 @@ private:
int32_t _old_depth;
};
+class ScopedRedisMaxAllocationSize {
+public:
+ explicit ScopedRedisMaxAllocationSize(int32_t size)
+ : _old_size(brpc::FLAGS_redis_max_allocation_size) {
+ brpc::FLAGS_redis_max_allocation_size = size;
+ }
+ ~ScopedRedisMaxAllocationSize() {
+ brpc::FLAGS_redis_max_allocation_size = _old_size;
+ }
+
+private:
+ int32_t _old_size;
+};
+
class RedisTest : public testing::Test {
protected:
RedisTest() {}
@@ -921,6 +935,120 @@ TEST_F(RedisTest, redis_reply_rejects_deep_nested_arrays)
{
EXPECT_TRUE(valid_reply.is_array());
}
+TEST_F(RedisTest, redis_reply_rejects_nested_array_memory_amplification) {
+ // Each array allocation below is individually within -redis_max_
+ // allocation_size, yet the total for one reply must not exceed it.
+ // Otherwise the per-allocation cap is multiplied by nesting depth and a
+ // tiny reply commits depth * cap bytes.
+ ScopedRedisMaxAllocationSize scoped_size(1024);
+ const int32_t count_at_cap =
+ brpc::FLAGS_redis_max_allocation_size / sizeof(brpc::RedisReply);
+
+ // One maximal array nested inside another.
+ {
+ butil::IOBuf buf;
+ buf.append("*2\r\n");
+ buf.append("*" + std::to_string(count_at_cap) + "\r\n");
+ butil::Arena arena;
+ brpc::RedisReply reply(&arena);
+ EXPECT_EQ(brpc::PARSE_ERROR_ABSOLUTELY_WRONG,
+ reply.ConsumePartialIOBuf(buf));
+ }
+
+ // The budget must survive suspension: the outer header arrives first,
+ // the inner one only after a resume. A fresh budget per call would let
+ // the peer reset it by withholding data.
+ {
+ butil::IOBuf buf;
+ buf.append("*2\r\n");
+ butil::Arena arena;
+ brpc::RedisReply reply(&arena);
+ EXPECT_EQ(brpc::PARSE_ERROR_NOT_ENOUGH_DATA,
+ reply.ConsumePartialIOBuf(buf));
+ buf.append("*" + std::to_string(count_at_cap) + "\r\n");
+ EXPECT_EQ(brpc::PARSE_ERROR_ABSOLUTELY_WRONG,
+ reply.ConsumePartialIOBuf(buf));
+ }
+
+ // Feeding one array header per call must not bypass the budget either.
+ {
+ butil::IOBuf buf;
+ butil::Arena arena;
+ brpc::RedisReply reply(&arena);
+ // Each "*2" costs sizeof(RedisReply) * 2 = 64 bytes, so the 17th
+ // nested array exceeds the 1024-byte budget.
+ for (int i = 0; i < 20; ++i) {
+ buf.append("*2\r\n");
+ brpc::ParseError err = reply.ConsumePartialIOBuf(buf);
+ if (i < 16) {
+ EXPECT_EQ(brpc::PARSE_ERROR_NOT_ENOUGH_DATA, err);
+ } else {
+ EXPECT_EQ(brpc::PARSE_ERROR_ABSOLUTELY_WRONG, err);
+ break;
+ }
+ }
+ }
+
+ // Valid replies are unaffected: a nested reply well within the budget...
+ {
+ butil::IOBuf buf;
+ buf.append("*2\r\n*2\r\n:1\r\n:2\r\n:3\r\n");
+ butil::Arena arena;
+ brpc::RedisReply reply(&arena);
+ EXPECT_EQ(brpc::PARSE_OK, reply.ConsumePartialIOBuf(buf));
+ EXPECT_TRUE(reply.is_array());
+ EXPECT_EQ(2u, reply.size());
+ EXPECT_EQ(2u, reply[0].size());
+ EXPECT_EQ(1, reply[0][0].integer());
+ }
+ // ... and a single flat array exactly at the budget boundary.
+ {
+ butil::IOBuf buf;
+ buf.append("*" + std::to_string(count_at_cap) + "\r\n");
+ for (int i = 0; i < count_at_cap; ++i) {
+ buf.append(":7\r\n");
+ }
+ butil::Arena arena;
+ brpc::RedisReply reply(&arena);
+ EXPECT_EQ(brpc::PARSE_OK, reply.ConsumePartialIOBuf(buf));
+ EXPECT_TRUE(reply.is_array());
+ EXPECT_EQ((size_t)count_at_cap, reply.size());
+ }
+}
+
+TEST_F(RedisTest, command_parser_does_not_preallocate_declared_args) {
+ // A declared RESP array count must not commit memory before the arguments
+ // actually arrive: "*<count>\r\n" used to resize _args upfront, costing
+ // up to -redis_max_allocation_size per connection from ~12 bytes.
+ ScopedRedisMaxAllocationSize scoped_size(1024 * 1024);
+ const int32_t count_at_cap =
+ brpc::FLAGS_redis_max_allocation_size / sizeof(butil::StringPiece);
+
+ brpc::RedisCommandParser parser;
+ butil::Arena arena;
+ butil::IOBuf buf;
+ buf.append("*" + std::to_string(count_at_cap) + "\r\n$3\r\nget\r\n");
+ std::vector<butil::StringPiece> args;
+ EXPECT_EQ(brpc::PARSE_ERROR_NOT_ENOUGH_DATA,
+ parser.Consume(buf, &args, &arena));
+ // Only the argument that has arrived is stored.
+ EXPECT_EQ(1u, parser.ParsedArgsSize());
+
+ // Complete commands still parse into the full argument list.
+ {
+ brpc::RedisCommandParser parser2;
+ butil::Arena arena2;
+ butil::IOBuf buf2;
+ buf2.append("*3\r\n$3\r\nget\r\n$3\r\nfoo\r\n$3\r\nbar\r\n");
+ std::vector<butil::StringPiece> args2;
+ EXPECT_EQ(brpc::PARSE_OK, parser2.Consume(buf2, &args2, &arena2));
+ ASSERT_EQ(3u, args2.size());
+ EXPECT_EQ("get", args2[0].as_string());
+ EXPECT_EQ("foo", args2[1].as_string());
+ EXPECT_EQ("bar", args2[2].as_string());
+ }
+}
+
butil::Mutex s_mutex;
std::unordered_map<std::string, std::string> m;
std::unordered_map<std::string, int64_t> int_map;
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]