fix(sentinel): validate push containers payload

Reject malformed sentinel push payloads before updating heartbeat state,
dispatching jobs, or writing deduplication cache entries.
This commit is contained in:
Andras Bacsai
2026-05-26 14:07:41 +02:00
parent ed3780b2a7
commit 7677fac2f5
2 changed files with 33 additions and 0 deletions
@@ -8,6 +8,7 @@ use App\Models\Server;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Validator;
class SentinelController extends Controller
{
@@ -77,6 +78,17 @@ class SentinelController extends Controller
return response()->json(['message' => 'Unauthorized'], 401);
}
$validator = Validator::make($request->all(), [
'containers' => ['required', 'array', 'min:1'],
]);
if ($validator->fails()) {
return response()->json(serializeApiResponse([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
]), 422);
}
$data = $request->all();
// Heartbeat MUST update on every push — drives isSentinelLive() and SSH-check skipping.
@@ -11,6 +11,8 @@ use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.maintenance.store' => 'array']);
Queue::fake();
Cache::flush();
@@ -69,6 +71,25 @@ it('updates the heartbeat even when the job is skipped', function () use ($runni
expect(Carbon::parse($this->server->fresh()->sentinel_updated_at)->diffInSeconds(now()))->toBeLessThan(5);
});
it('rejects malformed sentinel payloads before touching server state', function (array $payload) {
$this->server->update(['sentinel_updated_at' => now()->subHour()]);
$originalHeartbeat = $this->server->fresh()->sentinel_updated_at;
pushSentinel($this->token, $payload)
->assertUnprocessable()
->assertJsonPath('message', 'Validation failed.')
->assertJsonValidationErrors('containers');
Queue::assertNotPushed(PushServerUpdateJob::class);
expect($this->server->fresh()->sentinel_updated_at)->toBe($originalHeartbeat);
expect(Cache::has('sentinel:push-hash:'.$this->server->id))->toBeFalse();
expect(Cache::has('sentinel:push-force:'.$this->server->id))->toBeFalse();
})->with([
'missing containers' => [[]],
'non-array containers' => [['containers' => 'not-an-array']],
'empty containers' => [['containers' => []]],
]);
it('dispatches the job when container state changes', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running()))->assertOk();