Skip to content

Secure the task runners #20

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Apr 17, 2025
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,19 @@ Some tips:
- If multiple tasks are started with the same task ID, then the task status object will only track the first task that was started
- Known issue: on Windows, checking task statuses can be slow (about 0.5 - 1 seconds) due to underlying bottlenecks

### Securing the task runners
The way this library works means that attackers (or other unwanted parties) may simply craft malicious commands that mimic legitimate usage of this library.

To secure the task runners from being started illegitimately, you may configure the `.env` file to contain the following key:

```
PROCESS_ASYNC_SECRET_KEY=[your secret key here]
```

You may need to clear your Laravel optimisation cache after changing this value.

The contents of the async tasks will be signed by this secret key, so that this library can know whether the tasks are started by this library itself or someone else.

## Testing
PHPUnit via Composer script:
```sh
Expand Down
25 changes: 24 additions & 1 deletion src/AsyncTask.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
use loophp\phposinfo\OsInfo;
use RuntimeException;

use function Opis\Closure\{serialize, unserialize};
use function Opis\Closure\init;
use function Opis\Closure\serialize;
use function Opis\Closure\set_security_provider;
use function Opis\Closure\unserialize;

/**
* The common handler of an AsyncTask; this can be a closure (will be wrapped inside AsyncTask) or an interface instance.
Expand Down Expand Up @@ -147,6 +150,26 @@ public function __unserialize($data): void
}

/**
* Loads (or reloads) the secret key used by this library.
*
* Normally, this function does not need to be called by linrary users.
* @return void
*/
public static function loadSecretKey(): void
{
// read from the env file for the secret key (if exists) to verify our identity
$secretKey = env("PROCESS_ASYNC_SECRET_KEY");
init(null);
if ($secretKey != null && strlen($secretKey) > 0) {
// we can set the secret key
set_security_provider($secretKey);
} else {
// no secret key given, clear the security
set_security_provider(null);
}
}

/*
* Returns a status object for the started AsyncTask.
*
* If this task does not have an explicit task ID, a new one will be generated on-the-fly.
Expand Down
11 changes: 10 additions & 1 deletion src/AsyncTaskRunnerCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Vectorial1024\LaravelProcessAsync;

use Illuminate\Console\Command;
use Opis\Closure\Security\SecurityException;

/**
* The Artisan command to run AsyncTask. DO NOT USE DIRECTLY!
Expand All @@ -23,6 +24,8 @@ class AsyncTaskRunnerCommand extends Command
*/
protected $description = 'Runs a background async task (DO NOT USE DIRECTLY)';

protected $hidden = true;

/**
* Execute the console command.
*
Expand All @@ -33,7 +36,13 @@ public function handle(): int
// first, unpack the task
// (Symfony already safeguards the "task" argument to make it required)
$theTask = $this->argument('task');
$theTask = AsyncTask::fromBase64Serial($theTask);
try {
$theTask = AsyncTask::fromBase64Serial($theTask);
} catch (SecurityException $x) {
// bad secret key; cannot verify sender identity
$this->error("Unrecognized task giver is trying to start AsyncTaskRunner.");
return self::FAILURE;
}
if ($theTask === null) {
// bad underializing; probably bad data
$this->error("Invalid task details!");
Expand Down
1 change: 0 additions & 1 deletion src/AsyncTaskStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,6 @@ private function findTaskRunnerProcess(): bool
// we can assume we are in cmd, but wcim in cmd is deprecated, and the replacement gcim requires powershell
$results = [];
$fullCmd = "powershell echo \"\"(gcim Win32_Process -Filter \\\"CommandLine LIKE '%id=\'$encodedTaskID\'%'\\\").ProcessId\"\"";
\Illuminate\Support\Facades\Log::info($fullCmd);
exec("powershell echo \"\"(gcim Win32_Process -Filter \\\"CommandLine LIKE '%id=\'$encodedTaskID\'%'\\\").ProcessId\"\"", $results);
// will output many lines, each line being a PID
foreach ($results as $candidatePID) {
Expand Down
3 changes: 2 additions & 1 deletion src/ProcessAsyncServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ class ProcessAsyncServiceProvider extends ServiceProvider
*/
public function register(): void
{
// pass
// load/reset our secret key
AsyncTask::loadSecretKey();
}

/**
Expand Down
47 changes: 46 additions & 1 deletion tests/AsyncTaskTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use InvalidArgumentException;
use LogicException;
use Opis\Closure\Security\SecurityException;
use RuntimeException;
use Vectorial1024\LaravelProcessAsync\AsyncTask;
use Vectorial1024\LaravelProcessAsync\AsyncTaskStatus;
Expand All @@ -13,10 +14,18 @@
use Vectorial1024\LaravelProcessAsync\Tests\Tasks\TestTimeoutErrorTask;
use Vectorial1024\LaravelProcessAsync\Tests\Tasks\TestTimeoutNoOpTask;
use Vectorial1024\LaravelProcessAsync\Tests\Tasks\TestTimeoutNormalTask;
use function Opis\Closure\init;
use function Opis\Closure\set_security_provider;

class AsyncTaskTest extends BaseTestCase
{
// directly running the runner
public function setUp(): void
{
// we need to reset the security key in case some of our tests change the secret key
AsyncTask::loadSecretKey();
}

// directly running the runner

public function testCanRunClosure()
{
Expand Down Expand Up @@ -74,6 +83,42 @@ public function testConfigNoTimeLimit()
$this->assertNull($task->getTimeLimit());
}

public function testCanVerifySender()
{
// test that, when given a serialized closure signed by us, we can correctly reproduce the closure
$testSecretKey = "LaravelProcAsync";
$testClosure = fn () => "Hello there!";
// reset the security provider
set_security_provider(null);
init($testSecretKey);
// serialize once
$testTask = new AsyncTask($testClosure);
$checkingString = $testTask->toBase64Serial();
// then unserialize it
$reproducedTask = AsyncTask::fromBase64Serial($checkingString);
$this->assertTrue($reproducedTask instanceof AsyncTask);
}

public function testCannotVerifySender()
{
// test that, when given a serialized closure signed by Mallory, we can correctly reject the closure
$testSecretKey = "LaravelProcAsync";
$testMalloryKey = "HereComesDatMallory";
$testClosure = fn () => "Hello there!";
// reset the security provider
// mallory appears!
set_security_provider($testMalloryKey);
// serialize once
$testTask = new AsyncTask($testClosure);
$checkingString = $testTask->toBase64Serial();
// then unserialize it
set_security_provider($testSecretKey);
$this->expectException(SecurityException::class);
// should throw
$reproducedTask = AsyncTask::fromBase64Serial($checkingString);
unset($reproducedTask);
}

// ---------

// integration test with the cli artisan via a mocked artisan file, which tests various features of this library
Expand Down