Files
nis2-agile/application/controllers/StakeholderPortalController.php
T
DevEnv nis2-agileandClaude Opus 4.8 b917d2da14 [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>
2026-06-16 22:52:31 +02:00

248 lines
13 KiB
PHP

<?php
/**
* NIS2 Agile - Portale esterno Stakeholder (Epic C / C5.2b)
* ----------------------------------------------------------------------------
* Accesso self-service degli stakeholder a un'attività (questionario da compilare
* o firma di avvenuta lettura) tramite MAGIC-LINK per-destinatario: il token (in
* chiaro nell'URL) viene confrontato con l'hash SHA-256 salvato su
* stk_activity_targets.access_token_hash (mig.052). NESSUN account, NESSUN JWT.
* Stesso spirito dei token sq_ legacy del supplier-portal.
*
* Poiché le email sono disattivate (kill-switch), il magic-link viene generato e
* condiviso manualmente dal compliance manager (StakeholderActivityController::send).
*
* Sicurezza: il token risolve UN solo destinatario; non si espone nulla di altre
* organizzazioni o di altri target. Submit consumabile una sola volta (409 dopo).
*
* NOTE: niente requireAuth. Database::query/fetchAll/fetchOne/insert/update.
*/
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(
'SELECT answers, acknowledged_at, submitted_at FROM stk_activity_responses WHERE target_id = ?',
[(int) $tg['target_id']]
);
$existing = null;
if ($resp) {
$ans = $resp['answers'] ? json_decode($resp['answers'], true) : null;
$existing = [
'answers' => is_array($ans) ? $ans : null,
'acknowledged_at' => $resp['acknowledged_at'],
'submitted_at' => $resp['submitted_at'],
];
}
$this->jsonSuccess([
'activity' => ['title' => $tg['title'], 'type' => $tg['type'], 'description' => $tg['description']],
'stakeholder_name' => $tg['stakeholder_name'],
'state' => $tg['state'],
'template' => $template,
'submitted' => in_array($tg['state'], ['responded', 'acknowledged'], true),
'existing' => $existing,
]);
}
/** 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'); }
$b = $this->getJsonBody();
$answers = $b['answers'] ?? null;
if (!is_array($answers) || !$answers) { $this->jsonError('Nessuna risposta fornita', 422, 'NO_ANSWERS'); }
// verifica risposte obbligatorie del template (se presente)
$template = $this->loadTemplate($tg);
if ($template && !empty($template['questions'])) {
$missing = [];
foreach ($template['questions'] as $q) {
if (!empty($q['required'])) {
$code = $q['code'] ?? '';
$v = $answers[$code] ?? null;
if ($v === null || $v === '' || (is_array($v) && !$v)) { $missing[] = $code; }
}
}
if ($missing) { $this->jsonError('Mancano risposte obbligatorie.', 422, 'REQUIRED_MISSING', ['missing' => $missing]); }
}
$this->saveResponse((int) $tg['target_id'], json_encode($answers, JSON_UNESCAPED_UNICODE), null, $b['respondent_name'] ?? null);
Database::update('stk_activity_targets', ['state' => 'responded', 'responded_at' => date('Y-m-d H:i:s')], 'id = ?', [(int) $tg['target_id']]);
$this->maybeComplete((int) $tg['activity_id']);
$this->jsonSuccess(['state' => 'responded'], 'Grazie, risposta inviata.');
}
/** 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'); }
$b = $this->getJsonBody();
$now = date('Y-m-d H:i:s');
$this->saveResponse((int) $tg['target_id'], null, $now, $b['respondent_name'] ?? null);
Database::update('stk_activity_targets', ['state' => 'acknowledged', 'responded_at' => $now], 'id = ?', [(int) $tg['target_id']]);
$this->maybeComplete((int) $tg['activity_id']);
$this->jsonSuccess(['state' => 'acknowledged'], 'Firma di avvenuta lettura registrata. Grazie.');
}
/** 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', [
'activity_id' => (int) $tg['activity_id'],
'body' => mb_substr($body, 0, 5000),
'author_kind' => 'external',
'author_label'=> mb_substr((string) $tg['stakeholder_name'], 0, 255),
]);
$this->jsonSuccess(null, 'Commento inviato.');
}
/** 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)));
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 = '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,
'entity_type' => 'stk_activity',
'entity_id' => (int) $tg['activity_id'],
'file_name' => mb_substr((string) $file['name'], 0, 255),
'file_path' => "stk_activity/{$orgId}/{$filename}",
'file_size' => (int) $file['size'],
'mime_type' => mb_substr((string) ($file['type'] ?? ''), 0, 100),
'uploaded_by' => null,
]);
$this->jsonSuccess(null, 'Allegato caricato.', 201);
}
// ─────────────────────────────────────────────────────────────────────────
// HELPER
// ─────────────────────────────────────────────────────────────────────────
/** Risolve il destinatario dal token (?t= o body.t). 401 se assente, 404 se non valido. */
private function resolveTarget(): array
{
$token = trim((string) ($_GET['t'] ?? $this->getJsonBody()['t'] ?? ''));
if ($token === '' || !preg_match('/^[a-f0-9]{48}$/', $token)) {
$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, 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
WHERE g.access_token_hash = ?',
[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; }
$t = Database::fetchOne('SELECT kind, content, questions FROM stk_questionnaire_templates WHERE id = ?', [(int) $tg['template_id']]);
if (!$t) { return null; }
$q = $t['questions'] ? json_decode($t['questions'], true) : [];
return ['kind' => $t['kind'], 'content' => $t['content'], 'questions' => is_array($q) ? $q : []];
}
/** Upsert della risposta (una per target). */
private function saveResponse(int $targetId, ?string $answersJson, ?string $ackAt, $respondentName): void
{
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
$name = $respondentName !== null ? mb_substr(trim((string) $respondentName), 0, 255) : null;
Database::query(
'INSERT INTO stk_activity_responses (target_id, answers, acknowledged_at, respondent_name, ip_address, submitted_at)
VALUES (?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE answers = VALUES(answers), acknowledged_at = VALUES(acknowledged_at),
respondent_name = VALUES(respondent_name), ip_address = VALUES(ip_address), submitted_at = NOW()',
[$targetId, $answersJson, $ackAt, $name, $ip]
);
}
/** Se tutti i destinatari hanno risposto/firmato, segna l'attività 'completed'. */
private function maybeComplete(int $activityId): void
{
$tot = (int) Database::fetchOne('SELECT COUNT(*) AS c FROM stk_activity_targets WHERE activity_id = ?', [$activityId])['c'];
$done = (int) Database::fetchOne(
"SELECT COUNT(*) AS c FROM stk_activity_targets WHERE activity_id = ? AND state IN ('responded','acknowledged')",
[$activityId]
)['c'];
if ($tot > 0 && $done >= $tot) {
Database::update('stk_activities', ['status' => 'completed'], 'id = ?', [$activityId]);
} else {
Database::query("UPDATE stk_activities SET status = 'in_progress' WHERE id = ? AND status = 'sent'", [$activityId]);
}
}
}