OAuth 2.0 implementation vulnerabilities: errori comuni nelle API Laravel e come evitarli

OAuth 2.0 implementation vulnerabilities: errori comuni nelle API Laravel e come evitarli

Nel 2025 ho condotto audit di sicurezza su cinque API Laravel che implementavano OAuth 2.0 per l'autenticazione di client esterni - integrazioni con applicazioni mobile, portali partner e sistemi terzi. Quattro delle cinque avevano almeno una vulnerabilità critica nell'implementazione di OAuth. Non vulnerabilità nel framework o nella libreria (Laravel Passport e Socialite sono solidi quando usati correttamente), ma errori di implementazione commessi dagli sviluppatori che non avevano compreso i meccanismi di sicurezza del protocollo. La vulnerabilità più frequente - presente in tre delle cinque API - era l'assenza di validazione del parametro state, che permette a un attaccante di eseguire un attacco CSRF sul flusso di autorizzazione e associare il proprio account OAuth all'account della vittima. Un attacco che richiede meno di 30 secondi per essere eseguito e che compromette completamente l'identità dell'utente nell'applicazione.

OAuth 2.0 è il protocollo di autorizzazione più diffuso al mondo - lo usano Google, Facebook, GitHub, Apple e migliaia di API enterprise. Ma la sua diffusione ha creato un falso senso di sicurezza: gli sviluppatori assumono che "se uso OAuth, sono sicuro", quando in realtà OAuth è un protocollo complesso con almeno sei punti di fallimento nel flusso di autorizzazione, ciascuno dei quali può essere sfruttato se l'implementazione non è rigorosa. La specifica RFC 6749 di OAuth 2.0 e il framework di sicurezza OAuth 2.0 aggiornato nel RFC 6819 documentano questi rischi in dettaglio - ma quanti sviluppatori PHP hanno letto le RFC prima di implementare il flusso di autorizzazione?

In questo articolo ti mostro le cinque vulnerabilità più frequenti che trovo nelle implementazioni OAuth 2.0 su Laravel, con i payload di test che uso durante i penetration test e le fix concrete per ciascuna. Se la tua applicazione espone un flusso OAuth per client esterni, almeno una di queste vulnerabilità è probabilmente presente nel tuo codice.

Il parametro state mancante: la vulnerabilità che permette il furto dell'account in 30 secondi

Il parametro state nel flusso OAuth Authorization Code è un valore crittograficamente random che il client genera prima di redirigere l'utente al provider OAuth, e che verifica al ritorno per confermare che la risposta corrisponda alla richiesta originale. Senza validazione dello state, un attaccante può costruire un link di callback OAuth con il proprio authorization code e indurre la vittima a cliccarlo - il risultato è che l'account della vittima viene associato all'account OAuth dell'attaccante, dando all'attaccante accesso completo.

L'attacco funziona così: l'attaccante inizia il flusso OAuth sulla tua applicazione, ottiene un authorization code legittimo dal provider, ma non lo usa. Invece, costruisce un URL di callback con quel code e lo invia alla vittima (via email, chat o social engineering). La vittima, che è loggata nella tua applicazione, clicca il link. L'applicazione riceve il callback, scambia il code con un access token, e associa l'account OAuth dell'attaccante al profilo della vittima. Da quel momento, l'attaccante può loggarsi nell'applicazione con il proprio account OAuth e accedere ai dati della vittima.

// VULNERABILE: callback OAuth senza validazione dello state
Route::get('/oauth/callback', function (Request $request) {
    // ❌ Non verifica che lo state corrisponda alla sessione
    $code = $request->query('code');

    $token = Http::post('https://provider.com/oauth/token', [
        'grant_type' => 'authorization_code',
        'code' => $code,
        'client_id' => config('oauth.client_id'),
        'client_secret' => config('oauth.client_secret'),
        'redirect_uri' => config('oauth.redirect_uri'),
    ])->json();

    // Associa l'account OAuth all'utente corrente
    $user = Auth::user();
    $user->oauth_token = $token['access_token'];
    $user->save();
});

// CORRETTO: validazione dello state con token CSRF
Route::get('/oauth/authorize', function () {
    $state = Str::random(40);
    session(['oauth_state' => $state]);

    return redirect("https://provider.com/oauth/authorize?" . http_build_query([
        'client_id' => config('oauth.client_id'),
        'redirect_uri' => config('oauth.redirect_uri'),
        'response_type' => 'code',
        'scope' => 'read',
        'state' => $state,
    ]));
});

