Skip to content
Open
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
90 changes: 90 additions & 0 deletions ProcessMaker/Http/Middleware/BroadcastAuthDebug.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

namespace ProcessMaker\Http\Middleware;

use Closure;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;

class BroadcastAuthDebug
{
/**
* Log broadcast auth requests that fail (403, 401, 500) for debugging intermittent issues.
* Enable with BROADCAST_AUTH_DEBUG=true in .env
*
* @param \Illuminate\Http\Request $request
* @param Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
$response = $next($request);

if (!env('BROADCAST_AUTH_DEBUG', false)) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct env() call fails with cached config

Medium Severity

env('BROADCAST_AUTH_DEBUG', false) is called directly instead of through a config value. In production Laravel deployments where php artisan config:cache has been run, the .env file isn't loaded, so env() will always return the default false. Since BROADCAST_AUTH_DEBUG isn't registered in any config file, this middleware can never be activated in a cached-config environment — exactly the production scenario it's meant to debug.

Fix in Cursor Fix in Web

return $response;
}

if ($response->getStatusCode() < 400) {
return $response;
}

$user = Auth::user();
$channelName = $request->input('channel_name');
$channelInfo = $this->parseChannelInfo($channelName);

Log::error('Broadcast auth failed', [
'status' => $response->getStatusCode(),
'user_id' => $user?->id,
'user_type' => $user ? get_class($user) : null,
'user_is_anonymous' => $user && method_exists($user, 'isAnonymous') ? $user->isAnonymous : null,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

method_exists check fails for isAnonymous property

Medium Severity

isAnonymous is defined as a public property (public $isAnonymous = true;) on AnonymousUser, not a method. Using method_exists($user, 'isAnonymous') will always return false, so user_is_anonymous will always be logged as null — even for anonymous users. This undermines the debug middleware's ability to diagnose the exact 403 scenario it was built to investigate. The check needs property_exists instead.

Fix in Cursor Fix in Web

'has_session' => $request->hasSession(),
'session_id' => $request->session()?->getId(),
'channel_name' => $channelName,
'channel_type' => $channelInfo['type'],
'channel_resource_id' => $channelInfo['id'],
'user_channel_mismatch' => $channelInfo['type'] === 'User' && $user && $channelInfo['id']
? (string) $user->id !== (string) $channelInfo['id']
: null,
'cookie_present' => $request->hasCookie(config('session.cookie')),
'ip' => $request->ip(),
'origin' => $request->header('Origin'),
'referer' => $request->header('Referer'),
'user_agent' => $request->userAgent(),
'socket_id' => $request->input('socket_id'),
'response_body' => $this->getResponseBody($response),
'timestamp' => now()->toIso8601String(),
]);

return $response;
}

private function parseChannelInfo(?string $channelName): array
{
if (!$channelName) {
return ['type' => null, 'id' => null];
}
// Strip tenant prefix: tenant_X.ProcessMaker.Models.User.14 -> ProcessMaker.Models.User.14
$channel = preg_replace('/^tenant_\d+\./', '', $channelName);
if (preg_match('/ProcessMaker\.Models\.User\.(\d+)/', $channel, $m)) {
return ['type' => 'User', 'id' => $m[1]];
}
if (preg_match('/ProcessMaker\.Models\.ProcessRequest\.(\d+)/', $channel, $m)) {
return ['type' => 'ProcessRequest', 'id' => $m[1]];
}
if (preg_match('/ProcessMaker\.Models\.ProcessRequestToken\.(\d+)/', $channel, $m)) {
return ['type' => 'ProcessRequestToken', 'id' => $m[1]];
}

return ['type' => 'other', 'id' => null];
}

private function getResponseBody($response): ?string
{
$content = $response->getContent();
if (is_string($content) && strlen($content) < 500) {
return $content;
}

return $content ? '[truncated]' : null;
}
}
3 changes: 2 additions & 1 deletion ProcessMaker/Providers/BroadcastServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\ServiceProvider;
use ProcessMaker\Http\Middleware\BroadcastAuthDebug;

class BroadcastServiceProvider extends ServiceProvider
{
Expand All @@ -14,7 +15,7 @@ class BroadcastServiceProvider extends ServiceProvider
*/
public function boot()
{
Broadcast::routes(['middleware'=>['web', 'auth:anon']]);
Broadcast::routes(['middleware' => ['web', 'auth:web,anon', BroadcastAuthDebug::class]]);
require base_path('routes/channels.php');
}
}
45 changes: 44 additions & 1 deletion resources/js/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -340,14 +340,57 @@ if (userID) {
}

