Merge "Use Shellbox for Shell::command() etc."
This commit is contained in:
commit
c8a32fa7e3
19 changed files with 368 additions and 1101 deletions
|
|
@ -81,6 +81,10 @@ this is no longer recommended and the option has been removed.
|
|||
the Unicode NFC normalization done on inputs of type 'string', so it more
|
||||
suitable when the input is binary or may contain deprecated Unicode
|
||||
sequences or characters (such as U+2001) that should be passed unmodified.
|
||||
* A new abstraction for running shell commands has been introduced, called
|
||||
BoxedCommand. A BoxedCommand object can be obtained with
|
||||
MediaWikiServices::getInstance()->getCommandFactory()->createBoxed().
|
||||
(T260330)
|
||||
* …
|
||||
|
||||
=== External library changes in 1.36 ===
|
||||
|
|
@ -88,6 +92,7 @@ this is no longer recommended and the option has been removed.
|
|||
|
||||
==== New external libraries ====
|
||||
* Added pear/net_url2 2.2.2.
|
||||
* Added wikimedia/shellbox
|
||||
* …
|
||||
|
||||
===== New development-only external libraries =====
|
||||
|
|
@ -367,6 +372,9 @@ because of Phabricator reports.
|
|||
in public MediaWiki-related git, was removed.
|
||||
* Passing Title as a second parameter to RevisionStore::getPreviousRevision and
|
||||
getNextRevision, hard deprecated since 1.31, was prohibited.
|
||||
* The internal class FirejailCommand was removed.
|
||||
* Command::execute() now returns a Shellbox\Command\UnboxedResult instead of a
|
||||
MediaWiki\Shell\Result. Any type hints should be updated.
|
||||
* …
|
||||
|
||||
=== Deprecations in 1.36 ===
|
||||
|
|
@ -507,6 +515,9 @@ because of Phabricator reports.
|
|||
ProtectionFormAddFormFields hook instead.
|
||||
* RevisionStore::newMutableRevisionFromArray has beed hard deprecated. Instead,
|
||||
MutableRevisionRecord should be constructed directly via constructor.
|
||||
* Command::cgroup() is deprecated and no longer functional. $wgShellCgroup is
|
||||
now implemented as an Executor option.
|
||||
* Command::restrict() is deprecated. Instead use the new separate accessors.
|
||||
* …
|
||||
|
||||
=== Other changes in 1.36 ===
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@
|
|||
"wikimedia/running-stat": "1.2.1",
|
||||
"wikimedia/scoped-callback": "3.0.0",
|
||||
"wikimedia/services": "2.0.1",
|
||||
"wikimedia/shellbox": "1.0.1",
|
||||
"wikimedia/utfnormal": "3.0.1",
|
||||
"wikimedia/timestamp": "3.0.0",
|
||||
"wikimedia/wait-condition-loop": "1.0.1",
|
||||
|
|
|
|||
|
|
@ -8854,6 +8854,27 @@ $wgShellLocale = 'C.UTF-8';
|
|||
*/
|
||||
$wgShellRestrictionMethod = 'autodetect';
|
||||
|
||||
/**
|
||||
* Shell commands can be run on a remote server using Shellbox. To use this
|
||||
* feature, set this to the URL, and also configure $wgShellboxSecretKey.
|
||||
*
|
||||
* For more information about installing Shellbox, see
|
||||
* https://www.mediawiki.org/wiki/Shellbox
|
||||
*
|
||||
* @since 1.36
|
||||
* @var string|null
|
||||
*/
|
||||
$wgShellboxUrl = null;
|
||||
|
||||
/**
|
||||
* The secret key for HMAC verification of Shellbox requests. Set this to
|
||||
* a long random string.
|
||||
*
|
||||
* @since 1.36
|
||||
* @var string|null
|
||||
*/
|
||||
$wgShellboxSecretKey = null;
|
||||
|
||||
// endregion -- end Shell and process control
|
||||
|
||||
/***************************************************************************/
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ use MediaWiki\Revision\RevisionStore;
|
|||
use MediaWiki\Revision\RevisionStoreFactory;
|
||||
use MediaWiki\Revision\SlotRoleRegistry;
|
||||
use MediaWiki\Shell\CommandFactory;
|
||||
use MediaWiki\Shell\ShellboxClientFactory;
|
||||
use MediaWiki\SpecialPage\SpecialPageFactory;
|
||||
use MediaWiki\Storage\BlobStore;
|
||||
use MediaWiki\Storage\BlobStoreFactory;
|
||||
|
|
@ -1296,6 +1297,14 @@ class MediaWikiServices extends ServiceContainer {
|
|||
return $this->getService( 'SearchEngineFactory' );
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 1.36
|
||||
* @return ShellboxClientFactory
|
||||
*/
|
||||
public function getShellboxClientFactory() : ShellboxClientFactory {
|
||||
return $this->getService( 'ShellboxClientFactory' );
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 1.30
|
||||
* @return CommandFactory
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ use MediaWiki\Revision\RevisionStore;
|
|||
use MediaWiki\Revision\RevisionStoreFactory;
|
||||
use MediaWiki\Revision\SlotRoleRegistry;
|
||||
use MediaWiki\Shell\CommandFactory;
|
||||
use MediaWiki\Shell\ShellboxClientFactory;
|
||||
use MediaWiki\SpecialPage\SpecialPageFactory;
|
||||
use MediaWiki\Storage\BlobStore;
|
||||
use MediaWiki\Storage\BlobStoreFactory;
|
||||
|
|
@ -1219,6 +1220,14 @@ return [
|
|||
);
|
||||
},
|
||||
|
||||
'ShellboxClientFactory' => function ( MediaWikiServices $services ) : ShellboxClientFactory {
|
||||
return new ShellboxClientFactory(
|
||||
$services->getHttpRequestFactory(),
|
||||
$services->getMainConfig()->get( 'ShellboxUrl' ),
|
||||
$services->getMainConfig()->get( 'ShellboxSecretKey' )
|
||||
);
|
||||
},
|
||||
|
||||
'ShellCommandFactory' => function ( MediaWikiServices $services ) : CommandFactory {
|
||||
$config = $services->getMainConfig();
|
||||
|
||||
|
|
@ -1231,7 +1240,8 @@ return [
|
|||
$cgroup = $config->get( 'ShellCgroup' );
|
||||
$restrictionMethod = $config->get( 'ShellRestrictionMethod' );
|
||||
|
||||
$factory = new CommandFactory( $limits, $cgroup, $restrictionMethod );
|
||||
$factory = new CommandFactory( $services->getShellboxClientFactory(),
|
||||
$limits, $cgroup, $restrictionMethod );
|
||||
$factory->setLogger( LoggerFactory::getInstance( 'exec' ) );
|
||||
$factory->logStderr();
|
||||
|
||||
|
|
|
|||
|
|
@ -24,78 +24,40 @@ use Exception;
|
|||
use MediaWiki\ProcOpenError;
|
||||
use MediaWiki\ShellDisabledError;
|
||||
use Profiler;
|
||||
use Psr\Log\LoggerAwareTrait;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Wikimedia\AtEase\AtEase;
|
||||
use Shellbox\Command\UnboxedCommand;
|
||||
use Shellbox\Command\UnboxedExecutor;
|
||||
use Shellbox\Command\UnboxedResult;
|
||||
use Wikimedia\ScopedCallback;
|
||||
|
||||
/**
|
||||
* Class used for executing shell commands
|
||||
*
|
||||
* @since 1.30
|
||||
*/
|
||||
class Command {
|
||||
use LoggerAwareTrait;
|
||||
|
||||
/** @var string */
|
||||
protected $command = '';
|
||||
|
||||
/** @var array */
|
||||
private $limits = [
|
||||
// seconds
|
||||
'time' => 180,
|
||||
// seconds
|
||||
'walltime' => 180,
|
||||
// KB
|
||||
'memory' => 307200,
|
||||
// KB
|
||||
'filesize' => 102400,
|
||||
];
|
||||
|
||||
/** @var string[] */
|
||||
private $env = [];
|
||||
class Command extends UnboxedCommand {
|
||||
/** @var bool */
|
||||
private $everExecuted = false;
|
||||
|
||||
/** @var string */
|
||||
private $method;
|
||||
|
||||
/** @var string */
|
||||
private $inputString = '';
|
||||
|
||||
/** @var bool */
|
||||
private $doIncludeStderr = false;
|
||||
|
||||
/** @var bool */
|
||||
private $doLogStderr = false;
|
||||
|
||||
/** @var bool */
|
||||
private $doPassStdin = false;
|
||||
|
||||
/** @var bool */
|
||||
private $doPassStderr = false;
|
||||
|
||||
/** @var bool */
|
||||
private $everExecuted = false;
|
||||
|
||||
/** @var string|false */
|
||||
private $cgroup = false;
|
||||
|
||||
/**
|
||||
* Bitfield with restrictions
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $restrictions = 0;
|
||||
/** @var LoggerInterface */
|
||||
protected $logger;
|
||||
|
||||
/**
|
||||
* Don't call directly, instead use Shell::command()
|
||||
*
|
||||
* @param UnboxedExecutor $executor
|
||||
* @throws ShellDisabledError
|
||||
*/
|
||||
public function __construct() {
|
||||
public function __construct( UnboxedExecutor $executor ) {
|
||||
if ( Shell::isDisabled() ) {
|
||||
throw new ShellDisabledError();
|
||||
}
|
||||
|
||||
$this->setLogger( new NullLogger() );
|
||||
parent::__construct( $executor );
|
||||
$this->setLogger( new NullLogger );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -103,7 +65,7 @@ class Command {
|
|||
*/
|
||||
public function __destruct() {
|
||||
if ( !$this->everExecuted ) {
|
||||
$context = [ 'command' => $this->command ];
|
||||
$context = [ 'command' => $this->getCommandString() ];
|
||||
$message = __CLASS__ . " was instantiated, but execute() was never called.";
|
||||
if ( $this->method ) {
|
||||
$message .= ' Calling method: {method}.';
|
||||
|
|
@ -114,45 +76,11 @@ class Command {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds parameters to the command. All parameters are sanitized via Shell::escape().
|
||||
* Null values are ignored.
|
||||
*
|
||||
* @param string|string[] ...$args
|
||||
* @return $this
|
||||
*/
|
||||
public function params( ...$args ): Command {
|
||||
if ( count( $args ) === 1 && is_array( reset( $args ) ) ) {
|
||||
// If only one argument has been passed, and that argument is an array,
|
||||
// treat it as a list of arguments
|
||||
$args = reset( $args );
|
||||
public function setLogger( LoggerInterface $logger ) {
|
||||
$this->logger = $logger;
|
||||
if ( $this->executor ) {
|
||||
$this->executor->setLogger( $logger );
|
||||
}
|
||||
$this->command = trim( $this->command . ' ' . Shell::escape( $args ) );
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds unsafe parameters to the command. These parameters are NOT sanitized in any way.
|
||||
* Null values are ignored.
|
||||
*
|
||||
* @param string|string[] ...$args
|
||||
* @return $this
|
||||
*/
|
||||
public function unsafeParams( ...$args ): Command {
|
||||
if ( count( $args ) === 1 && is_array( reset( $args ) ) ) {
|
||||
// If only one argument has been passed, and that argument is an array,
|
||||
// treat it as a list of arguments
|
||||
$args = reset( $args );
|
||||
}
|
||||
$args = array_filter( $args,
|
||||
function ( $value ) {
|
||||
return $value !== null;
|
||||
}
|
||||
);
|
||||
$this->command = trim( $this->command . ' ' . implode( ' ', $args ) );
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -168,19 +96,18 @@ class Command {
|
|||
// if the latter was overridden and the former wasn't
|
||||
$limits['walltime'] = $limits['time'];
|
||||
}
|
||||
$this->limits = $limits + $this->limits;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets environment variables which should be added to the executed command environment
|
||||
*
|
||||
* @param string[] $env array of variable name => value
|
||||
* @return $this
|
||||
*/
|
||||
public function environment( array $env ): Command {
|
||||
$this->env = $env;
|
||||
if ( isset( $limits['filesize'] ) ) {
|
||||
$this->fileSizeLimit( $limits['filesize'] * 1024 );
|
||||
}
|
||||
if ( isset( $limits['memory'] ) ) {
|
||||
$this->memoryLimit( $limits['memory'] * 1024 );
|
||||
}
|
||||
if ( isset( $limits['time'] ) ) {
|
||||
$this->cpuTimeLimit( $limits['time'] );
|
||||
}
|
||||
if ( isset( $limits['walltime'] ) ) {
|
||||
$this->wallTimeLimit( $limits['walltime'] );
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
|
@ -206,77 +133,19 @@ class Command {
|
|||
* @return $this
|
||||
*/
|
||||
public function input( string $inputString ): Command {
|
||||
$this->inputString = $inputString;
|
||||
|
||||
return $this;
|
||||
return $this->stdin( $inputString );
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls whether stdin is passed through to the command, so that the
|
||||
* user can interact with the command when it is run in CLI mode. If this
|
||||
* is enabled:
|
||||
* - The wall clock timeout will be disabled to avoid stopping the
|
||||
* process with SIGTTIN/SIGTTOU (T206957).
|
||||
* - The string specified with input() will be ignored.
|
||||
*
|
||||
* @param bool $yesno
|
||||
* @return $this
|
||||
*/
|
||||
public function passStdin( bool $yesno = true ): Command {
|
||||
$this->doPassStdin = $yesno;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* If this is set to true, text written to stderr by the command will be
|
||||
* passed through to PHP's stderr. To avoid SIGTTIN/SIGTTOU, and to support
|
||||
* Result::getStderr(), the file descriptor is not passed through, we just
|
||||
* copy the data to stderr as we receive it.
|
||||
*
|
||||
* @param bool $yesno
|
||||
* @return $this
|
||||
*/
|
||||
public function forwardStderr( bool $yesno = true ): Command {
|
||||
$this->doPassStderr = $yesno;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls whether stderr should be included in stdout, including errors from limit.sh.
|
||||
* Default: don't include.
|
||||
*
|
||||
* @param bool $yesno
|
||||
* @return $this
|
||||
*/
|
||||
public function includeStderr( bool $yesno = true ): Command {
|
||||
$this->doIncludeStderr = $yesno;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* When enabled, text sent to stderr will be logged with a level of 'error'.
|
||||
*
|
||||
* @param bool $yesno
|
||||
* @return $this
|
||||
*/
|
||||
public function logStderr( bool $yesno = true ): Command {
|
||||
$this->doLogStderr = $yesno;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets cgroup for this command
|
||||
* Sets cgroup for this command. Has no effect since MW 1.36. This setting
|
||||
* is injected into the executor from CommandFactory instead.
|
||||
*
|
||||
* @deprecated since 1.36
|
||||
* @param string|false $cgroup Absolute file path to the cgroup, or false to not use a cgroup
|
||||
* @return $this
|
||||
*/
|
||||
public function cgroup( $cgroup ): Command {
|
||||
$this->cgroup = $cgroup;
|
||||
|
||||
wfDeprecated( __METHOD__, '1.36' );
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
@ -298,324 +167,66 @@ class Command {
|
|||
* $command->restrict( Shell::RESTRICT_NONE );
|
||||
* @endcode
|
||||
*
|
||||
* @deprecated since 1.36 Set the options using their separate accessors
|
||||
*
|
||||
* @since 1.31
|
||||
* @param int $restrictions
|
||||
* @return $this
|
||||
*/
|
||||
public function restrict( int $restrictions ): Command {
|
||||
$this->restrictions = $restrictions;
|
||||
$this->privateUserNamespace( (bool)( $restrictions & Shell::NO_ROOT ) );
|
||||
$this->firejailDefaultSeccomp( (bool)( $restrictions & Shell::SECCOMP ) );
|
||||
$this->noNewPrivs( (bool)( $restrictions & Shell::SECCOMP ) );
|
||||
$this->privateDev( (bool)( $restrictions & Shell::PRIVATE_DEV ) );
|
||||
$this->disableNetwork( (bool)( $restrictions & Shell::NO_NETWORK ) );
|
||||
if ( $restrictions & Shell::NO_EXECVE ) {
|
||||
$this->disabledSyscalls( [ 'execve' ] );
|
||||
} else {
|
||||
$this->disabledSyscalls( [] );
|
||||
}
|
||||
if ( $restrictions & Shell::NO_LOCALSETTINGS ) {
|
||||
$this->disallowedPaths( [ realpath( MW_CONFIG_FILE ) ] );
|
||||
} else {
|
||||
$this->disallowedPaths( [] );
|
||||
}
|
||||
if ( $restrictions === 0 ) {
|
||||
$this->disableSandbox();
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bitfield helper on whether a specific restriction is enabled
|
||||
*
|
||||
* @param int $restriction
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function hasRestriction( int $restriction ): bool {
|
||||
return ( $this->restrictions & $restriction ) === $restriction;
|
||||
}
|
||||
|
||||
/**
|
||||
* If called, only the files/directories that are
|
||||
* whitelisted will be available to the shell command.
|
||||
*
|
||||
* limit.sh will always be whitelisted
|
||||
*
|
||||
* @deprecated since 1.36 Use allowPath/disallowPath
|
||||
* @param string[] $paths
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function whitelistPaths( array $paths ): Command {
|
||||
// Default implementation is a no-op
|
||||
$this->allowedPaths( array_merge( $this->getAllowedPaths(), $paths ) );
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* String together all the options and build the final command
|
||||
* to execute
|
||||
*
|
||||
* @param string $command Already-escaped command to run
|
||||
* @return array [ command, whether to use log pipe ]
|
||||
*/
|
||||
protected function buildFinalCommand( string $command ): array {
|
||||
$envcmd = '';
|
||||
foreach ( $this->env as $k => $v ) {
|
||||
if ( wfIsWindows() ) {
|
||||
/* Surrounding a set in quotes (method used by wfEscapeShellArg) makes the quotes themselves
|
||||
* appear in the environment variable, so we must use carat escaping as documented in
|
||||
* https://technet.microsoft.com/en-us/library/cc723564.aspx
|
||||
* Note however that the quote isn't listed there, but is needed, and the parentheses
|
||||
* are listed there but doesn't appear to need it.
|
||||
*/
|
||||
$envcmd .= "set $k=" . preg_replace( '/([&|()<>^"])/', '^\\1', $v ) . '&& ';
|
||||
} else {
|
||||
/* Assume this is a POSIX shell, thus required to accept variable assignments before the command
|
||||
* http://www.opengroup.org/onlinepubs/009695399/utilities/xcu_chap02.html#tag_02_09_01
|
||||
*/
|
||||
$envcmd .= "$k=" . escapeshellarg( $v ) . ' ';
|
||||
}
|
||||
}
|
||||
|
||||
$useLogPipe = false;
|
||||
$cmd = $envcmd . trim( $command );
|
||||
|
||||
if ( is_executable( '/bin/bash' ) ) {
|
||||
$time = intval( $this->limits['time'] );
|
||||
$wallTime = $this->doPassStdin ? 0 : intval( $this->limits['walltime'] );
|
||||
$mem = intval( $this->limits['memory'] );
|
||||
$filesize = intval( $this->limits['filesize'] );
|
||||
|
||||
if ( $time > 0 || $mem > 0 || $filesize > 0 || $wallTime > 0 ) {
|
||||
$cmd = '/bin/bash ' . escapeshellarg( __DIR__ . '/limit.sh' ) . ' ' .
|
||||
escapeshellarg( $cmd ) . ' ' .
|
||||
escapeshellarg(
|
||||
"MW_INCLUDE_STDERR=" . ( $this->doIncludeStderr ? '1' : '' ) . ';' .
|
||||
"MW_CPU_LIMIT=$time; " .
|
||||
'MW_CGROUP=' . escapeshellarg( $this->cgroup ) . '; ' .
|
||||
"MW_MEM_LIMIT=$mem; " .
|
||||
"MW_FILE_SIZE_LIMIT=$filesize; " .
|
||||
"MW_WALL_CLOCK_LIMIT=$wallTime; " .
|
||||
"MW_USE_LOG_PIPE=yes"
|
||||
);
|
||||
$useLogPipe = true;
|
||||
}
|
||||
}
|
||||
if ( !$useLogPipe && $this->doIncludeStderr ) {
|
||||
$cmd .= ' 2>&1';
|
||||
}
|
||||
|
||||
if ( wfIsWindows() ) {
|
||||
$cmd = 'cmd.exe /c "' . $cmd . '"';
|
||||
}
|
||||
|
||||
return [ $cmd, $useLogPipe ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes command. Afterwards, getExitCode() and getOutput() can be used to access execution
|
||||
* results.
|
||||
*
|
||||
* @return Result
|
||||
* @return UnboxedResult
|
||||
* @throws Exception
|
||||
* @throws ProcOpenError
|
||||
* @throws ShellDisabledError
|
||||
*/
|
||||
public function execute(): Result {
|
||||
public function execute(): UnboxedResult {
|
||||
$this->everExecuted = true;
|
||||
|
||||
$profileMethod = $this->method ?: wfGetCaller();
|
||||
|
||||
list( $cmd, $useLogPipe ) = $this->buildFinalCommand( $this->command );
|
||||
|
||||
$this->logger->debug( __METHOD__ . ": $cmd" );
|
||||
|
||||
// Don't try to execute commands that exceed Linux's MAX_ARG_STRLEN.
|
||||
// Other platforms may be more accomodating, but we don't want to be
|
||||
// accomodating, because very long commands probably include user
|
||||
// input. See T129506.
|
||||
if ( strlen( $cmd ) > SHELL_MAX_ARG_STRLEN ) {
|
||||
throw new Exception( __METHOD__ .
|
||||
'(): total length of $cmd must not exceed SHELL_MAX_ARG_STRLEN' );
|
||||
}
|
||||
|
||||
$desc = [
|
||||
0 => $this->doPassStdin ? [ 'file', 'php://stdin', 'r' ] : [ 'pipe', 'r' ],
|
||||
1 => [ 'pipe', 'w' ],
|
||||
2 => [ 'pipe', 'w' ],
|
||||
];
|
||||
if ( $useLogPipe ) {
|
||||
$desc[3] = [ 'pipe', 'w' ];
|
||||
}
|
||||
$pipes = null;
|
||||
$scoped = Profiler::instance()->scopedProfileIn( __FUNCTION__ . '-' . $profileMethod );
|
||||
$proc = null;
|
||||
|
||||
if ( wfIsWindows() ) {
|
||||
// Windows Shell bypassed, but command run is "cmd.exe /C "{$cmd}"
|
||||
// This solves some shell parsing issues, see T207248
|
||||
$proc = proc_open( $cmd, $desc, $pipes, null, null, [ 'bypass_shell' => true ] );
|
||||
} else {
|
||||
$proc = proc_open( $cmd, $desc, $pipes );
|
||||
}
|
||||
|
||||
if ( !$proc ) {
|
||||
$this->logger->error( "proc_open() failed: {command}", [ 'command' => $cmd ] );
|
||||
throw new ProcOpenError();
|
||||
}
|
||||
|
||||
$buffers = [
|
||||
0 => $this->inputString, // input
|
||||
1 => '', // stdout
|
||||
2 => null, // stderr
|
||||
3 => '', // log
|
||||
];
|
||||
$emptyArray = [];
|
||||
$status = false;
|
||||
$logMsg = false;
|
||||
|
||||
/* According to the documentation, it is possible for stream_select()
|
||||
* to fail due to EINTR. I haven't managed to induce this in testing
|
||||
* despite sending various signals. If it did happen, the error
|
||||
* message would take the form:
|
||||
*
|
||||
* stream_select(): unable to select [4]: Interrupted system call (max_fd=5)
|
||||
*
|
||||
* where [4] is the value of the macro EINTR and "Interrupted system
|
||||
* call" is string which according to the Linux manual is "possibly"
|
||||
* localised according to LC_MESSAGES.
|
||||
*/
|
||||
$eintr = defined( 'SOCKET_EINTR' ) ? SOCKET_EINTR : 4;
|
||||
$eintrMessage = "stream_select(): unable to select [$eintr]";
|
||||
|
||||
/* The select(2) system call only guarantees a "sufficiently small write"
|
||||
* can be made without blocking. And on Linux the read might block too
|
||||
* in certain cases, although I don't know if any of them can occur here.
|
||||
* Regardless, set all the pipes to non-blocking to avoid T184171.
|
||||
*/
|
||||
foreach ( $pipes as $pipe ) {
|
||||
stream_set_blocking( $pipe, false );
|
||||
}
|
||||
|
||||
$running = true;
|
||||
$timeout = null;
|
||||
$numReadyPipes = 0;
|
||||
|
||||
while ( $pipes && ( $running === true || $numReadyPipes !== 0 ) ) {
|
||||
if ( $running ) {
|
||||
$status = proc_get_status( $proc );
|
||||
// If the process has terminated, switch to nonblocking selects
|
||||
// for getting any data still waiting to be read.
|
||||
if ( !$status['running'] ) {
|
||||
$running = false;
|
||||
$timeout = 0;
|
||||
}
|
||||
}
|
||||
|
||||
error_clear_last();
|
||||
|
||||
$readPipes = array_filter( $pipes, function ( $fd ) use ( $desc ) {
|
||||
return $desc[$fd][0] === 'pipe' && $desc[$fd][1] === 'r';
|
||||
}, ARRAY_FILTER_USE_KEY );
|
||||
$writePipes = array_filter( $pipes, function ( $fd ) use ( $desc ) {
|
||||
return $desc[$fd][0] === 'pipe' && $desc[$fd][1] === 'w';
|
||||
}, ARRAY_FILTER_USE_KEY );
|
||||
// stream_select parameter names are from the POV of us being able to do the operation;
|
||||
// proc_open desriptor types are from the POV of the process doing it.
|
||||
// So $writePipes is passed as the $read parameter and $readPipes as $write.
|
||||
AtEase::suppressWarnings();
|
||||
$numReadyPipes = stream_select( $writePipes, $readPipes, $emptyArray, $timeout );
|
||||
AtEase::restoreWarnings();
|
||||
if ( $numReadyPipes === false ) {
|
||||
$error = error_get_last();
|
||||
if ( strncmp( $error['message'], $eintrMessage, strlen( $eintrMessage ) ) == 0 ) {
|
||||
continue;
|
||||
} else {
|
||||
trigger_error( $error['message'], E_USER_WARNING );
|
||||
$logMsg = $error['message'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
foreach ( $writePipes + $readPipes as $fd => $pipe ) {
|
||||
// True if a pipe is unblocked for us to write into, false if for reading from
|
||||
$isWrite = array_key_exists( $fd, $readPipes );
|
||||
|
||||
if ( $isWrite ) {
|
||||
// Don't bother writing if the buffer is empty
|
||||
if ( $buffers[$fd] === '' ) {
|
||||
fclose( $pipes[$fd] );
|
||||
unset( $pipes[$fd] );
|
||||
continue;
|
||||
}
|
||||
$res = fwrite( $pipe, $buffers[$fd], 65536 );
|
||||
} else {
|
||||
$res = fread( $pipe, 65536 );
|
||||
}
|
||||
|
||||
if ( $res === false ) {
|
||||
$logMsg = 'Error ' . ( $isWrite ? 'writing to' : 'reading from' ) . ' pipe';
|
||||
break 2;
|
||||
}
|
||||
|
||||
if ( $res === '' || $res === 0 ) {
|
||||
// End of file?
|
||||
if ( feof( $pipe ) ) {
|
||||
fclose( $pipes[$fd] );
|
||||
unset( $pipes[$fd] );
|
||||
}
|
||||
} elseif ( $isWrite ) {
|
||||
$buffers[$fd] = (string)substr( $buffers[$fd], $res );
|
||||
if ( $buffers[$fd] === '' ) {
|
||||
fclose( $pipes[$fd] );
|
||||
unset( $pipes[$fd] );
|
||||
}
|
||||
} else {
|
||||
$buffers[$fd] .= $res;
|
||||
if ( $fd === 3 && strpos( $res, "\n" ) !== false ) {
|
||||
// For the log FD, every line is a separate log entry.
|
||||
$lines = explode( "\n", $buffers[3] );
|
||||
$buffers[3] = array_pop( $lines );
|
||||
foreach ( $lines as $line ) {
|
||||
$this->logger->info( $line );
|
||||
}
|
||||
}
|
||||
if ( $fd === 2 && $this->doPassStderr ) {
|
||||
fwrite( STDERR, $res );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ( $pipes as $pipe ) {
|
||||
fclose( $pipe );
|
||||
}
|
||||
|
||||
// Use the status previously collected if possible, since proc_get_status()
|
||||
// just calls waitpid() which will not return anything useful the second time.
|
||||
if ( $running ) {
|
||||
$status = proc_get_status( $proc );
|
||||
}
|
||||
|
||||
if ( $logMsg !== false ) {
|
||||
// Read/select error
|
||||
$retval = -1;
|
||||
proc_close( $proc );
|
||||
} elseif ( $status['signaled'] ) {
|
||||
$logMsg = "Exited with signal {$status['termsig']}";
|
||||
$retval = 128 + $status['termsig'];
|
||||
proc_close( $proc );
|
||||
} else {
|
||||
if ( $status['running'] ) {
|
||||
$retval = proc_close( $proc );
|
||||
} else {
|
||||
$retval = $status['exitcode'];
|
||||
proc_close( $proc );
|
||||
}
|
||||
if ( $retval == 127 ) {
|
||||
$logMsg = "Possibly missing executable file";
|
||||
} elseif ( $retval >= 129 && $retval <= 192 ) {
|
||||
$logMsg = "Probably exited with signal " . ( $retval - 128 );
|
||||
}
|
||||
}
|
||||
|
||||
if ( $logMsg !== false ) {
|
||||
$this->logger->warning( "$logMsg: {command}", [ 'command' => $cmd ] );
|
||||
}
|
||||
|
||||
// @phan-suppress-next-line PhanImpossibleCondition
|
||||
if ( $buffers[2] && $this->doLogStderr ) {
|
||||
$this->logger->error( "Error running {command}: {error}", [
|
||||
'command' => $cmd,
|
||||
'error' => $buffers[2],
|
||||
'exitcode' => $retval,
|
||||
'exception' => new Exception( 'Shell error' ),
|
||||
] );
|
||||
}
|
||||
|
||||
return new Result( $retval, $buffers[1], $buffers[2] );
|
||||
$result = parent::execute();
|
||||
ScopedCallback::consume( $scoped );
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -626,6 +237,6 @@ class Command {
|
|||
* @return string
|
||||
*/
|
||||
public function __toString(): string {
|
||||
return "#Command: {$this->command}";
|
||||
return "#Command: {$this->getCommandString()}";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ namespace MediaWiki\Shell;
|
|||
use ExecutableFinder;
|
||||
use Psr\Log\LoggerAwareTrait;
|
||||
use Psr\Log\NullLogger;
|
||||
use Shellbox\Command\BoxedCommand;
|
||||
use Shellbox\Command\RemoteBoxedExecutor;
|
||||
use Shellbox\Shellbox;
|
||||
|
||||
/**
|
||||
* Factory facilitating dependency injection for Command
|
||||
|
|
@ -47,21 +50,31 @@ class CommandFactory {
|
|||
private $restrictionMethod;
|
||||
|
||||
/**
|
||||
* @var string|bool
|
||||
* @var string|bool|null
|
||||
*/
|
||||
private $firejail;
|
||||
|
||||
/** @var bool */
|
||||
private $useAllUsers;
|
||||
|
||||
/** @var ShellboxClientFactory */
|
||||
private $shellboxClientFactory;
|
||||
|
||||
/**
|
||||
* @param ShellboxClientFactory $shellboxClientFactory
|
||||
* @param array $limits See {@see Command::limits()}
|
||||
* @param string|bool $cgroup See {@see Command::cgroup()}
|
||||
* @param string|bool $restrictionMethod
|
||||
*/
|
||||
public function __construct( array $limits, $cgroup, $restrictionMethod ) {
|
||||
public function __construct( ShellboxClientFactory $shellboxClientFactory,
|
||||
array $limits, $cgroup, $restrictionMethod
|
||||
) {
|
||||
$this->shellboxClientFactory = $shellboxClientFactory;
|
||||
$this->limits = $limits;
|
||||
$this->cgroup = $cgroup;
|
||||
if ( $restrictionMethod === 'autodetect' ) {
|
||||
// On Linux systems check for firejail
|
||||
if ( PHP_OS === 'Linux' && $this->findFirejail() !== null ) {
|
||||
if ( PHP_OS === 'Linux' && $this->findFirejail() ) {
|
||||
$this->restrictionMethod = 'firejail';
|
||||
} else {
|
||||
$this->restrictionMethod = false;
|
||||
|
|
@ -72,10 +85,12 @@ class CommandFactory {
|
|||
$this->setLogger( new NullLogger() );
|
||||
}
|
||||
|
||||
protected function findFirejail(): ?string {
|
||||
/**
|
||||
* @return bool|string
|
||||
*/
|
||||
protected function findFirejail() {
|
||||
if ( $this->firejail === null ) {
|
||||
// Convert a `false` from ExecutableFinder to `null` (T257282)
|
||||
$this->firejail = ExecutableFinder::findInDefaultPaths( 'firejail' ) ?: null;
|
||||
$this->firejail = ExecutableFinder::findInDefaultPaths( 'firejail' );
|
||||
}
|
||||
|
||||
return $this->firejail;
|
||||
|
|
@ -91,23 +106,91 @@ class CommandFactory {
|
|||
$this->doLogStderr = $yesno;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the options which will be used for local unboxed execution.
|
||||
* Shellbox should be configured to act in an approximately backwards
|
||||
* compatible way, equivalent to the pre-Shellbox MediaWiki shell classes.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function getLocalShellboxOptions() {
|
||||
$options = [
|
||||
'tempDir' => wfTempDir(),
|
||||
'useBashWrapper' => file_exists( '/bin/bash' ),
|
||||
'cgroup' => $this->cgroup
|
||||
];
|
||||
if ( $this->restrictionMethod === 'firejail' ) {
|
||||
$firejailPath = $this->findFirejail();
|
||||
if ( !$firejailPath ) {
|
||||
throw new \RuntimeException( 'firejail is enabled, but cannot be found' );
|
||||
}
|
||||
$options['useFirejail'] = true;
|
||||
$options['firejailPath'] = $firejailPath;
|
||||
$options['firejailProfile'] = __DIR__ . '/firejail.profile';
|
||||
}
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiates a new Command
|
||||
*
|
||||
* @return Command
|
||||
*/
|
||||
public function create(): Command {
|
||||
$allUsers = false;
|
||||
if ( $this->restrictionMethod === 'firejail' ) {
|
||||
$command = new FirejailCommand( $this->findFirejail() );
|
||||
$command->restrict( Shell::RESTRICT_DEFAULT );
|
||||
} else {
|
||||
$command = new Command();
|
||||
if ( $this->useAllUsers === null ) {
|
||||
global $IP;
|
||||
// In case people are doing funny things with symlinks
|
||||
// or relative paths, resolve them all.
|
||||
$realIP = realpath( $IP );
|
||||
$currentUser = posix_getpwuid( posix_geteuid() );
|
||||
$this->useAllUsers = ( strpos( $realIP, '/home/' ) === 0 )
|
||||
&& ( strpos( $realIP, $currentUser['dir'] ) !== 0 );
|
||||
if ( $this->useAllUsers ) {
|
||||
$this->logger->warning( 'firejail: MediaWiki is located ' .
|
||||
'in a home directory that does not belong to the ' .
|
||||
'current user, so allowing access to all home ' .
|
||||
'directories (--allusers)' );
|
||||
}
|
||||
}
|
||||
$allUsers = $this->useAllUsers;
|
||||
}
|
||||
$command->setLogger( $this->logger );
|
||||
$executor = Shellbox::createUnboxedExecutor(
|
||||
$this->getLocalShellboxOptions(), $this->logger );
|
||||
|
||||
$command = new Command( $executor );
|
||||
$command->setLogger( $this->logger );
|
||||
if ( $allUsers ) {
|
||||
$command->allowPath( '/home' );
|
||||
}
|
||||
return $command
|
||||
->limits( $this->limits )
|
||||
->cgroup( $this->cgroup )
|
||||
->logStderr( $this->doLogStderr );
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiates a new BoxedCommand.
|
||||
*
|
||||
* @return BoxedCommand
|
||||
*/
|
||||
public function createBoxed(): BoxedCommand {
|
||||
if ( $this->shellboxClientFactory->isEnabled() ) {
|
||||
$client = $this->shellboxClientFactory->getClient( [
|
||||
'timeout' => $this->limits['walltime'] + 1
|
||||
] );
|
||||
$executor = new RemoteBoxedExecutor( $client );
|
||||
$executor->setLogger( $this->logger );
|
||||
} else {
|
||||
$executor = Shellbox::createBoxedExecutor(
|
||||
$this->getLocalShellboxOptions(),
|
||||
$this->logger );
|
||||
}
|
||||
return $executor->createCommand()
|
||||
->cpuTimeLimit( $this->limits['time'] )
|
||||
->wallTimeLimit( $this->limits['walltime'] )
|
||||
->memoryLimit( $this->limits['memory'] * 1024 )
|
||||
->fileSizeLimit( $this->limits['filesize'] * 1024 )
|
||||
->logStderr( $this->doLogStderr );
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,192 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* Copyright (C) 2017 Kunal Mehta <legoktm@member.fsf.org>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation; either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
*
|
||||
*/
|
||||
|
||||
namespace MediaWiki\Shell;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Restricts execution of shell commands using firejail
|
||||
*
|
||||
* @see https://firejail.wordpress.com/
|
||||
* @since 1.31
|
||||
*/
|
||||
class FirejailCommand extends Command {
|
||||
|
||||
/**
|
||||
* @var string Path to firejail
|
||||
*/
|
||||
private $firejail;
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
private $whitelistedPaths = [];
|
||||
|
||||
/**
|
||||
* @param string $firejail Path to firejail
|
||||
*/
|
||||
public function __construct( string $firejail ) {
|
||||
parent::__construct();
|
||||
$this->firejail = $firejail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject any parameters that start with --output to prevent
|
||||
* exploitation of a firejail RCE (CVE-2020-17367 and CVE-2020-17368)
|
||||
*
|
||||
* @param string|string[] ...$args
|
||||
* @return $this
|
||||
*/
|
||||
public function params( ...$args ): Command {
|
||||
if ( count( $args ) === 1 && is_array( reset( $args ) ) ) {
|
||||
// If only one argument has been passed, and that argument is an array,
|
||||
// treat it as a list of arguments
|
||||
$args = reset( $args );
|
||||
}
|
||||
foreach ( $args as $arg ) {
|
||||
if ( substr( $arg, 0, 8 ) === '--output' ) {
|
||||
$ex = new RuntimeException(
|
||||
'FirejailCommand does not support parameters that start with --output'
|
||||
);
|
||||
$this->logger->error(
|
||||
'command tried to shell out with a parameter starting with --output',
|
||||
[
|
||||
'arg' => $arg,
|
||||
'exception' => $ex
|
||||
]
|
||||
);
|
||||
throw $ex;
|
||||
}
|
||||
}
|
||||
|
||||
return parent::params( ...$args );
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function whitelistPaths( array $paths ): Command {
|
||||
$this->whitelistedPaths = array_merge( $this->whitelistedPaths, $paths );
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function buildFinalCommand( string $command ): array {
|
||||
// If there are no restrictions, don't use firejail
|
||||
if ( $this->restrictions === 0 ) {
|
||||
$splitCommand = explode( ' ', $command, 2 );
|
||||
$this->logger->debug(
|
||||
"firejail: Command {$splitCommand[0]} {params} has no restrictions",
|
||||
[ 'params' => $splitCommand[1] ?? '' ]
|
||||
);
|
||||
return parent::buildFinalCommand( $command );
|
||||
}
|
||||
|
||||
if ( $this->firejail === false ) {
|
||||
throw new RuntimeException( 'firejail is enabled, but cannot be found' );
|
||||
}
|
||||
// quiet has to come first to prevent firejail from adding
|
||||
// any output.
|
||||
$cmd = [ $this->firejail, '--quiet' ];
|
||||
// Use a profile that allows people to add local overrides
|
||||
// if their system is setup in an incompatible manner. Also it
|
||||
// prevents any default profiles from running.
|
||||
// FIXME: Doesn't actually override command-line switches?
|
||||
$cmd[] = '--profile=' . __DIR__ . '/firejail.profile';
|
||||
|
||||
// By default firejail hides all other user directories, so if
|
||||
// MediaWiki is inside a home directory (/home) but not the
|
||||
// current user's home directory, pass --allusers to whitelist
|
||||
// the home directories again.
|
||||
static $useAllUsers = null;
|
||||
if ( $useAllUsers === null ) {
|
||||
global $IP;
|
||||
// In case people are doing funny things with symlinks
|
||||
// or relative paths, resolve them all.
|
||||
$realIP = realpath( $IP );
|
||||
$currentUser = posix_getpwuid( posix_geteuid() );
|
||||
$useAllUsers = ( strpos( $realIP, '/home/' ) === 0 )
|
||||
&& ( strpos( $realIP, $currentUser['dir'] ) !== 0 );
|
||||
if ( $useAllUsers ) {
|
||||
$this->logger->warning( 'firejail: MediaWiki is located ' .
|
||||
'in a home directory that does not belong to the ' .
|
||||
'current user, so allowing access to all home ' .
|
||||
'directories (--allusers)' );
|
||||
}
|
||||
}
|
||||
|
||||
if ( $useAllUsers ) {
|
||||
$cmd[] = '--allusers';
|
||||
}
|
||||
|
||||
if ( $this->whitelistedPaths ) {
|
||||
// Always whitelist limit.sh
|
||||
$cmd[] = '--whitelist=' . __DIR__ . '/limit.sh';
|
||||
foreach ( $this->whitelistedPaths as $whitelistedPath ) {
|
||||
$cmd[] = "--whitelist={$whitelistedPath}";
|
||||
}
|
||||
}
|
||||
|
||||
if ( $this->hasRestriction( Shell::NO_LOCALSETTINGS ) ) {
|
||||
$cmd[] = '--blacklist=' . realpath( MW_CONFIG_FILE );
|
||||
}
|
||||
|
||||
if ( $this->hasRestriction( Shell::NO_ROOT ) ) {
|
||||
$cmd[] = '--noroot';
|
||||
}
|
||||
|
||||
$useSeccomp = $this->hasRestriction( Shell::SECCOMP );
|
||||
$extraSeccomp = [];
|
||||
|
||||
if ( $this->hasRestriction( Shell::NO_EXECVE ) ) {
|
||||
$extraSeccomp[] = 'execve';
|
||||
// Normally firejail will run commands in a bash shell,
|
||||
// but that won't work if we ban the execve syscall, so
|
||||
// run the command without a shell.
|
||||
$cmd[] = '--shell=none';
|
||||
}
|
||||
|
||||
if ( $useSeccomp ) {
|
||||
$seccomp = '--seccomp';
|
||||
if ( $extraSeccomp ) {
|
||||
// The "@default" seccomp group will always be enabled
|
||||
$seccomp .= '=' . implode( ',', $extraSeccomp );
|
||||
}
|
||||
$cmd[] = $seccomp;
|
||||
}
|
||||
|
||||
if ( $this->hasRestriction( Shell::PRIVATE_DEV ) ) {
|
||||
$cmd[] = '--private-dev';
|
||||
}
|
||||
|
||||
if ( $this->hasRestriction( Shell::NO_NETWORK ) ) {
|
||||
$cmd[] = '--net=none';
|
||||
}
|
||||
|
||||
$builtCmd = implode( ' ', $cmd );
|
||||
|
||||
// Prefix the firejail command in front of the wanted command
|
||||
return parent::buildFinalCommand( "$builtCmd -- {$command}" );
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -18,61 +18,6 @@
|
|||
* @file
|
||||
*/
|
||||
|
||||
declare( strict_types = 1 );
|
||||
use Shellbox\Command\UnboxedResult;
|
||||
|
||||
namespace MediaWiki\Shell;
|
||||
|
||||
/**
|
||||
* Returned by MediaWiki\Shell\Command::execute()
|
||||
*
|
||||
* @since 1.30
|
||||
*/
|
||||
class Result {
|
||||
/** @var int */
|
||||
private $exitCode;
|
||||
|
||||
/** @var string */
|
||||
private $stdout;
|
||||
|
||||
/** @var string|null */
|
||||
private $stderr;
|
||||
|
||||
/**
|
||||
* @param int $exitCode
|
||||
* @param string $stdout
|
||||
* @param string|null $stderr
|
||||
*/
|
||||
public function __construct( int $exitCode, string $stdout, ?string $stderr ) {
|
||||
$this->exitCode = $exitCode;
|
||||
$this->stdout = $stdout;
|
||||
$this->stderr = $stderr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns exit code of the process
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getExitCode() : int {
|
||||
return $this->exitCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns stdout of the process
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getStdout() : string {
|
||||
return $this->stdout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns stderr of the process or null if the Command was configured to add stderr to stdout
|
||||
* with includeStderr( true )
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getStderr() : ?string {
|
||||
return $this->stderr;
|
||||
}
|
||||
}
|
||||
class_alias( UnboxedResult::class, 'MediaWiki\\Shell\\Result', true );
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ namespace MediaWiki\Shell;
|
|||
|
||||
use Hooks;
|
||||
use MediaWiki\MediaWikiServices;
|
||||
use Shellbox\Shellbox;
|
||||
|
||||
/**
|
||||
* Executes shell commands
|
||||
|
|
@ -161,62 +162,7 @@ class Shell {
|
|||
* @return string
|
||||
*/
|
||||
public static function escape( ...$args ): string {
|
||||
if ( count( $args ) === 1 && is_array( reset( $args ) ) ) {
|
||||
// If only one argument has been passed, and that argument is an array,
|
||||
// treat it as a list of arguments
|
||||
$args = reset( $args );
|
||||
}
|
||||
|
||||
$first = true;
|
||||
$retVal = '';
|
||||
foreach ( $args as $arg ) {
|
||||
if ( $arg === null ) {
|
||||
continue;
|
||||
}
|
||||
if ( !$first ) {
|
||||
$retVal .= ' ';
|
||||
} else {
|
||||
$first = false;
|
||||
}
|
||||
|
||||
if ( wfIsWindows() ) {
|
||||
// Escaping for an MSVC-style command line parser and CMD.EXE
|
||||
// Refs:
|
||||
// * https://web.archive.org/web/20020708081031/http://mailman.lyra.org/pipermail/scite-interest/2002-March/000436.html
|
||||
// * https://technet.microsoft.com/en-us/library/cc723564.aspx
|
||||
// * T15518
|
||||
// * CR r63214
|
||||
// Double the backslashes before any double quotes. Escape the double quotes.
|
||||
$tokens = preg_split( '/(\\\\*")/', $arg, -1, PREG_SPLIT_DELIM_CAPTURE );
|
||||
$arg = '';
|
||||
$iteration = 0;
|
||||
foreach ( $tokens as $token ) {
|
||||
if ( $iteration % 2 == 1 ) {
|
||||
// Delimiter, a double quote preceded by zero or more slashes
|
||||
$arg .= str_replace( '\\', '\\\\', substr( $token, 0, -1 ) ) . '\\"';
|
||||
} elseif ( $iteration % 4 == 2 ) {
|
||||
// ^ in $token will be outside quotes, need to be escaped
|
||||
$arg .= str_replace( '^', '^^', $token );
|
||||
} else { // $iteration % 4 == 0
|
||||
// ^ in $token will appear inside double quotes, so leave as is
|
||||
$arg .= $token;
|
||||
}
|
||||
$iteration++;
|
||||
}
|
||||
// Double the backslashes before the end of the string, because
|
||||
// we will soon add a quote
|
||||
$m = [];
|
||||
if ( preg_match( '/^(.*?)(\\\\+)$/', $arg, $m ) ) {
|
||||
$arg = $m[1] . str_replace( '\\', '\\\\', $m[2] );
|
||||
}
|
||||
|
||||
// Add surrounding quotes
|
||||
$retVal .= '"' . $arg . '"';
|
||||
} else {
|
||||
$retVal .= escapeshellarg( $arg );
|
||||
}
|
||||
}
|
||||
return $retVal;
|
||||
return Shellbox::escape( ...$args );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
70
includes/shell/ShellboxClientFactory.php
Normal file
70
includes/shell/ShellboxClientFactory.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace MediaWiki\Shell;
|
||||
|
||||
use GuzzleHttp\Psr7\Uri;
|
||||
use MediaWiki\Http\HttpRequestFactory;
|
||||
use Shellbox\Client;
|
||||
|
||||
/**
|
||||
* This is a service which provides a configured client to access a remote
|
||||
* Shellbox installation.
|
||||
*
|
||||
* @since 1.36
|
||||
*/
|
||||
class ShellboxClientFactory {
|
||||
/** @var HttpRequestFactory */
|
||||
private $requestFactory;
|
||||
/** @var string|null */
|
||||
private $url;
|
||||
/** @var string|null */
|
||||
private $key;
|
||||
|
||||
/** The default request timeout, in seconds */
|
||||
public const DEFAULT_TIMEOUT = 10;
|
||||
|
||||
/**
|
||||
* @internal Use MediaWikiServices::getShellboxClientFactory()
|
||||
* @param HttpRequestFactory $requestFactory The factory which will be used
|
||||
* to make HTTP clients.
|
||||
* @param string|null $url The Shellbox base URL
|
||||
* @param string|null $key The shared secret key used for HMAC authentication
|
||||
*/
|
||||
public function __construct( HttpRequestFactory $requestFactory, $url, $key ) {
|
||||
$this->requestFactory = $requestFactory;
|
||||
$this->url = $url;
|
||||
$this->key = $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether remote Shellbox is enabled by configuration.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isEnabled() {
|
||||
return $this->url !== null && strlen( $this->key );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Shellbox client with the specified options. If remote Shellbox is
|
||||
* not configured (isEnabled() returns false), an exception will be thrown.
|
||||
*
|
||||
* @param array $options Associative array of options:
|
||||
* - timeout: The request timeout in seconds
|
||||
* @return Client
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public function getClient( array $options = [] ) {
|
||||
if ( !$this->isEnabled() ) {
|
||||
throw new \RuntimeException( 'To use a remote shellbox to run shell commands, ' .
|
||||
'$wgShellboxUrl and $wgShellboxSecretKey must be configured.' );
|
||||
}
|
||||
|
||||
return new Client(
|
||||
new ShellboxHttpClient( $this->requestFactory,
|
||||
$options['timeout'] ?? self::DEFAULT_TIMEOUT ),
|
||||
new Uri( $this->url ),
|
||||
$this->key
|
||||
);
|
||||
}
|
||||
}
|
||||
37
includes/shell/ShellboxHttpClient.php
Normal file
37
includes/shell/ShellboxHttpClient.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace MediaWiki\Shell;
|
||||
|
||||
use MediaWiki\Http\HttpRequestFactory;
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Shellbox\GuzzleHttpClient;
|
||||
|
||||
/**
|
||||
* The MediaWiki-specific implementation of a Shellbox HTTP client
|
||||
*/
|
||||
class ShellboxHttpClient extends GuzzleHttpClient {
|
||||
/** @var HttpRequestFactory */
|
||||
private $requestFactory;
|
||||
/** @var int|float Timeout in seconds */
|
||||
private $timeout;
|
||||
|
||||
/**
|
||||
* @param HttpRequestFactory $requestFactory
|
||||
* @param int|float $timeout
|
||||
*/
|
||||
public function __construct( HttpRequestFactory $requestFactory, $timeout ) {
|
||||
$this->requestFactory = $requestFactory;
|
||||
$this->timeout = $timeout;
|
||||
}
|
||||
|
||||
protected function modifyRequest( RequestInterface $request ): RequestInterface {
|
||||
return $request
|
||||
->withHeader( 'X-Request-Id', \WebRequest::getRequestId() );
|
||||
}
|
||||
|
||||
protected function createClient( RequestInterface $request ) {
|
||||
return $this->requestFactory->createGuzzleClient( [
|
||||
'timeout' => $this->timeout
|
||||
] );
|
||||
}
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# Resource limiting wrapper for command execution
|
||||
#
|
||||
# Why is this in shell script? Because bash has a setrlimit() wrapper
|
||||
# and is available on most Linux systems. If Perl was distributed with
|
||||
# BSD::Resource included, we would happily use that instead, but it isn't.
|
||||
|
||||
# Clean up cgroup
|
||||
cleanup() {
|
||||
# First we have to move the current task into a "garbage" group, otherwise
|
||||
# the cgroup will not be empty, and attempting to remove it will fail with
|
||||
# "Device or resource busy"
|
||||
if [ -w "$MW_CGROUP"/tasks ]; then
|
||||
GARBAGE="$MW_CGROUP"
|
||||
else
|
||||
GARBAGE="$MW_CGROUP"/garbage-`id -un`
|
||||
if [ ! -e "$GARBAGE" ]; then
|
||||
mkdir -m 0700 "$GARBAGE"
|
||||
fi
|
||||
fi
|
||||
echo $BASHPID > "$GARBAGE"/tasks
|
||||
|
||||
# Suppress errors in case the cgroup has disappeared due to a release script
|
||||
rmdir "$MW_CGROUP"/$$ 2>/dev/null
|
||||
}
|
||||
|
||||
updateTaskCount() {
|
||||
# There are lots of ways to count lines in a file in shell script, but this
|
||||
# is one of the few that doesn't create another process, which would
|
||||
# increase the returned number of tasks.
|
||||
readarray < "$MW_CGROUP"/$$/tasks
|
||||
NUM_TASKS=${#MAPFILE[*]}
|
||||
}
|
||||
|
||||
log() {
|
||||
echo limit.sh: "$*" >&3
|
||||
echo limit.sh: "$*" >&2
|
||||
}
|
||||
|
||||
MW_INCLUDE_STDERR=
|
||||
MW_USE_LOG_PIPE=
|
||||
MW_CPU_LIMIT=0
|
||||
MW_CGROUP=
|
||||
MW_MEM_LIMIT=0
|
||||
MW_FILE_SIZE_LIMIT=0
|
||||
MW_WALL_CLOCK_LIMIT=0
|
||||
|
||||
# Override settings
|
||||
eval "$2"
|
||||
|
||||
if [ -n "$MW_INCLUDE_STDERR" ]; then
|
||||
exec 2>&1
|
||||
fi
|
||||
if [ -z "$MW_USE_LOG_PIPE" ]; then
|
||||
# Open a dummy log FD
|
||||
exec 3>/dev/null
|
||||
fi
|
||||
|
||||
if [ "$MW_CPU_LIMIT" -gt 0 ]; then
|
||||
ulimit -t "$MW_CPU_LIMIT"
|
||||
fi
|
||||
if [ "$MW_MEM_LIMIT" -gt 0 ]; then
|
||||
if [ -n "$MW_CGROUP" ]; then
|
||||
# Create cgroup
|
||||
if ! mkdir -m 0700 "$MW_CGROUP"/$$; then
|
||||
log "failed to create the cgroup."
|
||||
MW_CGROUP=""
|
||||
fi
|
||||
fi
|
||||
if [ -n "$MW_CGROUP" ]; then
|
||||
echo $$ > "$MW_CGROUP"/$$/tasks
|
||||
if [ -n "$MW_CGROUP_NOTIFY" ]; then
|
||||
echo "1" > "$MW_CGROUP"/$$/notify_on_release
|
||||
fi
|
||||
# Memory
|
||||
echo $(($MW_MEM_LIMIT*1024)) > "$MW_CGROUP"/$$/memory.limit_in_bytes
|
||||
# Memory+swap
|
||||
# This will be missing if there is no swap
|
||||
if [ -e "$MW_CGROUP"/$$/memory.memsw.limit_in_bytes ]; then
|
||||
echo $(($MW_MEM_LIMIT*1024)) > "$MW_CGROUP"/$$/memory.memsw.limit_in_bytes
|
||||
fi
|
||||
else
|
||||
ulimit -v "$MW_MEM_LIMIT"
|
||||
fi
|
||||
else
|
||||
MW_CGROUP=""
|
||||
fi
|
||||
if [ "$MW_FILE_SIZE_LIMIT" -gt 0 ]; then
|
||||
ulimit -f "$MW_FILE_SIZE_LIMIT"
|
||||
fi
|
||||
if [ "$MW_WALL_CLOCK_LIMIT" -gt 0 -a -x "/usr/bin/timeout" ]; then
|
||||
/usr/bin/timeout $MW_WALL_CLOCK_LIMIT /bin/bash -c "$1" 3>&-
|
||||
STATUS="$?"
|
||||
if [ "$STATUS" == 124 ]; then
|
||||
log "timed out executing command \"$1\""
|
||||
fi
|
||||
else
|
||||
eval "$1" 3>&-
|
||||
STATUS="$?"
|
||||
fi
|
||||
|
||||
if [ -n "$MW_CGROUP" ]; then
|
||||
updateTaskCount
|
||||
|
||||
if [ $NUM_TASKS -gt 1 ]; then
|
||||
# Spawn a monitor process which will continue to poll for completion
|
||||
# of all processes in the cgroup after termination of the parent shell
|
||||
(
|
||||
while [ $NUM_TASKS -gt 1 ]; do
|
||||
sleep 10
|
||||
updateTaskCount
|
||||
done
|
||||
cleanup
|
||||
) >&/dev/null < /dev/null 3>&- &
|
||||
disown -a
|
||||
else
|
||||
cleanup
|
||||
fi
|
||||
fi
|
||||
exit "$STATUS"
|
||||
|
||||
|
|
@ -78,7 +78,6 @@ while ( ( $__line = Maintenance::readconsole() ) !== false ) {
|
|||
readline_write_history( $__historyFile );
|
||||
}
|
||||
try {
|
||||
// @phan-suppress-next-line SecurityCheck-RCE eval is wanted in this script
|
||||
$__val = eval( $__line . ";" );
|
||||
} catch ( Exception $__e ) {
|
||||
fwrite( STDERR, "Caught exception " . get_class( $__e ) .
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
<?php
|
||||
|
||||
use MediaWiki\Shell\FirejailCommand;
|
||||
use MediaWiki\Shell\Shell;
|
||||
|
||||
/**
|
||||
* Integration tests to ensure that firejail actually prevents execution.
|
||||
* Meant to run on vagrant, although will probably work on other setups
|
||||
* as long as firejail and sudo has similar config.
|
||||
*
|
||||
* @group large
|
||||
* @group Shell
|
||||
* @covers FirejailCommand
|
||||
*/
|
||||
class FirejailCommandIntegrationTest extends PHPUnit\Framework\TestCase {
|
||||
|
||||
protected function setUp() : void {
|
||||
parent::setUp();
|
||||
if ( Shell::isDisabled() ) {
|
||||
$this->markTestSkipped( 'shelling out is disabled' );
|
||||
} elseif ( Shell::command( 'which', 'firejail' )->execute()->getExitCode() ) {
|
||||
$this->markTestSkipped( 'firejail not installed' );
|
||||
} elseif ( wfIsWindows() ) {
|
||||
$this->markTestSkipped( 'test supports POSIX environments only' );
|
||||
}
|
||||
}
|
||||
|
||||
public function testSanity() {
|
||||
// Make sure that firejail works at all.
|
||||
$command = new FirejailCommand( 'firejail' );
|
||||
$command
|
||||
->unsafeParams( 'ls .' )
|
||||
->restrict( Shell::RESTRICT_DEFAULT );
|
||||
$result = $command->execute();
|
||||
$this->assertSame( 0, $result->getExitCode() );
|
||||
}
|
||||
|
||||
/**
|
||||
* @coversNothing
|
||||
* @dataProvider provideExecute
|
||||
*/
|
||||
public function testExecute( $testCommand, $flag ) {
|
||||
if ( preg_match( '/^sudo /', $testCommand )
|
||||
&& Shell::command( 'sudo', '-n', 'ls', '/' )->execute()->getExitCode()
|
||||
) {
|
||||
$this->markTestSkipped( 'need passwordless sudo' );
|
||||
}
|
||||
|
||||
$command = new FirejailCommand( 'firejail' );
|
||||
$command
|
||||
->unsafeParams( $testCommand )
|
||||
// If we don't restrict at all, firejail won't be invoked,
|
||||
// so the test will give a false positive if firejail breaks
|
||||
// the command for some non-flag-related reason. Instead,
|
||||
// set some flag that won't get in the way.
|
||||
->restrict( $flag === Shell::NO_NETWORK ? Shell::PRIVATE_DEV : Shell::NO_NETWORK );
|
||||
$result = $command->execute();
|
||||
$this->assertSame( 0, $result->getExitCode(), 'sanity check' );
|
||||
|
||||
$command = new FirejailCommand( 'firejail' );
|
||||
$command
|
||||
->unsafeParams( $testCommand )
|
||||
->restrict( $flag );
|
||||
$result = $command->execute();
|
||||
$this->assertNotSame( 0, $result->getExitCode(), 'real check' );
|
||||
}
|
||||
|
||||
public function provideExecute() {
|
||||
global $IP;
|
||||
return [
|
||||
[ 'sudo -n ls /', Shell::NO_ROOT ],
|
||||
[ 'sudo -n ls /', Shell::SECCOMP ], // not a great test but seems to work
|
||||
[ 'ls /dev/cpu', Shell::PRIVATE_DEV ],
|
||||
[ 'curl -fsSo /dev/null https://wikipedia.org/', Shell::NO_NETWORK ],
|
||||
[ 'exec ls /', Shell::NO_EXECVE ],
|
||||
[ "cat $IP/LocalSettings.php", Shell::NO_LOCALSETTINGS ],
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
use MediaWiki\Shell\Command;
|
||||
use MediaWiki\Shell\Shell;
|
||||
use Wikimedia\TestingAccessWrapper;
|
||||
use Shellbox\Shellbox;
|
||||
|
||||
/**
|
||||
* @covers \MediaWiki\Shell\Command
|
||||
|
|
@ -18,6 +18,10 @@ class CommandTest extends PHPUnit\Framework\TestCase {
|
|||
}
|
||||
}
|
||||
|
||||
private function createCommand() {
|
||||
return new Command( Shellbox::createUnboxedExecutor() );
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideExecute
|
||||
*/
|
||||
|
|
@ -63,7 +67,7 @@ class CommandTest extends PHPUnit\Framework\TestCase {
|
|||
// The double redirection doesn't work on Windows
|
||||
$this->requirePosix();
|
||||
|
||||
$command = new Command();
|
||||
$command = $this->createCommand();
|
||||
|
||||
$result = $command
|
||||
->params( 'bash', '-c', 'echo ThisIsStderr 1>&2' )
|
||||
|
|
@ -71,7 +75,7 @@ class CommandTest extends PHPUnit\Framework\TestCase {
|
|||
->execute();
|
||||
|
||||
$this->assertEquals( "ThisIsStderr\n", $result->getStdout() );
|
||||
$this->assertNull( $result->getStderr() );
|
||||
$this->assertSame( '', $result->getStderr() );
|
||||
}
|
||||
|
||||
public function testOutput() {
|
||||
|
|
@ -81,7 +85,7 @@ class CommandTest extends PHPUnit\Framework\TestCase {
|
|||
);
|
||||
$result = $command->execute();
|
||||
$this->assertSame( 'correct stdout', $result->getStdout() );
|
||||
$this->assertSame( null, $result->getStderr() );
|
||||
$this->assertSame( '', $result->getStderr() );
|
||||
|
||||
$command = $this->getPhpCommand(
|
||||
'stdout_stderr.php',
|
||||
|
|
@ -92,7 +96,7 @@ class CommandTest extends PHPUnit\Framework\TestCase {
|
|||
->execute();
|
||||
$this->assertRegExp( '/correct stdout/m', $result->getStdout() );
|
||||
$this->assertRegExp( '/correct stderr/m', $result->getStdout() );
|
||||
$this->assertSame( null, $result->getStderr() );
|
||||
$this->assertSame( '', $result->getStderr() );
|
||||
|
||||
$command = $this->getPhpCommand(
|
||||
'stdout_stderr.php',
|
||||
|
|
@ -108,14 +112,14 @@ class CommandTest extends PHPUnit\Framework\TestCase {
|
|||
* Test that null values are skipped by params() and unsafeParams()
|
||||
*/
|
||||
public function testNullsAreSkipped() {
|
||||
$command = TestingAccessWrapper::newFromObject( new Command );
|
||||
$command = $this->createCommand();
|
||||
$command->params( 'echo', 'a', null, 'b' );
|
||||
$command->unsafeParams( 'c', null, 'd' );
|
||||
|
||||
if ( wfIsWindows() ) {
|
||||
$this->assertEquals( '"echo" "a" "b" c d', $command->command );
|
||||
$this->assertEquals( '"echo" "a" "b" c d', $command->getCommandString() );
|
||||
} else {
|
||||
$this->assertEquals( "'echo' 'a' 'b' c d", $command->command );
|
||||
$this->assertEquals( "'echo' 'a' 'b' c d", $command->getCommandString() );
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -182,12 +186,18 @@ class CommandTest extends PHPUnit\Framework\TestCase {
|
|||
* @see T257278
|
||||
*/
|
||||
public function testDisablingRestrictions() {
|
||||
$command = TestingAccessWrapper::newFromObject( new Command() );
|
||||
$command = $this->createCommand();
|
||||
// As CommandFactory does for the firejail case:
|
||||
$command->restrict( Shell::RESTRICT_DEFAULT );
|
||||
// Disable restrictions
|
||||
$command->restrict( Shell::RESTRICT_NONE );
|
||||
$this->assertSame( 0, $command->restrictions );
|
||||
$this->assertFalse( $command->getPrivateUserNamespace() );
|
||||
$this->assertFalse( $command->getFirejailDefaultSeccomp() );
|
||||
$this->assertFalse( $command->getNoNewPrivs() );
|
||||
$this->assertFalse( $command->getPrivateDev() );
|
||||
$this->assertFalse( $command->getDisableNetwork() );
|
||||
$this->assertSame( [], $command->getDisabledSyscalls() );
|
||||
$this->assertTrue( $command->getDisableSandbox() );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -203,7 +213,7 @@ class CommandTest extends PHPUnit\Framework\TestCase {
|
|||
* @return Command a command instance pointing to the right script
|
||||
*/
|
||||
private function getPhpCommand( $fileName, array $args = [] ) {
|
||||
$command = new Command;
|
||||
$command = new Command( Shellbox::createUnboxedExecutor() );
|
||||
$params = [
|
||||
PHP_BINARY,
|
||||
__DIR__
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
use MediaWiki\Shell\Command;
|
||||
use MediaWiki\Shell\Shell;
|
||||
use Wikimedia\TestingAccessWrapper;
|
||||
|
||||
/**
|
||||
* @covers \MediaWiki\Shell\Shell
|
||||
|
|
@ -65,14 +64,12 @@ class ShellTest extends MediaWikiIntegrationTestCase {
|
|||
|
||||
$this->assertInstanceOf( Command::class, $command );
|
||||
|
||||
$wrapper = TestingAccessWrapper::newFromObject( $command );
|
||||
|
||||
if ( wfIsWindows() ) {
|
||||
$this->assertEquals( $expectedWin, $wrapper->command );
|
||||
$this->assertEquals( $expectedWin, $command->getCommandString() );
|
||||
} else {
|
||||
$this->assertEquals( $expected, $wrapper->command );
|
||||
$this->assertEquals( $expected, $command->getCommandString() );
|
||||
}
|
||||
$this->assertSame( 0, $wrapper->restrictions & Shell::NO_LOCALSETTINGS );
|
||||
$this->assertSame( [], $command->getDisallowedPaths() );
|
||||
}
|
||||
|
||||
public function provideMakeScriptCommand() {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
use MediaWiki\Shell\Command;
|
||||
use MediaWiki\Shell\CommandFactory;
|
||||
use MediaWiki\Shell\FirejailCommand;
|
||||
use MediaWiki\Shell\ShellboxClientFactory;
|
||||
use Psr\Log\NullLogger;
|
||||
use Wikimedia\TestingAccessWrapper;
|
||||
|
||||
|
|
@ -24,28 +24,31 @@ class CommandFactoryTest extends MediaWikiUnitTestCase {
|
|||
'walltime' => 40,
|
||||
];
|
||||
|
||||
$factory = new CommandFactory( $limits, $cgroup, false );
|
||||
$clientFactory = new class extends ShellboxClientFactory {
|
||||
public function __construct() {
|
||||
}
|
||||
|
||||
public function isEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getClient( array $options = [] ) {
|
||||
throw new \Exception( 'unreachable' );
|
||||
}
|
||||
};
|
||||
|
||||
$factory = new CommandFactory( $clientFactory,
|
||||
$limits, $cgroup, false );
|
||||
$factory->setLogger( $logger );
|
||||
$factory->logStderr();
|
||||
$command = $factory->create();
|
||||
$this->assertInstanceOf( Command::class, $command );
|
||||
|
||||
$wrapper = TestingAccessWrapper::newFromObject( $command );
|
||||
$this->assertSame( $logger, $wrapper->logger );
|
||||
$this->assertSame( $cgroup, $wrapper->cgroup );
|
||||
$this->assertSame( $limits, $wrapper->limits );
|
||||
$this->assertTrue( $wrapper->doLogStderr );
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers MediaWiki\Shell\CommandFactory::create
|
||||
*/
|
||||
public function testFirejailCreate() {
|
||||
$mock = $this->getMockBuilder( CommandFactory::class )
|
||||
->setConstructorArgs( [ [], false, 'firejail' ] )
|
||||
->setMethods( [ 'findFirejail' ] )
|
||||
->getMock();
|
||||
$mock->method( 'findFirejail' )->willReturn( '/usr/bin/firejail' );
|
||||
$this->assertInstanceOf( FirejailCommand::class, $mock->create() );
|
||||
$this->assertSame( $limits['filesize'] * 1024, $command->getFileSizeLimit() );
|
||||
$this->assertSame( $limits['memory'] * 1024, $command->getMemoryLimit() );
|
||||
$this->assertSame( $limits['time'], $command->getCpuTimeLimit() );
|
||||
$this->assertSame( $limits['walltime'], $command->getWallTimeLimit() );
|
||||
$this->assertTrue( $command->getLogStderr() );
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2017 Kunal Mehta <legoktm@member.fsf.org>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation; either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
*
|
||||
*/
|
||||
|
||||
use MediaWiki\Shell\FirejailCommand;
|
||||
use MediaWiki\Shell\Shell;
|
||||
use Wikimedia\TestingAccessWrapper;
|
||||
|
||||
class FirejailCommandTest extends MediaWikiUnitTestCase {
|
||||
|
||||
public function provideBuildFinalCommand() {
|
||||
global $IP;
|
||||
// phpcs:ignore Generic.Files.LineLength
|
||||
$env = "'MW_INCLUDE_STDERR=;MW_CPU_LIMIT=180; MW_CGROUP='\'''\''; MW_MEM_LIMIT=307200; MW_FILE_SIZE_LIMIT=102400; MW_WALL_CLOCK_LIMIT=180; MW_USE_LOG_PIPE=yes'";
|
||||
$limit = "/bin/bash '$IP/includes/shell/limit.sh'";
|
||||
$profile = "--profile=$IP/includes/shell/firejail.profile";
|
||||
$blacklist = '--blacklist=' . realpath( MW_CONFIG_FILE );
|
||||
$default = "$blacklist --noroot --seccomp --private-dev";
|
||||
return [
|
||||
[
|
||||
'No restrictions',
|
||||
'ls', 0, "$limit ''\''ls'\''' $env"
|
||||
],
|
||||
[
|
||||
'default restriction',
|
||||
'ls', Shell::RESTRICT_DEFAULT,
|
||||
"$limit 'firejail --quiet $profile $default -- '\''ls'\''' $env"
|
||||
],
|
||||
[
|
||||
'no network',
|
||||
'ls', Shell::NO_NETWORK,
|
||||
"$limit 'firejail --quiet $profile --net=none -- '\''ls'\''' $env"
|
||||
],
|
||||
[
|
||||
'default restriction & no network',
|
||||
'ls', Shell::RESTRICT_DEFAULT | Shell::NO_NETWORK,
|
||||
"$limit 'firejail --quiet $profile $default --net=none -- '\''ls'\''' $env"
|
||||
],
|
||||
[
|
||||
'seccomp',
|
||||
'ls', Shell::SECCOMP,
|
||||
"$limit 'firejail --quiet $profile --seccomp -- '\''ls'\''' $env"
|
||||
],
|
||||
[
|
||||
'seccomp & no execve',
|
||||
'ls', Shell::SECCOMP | Shell::NO_EXECVE,
|
||||
"$limit 'firejail --quiet $profile --shell=none --seccomp=execve -- '\''ls'\''' $env"
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @requires OS Linux
|
||||
* @covers \MediaWiki\Shell\FirejailCommand::buildFinalCommand()
|
||||
* @dataProvider provideBuildFinalCommand
|
||||
*/
|
||||
public function testBuildFinalCommand( $desc, $params, $flags, $expected ) {
|
||||
$command = new FirejailCommand( 'firejail' );
|
||||
$command
|
||||
->params( $params )
|
||||
->restrict( $flags );
|
||||
$wrapper = TestingAccessWrapper::newFromObject( $command );
|
||||
$output = $wrapper->buildFinalCommand( $wrapper->command );
|
||||
$this->assertEquals( $expected, $output[0], $desc );
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers \MediaWiki\Shell\FirejailCommand::params
|
||||
*/
|
||||
public function testParamsOutput() {
|
||||
$this->expectException( RuntimeException::class );
|
||||
( new FirejailCommand( 'firejail' ) )->params( 'echo', 'a', '--output=/tmp/fjout', ';id' );
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in a new issue