[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
@@ -18,12 +18,19 @@
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
require_once APP_PATH . '/services/RateLimitService.php';
|
||||
|
||||
class StakeholderPortalController extends BaseController
|
||||
{
|
||||
// Estensioni consentite per gli allegati (ALLOWLIST: niente html/svg/js eseguibili same-origin)
|
||||
private const ALLOWED_EXT = ['pdf','png','jpg','jpeg','gif','webp','txt','csv','xlsx','xls','docx','doc','pptx','ppt','odt','ods','zip'];
|
||||
private const RL_READ = [['max' => 30, 'window_seconds' => 60], ['max' => 200, 'window_seconds' => 3600]];
|
||||
private const RL_WRITE = [['max' => 10, 'window_seconds' => 60], ['max' => 60, 'window_seconds' => 3600]];
|
||||
|
||||
/** GET /api/stakeholder-portal/access?t=<token> */
|
||||
public function access(): void
|
||||
{
|
||||
$this->rateLimit('read');
|
||||
$tg = $this->resolveTarget();
|
||||
$template = $this->loadTemplate($tg);
|
||||
$resp = Database::fetchOne(
|
||||
@@ -52,6 +59,7 @@ class StakeholderPortalController extends BaseController
|
||||
/** POST /api/stakeholder-portal/respond Body: {t*, answers*, respondent_name?} */
|
||||
public function respond(): void
|
||||
{
|
||||
$this->rateLimit('write');
|
||||
$tg = $this->resolveTarget();
|
||||
if ($tg['type'] === 'read_ack') { $this->jsonError('Questa attività richiede una firma di avvenuta lettura, non un questionario.', 422, 'WRONG_TYPE'); }
|
||||
if (in_array($tg['state'], ['responded', 'acknowledged'], true)) { $this->jsonError('Risposta già inviata: non è più modificabile.', 409, 'ALREADY_SUBMITTED'); }
|
||||
@@ -83,6 +91,7 @@ class StakeholderPortalController extends BaseController
|
||||
/** POST /api/stakeholder-portal/acknowledge Body: {t*, respondent_name?} */
|
||||
public function acknowledge(): void
|
||||
{
|
||||
$this->rateLimit('write');
|
||||
$tg = $this->resolveTarget();
|
||||
if ($tg['type'] !== 'read_ack') { $this->jsonError('Questa attività è un questionario da compilare.', 422, 'WRONG_TYPE'); }
|
||||
if (in_array($tg['state'], ['responded', 'acknowledged'], true)) { $this->jsonError('Firma già registrata.', 409, 'ALREADY_SUBMITTED'); }
|
||||
@@ -98,7 +107,9 @@ class StakeholderPortalController extends BaseController
|
||||
/** POST /api/stakeholder-portal/comment Body: {t*, body*} */
|
||||
public function comment(): void
|
||||
{
|
||||
$this->rateLimit('write');
|
||||
$tg = $this->resolveTarget();
|
||||
$this->assertWritable($tg);
|
||||
$body = trim((string) ($this->getJsonBody()['body'] ?? ''));
|
||||
if ($body === '') { $this->jsonError('Commento vuoto', 422, 'EMPTY_COMMENT'); }
|
||||
Database::insert('stk_activity_comments', [
|
||||
@@ -113,20 +124,21 @@ class StakeholderPortalController extends BaseController
|
||||
/** POST /api/stakeholder-portal/attachment?t=<token> (multipart: file) */
|
||||
public function attachment(): void
|
||||
{
|
||||
$this->rateLimit('write');
|
||||
$tg = $this->resolveTarget();
|
||||
$this->assertWritable($tg);
|
||||
if (!isset($_FILES['file']) || ($_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
$this->jsonError('File non fornito', 400, 'NO_FILE');
|
||||
}
|
||||
$file = $_FILES['file'];
|
||||
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'); }
|
||||
if ($ext === '' || !in_array($ext, self::ALLOWED_EXT, true)) { $this->jsonError('Tipo di file non consentito', 422, 'BAD_FILE_TYPE'); }
|
||||
|
||||
$orgId = (int) $tg['organization_id'];
|
||||
$dir = UPLOAD_PATH . "/stk_activity/{$orgId}";
|
||||
if (!is_dir($dir)) { mkdir($dir, 0755, true); }
|
||||
$filename = uniqid('sa_') . '.' . $ext;
|
||||
$filename = 'sa_' . bin2hex(random_bytes(16)) . '.' . $ext;
|
||||
if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) { $this->jsonError('Errore caricamento', 500, 'UPLOAD_ERROR'); }
|
||||
Database::insert('evidence_files', [
|
||||
'organization_id' => $orgId,
|
||||
@@ -153,8 +165,8 @@ class StakeholderPortalController extends BaseController
|
||||
$this->jsonError('Link di accesso mancante o non valido.', 401, 'MISSING_TOKEN');
|
||||
}
|
||||
$row = Database::fetchOne(
|
||||
'SELECT g.id AS target_id, g.activity_id, g.state, s.name AS stakeholder_name,
|
||||
a.organization_id, a.title, a.type, a.description, a.template_id
|
||||
'SELECT g.id AS target_id, g.activity_id, g.state, g.token_expires_at, s.name AS stakeholder_name,
|
||||
a.organization_id, a.title, a.type, a.description, a.template_id, a.status AS activity_status
|
||||
FROM stk_activity_targets g
|
||||
JOIN stk_activities a ON a.id = g.activity_id
|
||||
JOIN stakeholders s ON s.id = g.stakeholder_id
|
||||
@@ -162,9 +174,39 @@ class StakeholderPortalController extends BaseController
|
||||
[hash('sha256', $token)]
|
||||
);
|
||||
if (!$row) { $this->jsonError('Link di accesso non valido o scaduto.', 404, 'INVALID_TOKEN'); }
|
||||
if (!empty($row['token_expires_at']) && strtotime($row['token_expires_at']) < time()) {
|
||||
$this->jsonError('Link di accesso scaduto. Richiedi un nuovo invio all\'organizzazione.', 410, 'TOKEN_EXPIRED');
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
/** Le scritture (commenti/allegati) sono bloccate se l'attività è chiusa. */
|
||||
private function assertWritable(array $tg): void
|
||||
{
|
||||
if (in_array($tg['activity_status'] ?? '', ['completed', 'cancelled'], true)) {
|
||||
$this->jsonError('Attività chiusa: non sono più ammessi contributi.', 409, 'ACTIVITY_CLOSED');
|
||||
}
|
||||
}
|
||||
|
||||
/** Rate limit per-IP sugli endpoint pubblici del portale. */
|
||||
private function rateLimit(string $kind): void
|
||||
{
|
||||
$ip = $this->getClientIP();
|
||||
$limits = $kind === 'write' ? self::RL_WRITE : self::RL_READ;
|
||||
RateLimitService::check("stkp_{$kind}_ip:" . $ip, $limits);
|
||||
RateLimitService::increment("stkp_{$kind}_ip:" . $ip);
|
||||
}
|
||||
|
||||
private function getClientIP(): string
|
||||
{
|
||||
$xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
|
||||
if ($xff !== '') {
|
||||
$first = trim(explode(',', $xff)[0]);
|
||||
if (filter_var($first, FILTER_VALIDATE_IP)) { return $first; }
|
||||
}
|
||||
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
}
|
||||
|
||||
private function loadTemplate(array $tg): ?array
|
||||
{
|
||||
if (empty($tg['template_id'])) { return null; }
|
||||
|
||||
Reference in New Issue
Block a user