Merge "Use Shellbox for Shell::command() etc."

This commit is contained in:
jenkins-bot 2021-02-07 20:40:03 +00:00 committed by Gerrit Code Review
commit c8a32fa7e3
19 changed files with 368 additions and 1101 deletions

View file

@ -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 ===

View file

@ -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",

View file

@ -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
/***************************************************************************/

View file

@ -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

View file

@ -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();

View file

@ -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()}";
}
}

View file

@ -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 );
}
}

View file

@ -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}" );
}
}

View file

@ -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 );

View file

@ -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 );
}
/**

View 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
);
}
}

View 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
] );
}
}

View file

@ -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"

View file

@ -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 ) .

View file

@ -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 ],
];
}
}

View file

@ -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__

View file

@ -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() {

View file

@ -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() );
}
}

View file

@ -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' );
}
}