Files
preland/admin/tests/transport-command-limit.php
Panfilov Konstantin 4fc4fd8162
deploy-preland / deploy zoomstage.online (push) Successful in 1m3s
fix(admin): route multi-megabyte sets to SFTP before shell escaping
2026-10-25 22:14:15 +03:00

98 lines
8.4 KiB
PHP

<?php
/** #22: сервер разрывает SSH exec длиннее 8 KiB; helper и payload не теряются. */
declare(strict_types=1);
namespace phpseclib3\Net {
class SSH2 {
public static array $commands=[];
public static bool $withoutZlib=false;
public static bool $dropInbox=false;
public function __construct(...$args) {}
public function getServerPublicHostKey(): string {return 'ssh-ed25519 '.base64_encode('fixture-key');}
public function login(...$args): bool {return true;}
public function setTimeout($seconds): void {}
public function isTimeout(): bool {return false;}
public function exec(string $command): string|false {
self::$commands[]=$command;
if(strlen($command)>8192)return false; // фактическая граница REG.RU, старая реализация падает
if(self::$dropInbox&&str_contains($command,'/inbox-'))return false;
if(self::$withoutZlib)$command=preg_replace('/^php /','php -d disable_functions=gzinflate ',$command);
$process=proc_open($command,[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
fclose($pipes[0]);$output=stream_get_contents($pipes[1]);$error=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);proc_close($process);
if($error!=='')throw new \RuntimeException($error);
return $output;
}
}
class SFTP extends SSH2 {
public static array $uploads=[];
public function put(string $path,string $bytes): bool {self::$uploads[]=$path;return file_put_contents($path,$bytes)!==false;}
public function chmod(int $mode,string $path): bool {return chmod($path,$mode);}
public function delete(string $path): bool {return !file_exists($path)||unlink($path);}
}
}
namespace {
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Store,Transport,Problem};
use phpseclib3\Net\{SSH2,SFTP};
function ensure(bool $condition,string $message): void {if(!$condition)throw new RuntimeException($message);echo 'PASS: '.$message."\n";}
$fixture=sys_get_temp_dir().'/preland22-command-'.bin2hex(random_bytes(6));mkdir($fixture);mkdir($fixture.'/site');
$site=$fixture.'/site';file_put_contents($site.'/config.json','{"host":"initial.example","utm":{}}');
$store=new Store($fixture.'/state');
$target=['id'=>'limit','type'=>'sftp','host'=>'fixture','port'=>22,'user'=>'fixture','secret'=>'fixture','host_key'=>'ssh-ed25519 '.base64_encode('fixture-key'),'root'=>$site];
$transport=new Transport($store,$target);
try {
$transport->test();$before=$transport->readConfig();
ensure($before['config']->host==='initial.example','compressed helper reads under SSH 8KiB limit');
$transport->writeConfig($before['version'],'{"host":"small.example","utm":{}}');
ensure($transport->readConfig()['config']->host==='small.example','compressed RPC performs CAS write/read');
$large=json_encode(['host'=>'large.example','utm'=>(object)[],'padding'=>str_repeat('abcdefgh',3000)],JSON_THROW_ON_ERROR);
$before=$transport->readConfig();$transport->writeConfig($before['version'],$large);
ensure($transport->readConfig()['content']===$large,'large payload uses inbox and preserves bytes');
ensure(count(SFTP::$uploads)===1,'large payload uploaded through SFTP exactly once');
foreach(SFTP::$uploads as $path)ensure(!file_exists($path),'SFTP inbox removed after RPC');
$maximum=max(array_map('strlen',SSH2::$commands));
ensure($maximum<=7000,'all final shell commands stay within 7000 bytes (maximum '.$maximum.')');
$commandCount=count(SSH2::$commands);$uploads=count(SFTP::$uploads);
// Inline test может оказаться длиннее inbox runner из-за base64 длинного
// root, даже когда сам inbox помещается: этот preflight нельзя рекурсировать.
$longRootTarget=$target;$longRootTarget['root']='/'.str_repeat('a',1800);
try{(new Transport($store,$longRootTarget))->test();throw new RuntimeException('oversized test recursed');}
catch(Problem $error){ensure(str_contains($error->getMessage(),'SSH bootstrap'),'oversized test RPC rejected before inbox/preflight recursion');}
ensure(count(SSH2::$commands)===$commandCount&&count(SFTP::$uploads)===$uploads,'oversized test performs no exec/upload');
$bootstrap=new ReflectionMethod(Transport::class,'inboxCommand');
try{$bootstrap->invoke(null,'/*'.base64_encode(random_bytes(16000)).'*/',$site.'/inbox-test');throw new RuntimeException('oversized bootstrap accepted');}
catch(Problem $error){ensure(str_contains($error->getMessage(),'bootstrap'),'oversized helper rejected before recursive preflight');}
ensure(count(SSH2::$commands)===$commandCount&&count(SFTP::$uploads)===$uploads,'oversized bootstrap performs no remote call/upload');
$before=$transport->readConfig();SSH2::$dropInbox=true;
try{$transport->writeConfig($before['version'],$large);throw new RuntimeException('dropped exec accepted');}
catch(Problem $error){ensure(str_contains($error->getMessage(),'Неизвестный результат'),'dropped inbox exec remains unknown result');}
SSH2::$dropInbox=false;
foreach(SFTP::$uploads as $path)ensure(!file_exists($path),'inbox cleaned even when exec is interrupted');
SSH2::$withoutZlib=true;
try{$transport->test();throw new RuntimeException('missing zlib accepted');}catch(Problem $error){ensure(str_contains($error->getMessage(),'zlib'),'missing gzinflate returns clear structured error');}
SSH2::$withoutZlib=false;
// Настоящие 90 изображений: два слоя base64 дают аргумент заметно больше PHP shell-limit 2 MiB.
$sourceSet=dirname(__DIR__,2).'/1';$files=[];
foreach(array_keys(\PrelandAdmin\IndpicSetStorage::expectedDimensions()) as $filename){$bytes=file_get_contents($sourceSet.'/'.$filename);$files[$filename]=['data'=>base64_encode($bytes),'sha256'=>hash('sha256',$bytes)];}
$hugePayload=['op'=>'set-write','number'=>3,'files'=>$files,'expected_manifest'=>null];
ensure(count($files)===90&&strlen(base64_encode(json_encode($hugePayload,JSON_THROW_ON_ERROR)))>2097152,'real 90-image set-write payload exceeds native 2 MiB shell argument limit');
$uploadsBeforeHuge=count(SFTP::$uploads);$configBeforeHuge=$transport->readConfig();
$transport->call($hugePayload);
ensure(\PrelandAdmin\Remote::manifest($site.'/3')===\PrelandAdmin\Remote::manifest($sourceSet),'multi-MiB set-write activates all 90 real files with exact hashes');
ensure(count(SFTP::$uploads)===$uploadsBeforeHuge+1,'multi-MiB set-write uses exactly one SFTP inbox');
$hugeInbox=SFTP::$uploads[array_key_last(SFTP::$uploads)];
ensure(!file_exists($hugeInbox),'multi-MiB set-write inbox removed after actual RPC');
ensure(max(array_map('strlen',SSH2::$commands))<=7000,'multi-MiB set-write final exec stays below bootstrap limit');
ensure($transport->readConfig()['version']===$configBeforeHuge['version'],'image upload preserves config and the 2 MiB config contract');
// Обрыв до исполнения большой команды не должен трогать опубликованный набор; inbox удаляется.
$hugePayload['expected_manifest']=\PrelandAdmin\Remote::manifest($site.'/3');$keptManifest=$hugePayload['expected_manifest'];SSH2::$dropInbox=true;
try{$transport->call($hugePayload);throw new RuntimeException('dropped multi-MiB set-write accepted');}
catch(Problem $error){ensure($error->status===504,'interrupted multi-MiB set-write remains unknown, not false success');}
finally{SSH2::$dropInbox=false;}
ensure(\PrelandAdmin\Remote::manifest($site.'/3')===$keptManifest,'interrupted multi-MiB exec preserves previously active set');
ensure(!file_exists(SFTP::$uploads[array_key_last(SFTP::$uploads)]),'interrupted multi-MiB set-write cleans private inbox');
} finally {
$iterator=new RecursiveIteratorIterator(new RecursiveDirectoryIterator($fixture,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST);
foreach($iterator as $file)$file->isDir()?rmdir($file->getPathname()):unlink($file->getPathname());rmdir($fixture);
}
}