2026-03-10 21:15:05 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
use App\Models\Server;
|
|
|
|
|
use App\Models\ServerSetting;
|
|
|
|
|
use App\Models\User;
|
2026-04-29 14:44:12 +00:00
|
|
|
use Illuminate\Contracts\Encryption\DecryptException;
|
2026-03-10 21:15:05 +00:00
|
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
2026-04-29 14:44:12 +00:00
|
|
|
use Illuminate\Support\Facades\Crypt;
|
|
|
|
|
use Illuminate\Support\Facades\DB;
|
2026-03-10 21:15:05 +00:00
|
|
|
|
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
|
|
|
|
|
|
beforeEach(function () {
|
|
|
|
|
$user = User::factory()->create();
|
|
|
|
|
$this->team = $user->teams()->first();
|
|
|
|
|
|
|
|
|
|
$this->server = Server::factory()->create([
|
|
|
|
|
'team_id' => $this->team->id,
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('ServerSetting::isValidSentinelToken', function () {
|
|
|
|
|
it('accepts alphanumeric tokens', function () {
|
|
|
|
|
expect(ServerSetting::isValidSentinelToken('abc123'))->toBeTrue();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('accepts tokens with dots, hyphens, and underscores', function () {
|
|
|
|
|
expect(ServerSetting::isValidSentinelToken('my-token_v2.0'))->toBeTrue();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('accepts long base64-like encrypted tokens', function () {
|
|
|
|
|
$token = 'eyJpdiI6IjRGN0V4YnRkZ1p0UXdBPT0iLCJ2YWx1ZSI6IjZqQT0iLCJtYWMiOiIxMjM0NTY3ODkwIn0';
|
|
|
|
|
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('accepts tokens with base64 characters (+, /, =)', function () {
|
|
|
|
|
expect(ServerSetting::isValidSentinelToken('abc+def/ghi='))->toBeTrue();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects tokens with double quotes', function () {
|
|
|
|
|
expect(ServerSetting::isValidSentinelToken('abc" ; id ; echo "'))->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects tokens with single quotes', function () {
|
|
|
|
|
expect(ServerSetting::isValidSentinelToken("abc' ; id ; echo '"))->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects tokens with semicolons', function () {
|
|
|
|
|
expect(ServerSetting::isValidSentinelToken('abc;id'))->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects tokens with backticks', function () {
|
|
|
|
|
expect(ServerSetting::isValidSentinelToken('abc`id`'))->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects tokens with dollar sign command substitution', function () {
|
|
|
|
|
expect(ServerSetting::isValidSentinelToken('abc$(whoami)'))->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects tokens with spaces', function () {
|
|
|
|
|
expect(ServerSetting::isValidSentinelToken('abc def'))->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects tokens with newlines', function () {
|
|
|
|
|
expect(ServerSetting::isValidSentinelToken("abc\nid"))->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects tokens with pipe operator', function () {
|
|
|
|
|
expect(ServerSetting::isValidSentinelToken('abc|id'))->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects tokens with ampersand', function () {
|
|
|
|
|
expect(ServerSetting::isValidSentinelToken('abc&&id'))->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects tokens with redirection operators', function () {
|
|
|
|
|
expect(ServerSetting::isValidSentinelToken('abc>/tmp/pwn'))->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects empty strings', function () {
|
|
|
|
|
expect(ServerSetting::isValidSentinelToken(''))->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-29 14:44:12 +00:00
|
|
|
it('returns false for null sentinel token', function () {
|
|
|
|
|
expect(ServerSetting::isValidSentinelToken(null))->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-10 21:15:05 +00:00
|
|
|
it('rejects the reported PoC payload', function () {
|
|
|
|
|
expect(ServerSetting::isValidSentinelToken('abc" ; id >/tmp/coolify_poc_sentinel ; echo "'))->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-29 14:44:12 +00:00
|
|
|
describe('ServerSetting::ensureValidSentinelToken', function () {
|
|
|
|
|
it('regenerates empty sentinel token via ensureValidSentinelToken', function () {
|
|
|
|
|
$settings = $this->server->settings;
|
|
|
|
|
DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => '']);
|
|
|
|
|
|
|
|
|
|
$settings->refresh();
|
|
|
|
|
$token = $settings->ensureValidSentinelToken();
|
|
|
|
|
|
|
|
|
|
expect($token)->not->toBeEmpty();
|
|
|
|
|
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
|
|
|
|
|
expect($settings->fresh()->sentinel_token)->toBe($token);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('regenerates token when stored value cannot be decrypted', function () {
|
|
|
|
|
$settings = $this->server->settings;
|
|
|
|
|
DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => 'not-encrypted-junk']);
|
|
|
|
|
|
|
|
|
|
$settings->refresh();
|
|
|
|
|
$token = $settings->ensureValidSentinelToken();
|
|
|
|
|
|
|
|
|
|
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
|
|
|
|
|
expect($settings->fresh()->sentinel_token)->toBe($token);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns existing valid token without regenerating', function () {
|
|
|
|
|
$settings = $this->server->settings;
|
|
|
|
|
$original = $settings->sentinel_token;
|
|
|
|
|
|
|
|
|
|
$token = $settings->ensureValidSentinelToken();
|
|
|
|
|
|
|
|
|
|
expect($token)->toBe($original);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('throws RuntimeException only when regeneration also fails', function () {
|
|
|
|
|
$settings = $this->server->settings;
|
|
|
|
|
DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => '']);
|
|
|
|
|
|
|
|
|
|
$stub = new class extends ServerSetting
|
|
|
|
|
{
|
|
|
|
|
protected $table = 'server_settings';
|
|
|
|
|
|
|
|
|
|
public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false): string
|
|
|
|
|
{
|
|
|
|
|
DB::table('server_settings')->where('id', $this->id)->update([
|
|
|
|
|
'sentinel_token' => encrypt('invalid token with spaces!'),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
$stub->setRawAttributes($settings->fresh()->getAttributes(), true);
|
|
|
|
|
$stub->exists = true;
|
|
|
|
|
|
|
|
|
|
expect(fn () => $stub->ensureValidSentinelToken())
|
|
|
|
|
->toThrow(RuntimeException::class, 'Sentinel token invalid after regeneration');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-10 21:15:05 +00:00
|
|
|
describe('generated sentinel tokens are valid', function () {
|
|
|
|
|
it('generates tokens that pass format validation', function () {
|
|
|
|
|
$settings = $this->server->settings;
|
|
|
|
|
$settings->generateSentinelToken(save: false, ignoreEvent: true);
|
|
|
|
|
$token = $settings->sentinel_token;
|
|
|
|
|
|
|
|
|
|
expect($token)->not->toBeEmpty();
|
|
|
|
|
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
|
|
|
|
|
});
|
2026-04-29 14:44:12 +00:00
|
|
|
|
|
|
|
|
it('does not double-encrypt newly generated tokens', function () {
|
|
|
|
|
$settings = $this->server->settings;
|
|
|
|
|
$token = $settings->generateSentinelToken(save: true, ignoreEvent: true);
|
|
|
|
|
|
|
|
|
|
$raw = DB::table('server_settings')->where('id', $settings->id)->value('sentinel_token');
|
|
|
|
|
|
|
|
|
|
$once = Crypt::decryptString($raw);
|
|
|
|
|
expect($once)->toBe($token);
|
|
|
|
|
|
|
|
|
|
expect(fn () => Crypt::decryptString($once))
|
|
|
|
|
->toThrow(DecryptException::class);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns the same value the cast reads back', function () {
|
|
|
|
|
$settings = $this->server->settings;
|
|
|
|
|
$returned = $settings->generateSentinelToken(save: true, ignoreEvent: true);
|
|
|
|
|
|
|
|
|
|
expect($settings->fresh()->sentinel_token)->toBe($returned);
|
|
|
|
|
});
|
2026-03-10 21:15:05 +00:00
|
|
|
});
|