security: fix 27 vulnerabilities + UI integration (Issues tab, project nav, validation)

Security fixes (27 vulnerabilities across 20 files):
CRITICAL:
- MediaManager: whitelist mediable types prevents RCE via class instantiation
- MediaManager/OfflineSyncController: IDOR fixes, remove Auth::id()??1 fallback
- ClientProjects: verify project ownership on all mutations (IDOR)
- CompanyManagement: Admin role check on mount() and mutations (auth bypass)
- ProjectMap: scope feature/template lookups to current project (IDOR x5)
- PhaseList/TemplateManager/LayerManager: scope mutations to owned resources (IDOR)
- ProjectEditTabs: Gate::authorize on mount() and updateProject()
- routes/web.php: reports routes moved inside can:manage all middleware (auth bypass)

MEDIUM:
- layer-manager: escapeHtml() on Leaflet popup interpolations (XSS)
- MediaManager: server-side MIME validation + 50MB limit
- ProjectList/ProjectUsers/ProjectCompanies/PhaseProgress: auth checks added
- AdminUsers/ReportsDashboard/ExportController: role/permission checks added

LOW:
- config/session.php: secure cookie tied to production env
- OfflineSyncController: sanitize storage path (path traversal)

UI integration:
- project-map: Issues tab (4th) with open-count badge
- project-map: project navigation bar (Dashboard/Map/Gantt/Report/Issues)
- project-dashboard: action buttons for Map/Gantt/Report/Issues
- project-form: validation error summary + per-field @error spans
- template-manager: validation error display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 18:25:36 +02:00
parent 7d854ffb0a
commit f8a1310c0f
26 changed files with 1166 additions and 1195 deletions
+99 -40
View File
@@ -13,15 +13,27 @@ use Illuminate\Support\Facades\Storage;
class OfflineSyncController extends Controller
{
/**
* Allowed mediable model types (whitelist to prevent RCE via dynamic instantiation).
*/
private const ALLOWED_MEDIABLE_TYPES = [
'project' => \App\Models\Project::class,
'phase' => \App\Models\Phase::class,
'layer' => \App\Models\Layer::class,
'feature' => \App\Models\Feature::class,
'inspection' => \App\Models\Inspection::class,
'issue' => \App\Models\Issue::class,
];
public function storePending(Request $request)
{
$payload = $request->validate([
'action' => 'required|in:progress_update,inspection,feature_create,media_upload,task_complete',
'action' => 'required|in:progress_update,inspection,feature_create,media_upload,task_complete',
'payload' => 'required|array',
]);
$pending = PendingSync::create([
'user_id' => Auth::id() ?? 1,
'action' => $payload['action'],
PendingSync::create([
'user_id' => Auth::id(),
'action' => $payload['action'],
'payload' => $payload['payload'],
]);
return response()->json(['queued' => true]);
@@ -29,68 +41,114 @@ class OfflineSyncController extends Controller
public function sync(Request $request)
{
$user = Auth::user();
$user = Auth::user();
$pendings = PendingSync::where('user_id', $user->id)->whereNull('synced_at')->get();
$results = [];
foreach ($pendings as $pending) {
$result = ['id' => $pending->id, 'action' => $pending->action, 'success' => false, 'error' => null];
try {
if ($pending->action === 'progress_update') {
$phase = Phase::find($pending->payload['phase_id']);
$phaseId = (int) ($pending->payload['phase_id'] ?? 0);
$progress = (int) ($pending->payload['progress'] ?? 0);
$progress = max(0, min(100, $progress));
$phase = Phase::find($phaseId);
if ($phase) {
$phase->progress_percent = $pending->payload['progress'];
$phase->save();
$phase->progressUpdates()->create([
'user_id' => $user->id,
'progress_percent' => $pending->payload['progress'],
'comment' => $pending->payload['comment'] ?? '',
'location' => $pending->payload['location'] ?? null,
]);
// Verify user has access to this phase's project
if (!$user->hasRole('Admin') && !$phase->project->users()->where('user_id', $user->id)->exists()) {
$result['error'] = 'Access denied to this project.';
} else {
$phase->progress_percent = $progress;
$phase->save();
$phase->progressUpdates()->create([
'user_id' => $user->id,
'progress_percent' => $progress,
'comment' => substr($pending->payload['comment'] ?? '', 0, 500),
]);
$result['success'] = true;
}
} else {
$result['error'] = 'Phase not found.';
}
$result['success'] = true;
} elseif ($pending->action === 'inspection') {
$inspection = Inspection::create($pending->payload);
$p = $pending->payload;
$inspection = Inspection::create([
'project_id' => (int) ($p['project_id'] ?? 0),
'feature_id' => isset($p['feature_id']) ? (int) $p['feature_id'] : null,
'layer_id' => isset($p['layer_id']) ? (int) $p['layer_id'] : null,
'template_id' => isset($p['template_id'])? (int) $p['template_id']: null,
'user_id' => $user->id,
'inspector_user_id' => $user->id,
'status' => 'completed',
'completed_at' => now(),
'result' => in_array($p['result'] ?? '', Inspection::RESULTS) ? $p['result'] : null,
'notes' => substr($p['notes'] ?? '', 0, 2000),
'data' => is_array($p['data'] ?? null) ? $p['data'] : [],
]);
$result['success'] = true;
$result['data'] = ['inspection_id' => $inspection->id];
$result['data'] = ['inspection_id' => $inspection->id];
} elseif ($pending->action === 'feature_create') {
$feature = Feature::create($pending->payload);
$p = $pending->payload;
$feature = Feature::create([
'layer_id' => (int) ($p['layer_id'] ?? 0),
'name' => substr($p['name'] ?? 'Elemento', 0, 255),
'geometry' => is_array($p['geometry'] ?? null) ? $p['geometry'] : null,
'properties' => is_array($p['properties'] ?? null) ? $p['properties'] : [],
'template_id' => isset($p['template_id']) ? (int) $p['template_id'] : null,
'progress' => max(0, min(100, (int) ($p['progress'] ?? 0))),
'status' => in_array($p['status'] ?? '', Feature::STATUSES) ? $p['status'] : 'planned',
'responsible' => isset($p['responsible']) ? substr($p['responsible'], 0, 255) : null,
]);
$result['success'] = true;
$result['data'] = ['feature_id' => $feature->id];
$result['data'] = ['feature_id' => $feature->id];
} elseif ($pending->action === 'media_upload') {
// Assuming payload has: 'file' (base64), 'path', 'model_type', 'model_id'
// We'll decode the base64 and store the file
if (isset($pending->payload['file'], $pending->payload['path'])) {
$decoded = base64_decode($pending->payload['file']);
// Restrict path to safe uploads directory
$safePath = 'uploads/' . ltrim(basename($pending->payload['path']), '/');
$decoded = base64_decode($pending->payload['file'], true);
if ($decoded !== false) {
$path = Storage::put($pending->payload['path'], $decoded);
// Attach to model if model_type and model_id are provided
Storage::disk('public')->put($safePath, $decoded);
// Whitelist-based model type resolution (prevents RCE)
if (isset($pending->payload['model_type'], $pending->payload['model_id'])) {
$model = new $pending->payload['model_type'];
$model = $model->find($pending->payload['model_id']);
if ($model) {
$model->media()->create([
'name' => $pending->payload['name'] ?? 'unnamed',
'path' => $path,
'mime_type' => $pending->payload['mime_type'] ?? 'application/octet-stream',
'disk' => 'public',
]);
$typeKey = strtolower(trim($pending->payload['model_type']));
if (array_key_exists($typeKey, self::ALLOWED_MEDIABLE_TYPES)) {
$modelClass = self::ALLOWED_MEDIABLE_TYPES[$typeKey];
$model = $modelClass::find((int) $pending->payload['model_id']);
if ($model) {
$model->media()->create([
'name' => substr($pending->payload['name'] ?? 'unnamed', 0, 255),
'file_path' => $safePath,
'file_type' => substr($pending->payload['mime_type'] ?? 'application/octet-stream', 0, 100),
'file_extension' => pathinfo($safePath, PATHINFO_EXTENSION),
'file_size' => strlen($decoded),
'category' => 'other',
'uploaded_by' => $user->id,
]);
}
}
}
$result['success'] = true;
$result['data'] = ['path' => $path];
$result['data'] = ['path' => $safePath];
} else {
$result['error'] = 'Failed to decode base64 file';
$result['error'] = 'Failed to decode base64 file.';
}
} else {
$result['error'] = 'Missing file or path in payload';
$result['error'] = 'Missing file or path in payload.';
}
} elseif ($pending->action === 'task_complete') {
// Example: mark a task as complete (you can adjust as needed)
// For now, just log and mark as success
\Log::info('Task completed offline', $pending->payload);
// No-op placeholder, just mark as synced
$result['success'] = true;
} else {
$result['error'] = 'Unknown action type';
$result['error'] = 'Unknown action type.';
}
} catch (\Exception $e) {
$result['error'] = $e->getMessage();
@@ -103,6 +161,7 @@ class OfflineSyncController extends Controller
$results[] = $result;
}
return response()->json(['synced' => $results]);
}
}
@@ -11,21 +11,25 @@ use App\Exports\ProjectsExport;
use App\Exports\PhasesExport;
use App\Exports\InspectionsExport;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class ExportController extends Controller
{
public function exportProjects(Request $request)
{
Gate::authorize('manage all');
return Excel::download(new ProjectsExport, 'projects.xlsx');
}
public function exportPhases(Request $request)
{
Gate::authorize('manage all');
return Excel::download(new PhasesExport, 'phases.xlsx');
}
public function exportInspections(Request $request)
{
Gate::authorize('manage all');
return Excel::download(new InspectionsExport, 'inspections.xlsx');
}
}