Correggi bug di sicurezza e integrita' dei dati

I due piu' gravi potevano causare perdita di dati silenziosa.

AppServiceProvider chiamava key:generate --force a ogni richiesta quando
APP_KEY risultava vuota. Poiche' nome e cognome dei proclamatori sono
cifrati con quella chiave, ogni riga scritta sotto una chiave poi
sostituita diventava irrecuperabile. La chiave e' ora risolta una sola
volta all'avvio del container e il provider solleva un'eccezione se manca,
invece di rigenerarla.

importTerritoryPdfFolder() caricava i PDF, costruiva l'elenco dei file e
poi lo scartava senza mai accodare il job: nessun errore, nessun territorio
aggiornato, e i file restavano su disco a crescita illimitata perche' la
pulizia viveva solo nel finally del job mai eseguito.

Sicurezza:
- i permessi non venivano riapplicati alle richieste /livewire/update, per
  cui un permesso revocato restava attivo fino al reload della pagina
- host header injection: nginx passava l'Host del client come
  X-Forwarded-Host e Laravel si fidava di tutti i proxy, abbastanza per far
  generare a route() link verso domini arbitrari, inclusi i PDF pubblici
  che contengono un codice di accesso valido
- lo schema forwarded e' ora deciso dal server e non dall'header del client
- campagna_id in Rientra e' #[Locked]: era derivato lato server ma
  manipolabile dal client, permettendo di conteggiare un rientro su una
  campagna arbitraria
- sortField del registro passava non validato a orderBy(), con 500 su
  colonna inesistente
- SESSION_SECURE_COOKIE ora vale true fuori da local
- limiti su dimensione e numero di voci negli import ZIP

Integrita' e prestazioni:
- il default di assignment_link_ttl_hours era rimasto 24 dopo la
  conversione da ore a mesi: i link PDF pubblici duravano due anni invece
  di uno mese
- il seeder usava syncPermissions(), che a un secondo giro azzerava i
  permessi personalizzati dei ruoli; ora e' additivo
- pdftoppm senza timeout poteva bloccare il worker indefinitamente
- nomi file deterministici nell'import: "1 A" e "1-A" scrivevano sullo
  stesso PDF, sovrascrivendosi
- Setting::flushCache() dopo le cancellazioni massive, che non emettono
  eventi e lasciavano la cache puntata a una riga inesistente
- riga settings creata dal seeder, per evitare due singleton concorrenti
- rimosse due migrazioni ridondanti che rompevano migrate:rollback
- indici su assigned_at, (attivo, deleted_at) e deleted_at
- risolti gli N+1 negli elenchi territori e proclamatori

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 16:49:42 +02:00
parent cf78550516
commit 0502fad996
19 changed files with 347 additions and 93 deletions

View File

