15 juni 2009

Spam weren, maar dan zonder CAPTCHA

We herkennen het probleem allemaal wel, die eindeloze stromen welke binnenkomen in gastenboeken, op blogs, op forums. Zo'n beetje elke plaats op het internet waar gebruikers zelf iets kunnen toevoegen lijkt er aan te moeten geloven: Spam.

Natuurlijk zijn we allemaal zeer geïnteresseerd in die ge-wel-dige aanbiedingen van die Russische pillenboer, de echte Zwitserse uurwerken voor een dumpprijs, of die mooie business proposals van een Nigeriaanse neef. Maar wat als je, net als ik, niks te klagen hebt, en al ruim voorzien bent?

Voor de meeste webmasters is het antwoord al snel: Een CAPTCHA implementeren. Het grootste nadeel is bekend; een goede dient moeilijk te zijn voor computers, maar is dit helaas ook steeds vaker voor een mens.

Gelukkig is er een andere oplossing; spamfilters. Wie Wordpress gebruikt kent vast Akismet wel, een plugin die alle berichten controleert op spam-woorden. In deze post doe ik uit de doeken hoe je zelf zoiets maakt in PHP.

Achtergrond informatie
Maar eerst wat meer achtergrond informatie, ik was op zoek naar een anti-spam-oplossing die aan een aantal voorwaarden moest voldoen:

1. De gebruiker mag er geen last van ondervinden.
2. De oplossing moet snel zijn.
3. Berichten mogen niet in een queue belanden.
4. Bij het plaatsen moet voor de gebruiker direct bekend zijn of een bericht goed-, of afgekeurd wordt.

Ik had kunnen kiezen om een implementatie te schrijven voor Akismet, een bewezen oplossing binnen de blog wereld. Probleem bij Akismet is echter de wachtrij, het kan een hele poos duren voordat het bericht er door is.

Daarom heb ik besloten zelf een spamfilter te schrijven, maar waar moet je dan beginnen? Ik heb voor mezelf een klein lijstje gemaakt waar een typische spam-post uit bestaat:

1. Er staan vaak 1 of meerdere links in.
2. Deze links worden op allerlei manieren ingevoegd in een bericht, bijv.: [url], <a>, [link], http://, of enkel www.
3. Men plaatst ze onder een valse, gegenereerde naam.
4. Hetzelfde geld voor de e-mailadressen.
5. De inhoud bestaat veelal uit dezelfde Engelse woorden.
6. Vaak worden ze geplaatst via proxies, om IP-blokkeringen te voorkomen.
7. Veelal wordt er maar wat gegist qua input velden.

Dit laatste punt vereist wat meer uitleg, het is zo dat elk formulier bestaat uit <input>-velden, deze hebben allemaal een name-attribuut waarop je ze in je code kunt aanspreken. Een spammer heeft echter vaak geen tijd/zin om uit te vogelen welke name-attributen je hebt meegegeven.

Wat hij daarop doet is simpel: Hij probeert zo veel mogelijk uit. Dus stuurt hij niet alleen een e-mailadres mee, maar ook een e-mail, email, mail, mailadres, emailadres, enz. In de hoop dat 1 van de velden geaccepteerd wordt.

De oplossing
Vervolgens ben ik voor elk punt bij langs gegaan hoe ik zo'n controle het eenvoudigst in code kon omzetten.

POST-velden
Om even bij het name-attribuut voorbeeld te blijven, dit is bijvoorbeeld simpel te controleren door te kijken of een poster niet meer velden opgeeft, dan je formulier uit bestaat:
<?php
if(count($_POST)>5){
    //Vermoedelijk spam, te veel POST-velden
}
?>
Hiermee controleer je dus of er niet meer dan 5 velden worden meegestuurd, in mijn project werd er een naam, e-mail, website en bericht meegestuurd. De geoefende rekenaar telt er hier maar 4, en dat klopt, want ook een Verzenden-knop telt mee in $_POST, waarmee we dus op 5 uitkomen.

