Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/Executor/Logs.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ public static function get(string $containerId): array
$logsFile = $dir . "/logs.txt";
$timingsFile = $dir . "/timings.txt";

if (!\file_exists($logsFile) || !\file_exists($timingsFile)) {
// The build container writes both files, so a symlink or anything else reads as empty.
$logs = RegularFile::read($logsFile);
$timings = RegularFile::read($timingsFile);
if ($logs === '' || $timings === '') {
return [];
}

$logs = \file_get_contents($logsFile) ?: '';
$timings = \file_get_contents($timingsFile) ?: '';

$offset = 0; // Current offset from timing for reading logs content
$introOffset = self::getLogOffset($logs);

Expand Down
99 changes: 99 additions & 0 deletions src/Executor/RegularFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

namespace OpenRuntimes\Executor;

/**
* A file in a directory that a runtime or build container can write: its logs, logging,
* build output and cache mounts.
*
* The container controls what sits at the path, so it is untrusted. A symlink there made the
* executor read its own files (its environment, with OPR_EXECUTOR_SECRET) and hand them back
* as logs or build output. Only a regular file that is still the same file once opened is used.
*/
final class RegularFile
{
/**
* Copy $path to $destination, a new file outside the container's reach, from the handle that
* passed the checks, and return the bytes copied. Null when $path is not a regular file.
*
* Uploading $path by name would reopen it, and a container still running can swap a symlink
* in for that open and back again. The copy is what gets uploaded instead.
*/
public static function copy(string $path, string $destination): ?int
{
$source = self::open($path);
if ($source === null) {
return null;
}

try {
// 'x' fails if anything already sits at $destination, a symlink included.
$target = @\fopen($destination, 'xb');
if ($target === false) {
throw new \Exception('Failed to create ' . $destination);
}

try {
$copied = \stream_copy_to_stream($source, $target);
if ($copied === false) {
throw new \Exception('Failed to copy ' . $path);
}

return $copied;
} finally {
\fclose($target);
}
} finally {
\fclose($source);
}
}

/**
* Up to $length bytes of $path from $offset (all of it when $length is null), or '' when
* $path is not a regular file.
*/
public static function read(string $path, int $offset = 0, ?int $length = null): string
{
$handle = self::open($path);
if ($handle === null) {
return '';
}

try {
return (string) \stream_get_contents($handle, $length, $offset);
} finally {
\fclose($handle);
}
}

/**
* The open handle when $path is a regular file that opens as itself, otherwise null:
* a symlink, FIFO, device, directory or missing path.
*
* @return resource|null
*/
private static function open(string $path)
{
$link = @\lstat($path);
// lstat describes the entry itself, so a symlink is refused rather than followed.
if ($link === false || ($link['mode'] & 0170000) !== 0100000) {
return null;
}

// 'n' opens with O_NONBLOCK: a FIFO swapped in after the lstat would otherwise block
// this open forever with no writer. It opens at once instead, and the inode check refuses it.
$handle = @\fopen($path, 'rbn');
if ($handle === false) {
return null;
}

// Swapped for a symlink between lstat and fopen, the open followed it to another inode.
$opened = \fstat($handle);
if ($opened === false || $opened['dev'] !== $link['dev'] || $opened['ino'] !== $link['ino']) {
\fclose($handle);
return null;
}

return $handle;
}
}
94 changes: 50 additions & 44 deletions src/Executor/Runner/Docker.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use OpenRuntimes\Executor\Logs;
use OpenRuntimes\Executor\Exception as ExecutorException;
use OpenRuntimes\Executor\RegularFile;
use OpenRuntimes\Executor\Runner\Repository\Runtimes;
use OpenRuntimes\Executor\StorageFactory;
use OpenRuntimes\Executor\Validator\TCP;
Expand Down Expand Up @@ -83,7 +84,8 @@ public function getLogs(string $runtimeId, int $timeout, Response $response): vo
}

if (\file_exists($tmpLogging . '/logs.txt') && \file_exists($tmpLogging . '/timings.txt')) {
$timings = \file_get_contents($tmpLogging . '/timings.txt') ?: '';
// The build container writes these files, so they are read through RegularFile.
$timings = RegularFile::read($tmpLogging . '/timings.txt');
if ($timings !== '') {
break;
}
Expand Down Expand Up @@ -148,7 +150,7 @@ public function getLogs(string $runtimeId, int $timeout, Response $response): vo
});