@@ -7,20 +7,28 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* Guarded on both sides because two follow-up migrations used to re-add
* this same column. They have been removed, but a database that already
* ran them may have the column in either state, and an unguarded
* add/drop would abort the whole migration run.
*/
public function up(): void
{
if (Schema::hasColumn('territori', 'thumbnail_path')) {
return;
}
Schema::table('territori', function (Blueprint $table) {
$table->string('thumbnail_path')->nullable()->after('pdf_path');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (! Schema::hasColumn('territori', 'thumbnail_path')) {
return;
}
Schema::table('territori', function (Blueprint $table) {
$table->dropColumn('thumbnail_path');
});

View File

@@ -1,32 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (!Schema::hasColumn('territori', 'thumbnail_path')) {
Schema::table('territori', function (Blueprint $table) {
$table->string('thumbnail_path')->nullable()->after('pdf_path');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasColumn('territori', 'thumbnail_path')) {
Schema::table('territori', function (Blueprint $table) {
$table->dropColumn('thumbnail_path');
});
}
}
};

View File

@@ -1,32 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (!Schema::hasColumn('territori', 'thumbnail_path')) {
Schema::table('territori', function (Blueprint $table) {
$table->string('thumbnail_path')->nullable()->after('pdf_path');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasColumn('territori', 'thumbnail_path')) {
Schema::table('territori', function (Blueprint $table) {
$table->dropColumn('thumbnail_path');
});
}
}
};

View File

@@ -0,0 +1,67 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// ---------------------------------------------------------------
// assignment_link_ttl_hours now holds MONTHS (renamed in spirit by
// 2026_04_08_123500, which converted the rows but left the column
// default at 24). A settings row created without an explicit value
// therefore got 24 *months* — public PDF links valid for two years
// instead of the intended one month.
// ---------------------------------------------------------------
DB::table('settings')
->where('assignment_link_ttl_hours', 24)
->update(['assignment_link_ttl_hours' => 1]);
Schema::table('settings', function (Blueprint $table) {
$table->unsignedInteger('assignment_link_ttl_hours')->default(1)->change();
});
// ---------------------------------------------------------------
// Indexes. Every Eloquent query on these soft-deleting models appends
// `deleted_at is null`, and the dashboard/listing scopes filter on
// attivo/prioritario and range-scan assigned_at — all unindexed.
// ---------------------------------------------------------------
Schema::table('territori', function (Blueprint $table) {
$table->index(['attivo', 'deleted_at'], 'territori_attivo_deleted_at_index');
$table->index('prioritario', 'territori_prioritario_index');
});
Schema::table('proclamatori', function (Blueprint $table) {
$table->index('deleted_at', 'proclamatori_deleted_at_index');
});
// (territorio_id, returned_at) is already indexed by the create
// migration; only the assigned_at range scans are missing.
Schema::table('assegnazioni', function (Blueprint $table) {
$table->index('assigned_at', 'assegnazioni_assigned_at_index');
});
}
public function down(): void
{
Schema::table('assegnazioni', function (Blueprint $table) {
$table->dropIndex('assegnazioni_assigned_at_index');
});
Schema::table('proclamatori', function (Blueprint $table) {
$table->dropIndex('proclamatori_deleted_at_index');
});
Schema::table('territori', function (Blueprint $table) {
$table->dropIndex('territori_prioritario_index');
$table->dropIndex('territori_attivo_deleted_at_index');
});
Schema::table('settings', function (Blueprint $table) {
$table->unsignedInteger('assignment_link_ttl_hours')->default(24)->change();
});
}
};

View File

@@ -2,6 +2,7 @@
namespace Database\Seeders;
use App\Models\Setting;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\App;
@@ -11,6 +12,13 @@ class DatabaseSeeder extends Seeder
{
$this->call([RolesAndPermissionsSeeder::class]);
// Materialize the settings singleton up front. Setting::instance()
// falls back to firstOrCreate() on read, and read paths run on every
// page (Territorio scopes and accessors) — so on a fresh database two
// concurrent first requests can each insert a row, leaving two
// "singletons" with edits landing in whichever one wins.
Setting::instance();
if (App::environment('local') && env('SEED_DEV_DATA', false)) {
$this->call([DevSeeder::class]);
}

View File

@@ -31,13 +31,23 @@ class RolesAndPermissionsSeeder extends Seeder
Permission::firstOrCreate(['name' => $permission]);
}
// givePermissionTo() rather than syncPermissions(): sync *detaches*
// anything not in these lists. This seeder can re-run against a
// populated database (the deploy marker lives on a volume that may be
// recreated), and syncing would silently revoke every permission an
// administrator had granted through the UI. Granting is additive and
// therefore safe to repeat.
//
// These lists are the baseline each role starts with, not a
// continuously enforced contract.
// Amministratore: all permissions
$admin = Role::firstOrCreate(['name' => 'amministratore']);
$admin->syncPermissions($permissions);
$admin->givePermissionTo($permissions);
// Assistente: proclamatori, campagne, assign, return
$assistente = Role::firstOrCreate(['name' => 'assistente']);
$assistente->syncPermissions([
$assistente->givePermissionTo([
'proclamatori.manage',
'campagne.manage',
'territori.assign',
@@ -47,7 +57,7 @@ class RolesAndPermissionsSeeder extends Seeder
// Operatore: assign and return only
$operatore = Role::firstOrCreate(['name' => 'operatore']);
$operatore->syncPermissions([
$operatore->givePermissionTo([
'territori.assign',
'territori.return',
]);