Route::get('/oauth/callback', function (Request $request) {
    // ✅ Verifica che lo state corrisponda a quello in sessione
    if ($request->query('state') !== session('oauth_state')) {
        abort(403, 'OAuth state mismatch - possibile attacco CSRF');
    }
    session()->forget('oauth_state');

    // Procedi con lo scambio del code
    $code = $request->query('code');
    // ...
});

Se usi Laravel Socialite, la validazione dello state è gestita automaticamente dal pacchetto - ma solo se usi il metodo Socialite::driver('provider')->redirect() per iniziare il flusso e Socialite::driver('provider')->user() per gestire il callback. Se implementi il flusso a mano (cosa comune quando l'API OAuth del provider non è supportata da Socialite), la validazione dello state è responsabilità tua. Nel mio profilo professionale trovi il dettaglio dell'esperienza che porto negli audit di sicurezza OAuth - un'area dove la conoscenza offensiva (come sfruttare la vulnerabilità) è il prerequisito per una difesa efficace.

Token leakage: quando l'access token finisce dove non dovrebbe

La seconda vulnerabilità più frequente è la fuoriuscita dell'access token in contesti non sicuri. I tre vettori di leakage che trovo più spesso sono: il token incluso nei log applicativi (il middleware di logging registra l'header Authorization: Bearer ... con il token in chiaro), il token incluso nel referer header (l'applicazione redirige a un sito esterno e il browser include l'URL corrente - con il token se è stato passato come query parameter - nel header Referer), e il token salvato in localStorage (accessibile a qualsiasi script JavaScript nella pagina, inclusi script di terze parti come analytics, chat widget e social embed).

La fix per il primo vettore è un middleware di sanitizzazione dei log che maschera i token prima della scrittura:

// Middleware che sanitizza gli header sensibili nei log
class SanitizeLogHeaders
{
    public function handle(Request $request, Closure $next): Response
    {
        // Maschera il token nell'header Authorization prima del logging
        if ($request->hasHeader('Authorization')) {
            $token = $request->header('Authorization');
            $masked = substr($token, 0, 15) . '...[REDACTED]';
            // Il log vedrà "Bearer eyJhbGci...[REDACTED]"
            Log::shareContext(['auth_header' => $masked]);
        }

        return $next($request);
    }
}

La fix per il secondo vettore è l'header Referrer-Policy: no-referrer su tutte le risposte dell'API, e la regola assoluta di non passare mai token come query parameter (usare sempre l'header Authorization). La fix per il terzo vettore è usare cookie httpOnly per lo storage del token invece di localStorage - un cookie httpOnly non è accessibile da JavaScript e quindi non è vulnerabile a XSS. Laravel Sanctum implementa questo pattern nativamente con i cookie SPA.

Redirect URI manipulation: quando l'attaccante redirige il code verso il proprio server

La terza vulnerabilità riguarda la validazione insufficiente del parametro redirect_uri. Se il tuo server OAuth accetta redirect URI che non corrispondono esattamente all'URL registrato dal client, un attaccante può modificare il redirect_uri per puntare al proprio server e intercettare l'authorization code. La OWASP Testing Guide per OAuth documenta questo attacco in dettaglio nella sezione sugli authorization server.

La validazione deve essere esatta (match stringa completo), non parziale. Un match parziale che accetta qualsiasi URL che inizia con il dominio registrato è vulnerabile: se il redirect URI registrato è https://app.esempio.it/callback, un match parziale accetterebbe anche https://app.esempio.it/callback.attaccante.com o https://app.esempio.it/callback?redirect=https://evil.com. Laravel Passport implementa il match esatto di default, ma ho visto implementazioni custom che usano Str::startsWith() invece di === - un errore che apre a questo attacco.

Scope escalation e token lifetime eccessivo

La quarta vulnerabilità è la richiesta di scope eccessivi e il lifetime troppo lungo dei token. Un'API che emette un access token con scope read write admin valido per 30 giorni viola il principio del privilegio minimo: se il token viene compromesso, l'attaccante ha accesso admin per un mese. La best practice è emettere token con lo scope minimo necessario per l'operazione, con un lifetime breve (15-60 minuti per l'access token) e un refresh token con lifetime più lungo (7-30 giorni) che permette di ottenere nuovi access token senza richiedere all'utente di riautenticarsi. In Laravel Passport, la configurazione è:

// AuthServiceProvider - configurazione token lifetime
Passport::tokensExpireIn(now()->addMinutes(30));
Passport::refreshTokensExpireIn(now()->addDays(7));
Passport::personalAccessTokensExpireIn(now()->addHours(6));