if (window.Processmaker && window.Processmaker.broadcasting) {
const config = window.Processmaker.broadcasting;
const config = { ...window.Processmaker.broadcasting };

// Ensure auth request goes to Laravel app with cookies (reduces 403 from CORS/cookie issues)
if (!config.authEndpoint) {
config.authEndpoint = `${window.location.origin}/broadcasting/auth`;
}
config.auth = config.auth || {};
config.auth.headers = config.auth.headers || {};
if (config.auth.withCredentials === undefined) {
config.auth.withCredentials = true;
}

if (config.broadcaster == "pusher") {
window.Pusher = require("pusher-js");
window.Pusher.logToConsole = config.debug;
}

window.Echo = new TenantAwareEcho(config);

// Prevent private channel subscription when no user or wrong user (avoids 403 on /broadcasting/auth)
const noOpChannel = {
listen: () => noOpChannel,
notification: () => noOpChannel,
stopListening: () => noOpChannel,
listenForWhisper: () => noOpChannel,
error: () => noOpChannel,
};
const originalPrivate = window.Echo.private.bind(window.Echo);
const getUserId = () =>
window.Processmaker?.userId ||
window.ProcessMaker?.user?.id ||
document.head.querySelector('meta[name="user-id"]')?.content;

// Extract user id from ProcessMaker.Models.User.{id} channel (with optional tenant prefix)
const getUserIdFromChannel = (ch) => {
const match = ch.match(/ProcessMaker\.Models\.User\.(\d+)/);
return match ? match[1] : null;
};

window.Echo.private = (channel, ...args) => {
const currentUserId = String(getUserId() || "");
if (!currentUserId) {
return noOpChannel;
}
// Block subscription to another user's channel (would always 403)
const channelUserId = getUserIdFromChannel(channel);
if (channelUserId && channelUserId !== currentUserId) {
return noOpChannel;
}
return originalPrivate(channel, ...args);
};
}

if (userID) {
Expand Down
23 changes: 22 additions & 1 deletion routes/channels.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,25 @@
|
*/

use ProcessMaker\Models\AnonymousUser;
use ProcessMaker\Models\ProcessRequest;
use ProcessMaker\Models\ProcessRequestToken;

Broadcast::channel('ProcessMaker.Models.User.{id}', function ($user, $id) {
if (!$user || $user instanceof AnonymousUser) {
return false;
}

return (int) $user->id === (int) $id;
});

Broadcast::channel('ProcessMaker.Models.ProcessRequest.{id}', function ($user, $id) {
if (!$user || $user instanceof AnonymousUser) {
return false;
}

if ($id === 'undefined' || $user === 'undefined') {
return;
return false;
}

if ($user->is_administrator) {
Expand All @@ -35,6 +44,10 @@
});

Broadcast::channel('ProcessMaker.Models.ProcessRequestToken.{id}', function ($user, $id) {
if (!$user || $user instanceof AnonymousUser) {
return false;
}

if ($user->is_administrator) {
return true;
}
Expand All @@ -45,9 +58,17 @@
});

Broadcast::channel('test.status', function ($user) {
if (!$user || $user instanceof AnonymousUser) {
return false;
}

return true;
});

Broadcast::channel('ProcessMaker.Models.Process.{processId}.Language.{language}', function ($user, $processId, $language) {
if (!$user || $user instanceof AnonymousUser) {
return false;
}

return true;
});
3 changes: 0 additions & 3 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -215,9 +215,6 @@
Route::get('tasks/update_variable/{token_abe}', [TaskController::class, 'updateVariable'])->name('tasks.abe.update');
});

// Add our broadcasting routes
Broadcast::routes();

// Authentication Routes...
Route::get('login', [LoginController::class, 'showLoginForm'])->name('login');
Route::post('login', [LoginController::class, 'loginWithIntendedCheck']);
Expand Down
Loading