Файл: system/security.php
Строк: 449
<?php
/* Обновление игры под PHP 8.4 / PDO и усиление безопасности: by Метриум (Стэлп) */
declare(strict_types=1);
function security_client_ip(): string {
$ip=$_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$cfg=require __DIR__.'/config.php';
if(($cfg['security']['trust_proxy'] ?? false) && !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$candidate=trim(explode(',',$_SERVER['HTTP_X_FORWARDED_FOR'])[0]);
if(filter_var($candidate,FILTER_VALIDATE_IP)) $ip=$candidate;
}
return filter_var($ip,FILTER_VALIDATE_IP) ? $ip : '0.0.0.0';
}
function security_bootstrap(): void {
if(PHP_SAPI==='cli') return;
if((int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 2*1024*1024){ http_response_code(413); exit('Слишком большой объём данных.'); }
if(strlen((string)($_SERVER['QUERY_STRING'] ?? '')) > 4096){ http_response_code(414); exit('Слишком длинные данные.'); }
ini_set('session.use_strict_mode','1');
ini_set('session.use_only_cookies','1');
ini_set('session.cookie_httponly','1');
ini_set('session.cookie_samesite','Lax');
if(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS']!=='off') ini_set('session.cookie_secure','1');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()');
if(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS']!=='off') header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
header('Cross-Origin-Opener-Policy: same-origin');
header("Content-Security-Policy: default-src 'self' https: data:; img-src 'self' https: data:; style-src 'self' 'unsafe-inline' https:; script-src 'self' 'unsafe-inline'; connect-src 'self' https:; form-action 'self' https:; frame-ancestors 'self'; object-src 'none'; base-uri 'self'");
}
/* Дополнительный request-firewall без изменения подключения к БД — by Метриум (Стэлп). */
function security_request_guard(): void {
if (PHP_SAPI === 'cli') return;
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
if (!in_array($method, ['GET','HEAD','POST'], true)) {
http_response_code(405); header('Allow: GET, HEAD, POST'); exit('Метод не разрешён.');
}
$uri = (string)($_SERVER['REQUEST_URI'] ?? '/');
if (str_contains($uri, " ") || preg_match('/[rn]/', $uri)) { http_response_code(400); exit('Действие не принято.'); }
foreach ([$_GET, $_POST, $_COOKIE] as $bag) {
if (!security_input_tree_ok($bag, 0)) { http_response_code(400); exit('Некорректные входные данные.'); }
}
if ($method === 'POST' && !security_same_origin_request()) {
http_response_code(403); exit('Действие не подтверждено. Повторите попытку.');
}
// Legacy-игра содержит GET-действия. Fetch Metadata блокирует их запуск с чужих сайтов,
// не меняя старые внутренние игровые ссылки.
$fetchSite = strtolower((string)($_SERVER['HTTP_SEC_FETCH_SITE'] ?? ''));
if ($method === 'GET' && security_query_looks_mutating($_GET)) {
// Защита старых GET-действий до их полного перевода на POST: внешний Origin/Referer
// или Fetch Metadata никогда не могут запускать покупку, бой, кик, принятие и т.п.
if ($fetchSite === 'cross-site' || !security_same_origin_request()) {
http_response_code(403); exit('Это действие нельзя выполнить из внешнего источника.');
}
}
}
function security_input_tree_ok(mixed $value, int $depth=0): bool {
if ($depth > 5) return false;
if (is_array($value)) {
if (count($value) > 150) return false;
foreach ($value as $k=>$v) {
if (strlen((string)$k) > 128 || !security_input_tree_ok($v, $depth+1)) return false;
}
return true;
}
if (is_string($value)) return strlen($value) <= 65535 && !str_contains($value, " ");
return true;
}
function security_same_origin_request(): bool {
$host = strtolower(preg_replace('/:d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? '')));
if ($host === '') return true;
foreach (['HTTP_ORIGIN','HTTP_REFERER'] as $key) {
$v=(string)($_SERVER[$key] ?? ''); if($v==='') continue;
$h=strtolower((string)(parse_url($v, PHP_URL_HOST) ?? ''));
return $h !== '' && hash_equals($host,$h);
}
$fetchSite=strtolower((string)($_SERVER['HTTP_SEC_FETCH_SITE'] ?? ''));
return !in_array($fetchSite,['cross-site'],true);
}
function security_query_looks_mutating(array $q): bool {
$keys=['buy','sell','wear','unwear','use','attack','fight','win','lose','upgrade','up','delete','del','kick','ban','mute','claim','reward','make','startMaking','heal','dodge','exit','go','vugn','stone','grass','kamen','trava','read_all','nread','sread','enter','zay','yes','invite','cancel_invite','clan_msg_read','clan_invite_accept','clan_invite_decline','buy_chest','complete','vizok','vizno','delit_to','stick','close','comment','act','take','join','leave','accept','reject','promote','demote','remove','confirm','cancel','pay','open','craft'];
foreach($keys as $k) if(array_key_exists($k,$q)) return true;
$action=strtolower((string)($q['action'] ?? ''));
return in_array($action,['buy','sell','delete','del','kick','ban','mute','upgrade','claim','fight','attack','exit','sent','lose','win','enter','join','leave','accept','reject','promote','demote','remove','confirm','cancel','pay','open','craft','start','finish'],true);
}
function security_sql_identifier(string $value, array $allowed=[]): ?string {
if ($allowed && !in_array($value,$allowed,true)) return null;
return preg_match('/^[A-Za-z0-9_]+$/D',$value) ? $value : null;
}
/**
* Безопасный рендер старых системных сообщений без зависимости от functions.php.
* Разрешает только локальные картинки, локальные ссылки, известные span-классы
* и небольшой белый список BB-кодов. by Метриум (Стэлп)
*/
function security_system_text(mixed $value): string {
$text=(string)$value;
$safe=[];
$put=function(string $html) use (&$safe): string { $k='%%SAFE'.count($safe).'%%'; $safe[$k]=$html; return $k; };
// Локальные картинки старого формата, включая атрибуты без кавычек.
$text=preg_replace_callback('~<img\s+[^>]*src\s*=\s*["\']?(/images/[A-Za-z0-9_./-]+)["\']?[^>]*>~i',
fn($m)=>$put('<img src="'.security_html($m[1]).'" alt="*">'),$text) ?? $text;
// Только внутренние ссылки игры.
$text=preg_replace_callback('~<a\s+[^>]*href\s*=\s*["\']?(/[^"\'\s>]+)["\']?[^>]*>(.*?)</a>~is',
function($m) use ($put){
$href=(string)$m[1];
if(str_starts_with($href,'//') || preg_match('~[\r\n<>]~',$href)) return security_html(strip_tags((string)$m[2]));
return $put('<a href="'.security_html($href).'">'.security_system_text($m[2]).'</a>');
},$text) ?? $text;
// Известные цветовые классы.
$text=preg_replace_callback('~<span\s+class\s*=\s*["\']?([A-Za-z0-9_-]+)["\']?\s*>(.*?)</span>~is',
function($m) use ($put){
$allowed=['dgreen','green','red','dred','blue','grey','yellow','orange','purple','white','bold','medium','small'];
$cls=strtolower((string)$m[1]);
if(!in_array($cls,$allowed,true)) return (string)$m[2];
return $put('<span class="'.$cls.'">'.security_system_text($m[2]).'</span>');
},$text) ?? $text;
foreach(['br','hr'] as $tag){
$text=preg_replace_callback('~<'.$tag.'\s*/?>~i',fn()=> $put('<'.$tag.'>'),$text) ?? $text;
}
foreach(['b','strong','i','em','u'] as $tag){
$text=preg_replace_callback('~<'.$tag.'>(.*?)</'.$tag.'>~is',fn($m)=>$put('<'.$tag.'>'.security_system_text($m[1]).'</'.$tag.'>'),$text) ?? $text;
}
$text=security_html($text);
// Небольшой безопасный BBCode для системных сообщений.
foreach(['b'=>'strong','i'=>'em','u'=>'u','s'=>'s'] as $bb=>$tag){
$text=preg_replace('~\['.$bb.'\](.*?)\[/'.$bb.'\]~is','<'.$tag.'>$1</'.$tag.'>',$text) ?? $text;
}
$text=preg_replace_callback('~\[color=([#A-Za-z0-9]{3,16})\](.*?)\[/color\]~is',function($m){
$c=(string)$m[1]; if(!preg_match('/^(?:#[0-9a-f]{3,8}|[a-z]{3,16})$/i',$c)) return $m[2];
return '<span style="color:'.security_html($c).'">'.$m[2].'</span>';
},$text) ?? $text;
$text=preg_replace_callback('~\[img\](/{1}images/[A-Za-z0-9_./-]+)\[/img\]~i',fn($m)=>'<img src="'.security_html($m[1]).'" alt="*">',$text) ?? $text;
$text=preg_replace_callback('~\[url=(/[^\]<>\s]+)\](.*?)\[/url\]~is',fn($m)=>'<a href="'.security_html($m[1]).'">'.$m[2].'</a>',$text) ?? $text;
$text=nl2br($text,false);
foreach($safe as $k=>$html) $text=str_replace($k,$html,$text);
return $text;
}
function game_flash_render_pending(): string {
$out='';
foreach(['err'=>'error','ok'=>'ok','info'=>'info'] as $key=>$cls){
if(!isset($_SESSION[$key]) || $_SESSION[$key]==='') continue;
$msg=(string)$_SESSION[$key]; unset($_SESSION[$key]);
// Системные сообщения игры могут содержать только ограниченную локальную разметку.
$safe=security_system_text($msg);
$out.='<div class="game-flash game-flash-'.$cls.'" role="alert">'.$safe.'</div>';
}
return $out;
}
function security_html(mixed $value): string {
return htmlspecialchars((string)$value, ENT_QUOTES|ENT_SUBSTITUTE, 'UTF-8');
}
function security_csrf_token(): string {
if(session_status()!==PHP_SESSION_ACTIVE) session_start();
if(empty($_SESSION['_csrf'])) $_SESSION['_csrf']=bin2hex(random_bytes(32));
return $_SESSION['_csrf'];
}
function security_csrf_field(): string { return '<input type="hidden" name="_csrf" value="'.htmlspecialchars(security_csrf_token(),ENT_QUOTES,'UTF-8').'">'; }
function security_require_csrf(): void {
if(($_SERVER['REQUEST_METHOD'] ?? 'GET')!=='POST') return;
$token=(string)($_POST['_csrf'] ?? '');
if(!hash_equals((string)($_SESSION['_csrf'] ?? ''),$token)){ http_response_code(419); exit('Сессия формы устарела. Обновите страницу и повторите действие.'); }
}
function security_rate_limit(string $bucket,int $limit,int $window): bool {
$dir=sys_get_temp_dir().'/game_rate';
if(!is_dir($dir) && !@mkdir($dir,0700,true) && !is_dir($dir)) return true;
$now=time();
// Периодически удаляем старые rate-limit файлы, чтобы temp-каталог не рос бесконечно.
static $cleaned=false;
if(!$cleaned){
$cleaned=true;
$marker=$dir.'/.cleanup';
$last=@filemtime($marker) ?: 0;
if($now-$last>21600){
foreach((array)@glob($dir.'/*.json') as $oldFile){
$mt=@filemtime($oldFile) ?: $now;
if($now-$mt>172800) @unlink($oldFile);
}
@touch($marker);
}
}
$key=hash('sha256',$bucket.'|'.security_client_ip()); $file=$dir.'/'.$key.'.json';
$fp=@fopen($file,'c+'); if(!$fp) return true;
if(!@flock($fp,LOCK_EX)){@fclose($fp);return true;}
$raw=@stream_get_contents($fp); $d=json_decode($raw?:'{}',true)?:[];
$start=(int)($d['start']??$now); $count=(int)($d['count']??0);
if($now-$start>=$window){$start=$now;$count=0;} $count++;
$payload=json_encode(['start'=>$start,'count'=>$count]);
$ok=@ftruncate($fp,0);
if($ok){@rewind($fp);$ok=@fwrite($fp,$payload)!==false;@fflush($fp);}
@flock($fp,LOCK_UN);@fclose($fp);
// Если хранилище временно недоступно/переполнено, не ломаем игру и не создаём поток Notice.
if(!$ok) return true;
return $count <= $limit;
}
function security_honeypot_ok(): bool { return empty($_POST['website']); }
function security_auth_sign(int $userId): string {
$cfg=require __DIR__.'/config.php';
$payload=$userId.'|'.time();
$sig=hash_hmac('sha256',$payload,(string)$cfg['app_key']);
return rtrim(strtr(base64_encode($payload.'|'.$sig),'+/','-_'),'=');
}
function security_auth_user_id(): int {
$token=(string)($_COOKIE['auth'] ?? ''); if($token==='') return 0;
$raw=base64_decode(strtr($token,'-_','+/'),true); if($raw===false) return 0;
$parts=explode('|',$raw); if(count($parts)!==3) return 0;
[$id,$issued,$sig]=$parts; if(!ctype_digit($id)||!ctype_digit($issued)) return 0;
if((int)$issued < time()-31536000) return 0;
$cfg=require __DIR__.'/config.php'; $exp=hash_hmac('sha256',$id.'|'.$issued,(string)$cfg['app_key']);
return hash_equals($exp,$sig)?(int)$id:0;
}
function security_auth_login(int $userId): void {
if(session_status()===PHP_SESSION_ACTIVE) session_regenerate_id(true);
$secure=!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS']!=='off';
setcookie('id',(string)$userId,['expires'=>time()+31536000,'path'=>'/','secure'=>$secure,'httponly'=>true,'samesite'=>'Lax']);
setcookie('auth',security_auth_sign($userId),['expires'=>time()+31536000,'path'=>'/','secure'=>$secure,'httponly'=>true,'samesite'=>'Lax']);
setcookie('password','',['expires'=>time()-3600,'path'=>'/','secure'=>$secure,'httponly'=>true,'samesite'=>'Lax']);
}
function security_auth_logout(): void {
foreach(['id','auth','password'] as $n) setcookie($n,'',['expires'=>time()-3600,'path'=>'/','httponly'=>true,'samesite'=>'Lax']);
}
function security_password_hash(string $password): string { return password_hash($password,PASSWORD_DEFAULT); }
function security_password_verify(string $password,string $hash): bool {
if(str_starts_with($hash,'$2') || str_starts_with($hash,'$argon2')) return password_verify($password,$hash);
return hash_equals($hash,md5(md5($password))); // migration-only legacy fallback
}
/** Одноразовый токен для цепочек регистрации/старта. */
function security_flow_token(string $name='start', bool $rotate=false): string {
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
$current = $_SESSION['_flow'][$name] ?? null;
if (!$rotate && is_array($current) && is_string($current['token'] ?? null) && (int)($current['expires'] ?? 0) >= time()) {
return (string)$current['token'];
}
$token = bin2hex(random_bytes(24));
$_SESSION['_flow'][$name] = ['token'=>$token,'expires'=>time()+(($name==='save')?3600:900)];
return $token;
}
function security_flow_valid(string $token,string $name='start',bool $consume=false): bool {
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
$row=$_SESSION['_flow'][$name] ?? null;
if(!is_array($row) || ($row['expires'] ?? 0) < time() || !is_string($row['token'] ?? null)) return false;
$ok=$token!=='' && hash_equals($row['token'],$token);
if($ok && $consume) unset($_SESSION['_flow'][$name]);
return $ok;
}
function security_app_key(): string {
$cfg=require __DIR__.'/config.php';
return (string)($cfg['app_key'] ?? '');
}
function security_password_reset_token(array $user,int $expires): string {
$payload=(int)$user['id'].'|'.$expires;
$sig=hash_hmac('sha256',$payload.'|'.(string)$user['password'],security_app_key());
return rtrim(strtr(base64_encode($payload.'|'.$sig),'+/','-_'),'=');
}
function security_password_reset_verify(string $token,array $user): bool {
$raw=base64_decode(strtr($token,'-_','+/'),true);
if($raw===false) return false;
$parts=explode('|',$raw);
if(count($parts)!==3 || !ctype_digit($parts[0]) || !ctype_digit($parts[1])) return false;
[$id,$expires,$sig]=$parts;
if((int)$id !== (int)$user['id'] || (int)$expires < time()) return false;
$expected=hash_hmac('sha256',$id.'|'.$expires.'|'.(string)$user['password'],security_app_key());
return hash_equals($expected,$sig);
}