Files
InstanceBrick--nightcap/tests/browser_tracker_service_test.cc
T
vii 894ebf8eb7 fix(services): create BrowserTracker storage directories private and usable
The service created its own credential directory with create_directories
and then refused it, because IsPrivateDirectory rejects a group-writable
path and mkdir masks its mode with the process umask. On a umask of 002
that failed every first run; a umask carrying owner bits left the
directory unwritable by its owner instead.

CreatePrivateDirectories creates each missing component and tightens it to
0700 through a descriptor opened O_DIRECTORY | O_NOFOLLOW. Components that
already exist keep their mode and are still judged by IsPrivateDirectory.
2026-09-16 02:28:02 -05:00

408 lines
16 KiB
C++

#include "services/browser_tracker_service.h"
#include <gtest/gtest.h>
#include <sys/stat.h>
#include <unistd.h>
#include <filesystem>
#include <fstream>
#include <functional>
#include <nlohmann/json.hpp>
namespace mocktail {
namespace services {
namespace {
std::string ReadFile(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary);
return std::string(std::istreambuf_iterator<char>(input),
std::istreambuf_iterator<char>());
}
class FakeHttpClient final : public HttpClient {
public:
HttpResponse Get(const HttpRequest&) override { return {}; }
HttpResponse Post(const HttpRequest& request,
const std::string& body) override {
++post_count;
last_request = request;
last_body = body;
if (on_post) on_post();
return response;
}
int post_count = 0;
HttpRequest last_request;
std::string last_body;
HttpResponse response;
std::function<void()> on_post;
};
class BrowserTrackerServiceTest : public testing::Test {
protected:
void SetUp() override {
// The service refuses to hold credentials in a group- or world-writable
// directory, so the directories these tests create must be private
// whatever umask the developer or CI runner happens to use.
previous_umask_ = umask(077);
root_ = std::filesystem::temp_directory_path() /
("mocktail-browser-tracker-" + std::to_string(getpid()) + "-" +
::testing::UnitTest::GetInstance()->current_test_info()->name());
std::filesystem::remove_all(root_);
}
void TearDown() override {
std::filesystem::remove_all(root_);
umask(previous_umask_);
}
std::filesystem::path Storage() const {
return root_ / "appData/LocalStorage/appStorage.json";
}
std::filesystem::path Cookies() const { return root_ / "roblox.cookie"; }
std::filesystem::path root_;
mode_t previous_umask_ = 0;
};
TEST_F(BrowserTrackerServiceTest, ReusesValidStoredIdentityWithoutNetwork) {
std::filesystem::create_directories(Storage().parent_path());
std::ofstream(Storage()) << R"({"BrowserTrackerId":"123456","keep":true})";
std::ofstream(Cookies()) << ".ROBLOSECURITY=secret; RBXEventTrackerV2="
"CreateDate=1&rbxid=2&browserid=123456";
FakeHttpClient http;
const BrowserTrackerResult result =
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(), true);
ASSERT_TRUE(result) << result.error;
EXPECT_EQ(result.status, BrowserTrackerStatus::kExisting);
EXPECT_EQ(result.browser_tracker_id, "123456");
EXPECT_EQ(http.post_count, 0);
}
TEST_F(BrowserTrackerServiceTest, InitializesAndAtomicallyPreservesStorage) {
std::filesystem::create_directories(Storage().parent_path());
std::ofstream(Storage()) << R"({"keep":"value"})";
std::ofstream(Cookies()) << ".ROBLOSECURITY=secret";
FakeHttpClient http;
http.response = {true,
200,
R"({"browserTrackerId":987654321})",
"",
{"Set-Cookie: RBXEventTrackerV2="
"CreateDate=1&rbxid=2&browserid=987654321; Path=/"}};
const BrowserTrackerResult result =
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(), true);
ASSERT_TRUE(result) << result.error;
EXPECT_EQ(result.status, BrowserTrackerStatus::kCreated);
EXPECT_EQ(result.browser_tracker_id, "987654321");
EXPECT_EQ(http.post_count, 1);
EXPECT_EQ(http.last_body, "{}");
EXPECT_NE(http.last_request.url.find("suggestedBrowserTrackerId="),
std::string::npos);
std::ifstream input(Storage());
const nlohmann::json saved = nlohmann::json::parse(input);
EXPECT_EQ(saved.at("keep"), "value");
EXPECT_EQ(saved.at("BrowserTrackerId"), "987654321");
EXPECT_FALSE(std::filesystem::exists(Storage().string() + ".mocktail.tmp"));
std::ifstream cookies(Cookies());
const std::string cookie_contents((std::istreambuf_iterator<char>(cookies)),
std::istreambuf_iterator<char>());
EXPECT_NE(cookie_contents.find("browserid=987654321"), std::string::npos);
}
TEST_F(BrowserTrackerServiceTest, CookieBrowserIdReconcilesStoredMismatch) {
std::filesystem::create_directories(Storage().parent_path());
std::ofstream(Storage()) << R"({"BrowserTrackerId":"111","keep":true})";
std::ofstream(Cookies())
<< "RBXEventTrackerV2=CreateDate=1&rbxid=2&browserid=222";
FakeHttpClient http;
const BrowserTrackerResult result =
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(), true);
ASSERT_TRUE(result) << result.error;
EXPECT_EQ(result.browser_tracker_id, "222");
EXPECT_EQ(result.status, BrowserTrackerStatus::kCreated);
EXPECT_EQ(http.post_count, 0);
std::ifstream input(Storage());
const nlohmann::json saved = nlohmann::json::parse(input);
EXPECT_EQ(saved.at("BrowserTrackerId"), "222");
EXPECT_TRUE(saved.at("keep"));
}
TEST_F(BrowserTrackerServiceTest, RejectsMalformedResponseWithoutMutation) {
FakeHttpClient http;
std::ofstream(Cookies()) << ".ROBLOSECURITY=secret";
http.response = {true,
200,
R"({"browserTrackerId":"12x"})",
"",
{"Set-Cookie: RBXEventTrackerV2="
"CreateDate=1&rbxid=2&browserid=12x; Path=/"}};
const BrowserTrackerResult result =
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(), true);
EXPECT_FALSE(result);
EXPECT_EQ(result.error,
"BrowserTracker response does not contain a valid numeric ID");
EXPECT_FALSE(std::filesystem::exists(Storage()));
}
TEST_F(BrowserTrackerServiceTest,
CookieRefreshDuringHttpIsPreservedWithoutRestoringStaleCredential) {
std::filesystem::create_directories(root_);
const std::string original = ".ROBLOSECURITY=rejected";
const std::string refreshed = ".ROBLOSECURITY=refreshed";
std::ofstream(Cookies()) << original;
FakeHttpClient http;
http.response = {true,
200,
R"({"browserTrackerId":987654321})",
"",
{"Set-Cookie: RBXEventTrackerV2="
"CreateDate=1&rbxid=2&browserid=987654321; Path=/"}};
http.on_post = [this, &refreshed]() {
std::ofstream(Cookies(), std::ios::trunc) << refreshed;
};
const BrowserTrackerResult result =
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(), true);
EXPECT_FALSE(result);
EXPECT_EQ(result.error, "cookie file changed during BrowserTracker update");
EXPECT_EQ(http.post_count, 1);
EXPECT_EQ(ReadFile(Cookies()), refreshed);
EXPECT_EQ(ReadFile(Cookies()).find("rejected"), std::string::npos);
EXPECT_FALSE(std::filesystem::exists(Storage()));
}
TEST_F(BrowserTrackerServiceTest, CreatesPrivateStorageDirectoryUnderAnyUmask) {
std::filesystem::create_directories(root_);
ASSERT_EQ(chmod(root_.c_str(), 0700), 0);
std::ofstream(Cookies()) << ".ROBLOSECURITY=secret";
FakeHttpClient http;
http.response = {true,
200,
R"({"browserTrackerId":987654321})",
"",
{"Set-Cookie: RBXEventTrackerV2="
"CreateDate=1&rbxid=2&browserid=987654321; Path=/"}};
// A permissive umask must not widen the directories the service creates for
// its own credentials; it refuses to use any that are group or world
// writable.
const mode_t previous_umask = umask(0);
const BrowserTrackerResult result =
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(), true);
umask(previous_umask);
ASSERT_TRUE(result) << result.error;
struct stat storage_parent = {};
ASSERT_EQ(stat(Storage().parent_path().c_str(), &storage_parent), 0);
EXPECT_EQ(storage_parent.st_mode & 0777, 0700U);
}
TEST_F(BrowserTrackerServiceTest,
CreatesUsableStorageDirectoryUnderRestrictiveUmask) {
std::filesystem::create_directories(root_);
ASSERT_EQ(chmod(root_.c_str(), 0700), 0);
std::ofstream(Cookies()) << ".ROBLOSECURITY=secret";
FakeHttpClient http;
http.response = {true,
200,
R"({"browserTrackerId":987654321})",
"",
{"Set-Cookie: RBXEventTrackerV2="
"CreateDate=1&rbxid=2&browserid=987654321; Path=/"}};
// mkdir applies the umask to its mode argument, so a umask that strips owner
// bits would otherwise leave the service's own directory unwritable.
const mode_t previous_umask = umask(0200);
const BrowserTrackerResult result =
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(), true);
umask(previous_umask);
ASSERT_TRUE(result) << result.error;
struct stat storage_parent = {};
ASSERT_EQ(stat(Storage().parent_path().c_str(), &storage_parent), 0);
EXPECT_EQ(storage_parent.st_mode & 0777, 0700U);
}
TEST_F(BrowserTrackerServiceTest, RejectsCorruptStoredIdentityFailClosed) {
std::filesystem::create_directories(Storage().parent_path());
std::ofstream(Storage()) << R"({"BrowserTrackerId":"0"})";
std::ofstream(Cookies()) << ".ROBLOSECURITY=secret";
FakeHttpClient http;
const BrowserTrackerResult result =
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(), true);
EXPECT_FALSE(result);
EXPECT_EQ(result.error, "appStorage BrowserTrackerId is invalid");
EXPECT_EQ(http.post_count, 0);
}
TEST_F(BrowserTrackerServiceTest, RejectsTrackerCookieWithInvalidBrowserId) {
std::filesystem::create_directories(root_);
std::ofstream(Cookies())
<< "RBXEventTrackerV2=CreateDate=1&rbxid=2&browserid=invalid";
FakeHttpClient http;
const BrowserTrackerResult result =
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(), true);
EXPECT_FALSE(result);
EXPECT_EQ(result.error, "RBXEventTrackerV2 cookie has an invalid browserid");
EXPECT_EQ(http.post_count, 0);
}
TEST_F(BrowserTrackerServiceTest, DoesNotMutateExternalCookieWithoutTracker) {
std::filesystem::create_directories(root_);
std::ofstream(Cookies()) << ".ROBLOSECURITY=secret";
FakeHttpClient http;
const BrowserTrackerResult result =
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(),
false);
EXPECT_FALSE(result);
EXPECT_EQ(result.error,
"external cookie file has no RBXEventTrackerV2 browser identity");
EXPECT_EQ(http.post_count, 0);
std::ifstream input(Cookies());
std::string contents;
std::getline(input, contents);
EXPECT_EQ(contents, ".ROBLOSECURITY=secret");
}
TEST_F(BrowserTrackerServiceTest,
LegacyStorageTemporaryHardlinkDoesNotMutateExternalCookie) {
std::filesystem::create_directories(Storage().parent_path());
std::ofstream(Storage()) << R"({"BrowserTrackerId":"111"})";
const std::string cookies =
".ROBLOSECURITY=protected; RBXEventTrackerV2="
"CreateDate=1&rbxid=2&browserid=222";
std::ofstream(Cookies()) << cookies;
ASSERT_EQ(chmod(Cookies().c_str(), S_IRUSR), 0);
const std::filesystem::path legacy_temporary =
Storage().string() + ".mocktail.tmp";
std::error_code filesystem_error;
std::filesystem::create_hard_link(Cookies(), legacy_temporary,
filesystem_error);
ASSERT_FALSE(filesystem_error);
FakeHttpClient http;
const BrowserTrackerResult result =
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(),
false);
ASSERT_TRUE(result) << result.error;
EXPECT_EQ(result.browser_tracker_id, "222");
EXPECT_EQ(result.status, BrowserTrackerStatus::kCreated);
EXPECT_EQ(http.post_count, 0);
EXPECT_EQ(ReadFile(Cookies()), cookies);
EXPECT_EQ(ReadFile(legacy_temporary), cookies);
struct stat metadata = {};
ASSERT_EQ(lstat(Cookies().c_str(), &metadata), 0);
EXPECT_EQ(metadata.st_mode & (S_IRWXU | S_IRWXG | S_IRWXO), S_IRUSR);
}
TEST_F(BrowserTrackerServiceTest,
LegacyCookieTemporaryHardlinkDoesNotMutateProtectedFile) {
std::filesystem::create_directories(root_);
std::ofstream(Cookies()) << ".ROBLOSECURITY=managed";
const std::filesystem::path protected_file = root_ / "protected-sober.cookie";
const std::string protected_contents = ".ROBLOSECURITY=protected";
std::ofstream(protected_file) << protected_contents;
ASSERT_EQ(chmod(protected_file.c_str(), S_IRUSR), 0);
const std::filesystem::path legacy_temporary =
Cookies().string() + ".mocktail.tmp";
std::error_code filesystem_error;
std::filesystem::create_hard_link(protected_file, legacy_temporary,
filesystem_error);
ASSERT_FALSE(filesystem_error);
FakeHttpClient http;
http.response = {true,
200,
R"({"browserTrackerId":987654321})",
"",
{"Set-Cookie: RBXEventTrackerV2="
"CreateDate=1&rbxid=2&browserid=987654321; Path=/"}};
const BrowserTrackerResult result =
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(), true);
ASSERT_TRUE(result) << result.error;
EXPECT_EQ(result.browser_tracker_id, "987654321");
EXPECT_EQ(http.post_count, 1);
EXPECT_EQ(ReadFile(protected_file), protected_contents);
EXPECT_EQ(ReadFile(legacy_temporary), protected_contents);
struct stat metadata = {};
ASSERT_EQ(lstat(protected_file.c_str(), &metadata), 0);
EXPECT_EQ(metadata.st_mode & (S_IRWXU | S_IRWXG | S_IRWXO), S_IRUSR);
}
TEST_F(BrowserTrackerServiceTest,
RejectsHardlinkedStorageDestinationWithoutMutation) {
std::filesystem::create_directories(Storage().parent_path());
const std::filesystem::path protected_storage = root_ / "protected-storage";
const std::string storage_contents = R"({"BrowserTrackerId":"111"})";
std::ofstream(protected_storage) << storage_contents;
ASSERT_EQ(chmod(protected_storage.c_str(), S_IRUSR), 0);
std::error_code filesystem_error;
std::filesystem::create_hard_link(protected_storage, Storage(),
filesystem_error);
ASSERT_FALSE(filesystem_error);
std::ofstream(Cookies())
<< "RBXEventTrackerV2=CreateDate=1&rbxid=2&browserid=222";
FakeHttpClient http;
const BrowserTrackerResult result =
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(), true);
EXPECT_FALSE(result);
EXPECT_EQ(result.error, "cannot atomically replace appStorage");
EXPECT_EQ(http.post_count, 0);
EXPECT_EQ(ReadFile(protected_storage), storage_contents);
EXPECT_EQ(ReadFile(Storage()), storage_contents);
struct stat metadata = {};
ASSERT_EQ(lstat(protected_storage.c_str(), &metadata), 0);
EXPECT_EQ(metadata.st_mode & (S_IRWXU | S_IRWXG | S_IRWXO), S_IRUSR);
}
TEST_F(BrowserTrackerServiceTest,
RejectsHardlinkedLockWithoutMutatingExternalCookie) {
std::filesystem::create_directories(Storage().parent_path());
std::ofstream(Storage()) << R"({"BrowserTrackerId":"222"})";
const std::string cookies =
".ROBLOSECURITY=protected; RBXEventTrackerV2="
"CreateDate=1&rbxid=2&browserid=222";
std::ofstream(Cookies()) << cookies;
ASSERT_EQ(chmod(Cookies().c_str(), S_IRUSR | S_IWUSR), 0);
std::error_code filesystem_error;
std::filesystem::create_hard_link(Cookies(), Storage().string() + ".lock",
filesystem_error);
ASSERT_FALSE(filesystem_error);
FakeHttpClient http;
const BrowserTrackerResult result =
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(),
false);
EXPECT_FALSE(result);
EXPECT_EQ(result.error, "cannot lock appStorage");
EXPECT_EQ(http.post_count, 0);
EXPECT_EQ(ReadFile(Cookies()), cookies);
struct stat metadata = {};
ASSERT_EQ(lstat(Cookies().c_str(), &metadata), 0);
EXPECT_EQ(metadata.st_mode & (S_IRWXU | S_IRWXG | S_IRWXO),
S_IRUSR | S_IWUSR);
}
} // namespace
} // namespace services
} // namespace mocktail