, , ,

Kunden fikk «allerede brukt» på første klikk: Safe Links brenner engangslenker

Lenkekjede ut av en konvolutt der ett ledd er brent av en skanner

En kunde fikk «Denne innloggingslinken virker ikke lenger» på sitt aller første klikk. Lenka var sendt 40 sekunder tidligere, var gyldig i 30 minutter og hadde aldri vært brukt. Trodde jeg. Loggen sa noe annet: lenka var allerede løst inn, fra en IP-adresse kunden aldri har vært i nærheten av.

Oppsettet

Kundeportalen logger inn med magisk lenke. Kunden skriver e-postadressen sin, får en e-post med en URL som inneholder et 64 tegns token, klikker, og får en cookie som varer i sju dager. Tokenet er engangs og utløper etter 30 minutter. Innløsningen skjedde på GET, fordi det er det et klikk i en e-post gjør.

GET /portal/lenke/{token}   -> sett used_at, lag økt, sett cookie, redirect

Det fungerte i alle tester og for alle kunder på Gmail. Så kom den første kunden på Microsoft 365.

Hva loggen viste

Ingenting i laravel.log. Hele beviset lå i portalens egen hendelsestabell, som lagrer IP og user agent på hver innløsning og hver avvisning. Forenklet:

13:02:11  lenke_sendt    kontakt 7
13:02:41  lenke_brukt    ip=104.47.17.190   ua=""                       økt opprettet
13:02:44  lenke_avvist   ip=48.209.223.38   ua="Chrome/142 ..."         allerede brukt
13:02:45  lenke_avvist   ip=48.209.223.59   ua="Chrome/142 ..."         allerede brukt
13:04:20  lenke_avvist   ip=<kundens nett>  ua="Safari/605 ... iPhone"  allerede brukt

Tre ting å legge merke til.

  • 104.47.0.0/16 er Microsoft Exchange Online Protection. Den første forespørselen kom derfra, 30 sekunder etter utsending, uten user agent. Det er Defender for Office 365 sin Safe Links som henter hver eneste URL i innkommende e-post for å sjekke den.
  • Deretter kom to forespørsler fra Azure-adresser med en ekte, men litt gammel, Chrome-UA. Det er detonasjonssandkassa: en headless nettleser som rendrer sida for å se om den er phishing. Den klikker ikke på knapper.
  • Kundens ekte klikk kom nesten to minutter senere, fra kundens eget nett, og ble avvist.

Skanneren fikk altså en gyldig sju dagers økt. Den brukte den ikke til noe, men den hadde den. Kunden fikk ingenting.

Sekvensdiagram før og etter: skanneren henter lenka med GET, kunden avvises. Etter fiksen viser GET en knapp og POST løser inn.
Før: skanneren løser inn lenka 30 sekunder etter utsending. Etter: GET gir bare en side med knapp, og først kundens POST løser inn.

Hvorfor det er et designproblem, ikke en bug hos Microsoft

Safe Links gjør akkurat det den skal. Google, Proofpoint og Mimecast gjør det samme. En URL i en e-post kommer til å bli hentet av minst én maskin før mennesket ser den. Alt som skjer som konsekvens av en GET, skjer derfor uten at mottakeren har gjort noe. Det gjelder innlogging, bekreftelse av e-postadresse, godkjenning av tilbud, avmelding og «slett kontoen min».

Dette er samme grunn til at RFC 8058 krever POST for ettklikks avmelding i List-Unsubscribe. Alle som sender e-post i volum har truffet dette for lenge siden. Laravel sin innebygde passordtilbakestilling er også trygg, fordi lenka viser et skjema og selve tilbakestillingen skjer på POST. Det er egne magiske lenker og tredjeparts «login by email»-pakker som ofte gjør hele jobben på GET.

Fiksen: GET viser en knapp, POST løser inn

GET rører ikke tokenet lenger. Den sjekker at lenka finnes og er gyldig, og viser én knapp. Knappen POSTer med CSRF-token til samme URL. Skannere følger GET og redirect, men sender ikke skjemaer med et CSRF-felt de ikke kjenner.

// routes/web.php
Route::get('/portal/lenke/{token}',  [PortalMagicLinkController::class, 'vis'])
    ->where('token', '[A-Za-z0-9]{64}');
