Gestire risorse private in una applicazione Laravel cover image

Gestire risorse private in una applicazione Laravel

alberto • September 17, 2020

tutorial

Spesso in una web application può capitare che si voglia limitare l'accesso a determinate risorse ad alcuni utenti specifici. Immaginiamo di dover realizzare una applicazione di contabilità dove ogni utente possa caricare e disporre delle proprie fatture e note spese. Ovviamente questi documenti sono riservati e non devono essere visualizzati dagli altri utenti.

Nell'articolo introdurremo tre diverse modalità, per implementare questa feature, di cui la prima è una modalità assolutamente sconsigliata.

Aspetti comuni tra le diverse modalità

Per simulare questa funzionalità avremo bisogno di una struttura dati composta da due modelli (e dalle relative tabelle):

I modelli saranno associati da una classica relazione bi-direzionale hasMany/belongsTo: ogni User avrà più documenti, mentre ogni Document apparterrà ad un singolo utente.

Dovremo poi implementare una form con un relativo controller per gestire l'upload e il salvataggio del file riservato.

// routes/web.php
[...]
Route::view('/documenti/nuovo', 'document.form');
Route::post('/documenti/nuovo', 'DocumentController@upload')
    ->name('document.upload');
<!-- document.upload.blade.php -->
<form action="{% raw %}{{ route('document.upload') }}{% endraw %}" method="post" enctype="multipart/form-data">
    {% raw %}{{ csrf_field() }}{% endraw %}
    <input type="text" name="title" placeholder="Titolo documento">
    <input type="file" name="file">
    <input type="submit">
</form>

Per validare la richiesta utente sfrutteremo le Form Requests di Laravel.

// app/Http/Requests/DocumentUploadRequest.php
class DocumentUploadRequest
{
    public function rules()
    {
        return [
            'title' => 'required|max:255',
            'file' => 'required|file|mimes:pdf',
        ];
    }

}

Inoltre per le prime due modalità, dato che si appoggeranno alla folder pubblica Storage, avremo bisogno di lanciare il comando php artisan storage:link per creare il link simbolico tra public/storage e storage/app/public.

Modalità 1: {$document->id}

NB: questa modalità viene introdotta solamente come elemento formativo; ne sconsigliamo l'utilizzo in ambienti di produzione.

La prima modalità che introduciamo è sicuramente la più immediata e semplice da realizzare, ma è anche quella che offre la minor sicurezza. Un malintenzionato, anche non troppo scafato, potrebbe accedere abbastanza facilmente ai documenti privati degli altri utenti.

È comunque corretto introdurla anche come anti-pattern da evitare a tutti i costi.

In questo caso quindi, salveremo i file uploadati dagli utenti all'interno dello storage pubblico di Laravel e li nomineremo sfruttando l'id del modello appena salvato.

// app/Http/Controllers/DocumentController.php
class DocumentController
{
    public function upload(DocumentUploadRequest $request) {

        $document = new Document();
        $document->title = $request->title;
        $document->user()->associate(Auth::user());
        $document->save();

        $request->file('file')->storeAs(
            'documents', "{$document->id}.pdf", 'public'
        );

        return $document;

    }

}

Il controller è molto semplice. Una volta salvato l'oggetto Document a database, copiamo il file uploadato all'interno del disco public, all'interno della cartella documents e lo rinominiamo utilizzando l'id dell'oggetto appena persistito.

Oltre a questo, per praticità, possiamo aggiungere un accessor all'interno del nostro modello, per facilitare la composizione della url di download:

// app/Models/Document.php
class Document
{
    [...]

    public function getDownloadRouteAttribute()
    {
        return asset("storage/documents/{$this->id}.pdf");
    }

}

Ora, con estrema facilità possiamo costruire una url per scaricare il file semplicemente scrivendo $document->downloadRoute.

Problemi di sicurezza

Come anticipato qualche riga sopra, questo approccio, seppur molto facile da implementare prevede una falla di sicurezza non banale. Avendo nominato i documenti con il loro id ed avendoli pubblicati in una cartella pubblica, qualsiasi utente malitenzionato potrebbe sostituire l'id di un documento con un numero vicino per accedere ad un file privato di un altro utente.

Modalità 2: {$document->token}

La seconda modalità è simile alla prima ma sfrutta la presenza di un campo in più nella tabella documents all'interno della quale andremo a salvare un token randomico per evitare la possibile enumerazione degli id come veniva fatto nella prima modalità. Chiameremo appunto la colonna token.

