Op mijn "hobby-site" www.prijs-bewust.nl werden nog wel een paar producten getoond, maar dat mag eigenlijk geen naam hebben. Die site gebruik ik voor mijn affiliate-connectie met BOL, ik toon daar artikelen van BOL en als iemand erop doorklikt en wat koopt, dan ontvang ik een kleine vergoeding. Rijk wordt je er niet van, vooral niet als je zoals ik deze hosting hebt (ongeveer 100 euro per maand) en je eigenlijk al een paar jaar "er wat aan moet gaan doen", maar er geen tijd voor hebt (andere prioriteiten). Nu ik mijn inkomsten en uitgaven wat beter in beeld heb mezelf een "schop onder mijn kont" gegeven om er mee aan de slag te gaan.
Wat is een PET project? Volgens de definitie die op Google staat is het een onbetaald project welke een developer thuis doet, na werktijd (dus in eigen tijd). Het hoeft geen relatie te hebben met het werk wat iemand doet (maar vaak heeft het daar wel betrekking op). En dat is hier ook zo. Laat ik de onderdelen even langs gaan.
Umbraco 10
Bij TRES werken we met het CMS Umbraco. Natuurlijk heb ik de certificering-cursussen gevolgd (en gehaald), maar zoals met alles: je leert het meeste door ermee te werken. Zet een nieuwe site op, ga de boel inrichten en kijk tegen welke problemen je aanloopt. Umbraco 10 is een .NET Core oplossing, de standaard database is een SQL Server database (dat kost mij nu nog even iets teveel doekoe), dus ik heb de site nu gekoppeld aan de noSQL-database die ook gebruikt kan worden. Omdat ik niet weet hoe die qua belasting e.d. werkt heb ik besloten om die database alleen voor de structuur te gebruiken en mijn data (producten, teksten) via een MariaDB database in de website te krijgen. Interface daarvoor doe ik via... Wordpress. Het klinkt als een nogal exotische implementatie.. en dat is het ook. Maar goed, daarvoor is het ook een PET-project, wat extra uitdagingen zijn altijd welkom.
Domain Driven Design
In 2021 (alweer 2 jaar geleden!) hebben we het boek Secure by Design behandeld. Om je code robuuster/veiliger te maken wordt geadviseerd om te werken met "Domain Driven Design". Je kunt alle samenvattingen op dit blog terug vinden, de uiteindelijke samenvatting staat hier: link. Dit schoot mij meteen in gedachten toen ik aan de slag ging met de (waarschijnlijk) meest gebruikte feature van deze website: het zoeken. Je kunt zoeken op mijn site en krijgt hopelijk een lijst met artikelen die voldoen aan wat je zoekt, waar je op kunt doorklikken om naar de pagina van BOL te gaan. Maar dat zoeken: de meeste developers zullen waarschijnlijk de tekst die je invoert klakkeloos doorsturen naar de API van BOL en de resultaten terug geven. Zonder te valideren of de ingevoerde tekst wel goed is. En dat heb ik dus niet gedaan.
Ik heb er een "SearchObject" van gemaakt. Eigenlijk is het "alleen maar een wrapper om een string", maar kijk wat ik doe:
public record SearchObject
{
public SearchObject()
{
SearchString = string.Empty;
}[MinLength(3)]
[MaxLength(60)]
[RegularExpression(@"([a-zA-Z0-9\s\-_+\?]+)")]
public string SearchString { get; set; }}
In .NET 6 mag / wil je eigenlijk niets meer "nullable" hebben. Daarom initialiseer je de string met een string.Empty.
De basis van validatie van de invoer volgens Secure by Design bestaat uit deze onderdelen:
- Oorsprong: is de data afkomstig van een legitieme bron? Dit deel sla ik "nog even over", ik doe zelfs een IgnoreAntiForgeryToken. Ga ik nog wel implementeren!
- Grootte: is de grootte te verklaren? Min- en MaxLength. Productnamen met minder dan 3 letters vind ik niet relevant. Hetzelfde geldt voor meer dan 60 tekens. Dit zijn "arbitraire grenzen", maar volgens mij acceptabel om misbruik tegen te gaan.
- Lexicale inhoud: bevat de inhoud de juiste tekens en encoding? De reguliere expressie die ik erop heb gezet accepteert niet zoveel tekens. Letters, cijfers, streepje, underscore, spatie, vraagteken. Een scriptkiddie met zijn <script>... komt er niet in!
- Syntax: klopt het format? Hier valideer ik volgens mij niet op. Zie zo ook niet exact wat ik hiermee op een "zoekwoord" kan doen.
- Semantiek: "slaat het ergens op"? Is voor mij "overkill", je wilt ergens op zoeken, voldoet de invoer aan de lengtes en regex, dan vind ik het prima.
In je UmbracoApiController kun je nu "simpel" dit doen:
public class SearchController : UmbracoApiController
{
[AllowAnonymous]
[HttpPost]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> Index(SearchObject searchObject)
{if (!ModelState.IsValid) {
return new ContentResult() { StatusCode = 200, Content = ""};
}
...
In je controller heb je de ModelState en daarmee kun je automatisch valideren. Geen zelfgemaakte extra (foutgevoelige) controles. Dikke prima.
Polly
Tijdens de .NET Conf 2020 kwam in bij een presentatie Polly tegen: link. Via nuget makkelijk aan je project toe te voegen. Ik wilde deze al toevoegen, maar de noodzaak werd iets hoger toen ik zelf tegen een fout aanliep. Een stuk javascript ging kapot, waardoor ik continu mijn "zoek API" aanriep. Het is natuurlijk niet de bedoeling dat ik de API van BOL ga spammen en daar zitten ook vast limieten op, dus de hoeveelheid requests moeten beperkt worden. Dat doe ik niet op user-basis, maar op "totaal aantal calls per minuut", 30 in mijn geval. Dan maar een melding dat je het over een minuut weer moet proberen.
Het voorbeeld van Polly zelf werkte bij mij niet, omdat je de Policy in de Controller aanmaakte. En die Controller wordt elke keer opnieuw geladen, dus elke call initialiseerde de Policy weer op 0.
Ik heb een werkende oplossing door de volgende code:
// Startup.cs
using Polly;
using Polly.RateLimit;
...
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<AsyncRateLimitPolicy>(Policy.RateLimitAsync(30, TimeSpan.FromSeconds(60), 1));
...
}
// Controller:
public class SearchController : UmbracoApiController
{
private readonly AsyncRateLimitPolicy _policy;public SearchController(AsyncRateLimitPolicy policy)
{
_policy= policy;
}...
public async Task<IActionResult> Index(SearchObject searchObject)
{
...
try
{
var accessToken = await GetAccessToken();
var interimResult = await _policy.ExecuteAsync(() => SearchProducts(accessToken, searchObject.SearchString));
...
return new ContentResult() { StatusCode = 200, Content = cacheData };
}
catch (RateLimitRejectedException ex)
{
return new ContentResult() { StatusCode = 429, Content = Convert.ToInt32(ex.RetryAfter.TotalSeconds).ToString() };
}
Hangfire
Ik registreer waarop gezocht wordt. Want als er nu bijvoorbeeld heel vaak gezocht wordt op "lego", misschien moet ik daar wel een aparte pagina voor maken. Maar dat opslaan in de database, ik wil niet dat de persoon die aan het zoeken is daarop moet wachten. Je kunt dat zelf met een BackgroundJob of iets dergelijks doen, maar Hangfire biedt dat "out of the box". Ik heb een statische class DataHelper met een statische functie RegisterSearch aangemaakt, die voert het werk uit en geef ik (dus) door aan Hangfire. Een tijd geleden ben ik samen met mijn collega Erwin bezig geweest met een project om een Gallery binnen Visual Studio te krijgen. Ook daar gebruiken we Hangfire, maar omdat dit een redelijk simpel pakket was, wilden we daar eigenlijk niet een SQL Server Database aan hangen. En daar was gelukkig een oplossing voor: HangFire.MemoryStorage. Dat werkte prima, dus dat heb ik ook hier gebruikt. Mijn implementatie:
// Startup.cs
using Hangfire;
using Hangfire.MemoryStorage;
...
public void ConfigureServices(IServiceCollection services)
{
services.AddHangfire(x => x.UseMemoryStorage());
services.AddHangfireServer();
...
}
// Controller
...
Hangfire.BackgroundJob.Enqueue(() => DataHelper.RegisterSearch(remoteIP, searchObject));
...
HTTP Headers
Als je met een standaard Umbraco installatie naar securityheaders.io gaat en een scan doet dan schrik je: daar krijg je de laagste score, een F. Ik heb daar al eens eerder een artikel aan gewijd: link.
Helemaal onderaan staat hoe ik het toen voor een andere site gebruikt had, dat gaf me een A+ score. Dezelfde items in de web.config geplaatst van deze Umbraco 10 installatie en wederom: een A+.
Maar... nu is de Content-Security-Policy ook geldig in de beheer-omgeving van Umbraco (dus op URL /umbraco ). Dat zorgt ervoor dat daar niets meer geladen wordt.
Ik kies hier even voor de "quick fix". Op de /umbraco URL schakel ik de CSP header uit. Natuurlijk moet eigenlijk ook daar de boel goed ingericht worden. Zodat niet "stiekem" een script ingeladen wordt wat je daar niet wilt. Dat kan omdat Umbraco 10 met de "location-tag" in de web.config werkt. Die van mij ziet er nu zo uit:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<location path="." inheritInChildApplications="false">
<system.webServer>
<rewrite>
<rules>
<rule name="HTTPS afdwingen" stopProcessing="true">
<match url="(.*)" />
<conditions>
<add input="{HTTPS}" pattern="off" ignoreCase="true" />
</conditions>
<action type="Redirect" url="https://{HTTP_HOST}{REQUEST_URI}" redirectType="Permanent" appendQueryString="true" />
</rule>
</rules>
</rewrite>
<httpProtocol>
<customHeaders>
<remove name="X-Powered-By" />
<add name="X-Frame-Options" value="DENY" />
<add name="X-Xss-Protection" value="1; mode=block" />
<add name="X-Content-Type-Options" value="nosniff" />
<add name="Referrer-Policy" value="no-referrer" />
<add name="X-Permitted-Cross-Domain-Policies" value="none" />
<add name="Strict-Transport-Security" value="max-age=31536000" />
<add name="Permissions-Policy" value="accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" />
<add name="Content-Security-Policy" value="default-src 'self' media.s-bol.com" />
</customHeaders>
</httpProtocol>
<handlers>
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
</handlers>
<aspNetCore processPath="dotnet" arguments=".\pbw2023.dll" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout" hostingModel="inprocess" />
</system.webServer>
</location>
<location path="umbraco" inheritInChildApplications="false">
<system.webServer>
<httpProtocol>
<customHeaders>
<remove name="Content-Security-Policy" />
</customHeaders>
</httpProtocol>
<handlers>
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
</handlers>
<aspNetCore processPath="dotnet" arguments=".\pbw2023.dll" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout" hostingModel="inprocess" />
</system.webServer>
</location>
</configuration>
MemoryCache
Zoek je op LEGO en doe je dat nog een keer en nog een keer, de API van BOL zal je dezelfde resultaten terug geven. Daarom zet ik de resultaten 5 minuten in een MemoryCache. Mogelijk kan ik hier nog meer optimalisaties in doorvoeren.
Het zoeken, dat deel zit in een ApiController, je kunt daar makkelijk via dependency-injection gebruik maken van een MemoryCache. Maar ik gebruik in mijn Razor-templates ook een eigen "Helper"-class om data te tonen op mijn homepage. Die houdt de data voor een uur vast, want dat wijzigt bijna niet. Die Helper-class heb ik "static" gemaakt, zodat ik daar een static MemoryCache-object kan instellen. Of dit de meest praktische methode is, dat ga ik later nog eens bekijken, maar ik heb op dit moment binnen 100 milliseconden mijn site ervoor. Tenminste, als die al geladen is. De eerste keer laden vind ik te traag. Dus daar ga ik nog gebruik maken van een soort "keep-alive call".
In ieder geval, dit is de code die ik nu gebruik:
// Startup.cs
public Startup(IWebHostEnvironment webHostEnvironment, IConfiguration config)
{
...
HomepageHelper.MemCache = new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions());
}
// HomepageHelper.cspublic static class HomepageHelper
{
public static IMemoryCache MemCache;public static List<Product> GetPopularProducts()
{
const string keyName = "homepage_popularproducts";
var result = MemCache.Get<List<Product>>(keyName);
if (result != null && result.Any())
{
return result;
}
var connector = new ...
result = connector.GetPopularProducts();
if (result.Any())
{
MemCache.Set(keyName, result, DateTimeOffset.UtcNow.AddHours(1));
}
return result;
}