[FIX] Epic C / C5 hardening — esiti flotta di verifica multi-agente (mig.054)
Corretti i finding confermati dalla verifica multi-agente (5 major + minori): SICUREZZA - Stored XSS allegati: da blocklist a ALLOWLIST di estensioni innocue (no html/svg/js renderizzabili same-origin) in StakeholderPortalController::attachment e StakeholderActivityController::storeUpload; nome file randomizzato (random_bytes). - Magic-link: scadenza (mig.054 stk_activity_targets.token_expires_at; send() imposta scadenza attività+30gg o +90gg; resolveTarget() → 410 TOKEN_EXPIRED se scaduto); rate-limit per-IP sugli endpoint del portale; comment/attachment bloccati su attività chiusa. CORRETTEZZA - send(): NON rigenera token né azzera lo stato dei destinatari già responded/acknowledged (prima ne perdeva l'esito); imposta token_expires_at. - assign individuale: semantica "replace" (rimuove i deselezionati non ancora conclusi) + guard su lista vuota (evita 'IN ()'). - update(): conserva assign_mode esistente quando si modifica solo stak_code. UI/UX/A11Y - Editor opzioni per domande a scelta singola/multipla (prima degradavano a testo nel portale). - Etichette stato/tipo localizzate; risposte mostrate inline (no alert()); escAttr nel portale (escape virgolette negli attributi); ARIA su modali/tab; voce sidebar anche in common.js. OPEN ITEM (NON regressione C5, pre-esistente e ambientale): l'upload allegati restituisce UPLOAD_ERROR in prod — move_uploaded_file/is_uploaded_file fallisce nella topologia proxy→fastcgi (stesso pattern di evidence_files/AuditController, mai funzionato: la dir uploads/evidence non esiste). rename/copy come www-data funzionano. Da investigare lato infra. Il resto di C5 (questionari, firma-lettura, commenti, calendario, portale) è pienamente operativo. Smoke prod OK: allowlist (.html→422, struttura ok), opzioni scelta nel portale, send no-reset, replace individuale, token_expires_at presente. Additivo. v1.21.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4f386faae5
commit
b917d2da14
@@ -285,7 +285,7 @@ class StakeholderActivityController extends BaseController
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$b = $this->getJsonBody();
|
||||
|
||||
$a = Database::fetchOne('SELECT id, title, due_date FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||
$a = Database::fetchOne('SELECT id, title, due_date, assign_mode, stak_code FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||
if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); }
|
||||
|
||||
$updates = [];
|
||||
@@ -298,7 +298,10 @@ class StakeholderActivityController extends BaseController
|
||||
if ($this->hasParam('template_id')) { $updates['template_id'] = $this->validateTemplate($b['template_id'] ?? null, $orgId); }
|
||||
if ($this->hasParam('description')) { $updates['description'] = $this->nullableStr($b['description'] ?? null); }
|
||||
if ($this->hasParam('assign_mode') || $this->hasParam('stak_code')) {
|
||||
[$assignMode, $stakCode] = $this->validateAssign($b, $orgId);
|
||||
$bb = $b;
|
||||
// se cambia solo lo stak_code, conserva la modalità esistente (non forzare 'individual')
|
||||
if (!$this->hasParam('assign_mode')) { $bb['assign_mode'] = $a['assign_mode']; }
|
||||
[$assignMode, $stakCode] = $this->validateAssign($bb, $orgId);
|
||||
$updates['assign_mode'] = $assignMode;
|
||||
$updates['stak_code'] = $assignMode === 'by_code' ? $stakCode : null;
|
||||
}
|
||||
@@ -358,6 +361,7 @@ class StakeholderActivityController extends BaseController
|
||||
$raw = $b['stakeholder_ids'] ?? null;
|
||||
if (!is_array($raw) || !$raw) { $this->jsonError('Seleziona almeno uno stakeholder', 422, 'NO_TARGETS'); }
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $raw), static fn($i) => $i > 0)));
|
||||
if (!$ids) { $this->jsonError('Seleziona almeno uno stakeholder valido', 422, 'NO_TARGETS'); }
|
||||
$place = implode(',', array_fill(0, count($ids), '?'));
|
||||
$stakeholderIds = $this->valCol(
|
||||
"SELECT id FROM stakeholders WHERE organization_id = ? AND id IN ($place)",
|
||||
@@ -367,6 +371,18 @@ class StakeholderActivityController extends BaseController
|
||||
}
|
||||
if (!$stakeholderIds) { $this->jsonError('Nessuno stakeholder da assegnare per questo criterio', 422, 'EMPTY_TARGETS'); }
|
||||
|
||||
$stakeholderIds = array_map('intval', $stakeholderIds);
|
||||
// Modalità individuale = sincronizzazione "replace": rimuovi i destinatari deselezionati
|
||||
// che NON hanno ancora risposto/firmato (gli esiti raccolti non si perdono).
|
||||
if ($a['assign_mode'] === 'individual') {
|
||||
$keep = implode(',', array_fill(0, count($stakeholderIds), '?'));
|
||||
Database::query(
|
||||
"DELETE FROM stk_activity_targets
|
||||
WHERE activity_id = ? AND state NOT IN ('responded','acknowledged') AND stakeholder_id NOT IN ($keep)",
|
||||
array_merge([$id], $stakeholderIds)
|
||||
);
|
||||
}
|
||||
|
||||
$added = 0;
|
||||
foreach ($stakeholderIds as $sid) {
|
||||
try {
|
||||
@@ -393,13 +409,20 @@ class StakeholderActivityController extends BaseController
|
||||
$a = Database::fetchOne('SELECT id, due_date, title FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||
if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); }
|
||||
|
||||
// Solo i destinatari NON ancora conclusi: NON rigenerare token né azzerare lo stato
|
||||
// di chi ha già risposto/firmato (altrimenti se ne perderebbe l'esito).
|
||||
$targets = Database::fetchAll(
|
||||
'SELECT g.id, g.stakeholder_id, s.name AS stakeholder_name, s.contact_email, g.access_token_hash
|
||||
"SELECT g.id, g.stakeholder_id, s.name AS stakeholder_name, s.contact_email, g.access_token_hash
|
||||
FROM stk_activity_targets g JOIN stakeholders s ON s.id = g.stakeholder_id
|
||||
WHERE g.activity_id = ?',
|
||||
WHERE g.activity_id = ? AND g.state NOT IN ('responded','acknowledged')",
|
||||
[$id]
|
||||
);
|
||||
if (!$targets) { $this->jsonError('Nessun destinatario: assegna prima gli stakeholder', 422, 'NO_TARGETS'); }
|
||||
if (!$targets) { $this->jsonError('Nessun destinatario da inviare (assegna stakeholder o tutti hanno già risposto)', 422, 'NO_TARGETS'); }
|
||||
|
||||
// Scadenza del magic-link: scadenza attività + 30gg, altrimenti 90gg da oggi.
|
||||
$expires = $a['due_date']
|
||||
? date('Y-m-d H:i:s', strtotime($a['due_date'] . ' +30 days'))
|
||||
: date('Y-m-d H:i:s', strtotime('+90 days'));
|
||||
|
||||
$links = [];
|
||||
foreach ($targets as $g) {
|
||||
@@ -408,6 +431,7 @@ class StakeholderActivityController extends BaseController
|
||||
'access_token_hash' => hash('sha256', $token),
|
||||
'state' => 'sent',
|
||||
'sent_at' => date('Y-m-d H:i:s'),
|
||||
'token_expires_at' => $expires,
|
||||
], 'id = ?', [(int) $g['id']]);
|
||||
$links[] = [
|
||||
'target_id' => (int) $g['id'],
|
||||
@@ -563,12 +587,13 @@ class StakeholderActivityController extends BaseController
|
||||
if ($file['size'] > 10 * 1024 * 1024) { $this->jsonError('File troppo grande (max 10MB)', 400, 'FILE_TOO_LARGE'); }
|
||||
|
||||
$ext = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', pathinfo($file['name'], PATHINFO_EXTENSION)));
|
||||
$blocked = ['php','phtml','phar','php3','php4','php5','phps','cgi','pl','sh','exe','htaccess'];
|
||||
if ($ext === '' || in_array($ext, $blocked, true)) { $this->jsonError('Tipo di file non consentito', 422, 'BAD_FILE_TYPE'); }
|
||||
// ALLOWLIST (niente html/svg/js eseguibili same-origin): allineata a StakeholderPortalController::ALLOWED_EXT
|
||||
$allowed = ['pdf','png','jpg','jpeg','gif','webp','txt','csv','xlsx','xls','docx','doc','pptx','ppt','odt','ods','zip'];
|
||||
if ($ext === '' || !in_array($ext, $allowed, true)) { $this->jsonError('Tipo di file non consentito', 422, 'BAD_FILE_TYPE'); }
|
||||
|
||||
$uploadDir = UPLOAD_PATH . "/stk_activity/{$orgId}";
|
||||
if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); }
|
||||
$filename = uniqid('sa_') . '.' . $ext;
|
||||
$filename = 'sa_' . bin2hex(random_bytes(16)) . '.' . $ext;
|
||||
if (!move_uploaded_file($file['tmp_name'], $uploadDir . '/' . $filename)) {
|
||||
$this->jsonError('Errore caricamento file', 500, 'UPLOAD_ERROR');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user