$offset = 0; // Current offset from timing for reading logs content
$tempLogsContent = \file_get_contents($tmpLogging . '/logs.txt') ?: '';
$tempLogsContent = RegularFile::read($tmpLogging . '/logs.txt');
$introOffset = Logs::getLogOffset($tempLogsContent);

$datetime = new \DateTime("now", new \DateTimeZone("UTC")); // Date used for tracking absolute log timing
Expand All @@ -173,7 +175,7 @@ public function getLogs(string $runtimeId, int $timeout, Response $response): vo
$timestamp = $part['timestamp'] ?? '';
$length = \intval($part['length'] ?? '0');

$logContent = \file_get_contents($tmpLogging . '/logs.txt', false, null, $introOffset + $offset, \abs($length)) ?: '';
$logContent = RegularFile::read($logsPath, $introOffset + $offset, \abs($length));

$logContent = \str_replace("\n", "\\n", $logContent);

Expand Down Expand Up @@ -447,20 +449,28 @@ public function createRuntime(
* Move built code to expected build directory
*/
if ($destination !== '' && $destination !== '0') {
// Check if the build was successful by checking if file exists
if (!$localDevice->exists($tmpBuild)) {
throw new \Exception('Something went wrong when starting runtime.');
}
// The build container can write this path. Following a symlink there would upload the
// executor's own files (its environment) as the build, for the owner to download.
// The upload reads a copy in /tmp/<runtime>, which no container mounts.
$snapshot = \dirname($tmpBuild, 2) . '/output-' . \basename($tmpBuild);
try {
$size = RegularFile::copy($tmpBuild, $snapshot);
if ($size === null) {
throw new \Exception('Something went wrong when starting runtime.');
}

$size = $localDevice->getFileSize($tmpBuild);
$container['size'] = $size;
$container['size'] = $size;

$destinationDevice = StorageFactory::getDevice($destination, System::getEnv('OPR_EXECUTOR_CONNECTION_STORAGE'));
$path = $destinationDevice->getPath(\uniqid() . '.' . \pathinfo($tmpBuild, PATHINFO_EXTENSION));
$destinationDevice = StorageFactory::getDevice($destination, System::getEnv('OPR_EXECUTOR_CONNECTION_STORAGE'));
$path = $destinationDevice->getPath(\uniqid() . '.' . \pathinfo($tmpBuild, PATHINFO_EXTENSION));

if (!$localDevice->copy($tmpBuild, $path, $destinationDevice)) {
throw new \Exception('Failed to move built code to storage');
};
if (!$localDevice->copy($snapshot, $path, $destinationDevice)) {
throw new \Exception('Failed to move built code to storage');
};
} finally {
// A runtime kept after its build keeps /tmp/<runtime>, so the copy is removed here.
@\unlink($snapshot);
}

$container['path'] = $path;
}
Expand Down Expand Up @@ -631,15 +641,22 @@ private function storeBuildCacheArtifact(string $cacheKey, string $tmpCache, Loc
{
$artifact = $tmpCache . '/stores.sqfs';

if (!$localDevice->exists($artifact)) {
return;
}
// /cache is mounted into the build container, so anything but a regular file is skipped, and
// the upload reads a copy in /tmp/<runtime>, which no container mounts.
$snapshot = \dirname($tmpCache) . '/cache-stores.sqfs';
try {
if (RegularFile::copy($artifact, $snapshot) === null) {
return;
}

$cacheDevice = $this->getBuildCacheDevice();
$destinationArtifact = $this->getBuildCacheArtifactPath($cacheDevice, $cacheKey);
$cacheDevice = $this->getBuildCacheDevice();
$destinationArtifact = $this->getBuildCacheArtifactPath($cacheDevice, $cacheKey);

if (!$localDevice->copy($artifact, $destinationArtifact, $cacheDevice)) {
throw new \Exception('Failed to store build cache artifact');
if (!$localDevice->copy($snapshot, $destinationArtifact, $cacheDevice)) {
throw new \Exception('Failed to store build cache artifact');
}
} finally {
@\unlink($snapshot);
}
}

Expand Down Expand Up @@ -1023,34 +1040,23 @@ public function createExecution(

$logs = '';
$errors = '';
if ($fileId !== '' && $fileId !== '0') {
// The runtime sends this id, so it may only name a file directly in its own logs directory.
if ($fileId !== '' && $fileId !== '0' && \ctype_alnum($fileId)) {
Comment thread
hansi-codes[bot] marked this conversation as resolved.
Resolved
$logFile = '/tmp/' . $runtimeName . '/logs/' . $fileId . '_logs.log';
$errorFile = '/tmp/' . $runtimeName . '/logs/' . $fileId . '_errors.log';

$logDevice = new Local();

if ($logDevice->exists($logFile)) {
if ($logDevice->getFileSize($logFile) > MAX_LOG_SIZE) {
$maxToRead = MAX_LOG_SIZE;
$logs = (string) $logDevice->read($logFile, 0, $maxToRead);
$logs .= "\nLog file has been truncated to " . number_format(MAX_LOG_SIZE / 1048576, 2) . "MB.";
} else {
$logs = (string) $logDevice->read($logFile);
}

$logDevice->delete($logFile);
// The runtime writes these files, so a symlink is refused, never followed. unlink
// removes a symlink itself, never its target.
$logs = RegularFile::read($logFile, 0, MAX_LOG_SIZE + 1);
@\unlink($logFile);
if (\strlen($logs) > MAX_LOG_SIZE) {
$logs = \substr($logs, 0, MAX_LOG_SIZE) . "\nLog file has been truncated to " . number_format(MAX_LOG_SIZE / 1048576, 2) . "MB.";
}

if ($logDevice->exists($errorFile)) {
if ($logDevice->getFileSize($errorFile) > MAX_LOG_SIZE) {
$maxToRead = MAX_LOG_SIZE;
$errors = (string) $logDevice->read($errorFile, 0, $maxToRead);
$errors .= "\nError file has been truncated to " . number_format(MAX_LOG_SIZE / 1048576, 2) . "MB.";
} else {
$errors = (string) $logDevice->read($errorFile);
}

$logDevice->delete($errorFile);
$errors = RegularFile::read($errorFile, 0, MAX_LOG_SIZE + 1);
@\unlink($errorFile);
if (\strlen($errors) > MAX_LOG_SIZE) {
$errors = \substr($errors, 0, MAX_LOG_SIZE) . "\nError file has been truncated to " . number_format(MAX_LOG_SIZE / 1048576, 2) . "MB.";
}
}

Expand Down
100 changes: 100 additions & 0 deletions tests/e2e/ExecutorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,68 @@ public function testBuildOutputDirectory(): void
$this->assertEquals(200, $response['headers']['status-code']);
}

public function testBuildOutputSymlinkIsNotUploaded(): void
{
$output = '';
$stderr = '';
Console::execute('cd /app/tests/resources/functions/node && tar --exclude code.tar.gz -czf code.tar.gz .', '', $output, $stderr);

$runtimeId = \bin2hex(\random_bytes(4));

// The build leaves a link to /proc/self/environ as its output. Followed, the executor
// uploads its own environment, secret included, as a build the owner can download.
$response = $this->client->call(Client::METHOD_POST, '/runtimes', [], [
'runtimeId' => 'test-build-symlink-' . $runtimeId,
'source' => '/storage/functions/node/code.tar.gz',
'destination' => '/storage/builds/test',
'entrypoint' => 'index.js',
'image' => 'openruntimes/node:v5-22',
'command' => 'ln -sf /proc/self/environ /mnt/code/code.tar.gz',
'remove' => true
]);

$this->assertNotEquals(201, $response['headers']['status-code']);
$this->assertArrayNotHasKey('path', (array) $response['body']);
$this->assertStringNotContainsString('OPR_EXECUTOR_SECRET', (string) \json_encode($response['body']));
}

public function testExecutionLogSymlinkIsNotFollowed(): void
{
$output = '';
$stderr = '';
Console::execute('cd /app/tests/resources/functions/node-log-symlink && tar --exclude code.tar.gz -czf code.tar.gz .', '', $output, $stderr);

$runtimeId = \bin2hex(\random_bytes(4));

$response = $this->client->call(Client::METHOD_POST, '/runtimes', [], [
'runtimeId' => 'test-build-log-symlink-' . $runtimeId,
'source' => '/storage/functions/node-log-symlink/code.tar.gz',
'destination' => '/storage/builds/test',
'entrypoint' => 'index.js',
'image' => 'openruntimes/node:v5-22',
'command' => 'tar -zxf /tmp/code.tar.gz -C /mnt/code && bash helpers/build.sh "npm i"',
'remove' => true
]);
$this->assertEquals(201, $response['headers']['status-code']);

// The function swaps its log files for links to /proc/self/environ. Followed, they return
// the executor's own environment, secret included, as the execution's logs.
$response = $this->client->call(Client::METHOD_POST, '/runtimes/test-exec-log-symlink-' . $runtimeId . '/executions', [], [
'source' => $response['body']['path'],
'entrypoint' => 'index.js',
'image' => 'openruntimes/node:v5-22',
'runtimeEntrypoint' => 'cp /tmp/code.tar.gz /mnt/code/code.tar.gz && nohup helpers/start.sh "bash helpers/server.sh"',
]);

$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('OK', $response['body']['body']);
$this->assertStringNotContainsString('OPR_EXECUTOR_SECRET', (string) $response['body']['logs']);
$this->assertStringNotContainsString('OPR_EXECUTOR_SECRET', (string) $response['body']['errors']);

$response = $this->client->call(Client::METHOD_DELETE, '/runtimes/test-exec-log-symlink-' . $runtimeId, [], []);
$this->assertEquals(200, $response['headers']['status-code']);
}

public function testBuildUncompressed(): void
{
$output = '';
Expand Down Expand Up @@ -826,6 +888,44 @@ public function testBuildCache(): void
$this->assertEquals(200, $response['headers']['status-code']);
}

public function testBuildCacheSymlinkIsNotStored(): void
{
$output = '';
$stderr = '';
Console::execute('cd /app/tests/resources/functions/node && tar --exclude code.tar.gz -czf code.tar.gz .', '', $output, $stderr);

$runtimeId = \bin2hex(\random_bytes(4));
$cacheKey = 'test-build-cache-symlink-' . $runtimeId;

// The first build swaps its cache artifact for a link to /proc/self/environ. Followed, the
// executor stores its own environment, secret included, as the project's build cache.
$response = $this->client->call(Client::METHOD_POST, '/runtimes', [], [
'runtimeId' => 'test-build-cache-plant-' . $runtimeId,
'source' => '/storage/functions/node/code.tar.gz',
'destination' => '/storage/builds/test-cache-plant',
'entrypoint' => 'index.js',
'image' => 'openruntimes/node:v5-18.0',
'command' => 'tar -zxf /tmp/code.tar.gz -C /mnt/code && bash helpers/build.sh "npm install" && ln -sf /proc/self/environ /cache/stores.sqfs',
'cacheKey' => $cacheKey,
'remove' => true,
]);
$this->assertEquals(201, $response['headers']['status-code']);

// The second build prints whatever the executor restored as its cache.
$response = $this->client->call(Client::METHOD_POST, '/runtimes', [], [
'runtimeId' => 'test-build-cache-read-' . $runtimeId,
'source' => '/storage/functions/node/code.tar.gz',
'destination' => '/storage/builds/test-cache-read',
'entrypoint' => 'index.js',
'image' => 'openruntimes/node:v5-18.0',
'command' => 'tar -zxf /tmp/code.tar.gz -C /mnt/code && (cat /cache/stores.sqfs || true) && bash helpers/build.sh "npm install"',
'cacheKey' => $cacheKey,
'remove' => true,
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertStringNotContainsString('OPR_EXECUTOR_SECRET', (string) \json_encode($response['body']));
}

// We also test SSR two Set-cookie here
public function testSSRLogs(): void
{
Expand Down
14 changes: 14 additions & 0 deletions tests/resources/functions/node-log-symlink/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const fs = require('fs');

// Replaces this execution's log files with symlinks to /proc/self/environ. The runtime keeps
// writing to the files it already opened, but the executor reads them by path afterwards.
module.exports = async (context) => {
context.log('planted');

for (const file of fs.readdirSync('/mnt/logs')) {
fs.rmSync(`/mnt/logs/${file}`);
fs.symlinkSync('/proc/self/environ', `/mnt/logs/${file}`);
}

return context.res.send('OK');
}
Loading
Loading