fix: harden IPC callbacks and decouple auth lookup via configurable resolver

This commit is contained in:
Fabian @ Blax Software 2026-04-17 11:03:02 +02:00
parent ed371ac051
commit 2ad8d490b7
6 changed files with 138 additions and 29 deletions

View File

@ -39,6 +39,22 @@ return [
*/
'introspection' => env('WEBSOCKET_INTROSPECTION', false),
/*
|--------------------------------------------------------------------------
| Auth Resolver
|--------------------------------------------------------------------------
|
| Callable that receives the `authtoken` string from an incoming message and
| returns either a user model (Authenticatable) or null. Used by Controller
| self-heal when `need_auth = true` and the connection has no user yet.
|
| Defaults to Laravel Sanctum lookup when Sanctum is installed. Applications
| can supply their own by binding `websockets.auth_resolver` in the container
| or by setting this to a `[Class::class, 'method']` / Closure reference.
|
*/
'auth_resolver' => null,
/*
|--------------------------------------------------------------------------
| Max Concurrent Children (Fork Limit)

View File

@ -129,7 +129,10 @@ class WebSocketHandler implements MessageComponentInterface
$ch = $this->channelManager->find($connection->app->id, $channel);
if ($ch) {
$ch->broadcastToEveryoneExcept(
$payload, $connection->socketId, $connection->app->id, false
$payload,
$connection->socketId,
$connection->app->id,
false
);
}
}

View File

@ -10,7 +10,6 @@ use BlaxSoftware\LaravelWebSockets\Channels\PresenceChannel;
use BlaxSoftware\LaravelWebSockets\Channels\PrivateChannel;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Laravel\Sanctum\PersonalAccessToken;
use Ratchet\ConnectionInterface;
class Controller
@ -98,9 +97,9 @@ class Controller
$authtoken = @$message['data']['authtoken'] ?? null;
if ($authtoken) {
try {
$tokenRecord = PersonalAccessToken::findToken($authtoken);
if ($tokenRecord?->tokenable) {
$connection->user = $tokenRecord->tokenable;
$resolved = self::resolveUserFromToken($authtoken);
if ($resolved) {
$connection->user = $resolved;
Auth::login($connection->user);
// Clear parent's stale auth cache so it re-authenticates
if ($connection instanceof MockConnectionSocketPair) {
@ -166,6 +165,48 @@ class Controller
}
}
/**
* Resolve a user from an authtoken string. First tries the configured
* `websockets.auth_resolver` callable; falls back to Laravel Sanctum's
* `PersonalAccessToken::findToken()` if the class exists.
*
* Returns an Authenticatable user or null.
*/
protected static function resolveUserFromToken(string $authtoken)
{
// 1. Configured resolver (closure or [Class, method])
$resolver = config('websockets.auth_resolver');
if ($resolver && is_callable($resolver)) {
$user = $resolver($authtoken);
if ($user) {
return $user;
}
}
// 2. Container binding (useful for class-based resolvers)
if (app()->bound('websockets.auth_resolver')) {
$bound = app('websockets.auth_resolver');
if (is_callable($bound)) {
$user = $bound($authtoken);
if ($user) {
return $user;
}
}
}
// 3. Fallback to Sanctum if available (string class name to avoid
// autoload errors when the package isn't installed)
$sanctumClass = 'Laravel\\Sanctum\\PersonalAccessToken';
if (class_exists($sanctumClass)) {
$tokenRecord = $sanctumClass::findToken($authtoken);
if ($tokenRecord?->tokenable) {
return $tokenRecord->tokenable;
}
}
return null;
}
final public function progress(
mixed $payload = null,
?string $event = null,

View File

@ -686,9 +686,40 @@ class Handler implements MessageComponentInterface
$startTime = microtime(true);
$ipc->setupParent(
// onData callback - called INSTANTLY when child sends
// onData callback - called INSTANTLY when child sends.
// CRITICAL: this callback runs inside the React event loop. Any
// uncaught throwable here would propagate up through ExtEvLoop and
// crash the entire WebSocket server (supervisor would then restart
// it, dropping every connected client). We must catch and log.
function ($data) use ($connection, $message, $startTime) {
try {
$this->handleChildData($connection, $message, $data);
} catch (\Throwable $e) {
Log::channel('websocket')->error('handleChildData failed: ' . $e->getMessage(), [
'event' => $message['event'] ?? 'unknown',
'file' => $e->getFile() . ':' . $e->getLine(),
'data_preview' => is_string($data) ? substr($data, 0, 200) : gettype($data),
]);
if (app()->bound('sentry')) {
try {
app('sentry')->captureException($e);
} catch (\Throwable $_) {
// Sentry capture failed — never let logging crash the loop.
}
}
// Best-effort: notify the client so it doesn't hang forever.
try {
$connection->send(json_encode([
'event' => ($message['event'] ?? 'unknown') . ':error',
'data' => ['message' => 'Internal server error'],
'channel' => $message['channel'] ?? null,
]));
} catch (\Throwable $_) {
// Connection may already be gone — swallow.
}
}
// Log latency for debugging
$elapsed = (microtime(true) - $startTime) * 1000;
@ -696,14 +727,21 @@ class Handler implements MessageComponentInterface
Log::channel('websocket')->debug('IPC latency: ' . round($elapsed, 2) . 'ms');
}
},
// onClose callback - child process ended
// onClose callback - child process ended.
// Same isolation rules apply: must not throw out of the loop.
function () {
try {
// Cleanup zombie process
pcntl_waitpid(-1, $status, WNOHANG);
// Free up a child slot and process any queued messages
$this->activeChildCount = max(0, $this->activeChildCount - 1);
$this->processDeferredMessages();
} catch (\Throwable $e) {
Log::channel('websocket')->error('IPC onClose failed: ' . $e->getMessage(), [
'file' => $e->getFile() . ':' . $e->getLine(),
]);
}
}
);
}
@ -841,8 +879,14 @@ class Handler implements MessageComponentInterface
unset($connection->authLoaded);
$connection->user = null;
// Clear any custom connection data that was stored via C:SET
foreach (($connection->_connectionDataKeys ?? []) as $key => $_) {
// Clear any custom connection data that was stored via C:SET.
// Read-modify-write the tracker via a local copy because the
// connection may be wrapped in a decorator (e.g. ConnectionLogger)
// whose __get returns by value — direct array mutation on the
// overloaded property would raise "Indirect modification has no
// effect" and Laravel's error handler turns that into a fatal.
$keys = $connection->_connectionDataKeys ?? [];
foreach ($keys as $key => $_) {
unset($connection->$key);
}
$connection->_connectionDataKeys = [];
@ -854,15 +898,20 @@ class Handler implements MessageComponentInterface
$key = substr($rest, 0, $pos);
$value = json_decode(substr($rest, $pos + 1));
$connection->$key = $value;
$connection->_connectionDataKeys ??= [];
$connection->_connectionDataKeys[$key] = true;
// Read-modify-write via local copy (see note above).
$keys = $connection->_connectionDataKeys ?? [];
$keys[$key] = true;
$connection->_connectionDataKeys = $keys;
}
} elseif (str_starts_with($op, 'DEL:')) {
// C:DEL:key
$key = substr($op, 4);
unset($connection->$key);
if (isset($connection->_connectionDataKeys[$key])) {
unset($connection->_connectionDataKeys[$key]);
$keys = $connection->_connectionDataKeys ?? [];
if (isset($keys[$key])) {
unset($keys[$key]);
$connection->_connectionDataKeys = $keys;
}
}