Files
crewli/api/app/Http/Middleware/PortalTokenMiddleware.php
bert.hausmans 51e5dd6fcb security: comprehensive security regression test suite
61 tests across 4 test files covering all OWASP categories:

MultiTenancyIsolationTest (19 tests):
- Cross-org event, person, shift, section, time-slot, location,
  registration field, shift assignment, crowd list access
- Cross-org FK references (crowd_type, company, parent_event)
- Bulk operation isolation, invitation revocation
- Portal cross-event access prevention

AuthenticationSecurityTest (15 tests):
- Rate limiting: login (5/min), portal token-auth (10/min),
  invitation show (10/min)
- Account enumeration prevention: generic error on failed login,
  200 response on password reset for unknown email
- Token lifecycle: logout revokes token, password reset revokes
  all tokens, expired tokens rejected (7-day config verified)
- Password strength: weak/no-uppercase/no-numbers rejected
- Security headers present on all responses
- Protected routes require authentication

PortalTokenSecurityTest (10 tests):
- Invalid/empty/missing token handling
- Response shape: only safe fields (no milestones, no portal_token,
  no organisation_id, no internal event fields)
- Hash-based lookup: plain token works, hash does not
- Error messages: no schema/table info leakage
- Middleware: rejects without token, rejects invalid, accepts valid,
  rejects draft event status

InputValidationSecurityTest (17 tests):
- XSS payloads stored safely in person name, event name, section name
- Oversized inputs rejected (name >255, remarks >5000)
- Invalid enum values rejected (status, event_type)
- Cross-org FK references rejected (crowd_type, company, location,
  parent_event, person assignment)
- Invalid/nonexistent ULID format rejected
- SQL injection payloads harmless (PDO binding verified)

Also fixes PortalTokenMiddleware to use request->attributes->set()
instead of request->merge() for stdClass objects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:25:47 +02:00

84 lines
2.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Models\Event;
use App\Models\Scopes\OrganisationScope;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
final class PortalTokenMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$plainToken = $this->extractToken($request);
if ($plainToken === null) {
return response()->json(['message' => 'Portal token required.'], 401);
}
$hashedToken = hash('sha256', $plainToken);
// Try artists table
$artist = DB::table('artists')->where('portal_token', $hashedToken)->first();
if ($artist) {
$event = Event::withoutGlobalScope(OrganisationScope::class)->find($artist->event_id);
if (! $event || in_array($event->status, ['draft', 'closed'], true)) {
return response()->json(['message' => 'Portal token required.'], 401);
}
$request->attributes->set('portal_context', 'artist');
$request->attributes->set('portal_person', $artist);
$request->attributes->set('portal_event', $event);
return $next($request);
}
// Try production_requests table (may not exist yet)
try {
$productionRequest = DB::table('production_requests')->where('token', $hashedToken)->first();
if ($productionRequest) {
$event = Event::withoutGlobalScope(OrganisationScope::class)->find($productionRequest->event_id);
if (! $event || in_array($event->status, ['draft', 'closed'], true)) {
return response()->json(['message' => 'Portal token required.'], 401);
}
$request->attributes->set('portal_context', 'supplier');
$request->attributes->set('portal_person', $productionRequest);
$request->attributes->set('portal_event', $event);
return $next($request);
}
} catch (\Illuminate\Database\QueryException) {
// Table doesn't exist yet — skip
}
return response()->json(['message' => 'Portal token required.'], 401);
}
private function extractToken(Request $request): ?string
{
// Check Authorization: Bearer header
$bearer = $request->bearerToken();
if ($bearer !== null && $bearer !== '') {
return $bearer;
}
// Check query parameter
$queryToken = $request->query('token');
if (is_string($queryToken) && $queryToken !== '') {
return $queryToken;
}
return null;
}
}