implemented a lot of security measures

This commit is contained in:
Codex Agent
2025-12-09 20:29:32 +01:00
parent 4bdb93c171
commit 928d28fcaf
21 changed files with 953 additions and 134 deletions

View File

@@ -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;
}
}

View File

@@ -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(

View File

@@ -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',
]
);
}
}

View File

@@ -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)

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ResponseSecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
/** @var Response $response */
$response = $next($request);
$headers = [
'Referrer-Policy' => '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;
}
}

View File

@@ -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',
]
);
}
}

View File

@@ -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', [

View File

@@ -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

View File

@@ -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';