diff --git a/frontend/js/game/lobby.js b/frontend/js/game/lobby.js
index f0a096f..9740df2 100644
--- a/frontend/js/game/lobby.js
+++ b/frontend/js/game/lobby.js
@@ -13,6 +13,18 @@ let errorMessage = '';
let connected = false;
let nickname = 'cacto-' + Math.floor(Math.random() * 1000);
+// Ники приходят от других игроков (сервер их только режет по длине, не
+// экранирует) и попадают сюда через innerHTML — без экранирования это XSS.
+function escapeHtml(str) {
+ return String(str).replace(/[&<>"']/g, (c) => ({
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ }[c]));
+}
+
function roomRow(room) {
const full = room.players >= room.max_players;
return `
@@ -37,7 +49,7 @@ function renderLobbyView() {
${connected ? '' : '
подключение...
'}
-
+
@@ -66,7 +78,7 @@ function renderRoomView() {
room ${currentRoom.name}
режим: ${currentRoom.mode}
- ${currentRoom.players.map((p) => `- ${p.nickname}${p.ready ? ' — готов' : ''}
`).join('')}
+ ${currentRoom.players.map((p) => `- ${escapeHtml(p.nickname)}${p.ready ? ' — готов' : ''}
`).join('')}
${statusLine}
${readyBtn}
diff --git a/gameserver/Dockerfile b/gameserver/Dockerfile
index d218779..5f858c8 100644
--- a/gameserver/Dockerfile
+++ b/gameserver/Dockerfile
@@ -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"]
diff --git a/gameserver/src/Game.cpp b/gameserver/src/Game.cpp
index 6551cd9..2cdd79d 100644
--- a/gameserver/src/Game.cpp
+++ b/gameserver/src/Game.cpp
@@ -45,6 +45,21 @@ Game::Game(Room &room, std::function
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() {
diff --git a/gameserver/src/Lobby.cpp b/gameserver/src/Lobby.cpp
index fff334c..07307bd 100644
--- a/gameserver/src/Lobby.cpp
+++ b/gameserver/src/Lobby.cpp
@@ -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() на не-объекте/не том типе поля) — это кидает
+ // 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(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();
diff --git a/gameserver/src/Lobby.hpp b/gameserver/src/Lobby.hpp
index c2d592e..19872ce 100644
--- a/gameserver/src/Lobby.hpp
+++ b/gameserver/src/Lobby.hpp
@@ -46,6 +46,9 @@ public:
private:
static constexpr size_t kMaxPlayersPerRoom = 2;
+ // Верхняя граница на общее число комнат — без неё клиент, создающий и не
+ // покидающий комнаты, мог бы разогнать rooms_ до исчерпания памяти.
+ static constexpr size_t kMaxRooms = 200;
std::unordered_map rooms_;
std::unordered_map> games_;
diff --git a/nginx/nginx.conf b/nginx/nginx.conf
index 87b33b5..de476fd 100644
--- a/nginx/nginx.conf
+++ b/nginx/nginx.conf
@@ -1,9 +1,20 @@
+# на IP: не более 5 новых /ws соединений в секунду, всплеск до 10 без задержки
+limit_req_zone $binary_remote_addr zone=ws_connect:10m rate=5r/s;
+# на IP: не более 20 одновременных /ws соединений — не даёт одному клиенту
+# открыть тысячи сокетов и завалить lobby.create_room комнатами до OOM
+limit_conn_zone $binary_remote_addr zone=ws_conn:10m;
+
server {
listen 80;
server_name cactoz.su www.cactoz.su;
root /usr/share/nginx/html;
index index.html;
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options DENY always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Content-Security-Policy "default-src 'self'; connect-src 'self' wss://cactoz.su wss://www.cactoz.su; img-src 'self' data:; style-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'" always;
+
location / {
try_files $uri $uri.html $uri/ =404;
}
@@ -15,6 +26,9 @@ server {
}
location /ws {
+ limit_req zone=ws_connect burst=10 nodelay;
+ limit_conn ws_conn 20;
+
proxy_pass http://gameserver:9001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;