Route::post('/portal/lenke/{token}', [PortalMagicLinkController::class, 'apne'])
    ->where('token', '[A-Za-z0-9]{64}');
/** Bekreftelsessida. Rører ikke lenka. */
public function vis(Request $request, string $token): View|RedirectResponse
{
    $lenke = PortalMagicLink::where('token_hash', PortalMagicLink::hashFor($token))->first();

    if (! $lenke || ! $lenke->erBrukbar()) {
        $this->loggAvvist($lenke, $request, 'visning');
        return $this->tilbakeTilLogin();
    }

    return view('portal.lenke', ['token' => $token, 'utloper' => $lenke->expires_at]);
}

/** Selve innløsningen. Kun POST, kun med gyldig CSRF-token. */
public function apne(Request $request, string $token, PortalAccess $access): RedirectResponse
{
    $session = $access->losInn($token, $request->ip(), $request->userAgent());

    return $session
        ? redirect()->route('portal.hjem')
        : $this->tilbakeTilLogin();
}
<form method="POST" action="{{ route('portal.magic-link.apne', $token) }}">
    @csrf
    <button type="submit">Åpne kundeportalen</button>
</form>

Ingen JavaScript som sender skjemaet automatisk. Detonasjonssandkassa kjører en ekte nettleser, og et form.submit() ved lasting ville den utført. Kunden må trykke. Det er ett klikk ekstra, og det er hele poenget.

Avvisninger logges nå med hvilket steg de skjedde i, visning eller innlosing, så neste gang en kunde «ikke kommer inn» er det synlig om det er skanneren eller kunden som ble avvist.

Regresjonsvakt

Testen simulerer skanneren: tre GET på rad skal ikke bruke opp lenka, og kunden skal komme inn med POST etterpå.

public function test_get_paa_lenka_bruker_den_ikke_opp(): void
{
    $token = $this->loggInn($this->kontaktMedTilgang());

    $this->get(route('portal.magic-link', $token))->assertOk();
    $this->get(route('portal.magic-link', $token))->assertOk();
    $this->get(route('portal.magic-link', $token))->assertOk();

    $this->assertNull(PortalMagicLink::first()->used_at);

    $this->post(route('portal.magic-link.apne', $token))
        ->assertRedirect(route('portal.hjem'))
        ->assertCookie(PortalSession::COOKIE);
}

Alternativene, og hvorfor de er dårligere

  • La lenka virke flere ganger innenfor 30 minutter. Fikser symptomet. Men da kan alle som får e-posten videresendt logge inn så mange ganger de vil i vinduet, og skanneren beholder fortsatt sin økt.
  • Blokkere skanner-IP-er og tomme user agents. 104.47.0.0/16 er stabilt, men detonasjonsadressene i Azure er det ikke, og en tom UA er ikke et pålitelig signal. Du ender med en liste som må vedlikeholdes for hver e-postleverandør kundene dine bruker.
  • Be kunden unnta domenet i Safe Links-policyen. Det finnes en «Do not rewrite the following URLs»-liste i Defender. Du styrer ikke kundens tenant, og du vil ikke ha en støtterutine som starter med «be IT-avdelingen deres om å svekke e-postsikkerheten».

Sjekkliste for egne apper

Gå gjennom alle URL-er appen din sender på e-post og spør: hva skjer hvis en maskin henter denne med GET én gang, fem sekunder etter utsending, og deretter rendrer den i en headless Chrome?

  • Innlogging med magisk lenke: må ha bekreftelsesside og POST.
  • Bekreft e-postadresse: som regel ufarlig at den utløses tidlig, men engangs-token blir brent. Samme fiks.
  • Godkjenn eller avvis tilbud, ordre, timeliste: aldri på GET. Det er en beslutning, ikke en visning.
  • Avmelding: RFC 8058 sier POST. Ettklikks avmelding på GET blir utført av skanneren.
  • Sporingslenker og «se status»: idempotente, kan stå på GET.

Og legg IP og user agent i loggen på alt som løser inn et token. Uten det hadde denne feilen sett ut som «kunden gjør noe rart».


Trenger du hjelp med dette?

Ta kontakt for en uforpliktende prat om hvordan jeg kan hjelpe deg.



Navn