Subscription bombing op formulieren voorkomen
Voorkom dat uw aanmeldformulieren worden misbruikt voor subscription bombing. Implementeer rate limits en confirmed opt-in om spam en overbelasting van uw server te stoppen.
Wat is subscription bombing?
Subscription bombing is een aanval waarbij uw aanmeldformulier wordt gebruikt om de inbox van iemand anders te overspoelen. De aanvaller neemt het e-mailadres van een slachtoffer en voert dit in een kort tijdsbestek in bij honderden of duizenden onbeveiligde formulieren. Elke site stuurt vervolgens een welkomstbericht of een bevestigingsmail naar dat adres. Gezamenlijk verbergen deze berichten de e-mail die het slachtoffer daadwerkelijk moet lezen.
Het doelwit is de eigenaar van die inbox. Terwijl deze volstroomt met abonnementsbevestigingen, geeft de aanvaller geld uit met de creditcard van die persoon of stelt hij een wachtwoord van een van diens accounts opnieuw in. De fraudewaarschuwing van de bank komt nog steeds aan. Deze komt echter terecht onder tweeduizend andere berichten die in hetzelfde uur zijn binnengekomen, waardoor niemand de waarschuwing op tijd ziet.
Uw server is het instrument waarmee de aanval wordt uitgevoerd. Er is niets defect aan uw systeem. Er is geen account van u gecompromitteerd. Iemand heeft een adres ingevoerd in een openbaar formulier en uw software deed waarvoor deze is geschreven: het verstuurde e-mail naar dat adres. Dat is precies waarom dit lastig op te merken is. Er is geen inbraak te zien in uw logs, omdat er geen sprake was van een inbraak.
Hoe de aanval er vanaf uw kant uitziet
Deze komt in twee vormen voor.
De opvallende vorm is een plotselinge piek. Enkele honderden POST-verzoeken bereiken binnen enkele minuten één formulier, afkomstig van vele verschillende bron-IP-adressen, met e-mailadressen van domeinen waar u nog nooit eerder naar heeft verzonden. Dit is eenvoudig te zien zodra u ernaar kijkt.
De stille vorm is degene die vaak wordt gemist. De aanvaller beschikt over een lijst met duizenden kwetsbare formulieren, waardoor uw formulier slechts één of twee inzendingen per uur hoeft te verwerken. Jye Cusch beschreef een aanval van precies deze vorm op een site die hij beheert: geen piek in het verkeer, maar gestage aanmeldingen op uren die niet overeenkwamen met zijn doelgroep. Een enkel formulier ziet er onschuldig uit omdat het bijna niets doet. De schade is de som van alle formulieren op de lijst van de aanvaller.
Beide vormen delen achteraf één kenmerk: er gebeurt daarna niets meer. De adressen bevestigen nooit. Ze openen nooit een bericht en klikken nooit op een link. Op een lijst met bevestigde opt-ins blijven ze voor altijd op status unconfirmed staan, en die stapel is het duidelijkste bewijs dat u zult krijgen.
Begin met het tellen van het aantal inzendingen per minuut in uw access log.
sudo awk '/POST \/subscription\/form/ {print substr($4, 2, 17)}' \
/var/log/nginx/access.log | uniq -c | sort -rn | head$4 in het standaard combined log format is de tijdstempel tussen haakjes, dus dit commando print een telling voor elke minuut, met de hoogste aantallen eerst. Een formulier dat normaal gesproken vier aanmeldingen per dag krijgt en er zestig in één minuut toont, heeft geen normale dag.
Confirmed opt-in: de verdediging met het grootste effect
Confirmed opt-in, meestal double opt-in genoemd, betekent dat een adres pas een abonnee is nadat een persoon op een link heeft geklikt in een bericht dat naar dat adres is verzonden. Schakel dit in en één ingediend adres levert precies één bericht op, en nooit meer. Het adres wordt nooit aan de lijst toegevoegd, dus het ontvangt nooit een campagne of een welkomstreeks.
In listmonk, de self-hosted nieuwsbriefserver is dit een instelling per lijst: een lijst is single opt-in of double opt-in. De documentatie is duidelijk over het verschil. Op een double opt-in lijst "accepteren abonnees expliciet het abonnement door op de bevestigingsmail te klikken die zij ontvangen. Tot die tijd ontvangen zij geen campagnemessages." Een abonnee bevindt zich op unconfirmed, verplaatst naar confirmed na de klik, en alleen confirmed abonnees op een opt-in lijst ontvangen campagnemail.
Wees eerlijk over wat dit u oplevert. Confirmed opt-in brengt uw bijdrage niet terug naar nul. Het beperkt het tot één bericht per adres. Het slachtoffer ontvangt dat bericht nog steeds, en één bericht van elk van duizend sites vormt de volledige aanval. Wat confirmed opt-in verwijdert, is alles wat daarna komt: uw lijst blijft schoon en u verstuurt nooit een tweede bericht naar iemand die nooit om het eerste heeft gevraagd.
Twee andere instellingen zijn van belang en beide worden gemakkelijk vergeten. Ten eerste: beperk het opnieuw versturen van de bevestiging. Als hetzelfde adres opnieuw kan worden ingediend en telkens een nieuwe bevestigingsmail krijgt, heeft de aanvaller geen duizend formulieren nodig, omdat uw formulier alleen al duizend berichten zal versturen. Een adres dat al op unconfirmed op die lijst staat, zou gedurende ten minste een dag niets meer moeten ontvangen. Ten tweede: verwijder onbevestigde rijen volgens een schema. Een adres dat na dertig dagen niet heeft bevestigd, is geen abonnee in afwachting. Het bewaren ervan creëert alleen de kans dat er later per ongeluk iets naartoe wordt gemaild.
Rate-limiting van het aanmeldformulier op de reverse proxy
Plaats de limiet vóór de applicatie in plaats van erin. Een verzoek dat bij de proxy wordt geblokkeerd, opent nooit een databaseverbinding en start nooit een SMTP-sessie (simple mail transfer protocol). Een limiet binnen de applicatie wordt pas uitgevoerd nadat het verzoek al een worker-proces en een query heeft verbruikt. In veel stacks wordt het bericht bovendien in de wachtrij geplaatst voordat enige misbruikcontrole plaatsvindt. De proxy-limiet blijft ook behouden tijdens een applicatie-upgrade, omdat deze niet in de code staat die u vervangt.
Het onderstaande voorbeeld is voor nginx. Het principe is toepasbaar op elke reverse proxy die u voor uw app gebruikt, al verschillen de namen van de richtlijnen.
Plaats dit in het http-blok, in een bestand zoals /etc/nginx/conf.d/signup-limit.conf:
map $request_method $signup_key {
POST $binary_remote_addr;
default "";
}
limit_req_zone $signup_key zone=signup:10m rate=2r/m;
limit_req_status 429;
limit_req_log_level warn;De map verricht het eigenlijke werk. nginx telt geen verzoek waarvan de sleutel een lege string is, dus alleen POST-verzoeken komen in de zone terecht. Een bezoeker die de aanmeldpagina meerdere keren laadt, verbruikt niets. Zonder die map zou iemand die de pagina twee keer ververst het eigen quotum verbruiken voordat er überhaupt iets is ingediend.
$binary_remote_addr is het clientadres in gecomprimeerde vorm, waardoor een zone van 10 megabyte ongeveer 160.000 adressen kan bevatten. rate=2r/m staat één inzending per dertig seconden toe. limit_req_status 429 retourneert HTTP 429 Too Many Requests in plaats van de standaard 503 van nginx; dit is de correcte statuscode die een clientbibliotheek verwacht.
Plaats vervolgens in het server-blok voor uw site:
location = /subscription/form {
limit_req zone=signup burst=3 nodelay;
proxy_pass http://127.0.0.1:9000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}burst=3 nodelay staat toe dat iemand die dubbelklikt op de knop alsnog wordt toegelaten, en wijst het vierde verzoek direct af in plaats van het in de wachtrij te plaatsen.
sudo nginx -t && sudo systemctl reload nginxnginx -t zou configuration file /etc/nginx/nginx.conf test is successful moeten afdrukken. Dien het formulier nu vijf keer snel achter elkaar in en controleer het foutenlogboek:
sudo tail -f /var/log/nginx/error.logEen geblokkeerd verzoek schrijft één regel weg, en dit is de string waar u naar zoekt:
2026/08/13 09:14:22 [warn] 812#812: *4412 limiting requests, excess: 3.400 by zone "signup", client: 203.0.113.10, server: news.example.com, request: "POST /subscription/form HTTP/1.1", host: "news.example.com"Als er helemaal geen regel verschijnt, wordt de limiet niet toegepast. De gebruikelijke oorzaak is dat limit_req zich in een location-blok bevindt dat het verzoek nooit bereikt. Controleer dit met curl -si -X POST https://news.example.com/subscription/form een paar keer achter elkaar en bevestig dat u een 429 ontvangt.
Er zijn twee valkuilen waar u rekening mee moet houden voordat u vertrouwt op een limiet per IP-adres.
Achter een CDN of andere proxy is $binary_remote_addr die proxy. Elke bezoeker komt in dezelfde groep terecht, waardoor de eerste paar inzendingen per minuut iedereen anders buitensluiten. Los dit op met de real IP-module: set_real_ip_from voor elk van de gepubliceerde bereiken van uw CDN (Cloudflare vermeldt de hunne op cloudflare.com/ips) en real_ip_header CF-Connecting-IP. Bevestig de oplossing door $remote_addr in uw toegangslogboek te lezen en te controleren of dit een bezoekersadres is in plaats van dat van uw CDN.
IPv6 maakt een limiet per adres zwak. $binary_remote_addr bevat de volledige /128, en een residentiële IPv6-toewijzing is meestal een /64 of groter. Dat zijn veel meer adressen dan een aanvaller kan benutten, elk met een eigen schoon quotum. Voeg een tweede zone toe als plafond op het eindpunt zelf, gebaseerd op een constante, zodat het formulier een totaal limiet heeft ongeacht hoeveel bronadressen er worden gebruikt:
map $request_method $signup_total_key {
POST "signup";
default "";
}
limit_req_zone $signup_total_key zone=signup_total:1m rate=30r/m;Voeg limit_req zone=signup_total burst=10 nodelay; toe aan hetzelfde location. Stel de limiet in boven uw drukste werkelijke uur, met enige marge. Dit is een botte maatregel: tijdens een aanval worden ook echte aanmeldingen geweigerd. Dat is de juiste afweging, want het alternatief is dat uw server de mail verstuurt.
Waarom de limiet per adres niet op de proxy kan worden toegepast
Het e-mailadres komt binnen in de POST-body en nginx parseert geen request-bodies. Elke variabele waar limit_req_zone op kan filteren, is afkomstig van de request-regel, de headers of de verbinding. Een regel zoals "dit adres mag maximaal één bevestiging per dag ontvangen" moet daarom worden geplaatst in de eerste component die de body leest: uw applicatie.
Probeer dit niet te omzeilen door het adres in de query-string te plaatsen zodat $arg_email beschikbaar wordt. Hiermee schrijft u het adres van elke abonnee in leesbare tekst naar uw access-log en naar elke log-shipper die daarop volgt. U ruilt dan een rate-limit in voor een privacyprobleem.
Er is één echte uitzondering. De nginx JavaScript-module, njs, kan de request-body lezen en daarop een variabele instellen, waardoor u wel een sleutel per adres op de proxy kunt bouwen. Dit is een reële optie, maar het voegt ook nieuwe code toe aan uw request-pad. Voor de meeste sites hoort de limiet per adres thuis bij de database die al weet of dit adres een openstaande bevestiging heeft, terwijl de proxy de limieten per IP en per endpoint afhandelt waarvoor deze is ontworpen.
Herhaal de ingediende tekst niet in het bericht
Houd elke door een aanvaller ingevoerde string buiten het bericht dat u verstuurt. Hiervoor zijn twee afzonderlijke redenen, die beide in de praktijk worden misbruikt.
Als uw bevestigingsmail de lezer begroet met een naam die uit het formulier is gehaald, voert de aanvaller zijn bericht in het naamveld in. Uw server bezorgt die tekst vervolgens bij het slachtoffer, vanaf uw domein, ondertekend met uw DKIM (DomainKeys Identified Mail)-sleutel. Uw site is daarmee een bezorgdienst geworden voor misbruik door derden, en de ontvangende provider ziet uw domein op het bericht staan.
De tweede reden is ernstiger. Als een ingediend veld handmatig wordt samengevoegd in een mailheader, voegt een newline-karakter in dat veld headers toe die de aanvaller zelf kiest, inclusief Bcc. Moderne mailbibliotheken weigeren newlines in headervelden. Code die tekst naar sendmail sluist vanuit een shell-script doet dit vaak niet.
Een veilig bevestigingsbericht bevat uw sitenaam en één link, met één zin uitleg. Het adres zelf verschijnt alleen waar de mail transfer agent het nodig heeft, in de To header. Test dit: verstuur het formulier met een naamveld dat een newline en een duidelijke link bevat, lees daarna het ruwe bericht dat u ontvangt met less en controleer of geen van beide is overgenomen.
Nu u toch bezig bent, zorg er dan voor dat de succespagina voor elk adres hetzelfde aangeeft. Een pagina die voor het ene adres "u bent al geabonneerd" zegt en voor het andere "controleer uw inbox", maakt van uw formulier een lidmaatschapscontroleur voor iedereen die een lijst met adressen wil testen.
Welke bot-controle moet u gebruiken?
Kies voor toegankelijkheid met dezelfde zorgvuldigheid als voor effectiviteit. Een captcha waarbij afbeeldingen geselecteerd moeten worden, kan niet worden opgelost door een blinde gebruiker, en het audio-alternatief is lastig voor mensen met een gemiddeld gehoor. Een controle die een legitieme gebruiker ervan weerhoudt zich aan te melden, is een verdediging die ook kosten met zich meebrengt. Hier zijn vier opties, in de volgorde waarin u ze kunt proberen.
Proof of work in de browser. De browser berekent een hash die de server goedkoop kan verifiëren; er is geen actie vereist van de gebruiker. listmonk biedt dit aan onder Settings, gevolgd door Security, met behulp van ALTCHA, waarvoor geen externe dienst nodig is. Sinds augustus 2026 is dit de aanbeveling van listmonk boven de inmiddels verouderde hCaptcha-optie. De kosten liggen bij degene die de meeste verzoeken verstuurt: de aanvaller.
Een beheerde niet-interactieve controle. Cloudflare Turnstile toont de meeste bezoekers niets en voert alleen een controle uit wanneer de signalen verdacht zijn. Het is effectief, maar plaatst wel een derde partij in uw aanmeldproces.
Een honeypot-veld. Een tekstveld dat een gebruiker nooit ziet, maar dat een eenvoudige bot wel invult. Geef het een naam die uw formulier verder niet gebruikt en stel autocomplete="off", tabindex="-1" en aria-hidden="true" in, zodat een wachtwoordbeheerder het niet invult en een schermlezer het niet aankondigt. Een veld met de naam email2 of address wordt automatisch ingevuld door de browser, waardoor u legitieme gebruikers onterecht blokkeert.
<div style="position:absolute; left:-9999px;" aria-hidden="true">
<label for="hp_ref">Leave this field empty</label>
<input type="text" id="hp_ref" name="hp_ref" autocomplete="off" tabindex="-1">
</div>Een controle op de invultijd. Plaats een ondertekende tijdstempel in een verborgen veld wanneer de pagina wordt geladen en wijs inzendingen af die minder dan twee seconden later binnenkomen. Een mens kan een formulier niet zo snel lezen en een adres typen. Onderteken de tijdstempel, anders stuurt de bot simpelweg een oude waarde mee.
Eén punt om te verifiëren, ongeacht uw keuze: het token mag slechts eenmaal worden gebruikt. Als een script de controle één keer kan oplossen en dat token vervolgens duizend keer kan hergebruiken voor verschillende adressen, bewijst de controle alleen dat er eenmaal een browser is uitgevoerd en niets meer.
Hoe komt u hierachter voordat de abuse-melding binnenkomt?
U wilt dat uw eigen grafieken u waarschuwen, niet de abuse-afdeling van een hostingprovider. Houd twee zaken in de gaten.
Tel het aantal inzendingen per bronadres in het logbestand:
sudo awk '/POST \/subscription\/form/ {print $1}' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn | head -20Laat vervolgens fail2ban dezelfde limiting requests-regels lezen die nginx al schrijft, en blokkeer de veelplegers. fail2ban wordt geleverd met een filter dat precies hiervoor bedoeld is. Maak /etc/fail2ban/jail.d/nginx-limit-req.local aan:
[nginx-limit-req]
enabled = true
filter = nginx-limit-req
port = http,https
logpath = /var/log/nginx/error.log
findtime = 600
maxretry = 10
bantime = 3600sudo systemctl reload fail2ban
sudo fail2ban-client status nginx-limit-reqDe statusuitvoer toont het filter van de jail en het huidige aantal mislukte pogingen en blokkades. Currently banned: 0 op een rustige dag is correct. Als de jail helemaal niet verschijnt, heeft fail2ban het bestand nooit geladen en toont sudo fail2ban-client -d | grep nginx-limit-req de configuratie die daadwerkelijk is ingelezen. Het meegeleverde filter komt overeen met elke limit_req-zone. Beperk dit tot uw aanmeldingszone door ngx_limit_req_zones = signup in te stellen in een [Definition]-sectie van /etc/fail2ban/filter.d/nginx-limit-req.local. De structuur van het jail-bestand en de blokkeercommando's worden uitgebreider behandeld in de fail2ban-handleiding voor Ubuntu 24.04.
Het tweede signaal is een ratio waarvoor geen nieuwe software nodig is: het aantal inzendingen gedeeld door het aantal bevestigingen. Bij een gezonde lijst klikt het merendeel van de mensen die een adres indienen op de link, meestal ruim meer dan de helft. Wanneer die ratio instort terwijl het aantal inzendingen stijgt, wordt u misbruikt. Vergelijk het aantal unconfirmed-abonnees dat in het afgelopen uur is aangemaakt met het aantal confirmed-abonnees, volgens het schema waarop u uw rapporten al draait.
Wat het u kost: afzenderreputatie en blocklists
Dit is het punt waarop een ergernis verandert in een kostenpost.
De adreslijsten die worden gebruikt voor bombing zijn verzameld (harvested), en verzamelde lijsten bevatten spamtraps: adressen die zich nergens voor hebben aangemeld en enkel zijn gepubliceerd om afzenders te vangen die mailen zonder toestemming. Uw bevestigingsbericht bereikt er een. Sommige beheerders van blocklists hebben aan niets meer dan dat genoeg.
Ontvangers die nooit om uw bericht hebben gevraagd, klikken niet op afmelden. Zij klikken op "spam rapporteren". De regels van Google voor bulkverzenders, die sinds februari 2024 van kracht zijn, schrijven voor dat verzenders van 5.000 of meer berichten per dag naar Gmail het percentage gerapporteerde spam in Postmaster Tools onder de 0,3% moeten houden. Een kleinere verzender wordt niet aan dat getal gemeten, maar hetzelfde klachtsignaal voedt de filterbeslissingen die uw mail in de spammap doen belanden. De valse adressen in de run veroorzaken ook hard bounces, en een stijgend percentage hard bounces is op zichzelf een reputatiesignaal bij elke grote provider.
Als u uw eigen mailserver op een VPS met mailcow beheert, komt de notering terecht op uw IP-adres en uw domein. Delisting bij een operator zoals Spamhaus betekent een formulier invullen en wachten, en terwijl u wacht, worden ook uw facturen en wachtwoordresets niet afgeleverd. Als u via een gedeelde provider verstuurt, kunt u verwachten dat zij eerst uw account schorsen en daarna pas uw uitleg lezen, omdat uw verkeer een risico vormt voor elke andere verzender op dat IP-adres.
Daartegenover staat dat het werk minimaal is. Schakel vandaag nog confirmed opt-in in, want het is één instelling per lijst. Voeg daarna de proxy rate limit toe, want het is één bestand en een reload. De bot-check en de alarmering kunnen deze week volgen.
FAQ
Voorkomt double opt-in subscription bombing?
Het voorkomt dat uw lijst vervuild raakt en beperkt uw bijdrage tot één bericht per ingediend adres; dit is de grootste verbetering die u kunt doorvoeren. Het stopt echter niet dat de inbox van het slachtoffer volstroomt, omdat de aanval bestaat uit de som van één bericht van elk van duizend verschillende sites. Combineer dit met een rate limit per IP-adres op uw proxy en een limiet op het opnieuw versturen van bevestigingen, zodat hetzelfde adres dat tweemaal wordt ingediend niet tot een tweede bericht leidt.
Hoe onderscheid ik een aanval van een goede dag met echte aanmeldingen?
Kijk naar wat er gebeurt na de indiening. Echte aanmeldingen bevestigen hun inschrijving, meestal binnen enkele uren. Een aanval laat een stapel adressen achter die nooit bevestigen, nooit openen en nooit klikken. De indieningen vertonen ook vreemde patronen: veel bronadressen die u nooit eerder heeft gezien, domeinen van ontvangers waar u normaal niet naar verzendt, en aankomsttijden die gelijkmatig over de hele dag verspreid zijn in plaats van de dagelijkse ritmes van uw doelgroep te volgen.
Moet ik de ingediende adressen verwijderen?
Ja. Verwijder onbevestigde records die ouder zijn dan ongeveer dertig dagen en doe dit volgens een vast schema in plaats van handmatig. Stuur deze adressen nooit iets anders, inclusief een verontschuldiging of een "was u dit?"-bericht, omdat dit een tweede ongevraagd bericht is aan iemand die al bedolven is onder dergelijke e-mails. Als een van die adressen spamtraps waren, is een vervolgbericht precies de bevestiging waar de beheerder van de blocklist op wacht.
Zorgt rate limiting ervoor dat echte abonnees worden geweigerd?
Een limiet per IP-adres van één indiening per dertig seconden met een burst van drie is onzichtbaar voor een persoon die eenmaal een formulier invult. Het wordt pas zichtbaar wanneer veel echte mensen hetzelfde adres delen, zoals een kantoor achter één NAT (network address translation) gateway, of wanneer uw proxy het adres van uw CDN ziet in plaats van dat van de bezoeker. Lees $remote_addr in uw access log voordat u instellingen aanscherpt en houd de limiet voor het eindpunt boven uw drukste echte uur.
Mijn verzendende IP staat op een blocklist na een aanval. Wat moet ik eerst doen?
Stop met verzenden vanaf dat IP-adres voordat u actie onderneemt. Pauzeer de campagne-wachtrij, herstel het formulier en verwijder de onbevestigde adressen, omdat een delisting gevolgd door hetzelfde verkeer ertoe leidt dat u sneller opnieuw wordt geblokkeerd dan de eerste keer. Zoek vervolgens uit op welke lijst u staat, aangezien de meeste beheerders een opzoekpagina hebben op basis van uw IP-adres, en volg hun verwijderingsprocedure. Houd rekening met een wachttijd van enkele dagen en gebruik die tijd om te controleren of uw SPF (sender policy framework) record en uw DKIM-ondertekening nog steeds correct zijn.