Microsoft Safe Links løser inn engangslenker før mottakeren klikker

Safe Links i Microsoft Defender for Office 365 henter hver URL i innkommende e-post. En magisk innloggingslenke som løses inn på GET, blir derfor brukt opp av skanneren før mottakeren klikker. I kundeportalen ga det meldingen «Denne innloggingslinken virker ikke lenger» på kundens første klikk. Lenka var sendt 40 sekunder tidligere og var gyldig i 30 minutter. Loggen viste at den allerede var løst inn, fra en IP-adresse som ikke tilhørte kunden.
Oppsett
Kundeportalen logger inn med magisk lenke. Kunden skriver inn e-postadressen sin og får en e-post med en URL som inneholder et token på 64 tegn. Klikket gir en cookie som varer i sju dager. Tokenet kan brukes én gang og utløper etter 30 minutter. Innløsningen skjedde på GET, fordi det er det et klikk i en e-post sender.
GET /portal/lenke/{token} -> sett used_at, lag økt, sett cookie, redirectDet virket i alle tester og for alle kunder på Gmail. Den første kunden på Microsoft 365 kom ikke inn.
Loggen
laravel.log hadde ingenting. Beviset lå i portalens egen hendelsestabell, som lagrer IP og user agent ved hver innløsning og hver avvisning. Forenklet:
13:02:11 lenke_sendt kontakt 7Loggen viser tre ting:
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
104.47.0.0/16er Microsoft Exchange Online Protection. Den første forespørselen kom derfra, 30 sekunder etter utsending, uten user agent. Det er Safe Links i Defender for Office 365, som henter hver 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 eget klikk kom nesten to minutter senere, fra kundens nett, og ble avvist.
Skanneren fikk en gyldig økt på sju dager, som den ikke brukte. Kunden fikk ingen økt.

Feilen ligger i designet av lenka
Safe Links gjør det den skal, og Google, Proofpoint og Mimecast gjør det samme. En URL i en e-post blir hentet av minst én maskin før mennesket ser den. Alt som utløses 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».
RFC 8058 krever POST for ettklikks avmelding i List-Unsubscribe av samme grunn. Laravel sin innebygde passordtilbakestilling er 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.phpSkjemaet sendes ikke automatisk med JavaScript. Detonasjonssandkassa kjører en ekte nettleser og ville utført et
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>form.submit() ved lasting. Kunden må trykke på knappen, og det ekstra klikket er det skanneren ikke gjør.
Avvisninger logges nå med steget de skjedde i, visning eller innlosing. Neste gang en kunde ikke kommer inn, viser loggen om det var skanneren eller kunden som ble avvist.
Regresjonstest
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(): voidAlternativer jeg valgte bort
{
$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);
}
Å la lenka virke flere ganger innenfor 30 minutter fjerner symptomet. 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 holder ikke. 104.47.0.0/16 er stabilt, men detonasjonsadressene i Azure er ikke det, og en tom UA er ikke et pålitelig signal. Resultatet blir en liste som må vedlikeholdes for hver e-postleverandør kundene bruker.Kunden kan unnta domenet i Safe Links-policyen, siden Defender har en «Do not rewrite the following URLs»-liste. Men du styrer ikke kundens tenant, og en støtterutine som begynner med at kundens IT-avdeling må svekke e-postsikkerheten, fungerer ikke.
Sjekkliste for egne apper
Gå gjennom alle URL-er appen sender på e-post, og sjekk hva som skjer hvis en maskin henter URL-en 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, siden det er en beslutning.
- Avmelding: RFC 8058 sier POST. Ettklikks avmelding på GET blir utført av skanneren.
- Sporingslenker og «se status»: idempotente, kan stå på GET.
Legg IP og user agent i loggen på alt som løser inn et token. Uten det hadde denne feilen sett ut som om kunden gjorde noe feil.