Een geldig e-mailadres
Dan het volgende punt, controleren of een e-mailadres wel geldig is, alhoewel de meeste spammers hier wel aan denken, is het natuurlijk ook wel handig voor de bezoeker om hem te laten weten dat zijn e-mailadres niet juist is. Een simpele controle daarop luid:
<?php
if(!ereg('^[a-z0-9_\+-]+(\.[a-z0-9_\+-]+)*@[a-z0-9-]+(\.[a-z0-9-]+)*\.([a-z]{2,4})$',$_POST['email'])){
    //Het e-mailadres is ongeldig
}
?>
Ik ga er verder niet op in hoe deze zgn. regexp precies werkt, en neem voor het gemak aan dat het name-attribuut van je e-mail veld "email" heet.

Wat is de ratio van jouw naam?
Dan de naam, hoe controleer je nu of een naam "echt" of "nep" is? We zouden het bevolkingsregister kunnen raadplegen, maar omdat ik dan ruzie krijg met het CBP, zal ik hier een andere oplossing uitleggen.

Het is namelijk vrij simpel om de ernstige gevallen eruit te filteren, vaak lange willekeurige tekenreeksen zoals: YGaWqnXskCNidzp. Zelfs een kind van 6 ziet gelijk dat dit geen naam is, maar hoe leer je een computer dit?

De oplossing is wederom niet erg lastig; tel het aantal hoofdletters, het aantal kleine letters, en kijk wat de ratio ertussen is. Ik vind een ratio van 0.3 wel aardig klinken. Dat betekend dus 3 hoofdletters op 10 tekens, niets meer (maar wel minder).

Om te voorkomen dat onze geliefde "Jan" ineens wordt uitgesloten van reageren (immers, zijn naam kent een ratio van 0.33), controleren we ook of we voldoende naam hebben om te beoordelen. Laten we stellen dat men minimaal 8 letters moet hebben ingevuld om in aanmerking te komen voor onze razzia naar spam-namen.
<?php
$name = $_POST['name'];

$uppercased = preg_match_all('/[A-Z]/', $name, $null1);
$allcased = preg_match_all('/[A-Za-z]/', $name, $null2);
$namelen = strlen($name);

$ucratio = $uppercased/$allcased;
if($namelen > 8 && $ucratio > 0.3){
    //Waarschijnlijk een spam-naam
}
?>
Ik hoop dat de code voor zich spreekt, maar mocht er behoefte aan uitleg zijn, dan wil ik één en ander wel uitdiepen in de reacties.

Proxy-servers
Dan nu over op intercity-tempo, het controleren op proxy servers kan in PHP onder andere door:
<?php
if ((isset($_SERVER['HTTP_X_FORWARDED_FOR']) || isset($_SERVER['HTTP_VIA']) || isset($_SERVER['HTTP_COOKIE2']) || isset($_SERVER['HTTP_X_FORWARDED_SERVER']) || isset($_SERVER['HTTP_X_FORWARDED_HOST']) || isset($_SERVER['HTTP_MAX_FORWARDS']) || isset($_SERVER['HTTP_PROXY_CONNECTION']))){
    //Gebruiker gebruikt een proxy server
    $myscore += 5;
}
?>
Scores toekennen aan regels
De oplettende lezer spot gelijk "$myscore" (als je hem niet zag, en er problemen mee hebt: 0900-1450), hiermee ken ik dus een score toe aan een controle.

Zo kan ik stellen dat het hebben van een ongeldige naam, minder zwaar gerekend wordt dan het gebruik van een proxy server. Uiteindelijk kun je dan een drempelwaarde instellen waarboven een post als spam wordt aangemerkt. (Daaronder val je met de deur in huis, en staat je bericht direct online!)

