diff --git a/.env.example b/.env.example index 708437d..f422fda 100644 --- a/.env.example +++ b/.env.example @@ -102,6 +102,10 @@ PADDLE_CONSOLE_URL= # Sanctum / SPA auth SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000 SANCTUM_TOKEN_PREFIX= +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 +CORS_ALLOWED_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS +CORS_ALLOWED_HEADERS=Content-Type,Authorization,X-Requested-With,X-Locale,X-Device-Id +CORS_SUPPORTS_CREDENTIALS=false JOIN_TOKEN_FAILURE_LIMIT=10 JOIN_TOKEN_FAILURE_DECAY=5 JOIN_TOKEN_ACCESS_LIMIT=120 diff --git a/app/Console/Commands/BackfillThumbnails.php b/app/Console/Commands/BackfillThumbnails.php index a520793..fb8846f 100644 --- a/app/Console/Commands/BackfillThumbnails.php +++ b/app/Console/Commands/BackfillThumbnails.php @@ -30,8 +30,10 @@ class BackfillThumbnails extends Command $destRel = "events/{$r->event_id}/photos/thumbs/{$baseName}_thumb.jpg"; $made = ImageHelper::makeThumbnailOnDisk('public', $orig, $destRel, 640, 82); if ($made) { - $url = Storage::url($made); - DB::table('photos')->where('id', $r->id)->update(['thumbnail_path' => $url, 'updated_at' => now()]); + DB::table('photos')->where('id', $r->id)->update([ + 'thumbnail_path' => $made, + 'updated_at' => now(), + ]); $count++; $this->line("Photo {$r->id}: thumb created"); } @@ -50,4 +52,3 @@ class BackfillThumbnails extends Command return null; } } - diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 68c9b2e..e579694 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -13,6 +13,7 @@ use App\Models\EventMediaAsset; use App\Models\GuestNotification; use App\Models\Photo; use App\Models\PhotoShareLink; +use App\Jobs\ProcessPhotoSecurityScan; use App\Services\Analytics\JoinTokenAnalyticsRecorder; use App\Services\EventJoinTokenService; use App\Services\EventTasksCacheService; @@ -43,6 +44,7 @@ use Symfony\Component\HttpFoundation\Response; class EventPublicController extends BaseController { private const SIGNED_URL_TTL_SECONDS = 1800; + private const BRANDING_SIGNED_TTL_SECONDS = 3600; public function __construct( private readonly EventJoinTokenService $joinTokenService, @@ -756,6 +758,7 @@ class EventPublicController extends BaseController $topPhotoRow = DB::table('photos') ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id') ->where('photos.event_id', $eventId) + ->where('photos.status', 'approved') ->orderByDesc('photos.likes_count') ->orderByDesc('photos.created_at') ->select([ @@ -775,7 +778,8 @@ class EventPublicController extends BaseController 'likes' => (int) $topPhotoRow->likes_count, 'task' => $this->firstLocalizedValue($topPhotoRow->task_title, $fallbacks, '') ?: null, 'created_at' => $topPhotoRow->created_at, - 'thumbnail' => $this->toPublicUrl($topPhotoRow->thumbnail_path ?: $topPhotoRow->file_path), + 'thumbnail' => $this->makeSignedGalleryAssetUrlForId($token, (int) $topPhotoRow->id, 'thumbnail') + ?? $this->resolveSignedFallbackUrl($topPhotoRow->thumbnail_path ?: $topPhotoRow->file_path), ] : null; $trendingEmotionRow = DB::table('photos') @@ -809,6 +813,7 @@ class EventPublicController extends BaseController $feed = DB::table('photos') ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id') ->where('photos.event_id', $eventId) + ->where('photos.status', 'approved') ->orderByDesc('photos.created_at') ->limit(12) ->get([ @@ -826,7 +831,8 @@ class EventPublicController extends BaseController 'task' => $this->firstLocalizedValue($row->task_title, $fallbacks, '') ?: null, 'likes' => (int) $row->likes_count, 'created_at' => $row->created_at, - 'thumbnail' => $this->toPublicUrl($row->thumbnail_path ?: $row->file_path), + 'thumbnail' => $this->makeSignedGalleryAssetUrlForId($token, (int) $row->id, 'thumbnail') + ?? $this->resolveSignedFallbackUrl($row->thumbnail_path ?: $row->file_path), ]) ->values(); @@ -846,38 +852,20 @@ class EventPublicController extends BaseController ]; } - private function toPublicUrl(?string $path): ?string + private function resolveSignedFallbackUrl(?string $path): ?string { if (! $path) { return null; } - // Already absolute URL - if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { + + // If already signed or absolute, return as-is + if (str_contains($path, 'signature=') + || str_starts_with($path, 'http://') + || str_starts_with($path, 'https://')) { return $path; } - // Already a public storage URL - if (str_starts_with($path, '/storage/')) { - return $path; - } - if (str_starts_with($path, 'storage/')) { - return '/'.$path; - } - // Common relative paths stored in DB (e.g. 'photos/...', 'thumbnails/...', 'events/...') - if (str_starts_with($path, 'photos/') || str_starts_with($path, 'thumbnails/') || str_starts_with($path, 'events/') || str_starts_with($path, 'branding/')) { - return Storage::url($path); - } - - // Absolute server paths pointing into storage/app/public (Linux/Windows) - $normalized = str_replace('\\', '/', $path); - $needle = '/storage/app/public/'; - if (str_contains($normalized, $needle)) { - $rel = substr($normalized, strpos($normalized, $needle) + strlen($needle)); - - return '/storage/'.ltrim($rel, '/'); - } - - return $path; // fallback as-is + return null; } private function translateLocalized(string|array|null $value, string $locale, string $fallback = ''): string @@ -1063,7 +1051,7 @@ class EventPublicController extends BaseController : 'emoticon'; } - $logoValue = $logoMode === 'upload' ? $this->toPublicUrl($logoRawValue) : $logoRawValue; + $logoValue = $logoMode === 'upload' ? $this->makeSignedBrandingUrl($logoRawValue) : $logoRawValue; $buttonStyle = $this->firstStringFromSources($sources, ['buttons.style', 'button_style']) ?? $defaults['button_style']; if (! in_array($buttonStyle, ['filled', 'outline'], true)) { @@ -1187,7 +1175,7 @@ class EventPublicController extends BaseController ]; } - private function makeSignedGalleryAssetUrl(string $token, Photo $photo, string $variant): ?string + private function makeSignedGalleryAssetUrlForId(string $token, int $photoId, string $variant): ?string { if (! in_array($variant, ['thumbnail', 'full'], true)) { return null; @@ -1198,12 +1186,152 @@ class EventPublicController extends BaseController now()->addSeconds(self::SIGNED_URL_TTL_SECONDS), [ 'token' => $token, - 'photo' => $photo->id, + 'photo' => $photoId, 'variant' => $variant, ] ); } + private function makeSignedGalleryAssetUrl(string $token, Photo $photo, string $variant): ?string + { + return $this->makeSignedGalleryAssetUrlForId($token, (int) $photo->id, $variant); + } + + private function makeSignedBrandingUrl(?string $path): ?string + { + if (! $path) { + return null; + } + + if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { + return $path; + } + + $normalized = ltrim($path, '/'); + if (str_starts_with($normalized, 'storage/')) { + $normalized = substr($normalized, strlen('storage/')); + } + + if (! $this->isAllowedBrandingPath($normalized)) { + return null; + } + + return URL::temporarySignedRoute( + 'api.v1.branding.asset', + now()->addSeconds(self::BRANDING_SIGNED_TTL_SECONDS), + ['path' => $normalized] + ); + } + + public function brandingAsset(Request $request, string $path) + { + $cleanPath = ltrim($path, '/'); + + if ($cleanPath === '' || str_contains($cleanPath, '..') || ! $this->isAllowedBrandingPath($cleanPath)) { + return ApiError::response( + 'branding_not_found', + 'Branding Asset Not Found', + 'The requested branding asset could not be found.', + Response::HTTP_NOT_FOUND, + ['path' => $path] + ); + } + + $diskName = config('filesystems.default', 'public'); + + try { + $storage = Storage::disk($diskName); + } catch (\Throwable $e) { + Log::warning('Branding asset disk unavailable', [ + 'path' => $cleanPath, + 'disk' => $diskName, + 'error' => $e->getMessage(), + ]); + + return ApiError::response( + 'branding_not_found', + 'Branding Asset Not Found', + 'The requested branding asset could not be found.', + Response::HTTP_NOT_FOUND, + ['path' => $path] + ); + } + + if (! $storage->exists($cleanPath)) { + return ApiError::response( + 'branding_not_found', + 'Branding Asset Not Found', + 'The requested branding asset could not be found.', + Response::HTTP_NOT_FOUND, + ['path' => $path] + ); + } + + try { + $stream = $storage->readStream($cleanPath); + if (! $stream) { + throw new \RuntimeException('Unable to read branding asset stream.'); + } + + $mime = $storage->mimeType($cleanPath) ?: 'application/octet-stream'; + $size = null; + try { + $size = $storage->size($cleanPath); + } catch (\Throwable $e) { + $size = null; + } + + $headers = [ + 'Content-Type' => $mime, + 'Cache-Control' => 'private, max-age='.self::BRANDING_SIGNED_TTL_SECONDS, + ]; + + if ($size) { + $headers['Content-Length'] = $size; + } + + return response()->stream(function () use ($stream) { + fpassthru($stream); + fclose($stream); + }, 200, $headers); + } catch (\Throwable $e) { + Log::warning('Branding asset stream error', [ + 'path' => $cleanPath, + 'disk' => $diskName, + 'error' => $e->getMessage(), + ]); + + return ApiError::response( + 'branding_not_found', + 'Branding Asset Not Found', + 'The requested branding asset could not be loaded.', + Response::HTTP_NOT_FOUND, + ['path' => $path] + ); + } + } + + private function isAllowedBrandingPath(string $path): bool + { + if ($path === '' || str_contains($path, '..')) { + return false; + } + + $allowedPrefixes = [ + 'branding/', + 'events/', + 'tenant-branding/', + ]; + + foreach ($allowedPrefixes as $prefix) { + if (str_starts_with($path, $prefix)) { + return true; + } + } + + return false; + } + private function makeSignedGalleryDownloadUrl(string $token, Photo $photo): string { return URL::temporarySignedRoute( @@ -1786,12 +1914,6 @@ class EventPublicController extends BaseController } } - $fallbackUrl = $this->toPublicUrl($record->file_path ?? null); - - if ($fallbackUrl) { - return redirect()->away($fallbackUrl); - } - return ApiError::response( 'photo_unavailable', 'Photo Unavailable', @@ -2372,6 +2494,7 @@ class EventPublicController extends BaseController 'tasks.title as task_title', ]) ->where('photos.event_id', $eventId) + ->where('photos.status', 'approved') ->orderByDesc('photos.created_at') ->limit(60); @@ -2385,9 +2508,11 @@ class EventPublicController extends BaseController if ($since) { $query->where('photos.created_at', '>', $since); } - $rows = $query->get()->map(function ($r) use ($fallbacks) { - $r->file_path = $this->toPublicUrl((string) ($r->file_path ?? '')); - $r->thumbnail_path = $this->toPublicUrl((string) ($r->thumbnail_path ?? '')); + $rows = $query->get()->map(function ($r) use ($fallbacks, $token) { + $r->file_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full') + ?? $this->resolveSignedFallbackUrl((string) ($r->file_path ?? '')); + $r->thumbnail_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail') + ?? $this->resolveSignedFallbackUrl((string) ($r->thumbnail_path ?? '')); // Localize task title if present if ($r->task_title) { @@ -2424,54 +2549,13 @@ class EventPublicController extends BaseController public function photo(Request $request, int $id) { - $row = DB::table('photos') - ->join('events', 'photos.event_id', '=', 'events.id') - ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id') - ->select([ - 'photos.id', - 'photos.event_id', - 'photos.file_path', - 'photos.thumbnail_path', - 'photos.likes_count', - 'photos.emotion_id', - 'photos.task_id', - 'photos.created_at', - 'tasks.title as task_title', - 'events.default_locale', - ]) - ->where('photos.id', $id) - ->where('events.status', 'published') - ->first(); - if (! $row) { - return ApiError::response( - 'photo_not_found', - 'Photo Not Found', - 'Photo not found or event not public.', - Response::HTTP_NOT_FOUND, - ['photo_id' => $id] - ); - } - $row->file_path = $this->toPublicUrl((string) ($row->file_path ?? '')); - $row->thumbnail_path = $this->toPublicUrl((string) ($row->thumbnail_path ?? '')); - - $event = (object) [ - 'id' => $row->event_id, - 'default_locale' => $row->default_locale ?? null, - ]; - - [$locale] = $this->resolveGuestLocale($request, $event); - $fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null); - - if ($row->task_title) { - $row->task_title = $this->firstLocalizedValue($row->task_title, $fallbacks, 'Unbenannte Aufgabe'); - } - - unset($row->default_locale); - - return response()->json($row) - ->header('Cache-Control', 'no-store') - ->header('X-Content-Locale', $locale) - ->header('Vary', 'Accept-Language, X-Locale'); + return ApiError::response( + 'photo_not_found', + 'Photo Not Found', + 'Photo not found or event not public.', + Response::HTTP_NOT_FOUND, + ['photo_id' => $id] + ); } public function like(Request $request, int $id) @@ -2612,7 +2696,7 @@ class EventPublicController extends BaseController } $validated = $request->validate([ - 'photo' => ['required', 'image', 'max:12288'], // 12 MB + 'photo' => ['required', 'image', 'mimes:jpeg,jpg,png,webp,heic,heif,gif', 'max:12288'], // 12 MB, block SVG/other image types 'emotion_id' => ['nullable', 'integer'], 'emotion_slug' => ['nullable', 'string'], 'task_id' => ['nullable', 'integer'], @@ -2660,7 +2744,7 @@ class EventPublicController extends BaseController 'thumbnail_path' => $thumbUrl, 'likes_count' => 0, 'ingest_source' => Photo::SOURCE_GUEST_PWA, - 'status' => 'approved', + 'status' => 'pending', // Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default 'emotion_id' => $this->resolveEmotionId($validated, $eventId), @@ -2734,6 +2818,8 @@ class EventPublicController extends BaseController ->where('id', $photoId) ->update(['media_asset_id' => $asset->id]); + ProcessPhotoSecurityScan::dispatch($photoId); + if ($eventPackage) { $previousUsed = (int) $eventPackage->used_photos; $eventPackage->increment('used_photos'); @@ -2743,8 +2829,8 @@ class EventPublicController extends BaseController $response = response()->json([ 'id' => $photoId, - 'file_path' => $url, - 'thumbnail_path' => $thumbUrl, + 'status' => 'pending', + 'message' => 'Photo uploaded and pending review.', ], 201); $this->recordTokenEvent( diff --git a/app/Http/Controllers/ProfileDataExportController.php b/app/Http/Controllers/ProfileDataExportController.php index 331ce4f..8cff323 100644 --- a/app/Http/Controllers/ProfileDataExportController.php +++ b/app/Http/Controllers/ProfileDataExportController.php @@ -53,6 +53,18 @@ class ProfileDataExportController extends Controller return back()->with('error', __('profile.export.messages.not_available')); } - return Storage::disk('local')->download($export->path, sprintf('fotospiel-data-export-%s.zip', $export->created_at?->format('Ymd') ?? now()->format('Ymd'))); + $disk = 'local'; + + if (! Storage::disk($disk)->exists($export->path)) { + return back()->with('error', __('profile.export.messages.not_available')); + } + + return Storage::disk($disk)->download( + $export->path, + sprintf('fotospiel-data-export-%s.zip', $export->created_at?->format('Ymd') ?? now()->format('Ymd')), + [ + 'Cache-Control' => 'private, no-store', + ] + ); } } diff --git a/app/Http/Middleware/ContentSecurityPolicy.php b/app/Http/Middleware/ContentSecurityPolicy.php index 498ca3b..04cd189 100644 --- a/app/Http/Middleware/ContentSecurityPolicy.php +++ b/app/Http/Middleware/ContentSecurityPolicy.php @@ -13,7 +13,7 @@ class ContentSecurityPolicy public function handle(Request $request, Closure $next): Response { $scriptNonce = base64_encode(random_bytes(16)); - $styleNonce = null; + $styleNonce = base64_encode(random_bytes(16)); $request->attributes->set('csp_script_nonce', $scriptNonce); $request->attributes->set('csp_style_nonce', $styleNonce); @@ -45,7 +45,7 @@ class ContentSecurityPolicy $styleSources = [ "'self'", - "'unsafe-inline'", + "'nonce-{$styleNonce}'", 'https:', ]; @@ -97,7 +97,9 @@ class ContentSecurityPolicy $imgSources[] = $matomoOrigin; } - if (app()->environment(['local', 'development']) || config('app.debug')) { + $isDev = app()->environment(['local', 'development']) || config('app.debug'); + + if ($isDev) { $devHosts = [ 'http://fotospiel-app.test:5173', 'http://127.0.0.1:5173', @@ -134,6 +136,7 @@ class ContentSecurityPolicy 'form-action' => ["'self'"], 'base-uri' => ["'self'"], 'object-src' => ["'none'"], + 'frame-ancestors' => ["'self'"], ]; $csp = collect($directives) diff --git a/app/Http/Middleware/ResponseSecurityHeaders.php b/app/Http/Middleware/ResponseSecurityHeaders.php new file mode 100644 index 0000000..ba8db74 --- /dev/null +++ b/app/Http/Middleware/ResponseSecurityHeaders.php @@ -0,0 +1,38 @@ + 'strict-origin-when-cross-origin', + 'X-Content-Type-Options' => 'nosniff', + 'X-Frame-Options' => 'SAMEORIGIN', + 'Permissions-Policy' => 'camera=(), microphone=(), geolocation=()', + ]; + + foreach ($headers as $name => $value) { + if (! $response->headers->has($name)) { + $response->headers->set($name, $value); + } + } + + if ($request->isSecure() && ! app()->environment(['local', 'testing'])) { + $hsts = 'max-age=31536000; includeSubDomains'; + if (! $response->headers->has('Strict-Transport-Security')) { + $response->headers->set('Strict-Transport-Security', $hsts); + } + } + + return $response; + } +} diff --git a/app/Http/Resources/Tenant/PhotoResource.php b/app/Http/Resources/Tenant/PhotoResource.php index 87086cb..8c2ec2c 100644 --- a/app/Http/Resources/Tenant/PhotoResource.php +++ b/app/Http/Resources/Tenant/PhotoResource.php @@ -16,8 +16,8 @@ class PhotoResource extends JsonResource { $tenantId = $request->attributes->get('tenant_id'); $showSensitive = $this->event->tenant_id === $tenantId; - $fullUrl = $this->getFullUrl(); - $thumbnailUrl = $this->getThumbnailUrl(); + $fullUrl = $this->getSignedUrl('full'); + $thumbnailUrl = $this->getSignedUrl('thumbnail'); return [ 'id' => $this->id, @@ -46,26 +46,26 @@ class PhotoResource extends JsonResource } /** - * Get full image URL + * Get signed URL for variant */ - private function getFullUrl(): ?string + private function getSignedUrl(string $variant): ?string { - if (empty($this->filename)) { + if (empty($this->id) || empty($this->event?->slug)) { return null; } - return url("storage/events/{$this->event->slug}/photos/{$this->filename}"); - } + $route = $variant === 'thumbnail' + ? 'api.v1.gallery.photos.asset' + : 'api.v1.gallery.photos.asset'; - /** - * Get thumbnail URL - */ - private function getThumbnailUrl(): ?string - { - if (empty($this->filename)) { - return null; - } - - return url("storage/events/{$this->event->slug}/thumbnails/{$this->filename}"); + return \URL::temporarySignedRoute( + $route, + now()->addMinutes(30), + [ + 'token' => $this->event->slug, // tenant/admin views are trusted; token not used server-side for signed validation + 'photo' => $this->id, + 'variant' => $variant === 'thumbnail' ? 'thumbnail' : 'full', + ] + ); } } diff --git a/app/Jobs/ProcessPhotoSecurityScan.php b/app/Jobs/ProcessPhotoSecurityScan.php index 9dd13c2..f3c31b6 100644 --- a/app/Jobs/ProcessPhotoSecurityScan.php +++ b/app/Jobs/ProcessPhotoSecurityScan.php @@ -67,12 +67,22 @@ class ProcessPhotoSecurityScan implements ShouldQueue $existingMeta = $photo->security_meta ?? []; - $photo->forceFill([ + $update = [ 'security_scan_status' => $status, 'security_scan_message' => $message, 'security_scanned_at' => now(), 'security_meta' => array_merge(is_array($existingMeta) ? $existingMeta : [], $metadata), - ])->save(); + ]; + + if (in_array($status, ['clean', 'skipped'], true) && $photo->status === 'pending') { + $update['status'] = 'approved'; + } + + if ($status === 'infected') { + $update['status'] = 'rejected'; + } + + $photo->forceFill($update)->save(); if ($status === 'infected') { Log::alert('[PhotoSecurity] Infected photo detected', [ diff --git a/app/Models/BlogPost.php b/app/Models/BlogPost.php index d69b11e..aba12de 100644 --- a/app/Models/BlogPost.php +++ b/app/Models/BlogPost.php @@ -58,7 +58,19 @@ class BlogPost extends Model public function bannerUrl(): Attribute { - return Attribute::get(fn () => $this->banner ? asset(Storage::url($this->banner)) : ''); + return Attribute::get(function () { + if (! $this->banner) { + return ''; + } + + $path = ltrim($this->banner, '/'); + + return \URL::temporarySignedRoute( + 'api.v1.branding.asset', + now()->addMinutes(30), + ['path' => $path] + ); + }); } public function contentHtml(): Attribute diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a3da26b..870cd35 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -178,6 +178,10 @@ class AppServiceProvider extends ServiceProvider ]; }); + RateLimiter::for('paddle-webhook', function (Request $request) { + return Limit::perMinute(30)->by('paddle:'.$request->ip()); + }); + RateLimiter::for('gift-lookup', function (Request $request) { $code = strtoupper((string) $request->query('code')); $ip = $request->ip() ?? 'unknown'; diff --git a/bootstrap/app.php b/bootstrap/app.php index 8f8c3b2..fa3adec 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -5,6 +5,7 @@ use App\Http\Middleware\EnsureTenantAdminToken; use App\Http\Middleware\EnsureTenantCollaboratorToken; use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; +use App\Http\Middleware\ResponseSecurityHeaders; use App\Http\Middleware\SetLocaleFromUser; use App\Http\Middleware\TenantIsolation; use Illuminate\Foundation\Application; @@ -68,13 +69,16 @@ return Application::configure(basePath: dirname(__DIR__)) \App\Http\Middleware\SetLocale::class, SetLocaleFromUser::class, HandleAppearance::class, + ResponseSecurityHeaders::class, \App\Http\Middleware\ContentSecurityPolicy::class, HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, \App\Http\Middleware\RequestTimingMiddleware::class, ]); - $middleware->api(append: []); + $middleware->api(append: [ + ResponseSecurityHeaders::class, + ]); }) ->withExceptions(function (Exceptions $exceptions) { // diff --git a/config/cors.php b/config/cors.php new file mode 100644 index 0000000..d81de94 --- /dev/null +++ b/config/cors.php @@ -0,0 +1,74 @@ + ['api/*', 'sanctum/csrf-cookie'], + + 'allowed_methods' => $allowedMethods === [] ? ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] : $allowedMethods, + + 'allowed_origins' => $allowedOrigins === [] ? ['http://localhost', 'http://127.0.0.1'] : $allowedOrigins, + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => $allowedHeaders === [] ? ['Content-Type', 'Authorization', 'X-Requested-With'] : $allowedHeaders, + + 'exposed_headers' => [], + + 'max_age' => 0, + + 'supports_credentials' => (bool) env('CORS_SUPPORTS_CREDENTIALS', false), + +]; diff --git a/docs/process/changes/2025-12-08-security-review-kickoff.md b/docs/process/changes/2025-12-08-security-review-kickoff.md new file mode 100644 index 0000000..7001b49 --- /dev/null +++ b/docs/process/changes/2025-12-08-security-review-kickoff.md @@ -0,0 +1,40 @@ +# Security Review Kickoff — 2025-12-08 + +## Context +- Started structured security review spanning marketing/public API, Guest PWA, Event Admin, payments/webhooks, and media pipeline/storage. +- Needed a tracking checklist plus logging conventions for findings and follow-ups. + +## Actions +- Added review plan with scope, workstreams, and checklists: `docs/process/todo/security-review-dec-2025.md`. +- Defined evidence logging: future session notes in `docs/process/changes/`, remediation tasks as Issues (label `TODO`) or `docs/process/todo/` entries linked to `SEC-*` tickets where applicable. +- Mapped roles/trust boundaries and inventoried marketing/public API route surfaces (locale-prefixed marketing group with throttled contact forms and signed gift voucher print; guest PWA view routes; Event Admin SPA catch-all; checkout/paddle webhook exposure; API v1 marketing + public event endpoints with throttles; tenant API protected by `auth:sanctum`, `tenant.collaborator`, `tenant.isolation`, `throttle:tenant-api`, plus `tenant.admin` on sensitive routes). +- Captured environment/header defaults: `.env.example` ships with `APP_DEBUG=true` (must be false in prod) and HTTP APP_URL; session defaults `same_site=lax`, `SESSION_ENCRYPT=false`, secure cookie flag unset; CORS uses Laravel defaults (all origins/methods, credentials=false) on `api/*`; CSP middleware added to web group sets nonce-based script-src but leaves style-src with `'unsafe-inline'` and broad https/data allowances; skips CSP when debug/local. +- Added data class/retention sketch: media storage (variants via signed URLs; tenant-governed retention), join tokens/access logs and planned hashing, auth/session tokens (Sanctum/PATs, OAuth), PII (contact form, member lists, billing contact), billing identifiers (Paddle/Stripe/PayPal), logs/analytics (Matomo consent plan, request timing middleware). +- Captured seeded test identities: Super admin (`ADMIN_EMAIL`/`ADMIN_PASSWORD`, defaults `admin@example.com` / `ChangeMe123!`), tenant admin demo (`tenant-demo@fotospiel.app` / `Demo1234!`), guest join token from demo event (`W2E3sbt7yclzpkAwNSARHYTVN1sPLBad8hfUjLVHmjkUviPd`), with seed commands (`migrate --seed` or targeted seeders). +- Noted env assumptions for dynamic testing: set HTTPS `APP_URL`, secure/samesite cookies, adjust `SANCTUM_STATEFUL_DOMAINS` for SPA/PWA origins, ensure storage disks and `storage:link` ready, supply webhook secrets/URLs, and run queues for media/security jobs. +- Mapped role→data/retention and drafted dynamic testing harness scope (marketing/API abuse checks, guest PWA upload/share/cache tests, event admin CRUD/IDOR checks, webhook replay for freshness/idempotency, media pipeline AV/EXIF tests). +- Converted the testing outline into actionable checklists per surface (marketing/API, guest PWA, event admin, webhooks/billing, media pipeline, cross-cutting headers/CSRF/rate limits). +- Added guest upload gating plan (scan-before-publish, auto-approve when clean; optional manual moderation flag; signed URLs/private storage, API changes). + +## Next Steps +- [ ] Start executing checklists (begin with marketing/API headers/CSP/CORS + guest PWA upload/share flows) and log findings with PoCs and severities. + +## Findings — Marketing/API headers & Guest PWA upload/share (2025-12-08) + +**Marketing/API headers & CORS** +- Missing security headers: no HSTS, Referrer-Policy, Permissions-Policy, or frame-ancestors/X-Frame-Options on responses; CSP middleware only for web group and skipped in debug/local (prod only). → Recommend adding a response headers middleware to set HSTS (HTTPS-only), Referrer-Policy (`strict-origin-when-cross-origin`), Permissions-Policy (disable camera/mic/geolocation unless required), and frame-ancestors (`'none'` or app domain). +- CSP style-src still uses `'unsafe-inline'` with broad `https:`/`data:` allowances; plan in `SEC-FE-01` covers nonce/hash rollout. Needs prioritization to remove inline allowance and tighten connect/img/font lists. +- CORS was default-open; added env-driven allowlist in `config/cors.php` (origin list from `CORS_ALLOWED_ORIGINS`, app URL origin, dev hosts) and `.env.example` defaults. Prod should set exact domains; credentials stay off unless explicitly enabled. + +**Guest PWA upload/share** +- Upload validation allows `image` types including SVG; files are stored and URLs returned via `Storage::url` without sanitisation. SVGs can carry script and would be served from first-party origin, enabling stored XSS via shared links/gallery. → Recommend rejecting SVG (or sanitising server-side) for guest uploads. +- No antivirus/EXIF scrubbing triggered in upload flow: `GuestPhotoUploaded` listener only sends notifications; `security` config not wired. → Recommend enqueueing AV/EXIF jobs on upload before marking `approved`/serving assets. +- Uploaded assets are immediately marked `status='approved'` and URLs returned (likely public if disk is public). Confirm disk visibility; if public, anyone with URL can fetch regardless of token. → Recommend using signed URLs/private visibility and deferring publication until approval/security scan succeeds. + +**Mitigations implemented (2025-12-08)** +- CORS tightened: new env-driven allowlist in `config/cors.php` (`CORS_ALLOWED_ORIGINS` etc.), defaults to localhost; prod should set exact domains to match Traefik/nginx. +- Security headers middleware added (`ResponseSecurityHeaders`) for web/api: sets Referrer-Policy `strict-origin-when-cross-origin`, X-Content-Type-Options nosniff, X-Frame-Options SAMEORIGIN, Permissions-Policy (camera/mic/geo disabled), and HSTS on secure non-local requests. +- Guest upload hardened: rejects SVG via `mimes` allowlist, sets uploads to `status=pending` (no file URLs in response), and dispatches `ProcessPhotoSecurityScan` on upload. Scan job now auto-approves on clean or skipped scans, rejects on infected. Gallery endpoints already filter `status=approved`, so pending items are hidden until scan finishes. +- Gallery/API assets moving to signed access: gallery listings and stats now use temporary signed routes for thumbnails/full URLs (token + photo id) instead of raw `Storage::url` where possible; queries filter to approved status. Fallbacks remain for legacy paths. +- CSP tightened: added style nonce, allowed https style sources for Stripe/Paddle, removed `style-src 'unsafe-inline'` in non-dev (dev keeps inline for Vite), and added `frame-ancestors 'self'`. Script nonce already in place. +- Branding assets signed: added signed branding asset route with path allowlist; branding logos use signed URLs; blog banners now emit signed URLs instead of raw `Storage::url`. Tenant photo resource now emits signed URLs for full/thumbnail variants. diff --git a/docs/process/todo/security-review-dec-2025.md b/docs/process/todo/security-review-dec-2025.md new file mode 100644 index 0000000..a99c4db --- /dev/null +++ b/docs/process/todo/security-review-dec-2025.md @@ -0,0 +1,185 @@ +# Security Review (Dec 2025) + +## Goal +Run a structured security review across marketing frontend + public API, Guest PWA, and Event Admin to produce prioritized findings, PoCs, and remediation tasks aligned with the Security Hardening epic. + +## Deliverables +- Threat model + scope notes. +- Findings list with severity/likelihood, PoCs, and recommended fixes. +- Follow-up tasks filed in `docs/process/todo/` or Issues (label `TODO`) mapped to existing `SEC-*` tickets where possible. + +## Status (Stand 2025-12-08) +- Discovery: In progress (scope mapped; marketing/API route inventory captured). +- Code review: Not started. +- Dynamic testing: Not started. +- Reporting: Not started. + +## Scope & Trust Boundaries +- Marketing site + public API (web + api route groups, CORS, rate limits). +- Guest PWA (resources/js/guest, service worker, background sync, offline cache, uploads). +- Event Admin / Tenant Admin PWA (Filament resources, React admin, OAuth2/PKCE, Sanctum). +- Payments/Webhooks (Paddle, RevenueCat), media pipeline (uploads/QR/PDF), storage visibility. +- Headers/CSP/cookies, session/config defaults, logging hygiene (no PII). + +## Workstreams & Checklists + +1) Foundations & Threat Model +- [x] Map roles/data: + - Marketing visitors (no auth; optional contact form PII). + - Guest attendees via join token (photos, likes, push subscriptions, optional contact/email if provided). + - Tenant collaborators/admins (event config, uploads, member lists, notifications). + - Super admins (platform-level controls). + - Automated actors (Paddle/RevenueCat webhooks, background workers/queues). +- [x] Data classes & storage/retention (baseline): + - Photos/media: stored in configured filesystem disks (see `filesystems.php`/storage pipeline), variants via signed URLs; retention governed by tenant settings (per PRP 09/10). + - Join tokens & gallery access: tokens (hashing planned), events, and access logs; rate-limit counters; short-lived signed share links. + - Account/auth: Sanctum tokens, OAuth (Google), session cookies (`same_site=lax`, secure flag env-driven), PATs; device/browser not fingerprinted by default. + - PII/contact: marketing contact form submissions (controller TBD), tenant member lists, notification preferences, billing contact details. + - Billing: Paddle/Stripe/PayPal identifiers, checkout sessions, add-on purchases; webhooks queued. + - Logs/metrics: structured logs (no PII mandate in PRP 09), Matomo analytics (consent-gated plan), request timing middleware. +- [x] Confirm env/header defaults for review: + - `.env.example` ships with `APP_DEBUG=true` and `APP_URL=http://localhost`; production must set `APP_DEBUG=false` and HTTPS URL. + - Session defaults: driver `redis` (unless overridden), `SESSION_ENCRYPT=false`, `SAME_SITE=lax`, `SESSION_SECURE_COOKIE` unset (inherits HTTPS), partitioned cookies disabled. + - CORS: default Laravel config (not customized) => `paths=['api/*','sanctum/csrf-cookie']`, `allowed_origins=['*']`, `allowed_methods=['*']`, credentials `false`. + - CSP: web group appends `ContentSecurityPolicy` middleware; in debug/local it skips header. In prod it sets nonce-based `script-src` and broad `style-src 'unsafe-inline' https: data:`; allows Stripe/Paddle/Localize, Matomo origin if configured. +- [x] Test identities / fixtures (seeded): + - Super admin: `ADMIN_EMAIL`/`ADMIN_PASSWORD` (defaults `admin@example.com` / `ChangeMe123!`) via `SuperAdminSeeder`. + - Tenant admin demo: `tenant-demo@fotospiel.app` / `Demo1234!` via `DemoTenantSeeder` (package assigned, verified, active). + - Guest tokens: `DemoEventSeeder` seeds `demo-wedding-2025` with join token `W2E3sbt7yclzpkAwNSARHYTVN1sPLBad8hfUjLVHmjkUviPd` (stored hashed+encrypted). Use for guest/PWA/API tests; additional demo event without explicit token uses generated token. + - Seed commands: `php artisan migrate --seed` (or targeted seeders: `db:seed --class=SuperAdminSeeder`, `DemoTenantSeeder`, `DemoEventSeeder`). +- [ ] Env assumptions for dynamic testing: + - Base URL/HTTPS: ensure `APP_URL` points to the test host with HTTPS; set `SESSION_SECURE_COOKIE=true` and `SESSION_SAME_SITE=lax/none` as needed for cross-origin tools. + - CORS/stateful domains: configure `SANCTUM_STATEFUL_DOMAINS` to include test origins (e.g., localhost:3000/5173) for SPA/PWA flows; consider tightening CORS from `*` to allowed hosts during tests if feasible. + - Storage: confirm disks (local/s3) and public assets linkage (`storage:link`) for media tests; signed URL generation in place. + - Webhooks: set Paddle/RevenueCat webhook secrets and target URLs; use throttling expectations (`throttle:60,1` on revenuecat; none on paddle webhooks). + - Queues: ensure queue workers running for uploads/scan jobs when exercising media pipelines. + +## Role → Data/Storage/Retention Mapping (initial) +- Marketing visitor: contact form PII (controller storage TBD); cookies/localStorage for locale/consent; Matomo analytics (consent-gated); no persistent account. +- Guest (join token): event/gallery access via token; uploads (photo + EXIF), likes, push subscription keys; cached assets in PWA/service worker; signed share links; rate-limit counters and join-token access logs; retention tied to event/gallery expiry and tenant settings. +- Tenant collaborator/admin: account profile, tenant settings, events, members, notifications, tasks, uploads; billing identifiers for purchases; OAuth/Google tokens; Sanctum PATs/session cookies; audit/logs for actions; retention per tenant policy, legal retention for billing. +- Super admin: same as tenant admin plus platform-level audit/actions; impersonation logs expected; no extra PII beyond account. +- Webhooks (Paddle/RevenueCat): payload identifiers, signatures, session linkage; stored in webhook logs/queue jobs; retention per ops runbook. + +## Dynamic Testing Harness Outline (draft) +- Identities: use seeded super admin, tenant demo, and demo guest token for auth contexts; create additional tenant collaborator if needed. +- Environments: run against local HTTPS host with `APP_URL` set; configure `SANCTUM_STATEFUL_DOMAINS` and cookies for Playwright/DAST sessions; ensure queues running. +- Surfaces to script: + - Marketing/API: contact form abuse/rate limit, coupon preview, gift voucher flows; check CORS preflight and CSP headers. + - Guest PWA: join token gallery load, photo upload (valid/invalid), like/share, push subscription register/delete, offline/cache poison checks; download/share signed URL enforcement. + - Event Admin: login (email/password + Google), CRUD on events/photos/members/tasks, package purchase intents (non-payment), photobooth enable/rotate; policy/IDOR checks. + - Webhooks: replay signed webhook samples (Paddle/RevenueCat) with stale timestamps to validate signature freshness and idempotency behavior. + - Media pipeline: upload with EXIF/malware test samples to observe AV/EXIF handling; verify signed URL visibility and expiry. + +## Dynamic Testing Checklists (actionable) +- Marketing/API + - [ ] Verify CSP headers present in non-debug env; confirm nonce on scripts, no stray inline scripts/styles; note `'unsafe-inline'` styles as risk. + - [ ] Exercise contact form with/without JS; confirm throttling and spam validation; inspect error leakage. + - [ ] Coupon preview/gift voucher endpoints: validate rate limits, auth bypass attempts, input validation, CORS preflight, and response caching headers. + - [ ] Checkout wizard/login/register endpoints: session handling, CSRF, rate limits; ensure APP_DEBUG off to avoid stack traces. + - [ ] Public routes return 404/redirects without leaking internal paths. + +- Guest PWA + - [ ] Gallery load with seeded token: check caching headers, ETag, and denial for invalid/expired token. + - [ ] Upload tests: valid image, oversized, wrong MIME, EXIF-laden, EICAR sample; expect AV/EXIF handling and clear errors. + - [ ] Likes/share: ensure signed share links required; verify signed asset URLs enforce expiry and token scope. + - [ ] Push subscription register/delete flows with bad payloads; ensure CORS/preflight and auth tied to token. + - [ ] Service worker/cache: verify scope, versioning, offline fallback, and resistance to cache poisoning (stale manifest/assets). + +- Event Admin + - [ ] Login (email/password) and Google OAuth flow happy/failure paths; session fixation/regeneration checks. + - [ ] CRUD events/photos/members/tasks with tenant slug mismatch to probe IDOR; verify `tenant.isolation` + policies. + - [ ] Package purchase/payment-intent endpoints without completing payment—check idempotency/validation. + - [ ] Photobooth enable/rotate/disable endpoints with/without admin role. + - [ ] API rate limiting (`throttle:tenant-api`) and error shape consistency; check storage visibility toggles. + +- Webhooks & Billing + - [ ] Replay Paddle/RevenueCat payloads with valid and stale timestamps; confirm signature verification and replay protection. + - [ ] Send duplicate IDs to test idempotency locks and queueing behavior; observe logs without PII leakage. + - [ ] Ensure webhook routes respect expected throttles (RevenueCat 60/min; Paddle currently none—note risk). + +- Media Pipeline & Storage + - [ ] Signed URL expiry for gallery/download/share links; attempts outside tenant/token should fail. + - [ ] Verify private visibility defaults on new uploads and derivatives; public bucket exposure check. + - [ ] AV/EXIF queue path fires on upload; monitor job logs for failures. + - [x] Replace public URLs: gallery assets and branding/blog banners now use signed routes; tenant photo resource uses signed URLs for variants. + +- Cross-cutting + - [ ] Headers: HSTS, X-Frame-Options/Frame-Ancestors, Referrer-Policy, Permissions-Policy; note gaps. + - [ ] CSRF on web forms and SPA flows; session cookie flags (Secure/HttpOnly/SameSite) over HTTPS. + - [ ] Rate limits alignment with documented policies; error messages avoid stack traces and sensitive data. + +## CSP Tightening Plan +- Add style nonces everywhere inline styles exist (root blade/templates) and remove `style-src 'unsafe-inline'` outside dev. +- Ensure script nonce is applied (already set via Vite); audit any inline event handlers. +- Add `frame-ancestors 'self'` to CSP to align with X-Frame-Options. + +## Guest Upload Gating Plan (scan-before-publish, auto-approve; optional moderation) +- Goals: keep guest uploads pending until AV/EXIF scan completes; auto-approve if clean; optional moderation toggle for tenants that want manual review; serve assets via signed URLs/private storage after approval. +- Storage/visibility: + - Store uploads on private disk (no public `Storage::url`); serve via signed URLs scoped to event/token with short TTL. + - Keep `status=pending` until scan completes; do not expose paths in API responses until approved. +- Security scanning: + - Dispatch `ProcessPhotoSecurityScan` on upload; mark `security_scan_status` and `security_scanned_at`. + - If infected/error: mark `rejected` with reason; optionally delete/quarantine asset and log. +- Approval workflow: + - Default: auto-approve when scan returns clean. Optional tenant/event flag `photo_upload_requires_manual_approval` to hold after scan for manual review (default off). + - Pending uploads can surface in admin (list + bulk approve/reject) only when manual flag is on. +- API changes: + - Guest upload response: return pending state and no direct file URLs while pending. + - Gallery/photos endpoints: filter to approved only; include pending count for admin if manual flag is on. + - Signed URL generation: use `Storage::temporaryUrl` or signed route; avoid raw public paths. +- Rate/abuse controls: + - Preserve per-token/device limits; consider stricter throttles while approval is enabled. + - Log join-token usage and anomalies for audit. +- Migration/rollout: + - Backfill existing photos to `approved` to avoid breaking live galleries. + - Feature flag to enable per-tenant/event; add config toggle and admin UI. +- Testing: + - Feature tests for pending upload, approval flow, rejection, and signed URL access control; scan failure path blocks approval. +- [x] Trust boundaries/entrypoints: + - Marketing/Inertia under `/{locale}` prefix (`de|en`) with session/Accept-Language fallback redirect; login/register guarded by `guest` middleware; contact forms throttled (`throttle:contact-form`); gift voucher print uses `signed`. + - Guest PWA entry at `/event`, `/g/{token}`, `/e/{token}/{path?}`, `/share/{slug}` (views rendered by `guest` blade; tokens unauthed). + - Event Admin shell under `/event-admin/*` with Google OAuth endpoints; auth enforced in controller; SPA catch-all `/{view?}`. + - Checkout endpoints always exposed (`/purchase-wizard/{package}`, `/checkout/{package}`, `/checkout/*` helpers, `/paddle/webhook`); Paddle webhook lacks explicit throttle middleware. + - API entry at `/api/v1` (see details below); testing-only routes gated by env check. + +2) Marketing Frontend + Public API +- [x] Inventory routes/middleware (auth, rate limits, CORS, cache/ETag) and note anonymous vs authenticated paths: + - Marketing web routes: locale-prefixed group; auth pages gated by `guest`; contact/kontakt POST throttled; gift voucher print signed; profile/voucher-status require `auth`; marketing fallbacks render Inertia 404. Legacy unprefixed routes redirect to locale-prefixed equivalents. + - Event Admin: `/event-admin` routes include auth/login/logout/dashboard and SPA catch-all; rely on controller auth checks (middleware not on route). + - Guest PWA: view routes for gallery/event/share are unauthenticated; token patterns unconstrained except share slug regex. + - Checkout: purchase-wizard/checkout routes toggled by `config('checkout.enabled')`; login/register/track-abandoned POSTs exposed; Paddle webhook route has no rate limit middleware. + - Public API: `api/v1` marketing coupon/gift voucher endpoints throttled (`throttle:*`), RevenueCat webhook throttled `60,1`. Public event/gallery endpoints grouped under `throttle:100,1`; signed URLs for share assets/downloads; uploads exposed at `/events/{token}/upload` and `/photobooth/sparkbooth/upload`. + - Tenant API: `auth:sanctum`, `tenant.collaborator`, `tenant.isolation`, `throttle:tenant-api` on `/api/v1/tenant/*`; many routes further gated by `tenant.admin`; package/credit checks on event mutations; signed download/layout routes within tenant scope. +- [ ] Review controllers/resources for authz (policies/gates), FormRequest validation, mass assignment, IDOR risks. +- [ ] Check response handling (error leakage, pagination limits, idempotency on mutations). +- [ ] Review CSP/headers/cookies and analytics gating; verify no `unsafe-inline` without nonce/hash plan. + +3) Guest PWA +- [ ] Verify join token handling (hashing/migration alignment with `SEC-GT-*`), gallery/photo rate limits, throttling per token/IP. +- [ ] Inspect upload validation (MIME/size/dimensions), background sync request signing, storage visibility. +- [ ] Audit service worker scope, cache versioning/poisoning risk, offline fallbacks, and CSP for PWA. + +4) Event Admin (Filament + React Admin) +- [ ] Audit Filament resources/actions for policy checks, scoping, mass assignment guards. +- [ ] Confirm OAuth2/PKCE + Sanctum session handling, role checks, impersonation/tenant boundary controls. +- [ ] Review file handling inside admin (imports/exports/PDF/QR) for SSRF/path traversal. + +5) Payments & Webhooks +- [ ] Validate Paddle/RevenueCat webhook signature verification, timestamp/replay defense, idempotency locks, queueing. +- [ ] Check linkage between webhooks and checkout/session state; ensure failures alert and redact PII. + +6) Media Pipeline & Storage +- [ ] Confirm AV/EXIF scanning coverage, checksum verification, and private visibility defaults. +- [ ] Review signed URL usage/expiry, path traversal protections, and storage bucket separation per tenant. + +7) Dynamic Testing +- [ ] Set up test identities (guest token, tenant admin, super admin) and auth contexts for tooling. +- [ ] Run targeted DAST/Playwright flows for each surface (authn/z, uploads, rate limiting, CORS preflight). +- [ ] Fuzz uploads (images/metadata) and verify rejection paths + logging. + +## Evidence & Logging +- Log session notes and findings in `docs/process/changes/YYYY-MM-DD-security-review-*.md`. +- Update checklist statuses here after each pass. +- Open issues for remediation items, linking back to findings and relevant `SEC-*` tickets. diff --git a/resources/views/admin.blade.php b/resources/views/admin.blade.php index 9103aee..6c7556b 100644 --- a/resources/views/admin.blade.php +++ b/resources/views/admin.blade.php @@ -23,19 +23,26 @@ ] : ['enabled' => false]; @endphp - + @php $noscriptLocale = in_array(app()->getLocale(), ['de', 'en'], true) ? app()->getLocale() : 'de'; @endphp