This is an automated email from the ASF dual-hosted git repository.
chenBright 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 5b61020a Close two paths that reach services past their intended gate
(#3512)
5b61020a is described below
commit 5b61020a9533887bde2d8cc03f9306b6b7104906
Author: Bright Chen <[email protected]>
AuthorDate: Wed Sep 2 22:23:42 2026 +0800
Close two paths that reach services past their intended gate (#3512)
---
src/brpc/policy/http2_rpc_protocol.cpp | 10 +++
src/brpc/policy/http_rpc_protocol.cpp | 18 ++++++
test/brpc_http_message_unittest.cpp | 47 +++++++++++++-
test/brpc_http_rpc_protocol_unittest.cpp | 50 +++++++++++++++
test/brpc_server_unittest.cpp | 101 +++++++++++++++++++++++++++----
5 files changed, 211 insertions(+), 15 deletions(-)
diff --git a/src/brpc/policy/http2_rpc_protocol.cpp
b/src/brpc/policy/http2_rpc_protocol.cpp
index 2f4b5990..b20a0572 100644
--- a/src/brpc/policy/http2_rpc_protocol.cpp
+++ b/src/brpc/policy/http2_rpc_protocol.cpp
@@ -1373,6 +1373,16 @@ int
H2StreamContext::ConsumeHeaders(butil::IOBufBytesIterator& it) {
case 'p':
if (strcmp(name + 2, /*p*/"ath") == 0) {
matched = true;
+ // RFC 9113 8.3.1: :path MUST NOT be empty and MUST begin
+ // with '/', the only exception being the asterisk-form
+ // that OPTIONS uses. '*' is accepted for any method here,
+ // as http_parser does for HTTP/1: pinning it to OPTIONS
+ // would take the whole header block, since HPACK does not
+ // order pseudo-headers and :method may not have arrived.
+ if (pair.value != "*" && (pair.value.empty() ||
pair.value[0] != '/')) {
+ LOG(ERROR) << "Invalid path=" << pair.value;
+ return -1;
+ }
// Including path/query/fragment
h.uri().SetH2Path(pair.value);
}
diff --git a/src/brpc/policy/http_rpc_protocol.cpp
b/src/brpc/policy/http_rpc_protocol.cpp
index f09f2c83..4d1c8824 100644
--- a/src/brpc/policy/http_rpc_protocol.cpp
+++ b/src/brpc/policy/http_rpc_protocol.cpp
@@ -84,6 +84,13 @@ DEFINE_string(request_id_header, "x-request-id", "The http
header to mark a sess
DEFINE_bool(use_http_error_code, false, "Whether set the x-bd-error-code
header "
"of http response to brpc error code");
+DEFINE_bool(http_allow_empty_path_segments, false,
+ "Dispatch http paths containing empty segments (consecutive "
+ "slashes) as if the empty segments were absent. RFC 3986 treats "
+ "//foo and /foo as distinct paths, so accepting both lets a "
+ "request slip past a front proxy that only matches the collapsed "
+ "form. Turn this on to restore the old lenient behavior.");
+
// Read user address from the header specified by -http_header_of_user_ip
static bool GetUserAddressFromHeaderImpl(const HttpHeader& headers,
butil::EndPoint* user_addr) {
@@ -1159,6 +1166,17 @@ FindMethodPropertyByURIImpl(const std::string& uri_path,
const Server* server,
const Server::MethodProperty*
FindMethodPropertyByURI(const std::string& uri_path, const Server* server,
std::string* unresolved_path) {
+ // FindMethodPropertyByURIImpl() splits `uri_path` with a StringSplitter
+ // that skips empty fields, so //foo, /foo// and /foo//bar all resolve like
+ // their collapsed forms. A front proxy enforcing an ACL on the collapsed
+ // form does not match the padded ones and lets them through, which is how
+ // //flags?setvalue= reaches a builtin service that /flags cannot.
+ // Collapsing the path here would not help: the proxy has already passed
+ // the padded literal. Only rejecting it removes the differential.
+ if (!FLAGS_http_allow_empty_path_segments &&
+ uri_path.find("//") != std::string::npos) {
+ return nullptr;
+ }
const Server::MethodProperty* mp =
FindMethodPropertyByURIImpl(uri_path, server, unresolved_path);
if (mp != nullptr) {
diff --git a/test/brpc_http_message_unittest.cpp
b/test/brpc_http_message_unittest.cpp
index 87f3b373..951c9108 100644
--- a/test/brpc_http_message_unittest.cpp
+++ b/test/brpc_http_message_unittest.cpp
@@ -38,6 +38,7 @@ int main(int argc, char* argv[]) {
}
namespace policy {
+DECLARE_bool(http_allow_empty_path_segments);
Server::MethodProperty*
FindMethodPropertyByURI(const std::string& uri_path, const Server* server,
std::string* unknown_method_str);
@@ -442,8 +443,7 @@ TEST(HttpMessageTest, find_method_property_by_uri) {
ASSERT_EQ("index", mp->method->service()->name());
mp = FindMethodPropertyByURI("//", &server, nullptr);
- ASSERT_TRUE(mp);
- ASSERT_EQ("index", mp->method->service()->name());
+ ASSERT_FALSE(mp);
mp = FindMethodPropertyByURI("flags", &server, &unknown_method);
ASSERT_TRUE(mp);
@@ -484,6 +484,49 @@ TEST(HttpMessageTest, find_method_property_by_uri) {
ASSERT_FALSE(mp);
}
+// A path with empty segments is a different path per RFC 3986, but the
+// splitter used to resolve it skips them, so //flags used to reach the same
+// builtin service as /flags. That difference is what lets a request slip past
+// a front proxy whose ACL only matches the collapsed form, so such paths are
+// rejected rather than collapsed.
+TEST(HttpMessageTest, reject_empty_path_segments) {
+ brpc::Server server;
+ ASSERT_EQ(0, server.AddService(new test::EchoService(),
+ brpc::SERVER_OWNS_SERVICE));
+ ASSERT_EQ(0, server.Start("127.0.0.1:0", nullptr));
+ std::string unknown_method;
+
+ const char* const kRejected[] = {
+ "//",
+ "//flags",
+ "///flags",
+ "/flags//port",
+ "//EchoService/Echo",
+ "/EchoService//Echo",
+ "/EchoService/Echo//",
+ };
+ for (const char* path : kRejected) {
+ ASSERT_FALSE(FindMethodPropertyByURI(path, &server, &unknown_method))
+ << "path=" << path;
+ }
+
+ // The collapsed forms keep working.
+ ASSERT_TRUE(FindMethodPropertyByURI("/", &server, nullptr));
+ ASSERT_TRUE(FindMethodPropertyByURI("/flags/port", &server,
+ &unknown_method));
+ ASSERT_TRUE(FindMethodPropertyByURI("/EchoService/Echo", &server,
+ &unknown_method));
+
+ // -http_allow_empty_path_segments restores the old lenient behavior for
+ // deployments that depend on it.
+ brpc::policy::FLAGS_http_allow_empty_path_segments = true;
+ for (const char* path : kRejected) {
+ ASSERT_TRUE(FindMethodPropertyByURI(path, &server, &unknown_method))
+ << "path=" << path;
+ }
+ brpc::policy::FLAGS_http_allow_empty_path_segments = false;
+}
+
TEST(HttpMessageTest, http_header) {
brpc::HttpHeader header;
diff --git a/test/brpc_http_rpc_protocol_unittest.cpp
b/test/brpc_http_rpc_protocol_unittest.cpp
index 493418e6..e0c7b741 100644
--- a/test/brpc_http_rpc_protocol_unittest.cpp
+++ b/test/brpc_http_rpc_protocol_unittest.cpp
@@ -2356,6 +2356,56 @@ TEST_F(HttpTest, http2_handle_goaway_streams) {
}
}
+// RFC 9113 8.3.1: :path MUST NOT be empty and MUST begin with '/', the only
+// exception being the asterisk-form that OPTIONS uses.
+TEST_F(HttpTest, http2_reject_path_not_starting_with_slash) {
+ brpc::policy::H2Context* h2_ctx =
+ new brpc::policy::H2Context(_socket.get(), &_server);
+ ASSERT_EQ(0, h2_ctx->Init());
+ _socket->initialize_parsing_context(&h2_ctx);
+
+ // Encoding and decoding go through the same HPacker here, which is fine:
+ // it keeps the encoding and decoding tables apart and the header below is
+ // indexed into neither.
+ brpc::HPackOptions options;
+ options.index_policy = brpc::HPACK_NOT_INDEX_HEADER;
+
+ struct PathCase {
+ const char* path;
+ bool accepted;
+ };
+ const PathCase kCases[] = {
+ { "/flags", true },
+ { "/", true },
+ { "/flags?setvalue=1", true },
+ // Asterisk-form. Only OPTIONS may use it, but it is taken from any
+ // method here, see the comment on the check.
+ { "*", true },
+ { "flags", false },
+ { "", false },
+ { "flags/port", false },
+ { "*/flags", false },
+ { "http://somewhere/flags", false }, // absolute-form
+ };
+ int stream_id = 1;
+ for (const PathCase& c : kCases) {
+ butil::IOBufAppender appender;
+ brpc::HPacker::Header header(":path", c.path);
+ h2_ctx->hpacker().Encode(&appender, header, options);
+ butil::IOBuf buf;
+ appender.move_to(buf);
+ butil::IOBufBytesIterator it(buf);
+
+ brpc::policy::H2StreamContext* h2_msg =
+ new brpc::policy::H2StreamContext(false);
+ h2_msg->Init(h2_ctx, stream_id);
+ stream_id += 2;
+ ASSERT_EQ(c.accepted ? 0 : -1, h2_msg->ConsumeHeaders(it))
+ << "path=`" << c.path << '\'';
+ h2_msg->Destroy();
+ }
+}
+
TEST_F(HttpTest, spring_protobuf_content_type) {
const int port = 8923;
brpc::Server server;
diff --git a/test/brpc_server_unittest.cpp b/test/brpc_server_unittest.cpp
index 8e386b2a..4e64a851 100644
--- a/test/brpc_server_unittest.cpp
+++ b/test/brpc_server_unittest.cpp
@@ -70,6 +70,7 @@ DECLARE_bool(enable_dir_service);
namespace policy {
DECLARE_bool(use_http_error_code);
+DECLARE_bool(http_allow_empty_path_segments);
extern bool SerializeRpcMessage(const google::protobuf::Message& serializer,
Controller& cntl, ContentType content_type,
@@ -96,6 +97,22 @@ void* RunClosure(void* arg) {
bool g_verify_success = true;
const std::string g_unauthorized_error_text = "unauthorized";
+// Paths with empty segments (consecutive slashes) are rejected by default,
+// see -http_allow_empty_path_segments. Turns the leniency back on for the
+// duration of the scope.
+class AllowEmptyPathSegmentsScope {
+public:
+ AllowEmptyPathSegmentsScope()
+ : _saved(brpc::policy::FLAGS_http_allow_empty_path_segments) {
+ brpc::policy::FLAGS_http_allow_empty_path_segments = true;
+ }
+ ~AllowEmptyPathSegmentsScope() {
+ brpc::policy::FLAGS_http_allow_empty_path_segments = _saved;
+ }
+private:
+ const bool _saved;
+};
+
class MyAuthenticator : public brpc::Authenticator {
public:
MyAuthenticator() = default;
@@ -527,8 +544,21 @@ TEST_F(ServerTest, various_forms_of_uri_paths) {
cntl.http_request().set_method(brpc::HTTP_METHOD_POST);
cntl.request_attachment().append("{\"message\":\"foo\"}");
http_channel.CallMethod(nullptr, &cntl, nullptr, nullptr, nullptr);
- ASSERT_FALSE(cntl.Failed()) << cntl.ErrorText() <<
cntl.response_attachment();
- ASSERT_EQ(2, service_v1.ncalled.load());
+ ASSERT_TRUE(cntl.Failed());
+ ASSERT_EQ(brpc::EHTTP, cntl.ErrorCode());
+ LOG(INFO) << "Expected error: " << cntl.ErrorText();
+ ASSERT_EQ(1, service_v1.ncalled.load());
+
+ {
+ AllowEmptyPathSegmentsScope allow_empty_path_segments;
+ cntl.Reset();
+ cntl.http_request().uri() = "/EchoService///Echo//";
+ cntl.http_request().set_method(brpc::HTTP_METHOD_POST);
+ cntl.request_attachment().append("{\"message\":\"foo\"}");
+ http_channel.CallMethod(nullptr, &cntl, nullptr, nullptr, nullptr);
+ ASSERT_FALSE(cntl.Failed()) << cntl.ErrorText() <<
cntl.response_attachment();
+ ASSERT_EQ(2, service_v1.ncalled.load());
+ }
cntl.Reset();
cntl.http_request().uri() = "/EchoService /Echo/";
@@ -783,15 +813,31 @@ TEST_F(ServerTest, restful_mapping) {
ASSERT_EQ(2, service_v1.ncalled.load());
ASSERT_EQ("{\"message\":\"bar_v1\"}", cntl.response_attachment());
- // Adding extra slashes (and heading/trailing spaces) is OK.
+ // Heading/trailing spaces are OK, extra slashes are not: //v1/echo and
+ // /v1/echo are different paths per RFC 3986, and dispatching both to the
+ // same method lets a request slip past a front proxy whose ACL only
+ // matches the collapsed form.
cntl.Reset();
cntl.http_request().uri() = " //v1///echo//// ";
cntl.http_request().set_method(brpc::HTTP_METHOD_POST);
cntl.request_attachment().append("{\"message\":\"hello\"}");
http_channel.CallMethod(nullptr, &cntl, nullptr, nullptr, nullptr);
- ASSERT_FALSE(cntl.Failed()) << cntl.ErrorText();
- ASSERT_EQ(3, service_v1.ncalled.load());
- ASSERT_EQ("{\"message\":\"hello_v1\"}", cntl.response_attachment());
+ ASSERT_TRUE(cntl.Failed());
+ ASSERT_EQ(brpc::EHTTP, cntl.ErrorCode());
+ LOG(INFO) << "Expected error: " << cntl.ErrorText();
+ ASSERT_EQ(2, service_v1.ncalled.load());
+
+ {
+ AllowEmptyPathSegmentsScope allow_empty_path_segments;
+ cntl.Reset();
+ cntl.http_request().uri() = " //v1///echo//// ";
+ cntl.http_request().set_method(brpc::HTTP_METHOD_POST);
+ cntl.request_attachment().append("{\"message\":\"hello\"}");
+ http_channel.CallMethod(nullptr, &cntl, nullptr, nullptr, nullptr);
+ ASSERT_FALSE(cntl.Failed()) << cntl.ErrorText();
+ ASSERT_EQ(3, service_v1.ncalled.load());
+ ASSERT_EQ("{\"message\":\"hello_v1\"}", cntl.response_attachment());
+ }
// /v3/echo must be exactly matched.
cntl.Reset();
@@ -896,24 +942,53 @@ TEST_F(ServerTest, restful_mapping) {
ASSERT_EQ("{\"message\":\"1.flv_v1_Echo4\"}", cntl.response_attachment());
ASSERT_EQ(1, service_v1.ncalled_echo4.load());
+ // A path with empty segments is rejected by default, even when a restful
+ // mapping would match the collapsed form: //v6/d.flv and /v6/d.flv are
+ // different paths per RFC 3986, and dispatching both to the same method
+ // lets a request slip past a front proxy whose ACL only matches the
+ // collapsed form.
cntl.Reset();
cntl.http_request().uri() = "//v6//d.flv//";
cntl.http_request().set_method(brpc::HTTP_METHOD_POST);
cntl.request_attachment().append("{\"message\":\"d.flv\"}");
http_channel.CallMethod(nullptr, &cntl, nullptr, nullptr, nullptr);
- ASSERT_FALSE(cntl.Failed()) << cntl.ErrorText();
- ASSERT_EQ("{\"message\":\"d.flv_v1_Echo5\"}", cntl.response_attachment());
- ASSERT_EQ(1, service_v1.ncalled_echo5.load());
+ ASSERT_TRUE(cntl.Failed());
+ ASSERT_EQ(brpc::EHTTP, cntl.ErrorCode());
+ LOG(INFO) << "Expected error: " << cntl.ErrorText();
+ ASSERT_EQ(0, service_v1.ncalled_echo5.load());
- // matched the global restful map.
+ // Ditto for the global restful map.
cntl.Reset();
cntl.http_request().uri() = "//d.flv//";
cntl.http_request().set_method(brpc::HTTP_METHOD_POST);
cntl.request_attachment().append("{\"message\":\"d.flv\"}");
http_channel.CallMethod(nullptr, &cntl, nullptr, nullptr, nullptr);
- ASSERT_FALSE(cntl.Failed()) << cntl.ErrorText();
- ASSERT_EQ("{\"message\":\"d.flv_v1\"}", cntl.response_attachment());
- ASSERT_EQ(9, service_v1.ncalled.load());
+ ASSERT_TRUE(cntl.Failed());
+ ASSERT_EQ(brpc::EHTTP, cntl.ErrorCode());
+ LOG(INFO) << "Expected error: " << cntl.ErrorText();
+ ASSERT_EQ(8, service_v1.ncalled.load());
+
+ {
+ AllowEmptyPathSegmentsScope allow_empty_path_segments;
+ cntl.Reset();
+ cntl.http_request().uri() = "//v6//d.flv//";
+ cntl.http_request().set_method(brpc::HTTP_METHOD_POST);
+ cntl.request_attachment().append("{\"message\":\"d.flv\"}");
+ http_channel.CallMethod(nullptr, &cntl, nullptr, nullptr, nullptr);
+ ASSERT_FALSE(cntl.Failed()) << cntl.ErrorText();
+ ASSERT_EQ("{\"message\":\"d.flv_v1_Echo5\"}",
cntl.response_attachment());
+ ASSERT_EQ(1, service_v1.ncalled_echo5.load());
+
+ // matched the global restful map.
+ cntl.Reset();
+ cntl.http_request().uri() = "//d.flv//";
+ cntl.http_request().set_method(brpc::HTTP_METHOD_POST);
+ cntl.request_attachment().append("{\"message\":\"d.flv\"}");
+ http_channel.CallMethod(nullptr, &cntl, nullptr, nullptr, nullptr);
+ ASSERT_FALSE(cntl.Failed()) << cntl.ErrorText();
+ ASSERT_EQ("{\"message\":\"d.flv_v1\"}", cntl.response_attachment());
+ ASSERT_EQ(9, service_v1.ncalled.load());
+ }
cntl.Reset();
cntl.http_request().uri() = "/v7/e.flv";
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]