Lance Bay: hoe ik een kleine webapp veilig op internet heb gezet
Lance Bay begon klein. Ik hield mijn collectie BattleTech-miniaturen bij in een Excel-sheet, en dat werd onhandig zodra er meer mensen meekeken. Dus bouwde ik een webapp: collectie en schilderstatus bijhouden, lances samenstellen, potjes vastleggen, prijzen volgen. Eerst alleen voor thuis, daarna voor een paar bekenden, en inmiddels voor een groep spelers die sneller groeit dan ik had verwacht.
Op het moment dat je een app op internet zet en anderen er een account op aanmaken, verandert het karakter van een hobbyproject. Het gaat dan ook om de gegevens en het vertrouwen van andere mensen. In dit artikel loop ik langs de keuzes die ik daarvoor heb gemaakt, en waarom. Het is geen handleiding om Lance Bay aan te vallen: ik beschrijf de principes en maatregelen, niet de interne details waar een aanvaller iets aan heeft.
Het uitgangspunt: het internet is vijandig
De eerste regel die ik voor mezelf opschreef: de app gaat ervan uit dat alles wat binnenkomt vijandig kan zijn, en dat de laag ervoor alleen doorgeeft. Er staat een proxy voor (daarover zo meer), maar de app vertrouwt daar niet op. Elke controle die ertoe doet, zit in de app zelf: authenticatie, autorisatie, invoercontrole, limieten. Valt de buitenste laag weg of is die verkeerd ingesteld, dan moet de app nog steeds overeind blijven.
Dat klinkt vanzelfsprekend, maar ik zie in de praktijk vaak het omgekeerde: “het staat achter de WAF” als reden om in de applicatie minder streng te zijn. Een WAF is een extra laag, geen vervanging.
Minder onderdelen, minder aanvalsoppervlak
Een van de belangrijkste keuzes was om de app zo kaal mogelijk te houden:
- Alleen de standaardbibliotheek van Python aan de serverkant. Geen webframework, geen pakketten van PyPI.
- Geen bouwstap aan de voorkant. Gewone JavaScript- en CSS-bestanden, zonder framework en zonder npm.
- SQLite als opslag: één bestand, geen databaseserver die apart beveiligd moet worden.
Het grootste voordeel is supply chain. Elke afhankelijkheid is code van iemand anders die meedraait met de rechten van je app, en die je moet bijhouden. De incidenten met gecompromitteerde npm- en PyPI-pakketten van de afgelopen jaren laten zien hoe reëel dat risico is. Zonder externe pakketten valt die hele categorie weg. Het nadeel is ook duidelijk: dingen die een framework gratis geeft, zoals sessiebeheer, CSRF-bescherming en het escapen van uitvoer, moet je zelf goed doen. Daar komt de rest van dit artikel op neer.
De container: zo weinig rechten als mogelijk
Lance Bay draait als één Docker-container op eigen hardware. Die container heb ik zo dichtgezet als ik kon:
- Geen open poort. De container luistert alleen in een eigen Docker-netwerk. Van buiten is hij uitsluitend bereikbaar via een Cloudflare Tunnel, die vanuit mijn kant uitgaand wordt opgezet. Er staat dus geen poort open in de firewall, en het IP-adres van de server is niet zichtbaar.
- Alleen-lezen bestandssysteem. De code is read-only gekoppeld, net als de rest van het containerbestandssysteem. Alleen de map met gegevens is beschrijfbaar, plus een kleine tmpfs voor tijdelijke bestanden.
- Geen capabilities. Alle Linux-capabilities gaan eruit (
cap_drop: ALL), metno-new-privileges. Het proces start kort met een paar rechten om de eigenaar van de gegevensmap goed te zetten, en schakelt daarna over naar een gewone gebruiker. - Harde grenzen. Een limiet op geheugen en op het aantal processen. Een fout of aanval die resources opslokt, raakt dan de container, niet de rest van de machine.
- Een eigen netwerk. Alleen de app en de tunnel zitten in dat netwerk. Dat is belangrijk voor de volgende maatregel.
Achter een proxy krijg je het IP-adres van de bezoeker via een header zoals X-Forwarded-For. Die header kan iedereen meesturen. De app vertrouwt hem daarom alleen als het verzoek van het adres van de tunnel komt. Zonder die controle kan een aanvaller elk IP-adres opgeven en daarmee limieten per IP-adres omzeilen of de logboeken vervuilen. Daarom staat er in dat netwerk niets anders: wat erin zit, zou zich voor de tunnel kunnen uitgeven.
Inloggen: geen account zonder tweede factor
Accounts zijn besloten: je vraagt er een aan en ik keur het goed. Daarna geldt:
- Wachtwoorden worden opgeslagen als scrypt-hash met een eigen salt per account. Scrypt is bewust geheugenintensief, wat kraken met GPU’s duur maakt. Omdat dat geheugen ook voor de server zelf telt, mag maar een beperkt aantal berekeningen tegelijk lopen. Anders wordt het inlogscherm een manier om de server plat te leggen.
- 2FA is verplicht. Iedereen stelt bij de eerste keer inloggen TOTP in. Een code werkt maar één keer, zodat een onderschepte code niet nog eens bruikbaar is.
- Passkeys zijn sinds kort mogelijk als vervanging voor wachtwoord plus 2FA. Ik eis daarbij dat de gebruiker op het apparaat bevestigt met vingerafdruk, gezicht of pincode (“user verification”). De server controleert onder meer de herkomst, de challenge (eenmalig en kort geldig) en de teller van de sleutel. Een passkey is gebonden aan het domein, dus een phishingsite op een ander domein kan er niets mee. Dat is de grootste winst ten opzichte van TOTP, zoals ik ook schreef bij device code phishing.
- Sessies zijn 32 willekeurige bytes. De database bewaart alleen een hash, dus wie de database leest, heeft daarmee geen bruikbare sessie. Het cookie heeft het voorvoegsel
__Host-en de vlaggenHttpOnly,SecureenSameSite=Strict. Een sessie verloopt na een week niet gebruiken en hoe dan ook na een maand. Wie zijn wachtwoord wijzigt, logt alle andere sessies uit.
Voor een paar ingrijpende handelingen vraagt de server opnieuw om een verse 2FA-code, ook als je al bent ingelogd. Denk aan het verwijderen van je account of het installeren van een update. Een gestolen sessie is daarmee niet genoeg voor de handelingen die er het meest toe doen.
Foutmeldingen maken geen onderscheid tussen een onbekende gebruiker en een fout wachtwoord. Ook contacten zoek je niet op gebruikersnaam, maar via een persoonlijke code die de ander met je deelt. Zo valt niet te achterhalen welke accounts er bestaan.
Autorisatie: de server beslist, altijd
Er zijn rollen (beheerder, gebruiker, gast die alleen meekijkt) en per account een lijst onderdelen die aan of uit staan. Die controles gebeuren uitsluitend op de server, bij elke route. Wat de browser verbergt, is alleen gemak. Een knop die niet zichtbaar is, betekent niet dat de bijbehorende API-aanroep geweigerd wordt; dat moet de server zelf doen.
Een keuze waar ik bewust over heb nagedacht: een deel blijft privé, ook voor de beheerder. De lances van anderen kan ik als beheerder niet inzien. Een beheerdersaccount is het meest aantrekkelijke doelwit van de app, en hoe minder het kan zien, hoe kleiner de schade als het ooit wordt overgenomen.
De browser als laatste verdedigingslinie
Aan de voorkant draait een strikte Content-Security-Policy: default-src 'none', scripts en opmaak alleen van de eigen site, en geen inline code. Dat laatste kost discipline: geen onclick in HTML, geen <script>-blokken in de pagina, geen inline stijlen. Maar het betekent dat een XSS-fout, als die er ooit in sluipt, veel moeilijker te misbruiken is: geïnjecteerde inline code wordt niet uitgevoerd. Deze site, mpeeters.nl, gebruikt om dezelfde reden een strikte CSP.
Daarnaast de gebruikelijke headers: X-Content-Type-Options: nosniff, frame-ancestors 'none' tegen clickjacking, Referrer-Policy: no-referrer en een Permissions-Policy die camera, microfoon en locatie uitzet. Teksten van gebruikers worden als tekst in de pagina gezet, niet als HTML.
Tegen CSRF heb ik twee maatregelen naast het SameSite=Strict-cookie. Een wijzigend verzoek moet een Origin-header hebben die overeenkomt met het adres van de app, en de inhoud moet application/json zijn. Een gewoon HTML-formulier op een andere site kan geen JSON met de juiste header versturen.
Invoer: weigeren wat je niet verwacht
Elke invoer wordt gecontroleerd op vorm, lengte, toegestane tekens en aantallen. Onbekende velden worden geweigerd in plaats van genegeerd. Dat voelt streng, maar het voorkomt een hele klasse fouten waarbij een extra veld ongemerkt wordt opgeslagen of doorgegeven. Databasequeries gebruiken altijd parameters, nooit aan elkaar geplakte invoer.
Foto-uploads krijgen een maximale grootte, en het bestandstype bepaal ik aan de eerste bytes van het bestand, niet aan de extensie of aan wat de browser opgeeft. Er zijn limieten per miniatuur, per collectie en op de vrije schijfruimte, zodat niemand de schijf kan volschrijven.
Uitgaande verzoeken: een vaste lijst
Lance Bay haalt zelf gegevens op: de catalogus van miniaturen, spelgegevens, en prijzen bij een aantal webwinkels. Een server die op verzoek van gebruikers URL’s ophaalt, is een klassiek risico op server-side request forgery (SSRF): een aanvaller laat de server dan een adres in het interne netwerk opvragen.
Daarom gaan uitgaande verzoeken alleen naar een vaste lijst domeinen, alleen via https op de standaardpoort. Een doorverwijzing wordt alleen gevolgd naar https op hetzelfde domein, nooit naar een ander adres of naar gewoon http. Elk antwoord heeft een maximale omvang en een maximale totale duur. De prijsvolger respecteert bovendien robots.txt van de winkels en doet rustig aan per winkel. Een kwestie van fatsoen, en het voorkomt dat de server ergens wordt geblokkeerd.
Updates: ook de beheerder vertrouw ik niet blindelings
Updates installeer ik vanuit de app zelf, door een pakket te uploaden. De server controleert dat pakket streng: geldige paden, alleen bekende bestandstypen, verplichte bestanden, een hoger versienummer dan wat er draait, en grenzen aan omvang en aantal bestanden. Gaat de nieuwe versie bij het opstarten herhaaldelijk mis, dan valt de server automatisch terug op de vorige, ingebouwde versie.
De belangrijkste maatregel zit een laag dieper: de server kan eisen dat updates ondertekend zijn. Elk pakket bevat een Ed25519-handtekening over de lijst van alle bestanden met hun hash. De server kent alleen de publieke sleutel, en die staat in de configuratie van de container, buiten bereik van de app. De privésleutel staat nooit op de server.
De reden: een functie die code uploadt en uitvoert, is per definitie de gevaarlijkste functie van een app. Zonder ondertekening zou iemand die mijn beheerdersaccount overneemt, via de updatefunctie eigen code op de server kunnen zetten. Met verplichte ondertekening is een overgenomen account niet genoeg: zonder de privésleutel wordt elk pakket geweigerd. Dat is hetzelfde principe als code signing voor PowerShell-scripts, toegepast op een webapp.
Privacy: alleen bewaren wat nodig is
Omdat de app in de EU draait en gegevens van andere mensen bevat, valt hij onder de AVG. Lance Bay gebruikt geen trackers, geen advertenties en geen scripts van derden in de browser. Er is één cookie, voor de sessie. Voor elke soort gegevens heb ik een bewaartermijn vastgelegd: sessies en accountaanvragen verlopen vanzelf, statistieken worden na een vaste periode verwijderd, en het IP-adres wordt daarin niet bewaard. Wie zijn account verwijdert, raakt ook zijn collectie, foto’s, contacten en berichten kwijt. Dat staat allemaal op een privacypagina in de app, in gewone taal.
Controleren of het klopt
Maatregelen bedenken is één ding, controleren of ze werken iets anders. Ik doe daarom regelmatig een eigen beveiligingsonderzoek van de code: autorisatie per rol, CSRF, XSS, SSRF, uitputting van geheugen, threads en schijf, en de containerinstellingen. Elke bevinding leg ik vast met ernst en oplossing. Voor de opgeloste bevindingen staan er tests die de oorspronkelijke aanval naspelen, zodat een latere wijziging het lek niet ongemerkt terugbrengt.
Twee voorbeelden van bevindingen die inmiddels zijn opgelost, omdat ze laten zien waar je bij zo’n app niet direct aan denkt:
- Een trage reguliere expressie. Het uitlezen van winkelpagina’s gebruikte patronen die bij bepaalde kapotte HTML kwadratisch traag werden. Een gemanipuleerde pagina van een paar megabyte kon de server minutenlang bezighouden. Een winkel die gehackt wordt, of een aanvaller die daar een pagina plaatst, kon Lance Bay zo platleggen. Het uitlezen gaat nu lineair. De les: ook gegevens van “betrouwbare” bronnen zijn invoer.
- Een lek in de verbindingsplekken. Lukte het niet om een thread te starten, dan raakte de server die verbindingsplek kwijt. Na genoeg keer nam hij geen verbindingen meer aan. Precies het soort fout dat je alleen vindt als je bewust tegen de grenzen van je eigen container aan gaat duwen.
Daarnaast is er een security.txt volgens RFC 9116, zodat iemand die iets vindt, weet waar hij het kan melden.
Wat ik bewust niet beschrijf
Dit artikel gaat over principes. Interne details laat ik weg: hoe de infrastructuur precies is ingericht, de exacte drempels van de limieten, en punten die nog op mijn lijst staan. Niet omdat de beveiliging op geheimhouding leunt, want dat zou een slecht ontwerp zijn. Maar het heeft geen zin een aanvaller het werk makkelijker te maken.
Wat ik eruit meeneem
- Begin klein en houd het klein. Elke afhankelijkheid die je niet hebt, hoef je niet bij te houden en kan niet worden gecompromitteerd.
- De server beslist. De browser is voor gemak, niet voor beveiliging.
- Ga ervan uit dat accounts worden overgenomen, ook dat van de beheerder, en zorg dat de schade dan beperkt blijft. Verplichte 2FA, een nieuwe code voor ingrijpende handelingen en ondertekende updates doen dat elk op hun eigen niveau.
- Wat je ophaalt, is ook invoer. SSRF, trage regexen en te grote antwoorden komen allemaal van “vertrouwde” bronnen.
- Test je eigen grenzen. De interessantste fouten vond ik niet door code te lezen, maar door de container bewust te overbelasten.
Een hobbyproject wordt geen enterprise-applicatie. Maar de principes zijn dezelfde als in mijn dagelijkse werk, en het is leerzaam om ze een keer van begin tot eind zelf toe te passen.
Bronnen
- OWASP Application Security Verification Standard (ASVS)
- OWASP Cheat Sheet: Server-Side Request Forgery Prevention
- MDN: Content Security Policy (CSP)
- MDN: Set-Cookie, cookie prefixes
- W3C: Web Authentication (WebAuthn) Level 2
- RFC 6238: TOTP
- RFC 9116: security.txt
- Docker: Docker Engine security
- Cloudflare: Cloudflare Tunnel