Security-хардening: XSS в никах, non-root контейнер, rate-limiting, спавн-фикс

Клиент: ники других игроков рендерились через innerHTML без экранирования — XSS.
Добавлен escapeHtml() для nickname и в списке игроков комнаты.

Сервер:
- Санитайзинг ника и на бэкенде (обрезка длины, вырезание control/HTML
  символов) — защита не только для этого клиента, но и для любых будущих
  потребителей (логи, другие рендеры).
- onMessage обёрнут в try/catch: payload.value<T>() кидает json::type_error
  на несовпадении типа поля — одно кривое сообщение могло уронить процесс
  со всеми активными играми.
- Лимит на общее число комнат (200) — иначе клиент, спамящий
  lobby.create_room, разгонял rooms_ до исчерпания памяти.
- Fix: одна из раскладок coop-карты клала укрытие поверх стартовой позиции
  игрока — танк рождался внутри кирпича и не мог выехать. Тайл под каждым
  стартовым танком теперь расчищается после построения карты.

Docker: gameserver теперь работает от непривилегированного пользователя,
а не от root.

nginx: rate-limiting на /ws (5 подключений/с на IP, до 20 одновременных) —
защита от флуда соединениями/комнатами; CSP и security-заголовки
(X-Content-Type-Options, X-Frame-Options, Referrer-Policy).
This commit is contained in:
2026-08-06 14:00:31 +05:00
parent 1fc7af9ba0
commit 35041c7d48
6 changed files with 102 additions and 20 deletions
+3 -1
View File
@@ -17,7 +17,9 @@ RUN cmake --build build --target gameserver -j"$(nproc)"
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends zlib1g \
&& rm -rf /var/lib/apt/lists/*
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --no-create-home --shell /usr/sbin/nologin gameserver
COPY --from=build /app/build/gameserver /usr/local/bin/gameserver
USER gameserver
EXPOSE 9001
CMD ["gameserver"]
+15
View File
@@ -45,6 +45,21 @@ Game::Game(Room &room, std::function<void()> onFinished)
}
tanks_.push_back(t);
}
// На coop-карте укрытие у базы (в т.ч. кирпичная стена над ней) кладётся
// фиксированным блоком независимо от того, что уже занято, и один из
// вариантов раскладки перекрывал стартовый тайл игрока — танк рождался
// внутри кирпича и не мог выехать (rectHitsSolid блокировал любой шаг,
// т.к. новая позиция всё ещё пересекала тот же тайл). Расчищаем тайл под
// каждым стартовым танком уже после того, как вся карта построена, чтобы
// спавн был гарантированно проходим при любой раскладке.
for (const auto &t : tanks_) {
int tx = (int)std::floor(t.x);
int ty = (int)std::floor(t.y);
if (ty >= 0 && ty < kMapHeight && tx >= 0 && tx < kMapWidth) {
map_[ty][tx] = kEmpty;
}
}
}
Game::~Game() {
+53 -17
View File
@@ -92,31 +92,63 @@ void Lobby::onMessage(WS *ws, std::string_view message) {
json payload = request.value("payload", json::object());
if (type == "hello") {
handleHello(ws, payload);
} else if (type == "lobby.list_rooms") {
handleListRooms(ws);
} else if (type == "lobby.create_room") {
handleCreateRoom(ws, payload);
} else if (type == "lobby.join_room") {
handleJoinRoom(ws, payload);
} else if (type == "lobby.leave_room") {
handleLeaveRoom(ws);
} else if (type == "game.ready") {
handleGameReady(ws);
} else if (type == "game.input") {
handleGameInput(ws, payload);
} else {
sendError(ws, "unknown_type", "unknown message type: " + type);
try {
if (type == "hello") {
handleHello(ws, payload);
} else if (type == "lobby.list_rooms") {
handleListRooms(ws);
} else if (type == "lobby.create_room") {
handleCreateRoom(ws, payload);
} else if (type == "lobby.join_room") {
handleJoinRoom(ws, payload);
} else if (type == "lobby.leave_room") {
handleLeaveRoom(ws);
} else if (type == "game.ready") {
handleGameReady(ws);
} else if (type == "game.input") {
handleGameInput(ws, payload);
} else {
sendError(ws, "unknown_type", "unknown message type: " + type);
}
} catch (const std::exception &e) {
// payload может быть не тем типом, что ожидает хендлер (например,
// payload.value<T>() на не-объекте/не том типе поля) — это кидает
// json::type_error. Не даём одному кривому сообщению уронить процесс
// со всеми активными играми.
sendError(ws, "bad_payload", e.what());
}
}
namespace {
// Ник рассылается другим игрокам и рендерится на клиенте как текст — режем
// длину и выкидываем управляющие/разметочные символы, чтобы кривой или
// специально вредоносный ник от одного игрока не ломал UI остальных.
std::string sanitizeNickname(std::string nickname) {
constexpr size_t kMaxLen = 20;
if (nickname.empty()) {
return "anon";
}
std::string out;
out.reserve(std::min(nickname.size(), kMaxLen));
for (unsigned char c : nickname) {
if (out.size() >= kMaxLen) {
break;
}
if (c == '<' || c == '>' || c == '&' || c == '"' || c == '\'' || c < 0x20) {
continue;
}
out.push_back(static_cast<char>(c));
}
return out.empty() ? "anon" : out;
}
} // namespace
void Lobby::handleHello(WS *ws, const json &payload) {
auto *data = ws->getUserData();
if (data->player_id.empty()) {
data->player_id = "player-" + std::to_string(next_player_id_++);
}
data->nickname = payload.value("nickname", "anon");
data->nickname = sanitizeNickname(payload.value("nickname", "anon"));
send(ws, "hello.ack", {{"player_id", data->player_id}});
}
@@ -140,6 +172,10 @@ void Lobby::handleCreateRoom(WS *ws, const json &payload) {
sendError(ws, "invalid_mode", "mode must be \"coop\" or \"pvp\"");
return;
}
if (rooms_.size() >= kMaxRooms) {
sendError(ws, "too_many_rooms", "server is at capacity, try again later");
return;
}
Room room;
room.id = generateRoomId();
+3
View File
@@ -46,6 +46,9 @@ public:
private:
static constexpr size_t kMaxPlayersPerRoom = 2;
// Верхняя граница на общее число комнат — без неё клиент, создающий и не
// покидающий комнаты, мог бы разогнать rooms_ до исчерпания памяти.
static constexpr size_t kMaxRooms = 200;
std::unordered_map<std::string, Room> rooms_;
std::unordered_map<std::string, std::unique_ptr<Game>> games_;