Page Menu
Home
WickedGov Phorge
Search
Configure Global Search
Log In
Files
F5976280
UnboxedExecutor.php
No One
Temporary
Actions
Download File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
13 KB
Referenced Files
None
Subscribers
None
UnboxedExecutor.php
View Options
<?php
namespace
Shellbox\Command
;
use
Psr\Log\LoggerInterface
;
use
Psr\Log\NullLogger
;
use
Shellbox\FileUtils
;
use
Shellbox\Shellbox
;
use
Shellbox\ShellboxError
;
use
Shellbox\TempDirManager
;
/**
* A concrete class for executing UnboxedCommand objects
*/
class
UnboxedExecutor
{
/** @var LoggerInterface */
protected
$logger
;
/** @var Wrapper[] */
private
$wrappers
=
[];
/** @var string|null */
private
$stdoutPath
;
/** @var string|null */
private
$stderrPath
;
/** @var string|null */
private
$tempDirBase
;
/** @var TempDirManager|null */
private
$tempDirManager
;
/**
* @param string|null $tempDirBase The parent directory of the temporary
* directory to use if the command is run on Windows. For example /tmp.
* If this is null, sys_get_temp_dir() will be used. The temporary
* directory may also be overridden later using setTempDirManager().
*/
public
function
__construct
(
$tempDirBase
=
null
)
{
$this
->
logger
=
new
NullLogger
;
$this
->
tempDirBase
=
$tempDirBase
;
}
/**
* Create a Command linked to this executor.
*
* @return UnboxedCommand
*/
public
function
createCommand
()
{
return
new
UnboxedCommand
(
$this
);
}
/**
* Get a TempDirManager with optional lazy initialisation
*
* @return TempDirManager
*/
protected
function
getTempDirManager
()
{
if
(
!
$this
->
tempDirManager
)
{
$tempDirBase
=
$this
->
tempDirBase
;
if
(
$tempDirBase
===
null
)
{
$tempDirBase
=
sys_get_temp_dir
();
}
$this
->
tempDirManager
=
new
TempDirManager
(
$tempDirBase
.
'/shellbox-'
.
Shellbox
::
getUniqueString
()
);
}
return
$this
->
tempDirManager
;
}
/**
* Explicitly set a TempDirManager, overriding lazy initialisation config
*
* @param TempDirManager $manager
*/
public
function
setTempDirManager
(
TempDirManager
$manager
)
{
$this
->
tempDirManager
=
$manager
;
}
/**
* Set the logger.
*
* @param LoggerInterface $logger
*/
public
function
setLogger
(
LoggerInterface
$logger
)
{
$this
->
logger
=
$logger
;
foreach
(
$this
->
wrappers
as
$wrapper
)
{
$wrapper
->
setLogger
(
$logger
);
}
}
/**
* Add a Wrapper, which modifies the Command, typically providing more
* security.
*
* @param Wrapper $wrapper
*/
public
function
addWrapper
(
Wrapper
$wrapper
)
{
$wrapper
->
setLogger
(
$this
->
logger
);
$this
->
wrappers
[]
=
$wrapper
;
}
/**
* Add wrappers based on a configuration array
*
* @param array $config Associative array of configuration parameters:
* - useSystemd: If true, systemd-run will be used
* - useBashWrapper: If true, limit.sh will be used
* - useFirejail: If true, firejail will be used
* - firejailPath: The path to the firejail binary
* - firejailProfile: The path to the firejail profile
* - cgroup: A writable cgroup path which can be used for manual memory
* limiting
*/
public
function
addWrappersFromConfiguration
(
$config
)
{
$isWindows
=
PHP_OS_FAMILY
===
'Windows'
;
$useSystemd
=
!
empty
(
$config
[
'useSystemd'
]
);
if
(
isset
(
$config
[
'useBashWrapper'
]
)
)
{
$useBashWrapper
=
$config
[
'useBashWrapper'
];
}
elseif
(
$isWindows
||
$useSystemd
)
{
$useBashWrapper
=
false
;
}
else
{
$useBashWrapper
=
is_executable
(
'/bin/bash'
);
}
if
(
$useBashWrapper
)
{
$this
->
addWrapper
(
new
BashWrapper
(
$config
[
'cgroup'
]
??
''
)
);
}
if
(
!
empty
(
$config
[
'useFirejail'
]
)
)
{
$this
->
addWrapper
(
new
FirejailWrapper
(
$config
[
'firejailPath'
]
??
'/usr/bin/firejail'
,
$config
[
'firejailProfile'
]
??
__DIR__
.
'/firejail.profile'
)
);
}
if
(
$useSystemd
)
{
$this
->
addWrapper
(
new
SystemdWrapper
);
}
if
(
$isWindows
)
{
$this
->
addWrapper
(
new
WindowsWrapper
);
}
}
/**
* Get pipe descriptors for proc_open()
*
* @param Command $command
* @return array
*/
private
function
getPipeDescriptors
(
Command
$command
)
{
$desc
=
[
0
=>
$command
->
getPassStdin
()
?
[
'file'
,
'php://stdin'
,
'r'
]
:
[
'pipe'
,
'r'
]
];
if
(
PHP_OS_FAMILY
===
'Windows'
)
{
// PHP's view of Windows anonymous pipes is too broken to be usable
$this
->
stdoutPath
=
$this
->
getTempDirManager
()->
preparePath
(
'sb-stdout'
);
$this
->
stderrPath
=
$this
->
getTempDirManager
()->
preparePath
(
'sb-stderr'
);
$desc
[
1
]
=
[
'file'
,
$this
->
stdoutPath
,
'wb'
];
$desc
[
2
]
=
[
'file'
,
$this
->
stderrPath
,
'wb'
];
}
else
{
$desc
[
1
]
=
$desc
[
2
]
=
[
'pipe'
,
'w'
];
}
if
(
$command
->
getUseLogPipe
()
)
{
$desc
[
3
]
=
[
'pipe'
,
'w'
];
}
return
$desc
;
}
/**
* Get the environment to be passed through to the subprocess. In CLI mode
* this uses getenv() because that is backwards compatible and relatively
* sensible. In the other SAPIs, there's no way to get the real environment
* short of shell_exec('env'), but it's usually near-empty anyway. We add
* PATH for convenience.
*
* In the FastCGI SAPI, $_ENV and getenv() return CGI-like variables sent
* from the web server. So the PATH here is typically inherited from Apache
* not PHP-FPM.
*
* @return array
*/
private
function
getParentEnvironment
()
{
if
(
PHP_SAPI
===
'cli'
)
{
return
getenv
();
}
elseif
(
isset
(
$_ENV
[
'PATH'
]
)
)
{
return
[
'PATH'
=>
$_ENV
[
'PATH'
]
];
}
else
{
return
[];
}
}
/**
* If the environment contains an unsafe character set, filter out the
* variable.
*
* @param array &$env
*/
private
function
fixLocaleEnvironment
(
array
&
$env
)
{
if
(
PHP_OS_FAMILY
===
'Windows'
)
{
// Probably OK?
return
;
}
foreach
(
[
'LC_CTYPE'
,
'LC_ALL'
,
'LANG'
]
as
$name
)
{
if
(
isset
(
$env
[
$name
]
)
&&
preg_match
(
'/
\.
(gb
\w
*)/i'
,
$env
[
$name
]
)
)
{
$this
->
logger
->
warning
(
"Filtering out unsafe environment variable "
.
"$name={$env[$name]}"
);
unset
(
$env
[
$name
]
);
}
}
}
/**
* @param Command $command
* @return UnboxedResult
* @throws ShellboxError
*/
public
function
execute
(
Command
$command
)
{
$command
=
clone
$command
;
$this
->
buildFinalCommand
(
$command
);
$cmd
=
$command
->
getCommandString
();
$this
->
logger
->
info
(
"Executing: $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
)
>
Shellbox
::
getMaxCmdLength
()
)
{
throw
new
ShellboxError
(
'Total length of $cmd must not exceed MAX_ARG_STRLEN'
);
}
$desc
=
$this
->
getPipeDescriptors
(
$command
);
$cmd
=
$command
->
getCommandString
();
$options
=
$command
->
getProcOpenOptions
();
$combinedEnvironment
=
$command
->
getEnvironment
()
+
$this
->
getParentEnvironment
();
$this
->
fixLocaleEnvironment
(
$combinedEnvironment
);
$env
=
[];
foreach
(
$combinedEnvironment
as
$name
=>
$value
)
{
$env
[]
=
"$name=$value"
;
}
$pipes
=
null
;
$proc
=
proc_open
(
$cmd
,
$desc
,
$pipes
,
$command
->
getWorkingDirectory
(),
$env
,
$options
);
if
(
!
$proc
)
{
$this
->
logger
->
error
(
"proc_open() failed: {command}"
,
[
'command'
=>
$cmd
]
);
throw
new
ShellboxError
(
'proc_open() failed'
);
}
$buffers
=
[
0
=>
$command
->
getStdin
(),
// input
1
=>
''
,
// stdout
2
=>
''
,
// 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
,
static
function
(
$fd
)
use
(
$desc
)
{
return
$desc
[
$fd
][
0
]
===
'pipe'
&&
$desc
[
$fd
][
1
]
===
'r'
;
},
ARRAY_FILTER_USE_KEY
);
$writePipes
=
array_filter
(
$pipes
,
static
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 descriptor types are from the POV of the process doing it.
// So $writePipes is passed as the $read parameter and $readPipes as $write.
// phpcs:ignore Generic.PHP.NoSilencedErrors
$numReadyPipes
=
@
stream_select
(
$writePipes
,
$readPipes
,
$emptyArray
,
$timeout
);
if
(
$numReadyPipes
===
false
)
{
$error
=
error_get_last
();
if
(
!
$error
)
{
$logMsg
=
'unknown error'
;
break
;
}
elseif
(
strncmp
(
$error
[
'message'
],
$eintrMessage
,
strlen
(
$eintrMessage
)
)
==
0
)
{
continue
;
}
else
{
$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
);
}
}
}
}
}
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']}"
;
// Use the shell convention of setting the exit status to 128 + the signal number
$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
)
{
// Per the shell convention
$logMsg
=
"Probably exited with signal "
.
(
$retval
-
128
);
}
}
if
(
$logMsg
!==
false
)
{
$this
->
logger
->
warning
(
"$logMsg: {command}"
,
[
'command'
=>
$cmd
]
);
}
if
(
$this
->
stdoutPath
!==
null
)
{
$stdout
=
FileUtils
::
getContents
(
$this
->
stdoutPath
);
}
else
{
$stdout
=
$buffers
[
1
];
}
if
(
$this
->
stderrPath
!==
null
)
{
$stderr
=
FileUtils
::
getContents
(
$this
->
stderrPath
);
}
else
{
$stderr
=
$buffers
[
2
];
}
if
(
$stderr
!==
''
&&
$command
->
getForwardStderr
()
)
{
fwrite
(
STDERR
,
$stderr
);
}
if
(
$stderr
!==
''
&&
$command
->
getLogStderr
()
)
{
$this
->
logger
->
error
(
"Error running {command}: {error}"
,
[
'command'
=>
$cmd
,
'error'
=>
$stderr
,
'exitcode'
=>
$retval
,
'exception'
=>
new
ShellboxError
(
'Shell error'
),
]
);
}
return
(
new
UnboxedResult
)
->
exitCode
(
$retval
)
->
stdout
(
$stdout
)
->
stderr
(
$stderr
);
}
/**
* Modify the command by running wrappers on it.
*
* @param Command $command
*/
protected
function
buildFinalCommand
(
Command
$command
)
{
$wrappers
=
$this
->
wrappers
;
usort
(
$wrappers
,
static
function
(
Wrapper
$a
,
Wrapper
$b
)
{
return
$a
->
getPriority
()
<=>
$b
->
getPriority
();
}
);
foreach
(
$wrappers
as
$wrapper
)
{
$wrapper
->
wrap
(
$command
);
}
if
(
$command
->
getIncludeStderr
()
)
{
$command
->
unsafeCommand
(
$command
->
getCommandString
()
.
' 2>&1'
);
}
}
}
File Metadata
Details
Attached
Mime Type
text/x-php
Expires
Sat, Oct 3, 19:00 (1 d, 15 h ago)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
bc/55/637dc4f90350e3a0cf509ffd386c
Default Alt Text
UnboxedExecutor.php (13 KB)
Attached To
Mode
rMWPROD MediaWiki Production
Attached
Detach File
Event Timeline
Log In to Comment