De woordenfilter
Dan het belangrijkste onderdeel van onze spamfilter, de woordenfilter. Omdat we al met scores werken, lijkt het me handig om onderscheid te maken in 2 groepen: Hard-spam-woorden en Soft-spam-woorden (en ja, dat is redelijk vergelijkbaar met de classificatie in bepaalde natuurfilms).

Om te voorkomen dat ik straks heel populair ben in Google op deze spam-termen, heb ik een voorbeeld lijstje, wat ik gebruik, onder deze link geplaatst.

In mijn lijstje staan ook links onder de "hard"-classificatie, dit omdat ik voor het project heb gekozen gebruikers niet links te laten plaatsen in de comments, en dit ook duidelijk heb vermeld. Hiermee wordt al een hele hoop spam geweerd. En de gebruiker die graag zijn link wil zien, kan deze bij "website" invullen.

Vervolgens vullen we het lijstje aan met de volgende code:
<?php
$comments = $_POST['comments'];

foreach($spam_words_hard as $sw){
    if(strpos($comments,$sw) !== false){
        $myscore+=7;
    }
}

foreach($spam_words_soft as $sw){
    if(strpos($comments,$sw) !== false){
        $myscore+=3;
    }
}
?>
Hard krijg dus 7 punten, en soft 3 punten per woord. Een beetje spam bericht telt zo al lekker op, en ik ben in productie dan alleen op dit punt al scores boven de 50 tegengekomen.

Tot slot
Voor mensen die liever lui dan moe zijn, heb ik de door mij gebruikte functie online gezet op: dev.andrieslouw.nl/antispam.phps.

Een iets uitgebreidere versie van deze functie heeft tot nu toe in mijn project, een website met meer dan 12000 unieke bezoekers per maand, waaronder een 100-tal spambots, nog geen enkel spam-bericht doorgelaten (drempelwaarde van $myscore is bij mij 10).

Maar belangrijker is nog, dat de gebruikers er zelf geen last van ondervinden, de meesten merken niet eens op dat hun bericht door een filter gaat.

Ik wil iedereen wel van harte aanmoedigen om deze functie als basis te gebruiken, en niet zo maar in productie in te zetten. Het is natuurlijk aan jou om te bepalen wat er wel of niet met spam berichten gebeurd, welke woorden je filtert, welke score je toekent per onderdeel, en welke score je als drempelwaarde gebruikt.

Update: Ramon Fincken heeft een implementatie voor phpBB geschreven

6 opmerkingen:

  1. @Thomas:
    Bedankt! Hij zit gelukkig niet in productie, gezien dit een iets andere functie is, maar ik heb het verbeterd!

    BeantwoordenVerwijderen
  2. Andries, mag ik hier delen van gebruiken voor Phpbbantispam.com ?

    Uiteraard met vermelding naar je topic hier :)

    BeantwoordenVerwijderen
  3. Natuurlijk! De code is vrij voor een ieder om te gebruiken in zijn/haar toepassingen, een vermelding is aardig, maar absoluut niet verplicht. Hoe meer mensen zich beschermen tegen dit soort spam, hoe minder effectief/lucratief het zal zijn voor de spammers!

    BeantwoordenVerwijderen
  4. Thanks! Vermelding komt er altijd bij, je naam ratio kan bijzonder interessant wezen ..

    Mocht je een aggresieve spam lijst die ik voor mijn mod ter beschikking stel willen inzien, bekijk m hier: Antispam woorden lijst

    BeantwoordenVerwijderen
  5. Haha, ja, ik ben nog een programmeur van de "oude garde" ;) Maar ik zal er in het vervolg even om denken :D Een echt goede reden heb ik er dus niet voor..

    BeantwoordenVerwijderen
  6. [...] voor phpBB forum’s een prachtige antispam plugin gemaakt, welke onder andere gedeeltes uit mijn vorige tutorial gebruikt, maar nog veel meer nuttige toevoegingen heeft en ook actief onderhouden wordt! Meer info [...]

    BeantwoordenVerwijderen