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}

${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;