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:
@@ -13,6 +13,18 @@ let errorMessage = '';
|
|||||||
let connected = false;
|
let connected = false;
|
||||||
let nickname = 'cacto-' + Math.floor(Math.random() * 1000);
|
let nickname = 'cacto-' + Math.floor(Math.random() * 1000);
|
||||||
|
|
||||||
|
// Ники приходят от других игроков (сервер их только режет по длине, не
|
||||||
|
// экранирует) и попадают сюда через innerHTML — без экранирования это XSS.
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str).replace(/[&<>"']/g, (c) => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
function roomRow(room) {
|
function roomRow(room) {
|
||||||
const full = room.players >= room.max_players;
|
const full = room.players >= room.max_players;
|
||||||
return `
|
return `
|
||||||
@@ -37,7 +49,7 @@ function renderLobbyView() {
|
|||||||
${connected ? '' : '<p class="lobby-error">подключение...</p>'}
|
${connected ? '' : '<p class="lobby-error">подключение...</p>'}
|
||||||
<div class="lobby-panel__row">
|
<div class="lobby-panel__row">
|
||||||
<label class="prompt-inline">nickname</label>
|
<label class="prompt-inline">nickname</label>
|
||||||
<input id="nickname-input" type="text" value="${nickname}" maxlength="20" ${dis} />
|
<input id="nickname-input" type="text" value="${escapeHtml(nickname)}" maxlength="20" ${dis} />
|
||||||
</div>
|
</div>
|
||||||
<div class="lobby-panel__row">
|
<div class="lobby-panel__row">
|
||||||
<button data-action="create" data-mode="coop" ${dis}>создать комнату (coop)</button>
|
<button data-action="create" data-mode="coop" ${dis}>создать комнату (coop)</button>
|
||||||
@@ -66,7 +78,7 @@ function renderRoomView() {
|
|||||||
<h3 class="prompt">room ${currentRoom.name}</h3>
|
<h3 class="prompt">room ${currentRoom.name}</h3>
|
||||||
<p>режим: <span class="tag">${currentRoom.mode}</span></p>
|
<p>режим: <span class="tag">${currentRoom.mode}</span></p>
|
||||||
<ul class="room-players">
|
<ul class="room-players">
|
||||||
${currentRoom.players.map((p) => `<li>${p.nickname}${p.ready ? ' — готов' : ''}</li>`).join('')}
|
${currentRoom.players.map((p) => `<li>${escapeHtml(p.nickname)}${p.ready ? ' — готов' : ''}</li>`).join('')}
|
||||||
</ul>
|
</ul>
|
||||||
${statusLine}
|
${statusLine}
|
||||||
${readyBtn}
|
${readyBtn}
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ RUN cmake --build build --target gameserver -j"$(nproc)"
|
|||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends zlib1g \
|
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
|
COPY --from=build /app/build/gameserver /usr/local/bin/gameserver
|
||||||
|
USER gameserver
|
||||||
EXPOSE 9001
|
EXPOSE 9001
|
||||||
CMD ["gameserver"]
|
CMD ["gameserver"]
|
||||||
|
|||||||
@@ -45,6 +45,21 @@ Game::Game(Room &room, std::function<void()> onFinished)
|
|||||||
}
|
}
|
||||||
tanks_.push_back(t);
|
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() {
|
Game::~Game() {
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ void Lobby::onMessage(WS *ws, std::string_view message) {
|
|||||||
|
|
||||||
json payload = request.value("payload", json::object());
|
json payload = request.value("payload", json::object());
|
||||||
|
|
||||||
|
try {
|
||||||
if (type == "hello") {
|
if (type == "hello") {
|
||||||
handleHello(ws, payload);
|
handleHello(ws, payload);
|
||||||
} else if (type == "lobby.list_rooms") {
|
} else if (type == "lobby.list_rooms") {
|
||||||
@@ -109,14 +110,45 @@ void Lobby::onMessage(WS *ws, std::string_view message) {
|
|||||||
} else {
|
} else {
|
||||||
sendError(ws, "unknown_type", "unknown message type: " + type);
|
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) {
|
void Lobby::handleHello(WS *ws, const json &payload) {
|
||||||
auto *data = ws->getUserData();
|
auto *data = ws->getUserData();
|
||||||
if (data->player_id.empty()) {
|
if (data->player_id.empty()) {
|
||||||
data->player_id = "player-" + std::to_string(next_player_id_++);
|
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}});
|
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\"");
|
sendError(ws, "invalid_mode", "mode must be \"coop\" or \"pvp\"");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (rooms_.size() >= kMaxRooms) {
|
||||||
|
sendError(ws, "too_many_rooms", "server is at capacity, try again later");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Room room;
|
Room room;
|
||||||
room.id = generateRoomId();
|
room.id = generateRoomId();
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
static constexpr size_t kMaxPlayersPerRoom = 2;
|
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, Room> rooms_;
|
||||||
std::unordered_map<std::string, std::unique_ptr<Game>> games_;
|
std::unordered_map<std::string, std::unique_ptr<Game>> games_;
|
||||||
|
|||||||
@@ -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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name cactoz.su www.cactoz.su;
|
server_name cactoz.su www.cactoz.su;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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 / {
|
location / {
|
||||||
try_files $uri $uri.html $uri/ =404;
|
try_files $uri $uri.html $uri/ =404;
|
||||||
}
|
}
|
||||||
@@ -15,6 +26,9 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /ws {
|
location /ws {
|
||||||
|
limit_req zone=ws_connect burst=10 nodelay;
|
||||||
|
limit_conn ws_conn 20;
|
||||||
|
|
||||||
proxy_pass http://gameserver:9001;
|
proxy_pass http://gameserver:9001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
|||||||
Reference in New Issue
Block a user