Modifichiamo quindi il controller come segue:

// app/Http/Controllers/DocumentController.php
class DocumentController
{
    public function upload(DocumentUploadRequest $request) {

        $document = new Document();
        $document->title = $request->title;
        $document->token = Str::random(60);
        $document->user()->associate(Auth::user());
        $document->save();

        $request->file('file')->storeAs(
            'documents', "{$document->token}.pdf", 'public'
        );

        return $document;

    }

}

E ovviamente modifichiamo l'accessor:

// app/Models/Document.php
class Document
{
    [...]

    public function getDownloadRouteAttribute()
    {
        return asset("storage/documents/{$this->token}.pdf");
    }

}

Con queste piccole modifiche e con una colonna in più nel database, abbiamo risolto un serio problema di sicurezza in pochissimi minuti semplicemente usando un po' di buon senso.

Con questa modalità i file continuano ad essere pubblici, ma la sicurezza è garantita dal fatto che è pressochè impossibile ricostruire il nome del file da scaricare.

Siamo sicuri che sia davvero sicuro?

Qualcuno potrebbe obbiettare che anche questo sistema presenta delle falle di sicurezza. Essendo i file pubblici, la possibilità che le url vengano scoperte non è totalmente esclusa.

Verissimo! Ma se ci pensate, forse è piú facile scoprire la password di un utente che una stringa generata randomicamente da PHP, no?

Come attenuante, inoltre, cito un caso celebre: Facebook. Le immagini pubblicate dagli utenti, seppur private, sono disponibili a chiunque, qualora venisse scoperta la url. Questa mia foto per esempio, nonostate sia privata, risulta disponibile a tutti coloro che conoscono la url.

Modalità 3: DownloadController

Seppur la seconda modalità è forse l'approccio che preferisco, per completezza introdurrò anche una terza modalità che si differenzia dalle altre per il mancato utilizzo di una cartella pubblica dove salvare i documenti. Questi ultimi infatti verranno salvati in una folder privata e verranno resi disponibili tramite un Controller ad hoc.

In questo caso la colonna token, non sarà necessaria. Avremo però la necessità di configurare una nuova rotta, dedicata appunto al download dei file pdf.

// routes/web.php
[...]
Route::get('/documenti/download/{document}', 'DocumentController@download')
    ->name('document.download');

Partiamo quindi dal controller:

// app/Http/Controllers/DocumentController.php
class DocumentController
{
    public function upload(DocumentUploadRequest $request) {

        $document = new Document();
        $document->title = $request->title;
        $document->user()->associate(Auth::user());
        $document->save();

        $request->file('file')->storeAs(
            'documents', "{$document->id}.pdf", 'local'
        );

        return $document;

    }

}

Con questa modifica, abbiamo cambiato la cartella di destinazione dei nostri documenti uploadati. In questo caso il disco local fa riferimento alla cartella storage, cartella con permessi di scrittura, ma non accessibile direttamente dall'esterno.

È quindi giunto il momento di creare il nuovo metodo download:

// app/Http/Controllers/DocumentController.php
class DocumentController
{
    [...]

    public function download(Document $document)
    {
        if($document->user->id !== Auth::user()->id) {
            return abort(401);
        }
        return Storage::disk('local')->download("documents/{$document->id}.pdf");
    }

}

Questo metodo si occupa principalmente di due cose: innanzitutto verifica che il documento sia effettivamente del legittimo proprietario (l'utente loggato in questo momento) e in caso positivo, legge e forza il download del file pdf.

Nel caso un malintenzionato provasse a fare enumeration, si troverebbe di fronte ad un bel 401 Unauthorized.

Sicurezza estrema?

Tendenzialmente sí. Introducendo un controllo manuale sulla proprietà del file uploadato, non esiste nessun modo per l'utente ostile di accedere a file non di sua proprietà, se non cercare di appropriarsi di credenziali di accesso di altri utenti.

Questa sicurezza totale ha un piccolo side-effect, ovvero quello di caricare leggermente il server per questo ulteriore controllo.

Ma quindi, quale modalità devo implementare?

Sicuramente non la prima.

Tra la seconda e la terza opzione, sicuramente la terza non lascia scampo a nessuno. È leggermente più complessa da implementare ma neanche più di tanto.

La decisione finale è quindi lasciata al buon senso e al trade-off tra facilità di implementazione e manutenzione rispetto all'estrema sicurezza, che in alcuni contesti può risultare anche eccessiva.