Skip to content

Commit 0ceeb67

Browse files
committed
Validate group identifiers before using them in filesystem paths
Group names received via the set-group command were only checked for being non-empty before being concatenated into temporary and shared configuration file paths, and into the group download URL. A malicious or compromised manager could send a group name such as '../../etc/wazuh-agent' to resolve the destination outside the shared configuration directory and have the agent write there. Add IsValidGroupId() to restrict group names to alphanumeric characters, '_' and '-', rejecting path separators, '..' and leading dots before any path construction. As defense in depth, normalize the resolved destination path and verify it stays under the shared configuration directory before creating directories or moving the file, and compare normalized paths in the temporary-file cleanup guard. Closes #858
1 parent ca21132 commit 0ceeb67

2 files changed

Lines changed: 76 additions & 12 deletions

File tree

src/agent/centralized_configuration/src/centralized_configuration.cpp

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
#include <filesystem_wrapper.hpp>
77
#include <logger.hpp>
88

9+
#include <algorithm>
10+
#include <cctype>
911
#include <chrono>
1012
#include <filesystem>
1113
#include <random>
@@ -26,6 +28,19 @@ namespace
2628

2729
return std::to_string(timestamp) + "_" + std::to_string(random);
2830
}
31+
32+
bool IsValidGroupId(const std::string& groupId)
33+
{
34+
if (groupId.empty() || groupId == "." || groupId == ".." || groupId.front() == '.')
35+
{
36+
return false;
37+
}
38+
39+
return std::all_of(groupId.begin(),
40+
groupId.end(),
41+
[](const unsigned char character)
42+
{ return std::isalnum(character) != 0 || character == '_' || character == '-'; });
43+
}
2944
} // namespace
3045

3146
namespace centralized_configuration
@@ -64,12 +79,12 @@ namespace centralized_configuration
6479
if (command == module_command::SET_GROUP_COMMAND)
6580
{
6681
groupIds = parameters.at(module_command::GROUPS_ARG).get<std::vector<std::string>>();
67-
if (!std::all_of(groupIds.begin(), groupIds.end(), [](const std::string& id) { return !id.empty(); }))
82+
if (!std::all_of(groupIds.begin(), groupIds.end(), IsValidGroupId))
6883
{
69-
LogWarn("Group name can not be an empty string.");
84+
LogWarn("Invalid group name. Group names may only contain alphanumeric characters, '_' or '-'.");
7085
co_return module_command::CommandExecutionResult {
7186
module_command::Status::FAILURE,
72-
"CentralizedConfiguration group set failed, a group name can not be an empty string."};
87+
"CentralizedConfiguration group set failed, invalid group name received."};
7388
}
7489

7590
if (!m_setGroupIdFunction(groupIds))
@@ -136,7 +151,8 @@ namespace centralized_configuration
136151
try
137152
{
138153
if (m_fileSystemWrapper->exists(tmpGroupFile) &&
139-
tmpGroupFile.parent_path() == m_fileSystemWrapper->temp_directory_path())
154+
tmpGroupFile.parent_path().lexically_normal() ==
155+
m_fileSystemWrapper->temp_directory_path().lexically_normal())
140156
{
141157
if (!m_fileSystemWrapper->remove(tmpGroupFile))
142158
{
@@ -156,8 +172,19 @@ namespace centralized_configuration
156172
"CentralizedConfiguration validate file failed, invalid file received."};
157173
}
158174

159-
const std::filesystem::path destGroupFile = std::filesystem::path(config::DEFAULT_SHARED_CONFIG_PATH) /
160-
(groupId + config::DEFAULT_SHARED_FILE_EXTENSION);
175+
const std::filesystem::path sharedConfigPath =
176+
std::filesystem::path(config::DEFAULT_SHARED_CONFIG_PATH);
177+
const std::filesystem::path destGroupFile =
178+
(sharedConfigPath / (groupId + config::DEFAULT_SHARED_FILE_EXTENSION)).lexically_normal();
179+
180+
if (destGroupFile.parent_path().lexically_normal() != sharedConfigPath.lexically_normal())
181+
{
182+
LogWarn("Resolved group file path is outside the shared configuration directory: {}",
183+
destGroupFile.string());
184+
co_return module_command::CommandExecutionResult {
185+
module_command::Status::FAILURE,
186+
"CentralizedConfiguration group set failed, invalid group destination path."};
187+
}
161188

162189
try
163190
{

src/agent/centralized_configuration/tests/centralized_configuration_tests.cpp

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -163,12 +163,49 @@ TEST(CentralizedConfiguration, ExecuteCommandReturnsFailureOnParseParameters)
163163
"CentralizedConfiguration error while parsing parameters");
164164

165165
const nlohmann::json parameterListCase3 = nlohmann::json::parse(R"({"groups":["", "group2"]})");
166-
co_await TestExecuteCommand(
167-
centralizedConfiguration,
168-
"set-group",
169-
parameterListCase3,
170-
module_command::Status::FAILURE,
171-
"CentralizedConfiguration group set failed, a group name can not be an empty string.");
166+
co_await TestExecuteCommand(centralizedConfiguration,
167+
"set-group",
168+
parameterListCase3,
169+
module_command::Status::FAILURE,
170+
"CentralizedConfiguration group set failed, invalid group name received.");
171+
}(),
172+
boost::asio::detached);
173+
174+
io_context.run();
175+
}
176+
177+
TEST(CentralizedConfiguration, ExecuteCommandRejectsInvalidGroupNames)
178+
{
179+
boost::asio::io_context io_context;
180+
181+
boost::asio::co_spawn(
182+
io_context,
183+
[]() -> boost::asio::awaitable<void>
184+
{
185+
CentralizedConfiguration centralizedConfiguration(
186+
[](const std::vector<std::string>&) { return true; },
187+
[]() { return std::vector<std::string> {}; },
188+
[](std::string, std::string) -> boost::asio::awaitable<bool> { co_return true; },
189+
[](const std::filesystem::path&) { return true; },
190+
[]() {});
191+
192+
const std::vector<nlohmann::json> invalidGroupCases = {
193+
nlohmann::json::parse(R"({"groups":["../../etc/wazuh-agent"]})"),
194+
nlohmann::json::parse(R"({"groups":["/etc/wazuh-agent"]})"),
195+
nlohmann::json::parse(R"({"groups":["..\\windows"]})"),
196+
nlohmann::json::parse(R"({"groups":[".."]})"),
197+
nlohmann::json::parse(R"({"groups":[".hidden"]})"),
198+
nlohmann::json::parse(R"({"groups":["group/../escape"]})"),
199+
nlohmann::json::parse(R"({"groups":["good", "bad/name"]})")};
200+
201+
for (const auto& invalidCase : invalidGroupCases)
202+
{
203+
co_await TestExecuteCommand(centralizedConfiguration,
204+
"set-group",
205+
invalidCase,
206+
module_command::Status::FAILURE,
207+
"CentralizedConfiguration group set failed, invalid group name received.");
208+
}
172209
}(),
173210
boost::asio::detached);
174211

0 commit comments

Comments
 (0)