La quinta vulnerabilità è l'assenza di revocation dei token. Quando un utente cambia password, disconnette un'integrazione, o segnala un account compromesso, tutti i token OAuth emessi per quell'utente devono essere revocati immediatamente. Ho trovato implementazioni dove il cambio password non revocava i token OAuth - il che significava che un attaccante che aveva ottenuto un token poteva continuare ad accedere anche dopo che la vittima aveva cambiato la password.

PKCE: la protezione che manca nel 90% delle implementazioni

Il Proof Key for Code Exchange (PKCE, pronunciato "pixie") è un'estensione di OAuth 2.0 definita nella RFC 7636 che protegge il flusso Authorization Code dall'intercettazione del code durante il redirect. PKCE è stato originariamente progettato per i client pubblici (applicazioni mobile e SPA che non possono mantenere segreto il client_secret), ma la best practice attuale - raccomandata dal OAuth 2.0 Security Best Current Practice - è usarlo per tutti i client, inclusi quelli confidenziali. Laravel Passport supporta PKCE dalla versione 10 con il metodo Passport::enablePkce().

Il meccanismo è elegante: il client genera un code_verifier random di 128 byte, calcola il suo hash SHA-256 (code_challenge), e invia il code_challenge nella richiesta di autorizzazione. Quando scambia il code con il token, invia il code_verifier originale. Il server verifica che l'hash del code_verifier corrisponda al code_challenge ricevuto in precedenza - se non corrisponde, il code è stato intercettato da un terzo e lo scambio viene rifiutato. L'attaccante che intercetta il code non ha il code_verifier e non può completare lo scambio.

Delle cinque API auditate, nessuna usava PKCE - nemmeno per i client mobile. Abilitarlo è una riga di configurazione in Passport e una modifica minima nei client, ma l'impatto sulla sicurezza è significativo: elimina un'intera classe di attacchi di intercettazione del code che è particolarmente rilevante su reti Wi-Fi pubbliche e su dispositivi mobili.

Checklist di verifica rapida per la tua implementazione OAuth

Se non hai mai fatto un audit formale della tua implementazione OAuth, puoi eseguire una verifica rapida controllando questi sei punti in ordine di criticità:

  • State parameter: il tuo flusso di autorizzazione genera un valore random per il parametro state e lo verifica nel callback? Testa aprendo il tuo URL di callback con un state inventato - se l'applicazione non restituisce un errore 403, è vulnerabile a CSRF
  • PKCE abilitato: la tua applicazione usa code_challenge e code_verifier nel flusso Authorization Code? Se usi Laravel Passport, verifica che Passport::enablePkce() sia presente nel AuthServiceProvider
  • Redirect URI validation: il tuo server OAuth fa match esatto sul redirect_uri registrato? Testa con un redirect_uri leggermente modificato (aggiungendo un path o un query parameter) - se viene accettato, la validazione è insufficiente
  • Token nei log: cerca nei tuoi log applicativi (storage/logs/laravel.log) la stringa Bearer eyJ - se trovi match, i tuoi access token sono in chiaro nei log e chiunque abbia accesso ai log ha accesso ai token
  • Token lifetime: controlla Passport::tokensExpireIn() - se il lifetime è superiore a 60 minuti per l'access token o 30 giorni per il refresh token, è eccessivo per la maggior parte dei casi d'uso
  • Revocation on password change: cambia la password di un utente di test e verifica che i token OAuth precedenti non funzionino più - se continuano a funzionare, la revocation non è implementata

Se anche solo uno di questi sei punti fallisce, la tua implementazione OAuth ha una vulnerabilità sfruttabile. La buona notizia è che ciascuna fix richiede meno di un'ora di lavoro - il costo di non fixarla, in caso di compromissione, è incomparabilmente più alto.

L'audit di sicurezza delle implementazioni OAuth non è un lusso - è una necessità per qualsiasi API che espone dati di utenti o che gestisce autorizzazioni tra sistemi. Le cinque vulnerabilità che ho descritto sono presenti nella maggioranza delle implementazioni che audito, e ciascuna di esse è sfruttabile con strumenti standard di penetration testing senza conoscenze specialistiche. Ho documentato il framework completo di audit per applicazioni PHP nel mio articolo sull'audit di sicurezza per applicazioni PHP legacy, che copre anche SQL injection, XSS e le altre vulnerabilità OWASP Top 10. Se la tua API Laravel implementa OAuth 2.0 e non è mai stata auditata, contattami per un assessment di sicurezza mirato: in due giornate di lavoro testo tutti i flussi OAuth con payload reali, documento le vulnerabilità trovate con severity e remediation, e verifico le fix dopo l'implementazione.

Ultima modifica: