From 9d2ad83c2dce3bbd25e02877c94b3086dede5023 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 30 Nov 2025 15:27:38 +0300 Subject: [PATCH 01/62] Created domain typosquatting checker --- Apps/TyposquattingDetector/App.cs | 339 ++++++++++++++++++ Apps/TyposquattingDetector/Config.cs | 58 +++ .../TyposquattingDetector.cs | 173 +++++++++ .../TyposquattingDetector.csproj | 51 +++ Apps/TyposquattingDetector/dnsApp.config | 9 + DnsServer.sln | 12 +- README.md | 2 + 7 files changed, 642 insertions(+), 2 deletions(-) create mode 100644 Apps/TyposquattingDetector/App.cs create mode 100644 Apps/TyposquattingDetector/Config.cs create mode 100644 Apps/TyposquattingDetector/TyposquattingDetector.cs create mode 100644 Apps/TyposquattingDetector/TyposquattingDetector.csproj create mode 100644 Apps/TyposquattingDetector/dnsApp.config diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs new file mode 100644 index 000000000..175e9d5cd --- /dev/null +++ b/Apps/TyposquattingDetector/App.cs @@ -0,0 +1,339 @@ +/* +Technitium DNS Server +Copyright (C) 2025 Shreyas Zare (shreyas@technitium.com) +Copyright (C) 2025 Zafer Balkan (zafer@zaferbalkan.com) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using DnsServerCore.ApplicationCommon; +using System; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Dns; +using TechnitiumLibrary.Net.Dns.EDnsOptions; +using TechnitiumLibrary.Net.Dns.ResourceRecords; +using TechnitiumLibrary.Net.Http.Client; + +namespace TyposquattingDetector +{ + public sealed partial class App : IDnsApplication, IDnsRequestBlockingHandler + { + #region variables + string _domainListFilePath; + Config _config; + IDnsServer _dnsServer; + HttpClient _httpClient; + DnsSOARecordData _soaRecord; + TimeSpan _updateInterval; + Task _updateLoopTask; + TyposquattingDetector _detector; + CancellationTokenSource _appShutdownCts; + + #endregion variables + + #region IDisposable + + public void Dispose() + { + _appShutdownCts?.Cancel(); + try + { + if (_updateLoopTask != null) + { + _ = Task.WhenAny(_updateLoopTask, Task.Delay(TimeSpan.FromSeconds(2))).GetAwaiter().GetResult(); + } + } + catch + { + } + finally + { + _appShutdownCts?.Dispose(); + _httpClient?.Dispose(); + } + } + + #endregion IDisposable + + #region public + + public async Task InitializeAsync(IDnsServer dnsServer, string config) + { + _dnsServer = dnsServer; + try + { + _soaRecord = new DnsSOARecordData(_dnsServer.ServerDomain, _dnsServer.ResponsiblePerson.Address, 1, 14400, 3600, 604800, 60); + + JsonSerializerOptions options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + _config = JsonSerializer.Deserialize(config, options); + + Validator.ValidateObject(_config, new ValidationContext(_config), validateAllProperties: true); + _updateInterval = ParseUpdateInterval(_config.UpdateInterval); + _appShutdownCts = new CancellationTokenSource(); + + string configDir = _dnsServer.ApplicationFolder; + Directory.CreateDirectory(configDir); + _domainListFilePath = Path.Combine(configDir, "majestic_million.csv"); + + if (Path.Exists(_domainListFilePath)) + { + _dnsServer.WriteLog($"Typosquatting Detector: Domain list exists at path: '{_domainListFilePath}'."); + } + else + { + _dnsServer.WriteLog($"Typosquatting Detector: Started downloading domain list to path: '{_domainListFilePath}'."); + + Uri domainList = new Uri(_config.Url); + _httpClient = CreateHttpClient(domainList, _config.DisableTlsValidation); + await _httpClient.GetStreamAsync(domainList).ContinueWith(async t => + { + try + { + using (Stream stream = await t) + using (FileStream fs = new FileStream(_domainListFilePath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + await stream.CopyToAsync(fs); + } + _dnsServer.WriteLog($"Typosquatting Detector: Downloaded domain list from '{domainList}' to '{_domainListFilePath}'."); + } + catch (Exception ex) + { + _dnsServer.WriteLog($"FATAL: Failed to download domain list from '{domainList}'. Error: {ex.Message}"); + _dnsServer.WriteLog(ex); + } + }).GetAwaiter().GetResult().ConfigureAwait(false); + } + _dnsServer.WriteLog($"Typosquatting Detector: Domain list saved to path: '{_domainListFilePath}'."); + _dnsServer.WriteLog($"Typosquatting Detector: Processing domain list..."); + _detector = new TyposquattingDetector(_domainListFilePath, _config.FuzzyMatchThreshold); + _dnsServer.WriteLog($"Typosquatting Detector: Processing completed."); + + // We do not await this, as it's designed to run for the lifetime of the app. + _updateLoopTask = StartUpdateLoopAsync(_appShutdownCts.Token); + _ = _updateLoopTask.ContinueWith(t => + { + if (t.IsFaulted) + { + _dnsServer.WriteLog($"FATAL: Update loop terminated unexpectedly: {t.Exception?.GetBaseException().Message}"); + _dnsServer.WriteLog(t.Exception); + } + }, TaskContinuationOptions.OnlyOnFaulted); + } + catch (Exception ex) + { + _dnsServer.WriteLog($"FATAL: Typosquatting Detector failed to initialize. Check configuration. Error: {ex.Message}"); + _dnsServer.WriteLog(ex); + } + } + + public Task IsAllowedAsync(DnsDatagram request, IPEndPoint remoteEP) + { + return Task.FromResult(false); + } + + public async Task ProcessRequestAsync(DnsDatagram request, IPEndPoint remoteEP) + { + if (_config?.Enable != true) + { + return null; + } + + // Download takes time. Let's nor break the app. + if (_detector is null) + { + return null; + } + + DnsQuestionRecord question = request.Question[0]; + var res = await _detector.FuzzyMatchAsync(question.Name); + if (res.Status == DetectionStatus.Clean) + { + return null; + } + + string blockingReport = $"source=typosquatting-detector;domain={res.Query};severity={res.Severity};reason={res.Reason}"; + + EDnsOption[]? options = null; + if (_config.AddExtendedDnsError && request.EDNS is not null) + { + options = new EDnsOption[] { new EDnsOption(EDnsOptionCode.EXTENDED_DNS_ERROR, new EDnsExtendedDnsErrorOptionData(EDnsExtendedDnsErrorCode.Blocked, string.Empty)) }; + } + + if (_config.AllowTxtBlockingReport && question.Type == DnsResourceRecordType.TXT) + { + DnsResourceRecord[] answer = new DnsResourceRecord[] { new DnsResourceRecord(question.Name, DnsResourceRecordType.TXT, question.Class, 60, new DnsTXTRecordData(string.Empty)) }; + return new DnsDatagram( + ID: request.Identifier, + isResponse: true, + OPCODE: DnsOpcode.StandardQuery, + authoritativeAnswer: false, + truncation: false, + recursionDesired: request.RecursionDesired, + recursionAvailable: true, + authenticData: false, + checkingDisabled: false, + RCODE: DnsResponseCode.NoError, + question: request.Question, + answer: answer, + authority: null, + additional: null, + udpPayloadSize: request.EDNS is null ? ushort.MinValue : _dnsServer.UdpPayloadSize, + ednsFlags: EDnsHeaderFlags.None, + options: options + ); + } + + DnsResourceRecord[] authority = { new DnsResourceRecord(question.Name, DnsResourceRecordType.SOA, question.Class, 60, _soaRecord) }; + return new DnsDatagram( + ID: request.Identifier, + isResponse: true, + OPCODE: DnsOpcode.StandardQuery, + authoritativeAnswer: true, + truncation: false, + recursionDesired: request.RecursionDesired, + recursionAvailable: true, + authenticData: false, + checkingDisabled: false, + RCODE: DnsResponseCode.NxDomain, + question: request.Question, + answer: null, + authority: authority, + additional: null, + udpPayloadSize: request.EDNS is null ? ushort.MinValue : _dnsServer.UdpPayloadSize, + ednsFlags: EDnsHeaderFlags.None, + options: options + ); + } + + #endregion public + + #region private + private async Task StartUpdateLoopAsync(CancellationToken cancellationToken) + { + await Task.Delay(TimeSpan.FromSeconds(Random.Shared.Next(5, 30)), cancellationToken); + using (PeriodicTimer timer = new PeriodicTimer(_updateInterval)) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await UpdateDomainListAsync(cancellationToken); + } + catch (OperationCanceledException) + { + _dnsServer.WriteLog("Update loop is shutting down gracefully."); + break; + } + catch (Exception ex) + { + _dnsServer.WriteLog($"FATAL: The Typosquatting Detector update task failed unexpectedly. Error: {ex.Message}"); + _dnsServer.WriteLog(ex); + } + + await timer.WaitForNextTickAsync(cancellationToken); + } + } + } + private static TimeSpan ParseUpdateInterval(string interval) + { + if (string.IsNullOrWhiteSpace(interval) || interval.Length < 2) + { + throw new FormatException("Update interval is not in a valid format (e.g., '60m', '2h', '7d')."); + } + + string unit = interval.Substring(interval.Length - 1).ToLowerInvariant(); + string valueString = interval.Substring(0, interval.Length - 1); + + if (!int.TryParse(valueString, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) || value <= 0) + { + throw new FormatException($"Invalid numeric value '{valueString}' in update interval."); + } + + switch (unit) + { + case "m": + return TimeSpan.FromMinutes(value); + + case "h": + return TimeSpan.FromHours(value); + + case "d": + return TimeSpan.FromDays(value); + case "w": + return TimeSpan.FromDays(value * 7); + default: + throw new FormatException($"Invalid unit '{unit}' in update interval. Allowed units are 'm', 'h', 'd'. 'w'."); + } + } + + private HttpClient CreateHttpClient(Uri serverUrl, bool disableTlsValidation) + { + HttpClientNetworkHandler handler = new HttpClientNetworkHandler(); + handler.Proxy = _dnsServer.Proxy; + handler.NetworkType = _dnsServer.PreferIPv6 ? HttpClientNetworkType.PreferIPv6 : HttpClientNetworkType.Default; + handler.DnsClient = _dnsServer; + + if (disableTlsValidation) + { + handler.InnerHandler.SslOptions.RemoteCertificateValidationCallback = delegate (object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) + { + return true; + }; + + _dnsServer.WriteLog($"WARNING: TLS certificate validation is DISABLED for server: {serverUrl}"); + } + + return new HttpClient(handler); + } + + private async Task UpdateDomainListAsync(CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) return; + + try + { + var detector = new TyposquattingDetector(_domainListFilePath, _config.FuzzyMatchThreshold); + + _dnsServer.WriteLog($"Typosquatting Detector: Loaded Alexa Top 1M domains from file."); + } + catch (IOException ex) + { + _dnsServer.WriteLog($"ERROR: Failed to read cache file '{_domainListFilePath}'. Error: {ex.Message}"); + } + } + + #endregion private + + #region properties + + public string Description + { + get + { + return "Downloads Alexa toip 1 million domains, runs a fuzzy logic, and if the match is high but not 100, it may be a typosquatting attempt."; + } + } + + #endregion properties + } +} \ No newline at end of file diff --git a/Apps/TyposquattingDetector/Config.cs b/Apps/TyposquattingDetector/Config.cs new file mode 100644 index 000000000..59609a3fe --- /dev/null +++ b/Apps/TyposquattingDetector/Config.cs @@ -0,0 +1,58 @@ +/* +Technitium DNS Server +Copyright (C) 2025 Shreyas Zare (shreyas@technitium.com) +Copyright (C) 2025 Zafer Balkan (zafer@zaferbalkan.com) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace TyposquattingDetector +{ + public sealed partial class App + { + private class Config + { + [JsonPropertyName("enable")] + public bool Enable { get; set; } = true; + + [JsonPropertyName("url")] + [Required(ErrorMessage = "url is a required configuration property.")] + [Url(ErrorMessage = "url must be a valid URL.")] + public string Url { get; set; } + + [JsonPropertyName("disableTlsValidation")] + public bool DisableTlsValidation { get; set; } = false; + + [JsonPropertyName("updateInterval")] + [Required(ErrorMessage = "updateInterval is a required configuration property.")] + [RegularExpression(@"^\d+[mhd]$", ErrorMessage = "Invalid interval format. Use a number followed by 'm', 'h', or 'd' (e.g., '90m', '2h', '7d').", MatchTimeoutInMilliseconds = 3000)] + public string UpdateInterval { get; set; } + + [JsonPropertyName("allowTxtBlockingReport")] + public bool AllowTxtBlockingReport { get; set; } = true; + + + [JsonPropertyName("addExtendedDnsError")] + public bool AddExtendedDnsError { get; set; } = true; + + [JsonPropertyName("fuzzyMatchThreshold")] + [Range(75, 90, ErrorMessage = "fuzzyMatchThreshold must be between 75 and 90.")] + [Required(ErrorMessage = "fuzzyMatchThreshold is a required configuration property. The lower threshold means more false positives.")] + public int FuzzyMatchThreshold { get; set; } = 75; + } + } +} \ No newline at end of file diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs new file mode 100644 index 000000000..ce38402ff --- /dev/null +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -0,0 +1,173 @@ +/* +Technitium DNS Server +Copyright (C) 2025 Shreyas Zare (shreyas@technitium.com) +Copyright (C) 2025 Zafer Balkan (zafer@zaferbalkan.com) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using BloomFilter; +using FuzzySharp; +using Nager.PublicSuffix; +using Nager.PublicSuffix.RuleProviders; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace TyposquattingDetector +{ + public enum DetectionStatus { Clean, Possible, Suspicious } + public enum Severity { NONE, LOW, MEDIUM, HIGH } + public enum Reason { BloomReject, Exact, Typosquatting, Medium, Low, NoCandidates } + + public class Result + { + public string Query { get; } + public DetectionStatus Status { get; set; } + public Severity Severity { get; set; } + public Reason Reason { get; set; } + public string? BestMatch { get; set; } + public int FuzzyScore { get; set; } + + public Result(string query) => Query = query; + } + + public class TyposquattingDetector + { + private static IRuleProvider _sharedRuleProvider; + private readonly ThreadLocal _normalizer; + private IBloomFilter _bloomFilter; + private readonly Dictionary> _lenBuckets = new(); + private readonly int _threshold; + + public TyposquattingDetector(string path, int threshold) + { + _threshold = threshold; + + if (_sharedRuleProvider == null) + { + var cacheProvider = new Nager.PublicSuffix.RuleProviders.CacheProviders.LocalFileSystemCacheProvider(); + _sharedRuleProvider = new CachedHttpRuleProvider(cacheProvider, new HttpClient()); + _sharedRuleProvider.BuildAsync().GetAwaiter().GetResult(); + } + + _normalizer = new ThreadLocal(() => + new DomainParser(_sharedRuleProvider, new Nager.PublicSuffix.DomainNormalizers.UriDomainNormalizer())); + + LoadData(path); + } + + private void LoadData(string filePath) + { + _bloomFilter = FilterBuilder.Build(1_000_000, 0.01); + + using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 65536); + using var reader = new StreamReader(fs); + reader.ReadLine(); + + while (reader.ReadLine() is { } line) + { + string? domain = ExtractDomain(line); + if (string.IsNullOrEmpty(domain)) continue; + + _bloomFilter.Add(domain); + + if (!_lenBuckets.TryGetValue(domain.Length, out var list)) + { + list = new List(); + _lenBuckets[domain.Length] = list; + } + if (list.Count < 10000) list.Add(domain); + } + } + + public async Task FuzzyMatchAsync(string query) + { + var q = Normalize(query); + var r = new Result(q); + + // GATE 1: Known Famous Site + // If it's in the top 1M, it's 100% clean. + if (_bloomFilter.Contains(q)) + { + r.Status = DetectionStatus.Clean; + r.Reason = Reason.Exact; + return r; + } + + // GATE 2: Fuzzy Similarity Check + return await Task.Run(() => + { + var candidates = new List(); + for (int i = -1; i <= 1; i++) + if (_lenBuckets.TryGetValue(q.Length + i, out var bucket)) + candidates.AddRange(bucket); + + var best = candidates + .Select(d => new { d, score = Fuzz.WeightedRatio(q, d) }) + .OrderByDescending(x => x.score) + .FirstOrDefault(); + + // Logic: If score is [75-99], it's a suspicious lookalike. + // If score is < 75, it's just a random domain (Clean). + // Note: score of 100 would have been caught by the Bloom Filter. + if (best != null && best.score >= _threshold) + { + r.BestMatch = best.d; + r.FuzzyScore = best.score; + r.Status = DetectionStatus.Suspicious; + r.Severity = Severity.HIGH; + r.Reason = Reason.Typosquatting; + } + else + { + r.Status = DetectionStatus.Clean; + r.Reason = Reason.BloomReject; + } + + return r; + }); + } + + private string Normalize(string s) + { + if (string.IsNullOrWhiteSpace(s)) return s; + try + { + return _normalizer!.Value!.Parse(s)!.RegistrableDomain ?? s; + } + catch + { + return s.ToLowerInvariant().Trim().Replace("www.", ""); + } + } + + private string? ExtractDomain(string line) + { + ReadOnlySpan span = line.AsSpan(); + int firstComma = span.IndexOf(','); + if (firstComma == -1) return null; + ReadOnlySpan afterFirst = span.Slice(firstComma + 1); + int secondComma = afterFirst.IndexOf(','); + if (secondComma == -1) return null; + ReadOnlySpan afterSecond = afterFirst.Slice(secondComma + 1); + int thirdComma = afterSecond.IndexOf(','); + return (thirdComma == -1 ? afterSecond : afterSecond.Slice(0, thirdComma)).ToString(); + } + } +} diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.csproj b/Apps/TyposquattingDetector/TyposquattingDetector.csproj new file mode 100644 index 000000000..61b99f400 --- /dev/null +++ b/Apps/TyposquattingDetector/TyposquattingDetector.csproj @@ -0,0 +1,51 @@ + + + + net9.0 + false + 1.0 + false + Technitium + Technitium DNS Server + Zafer Balkan + TyposquattingDetector + TyposquattingDetector + https://technitium.com/dns/ + https://github.com/TechnitiumSoftware/DnsServer + Detects if a queried domainname MIGHT be a typosquatting attempt or not. + false + Library + true + enable + + + + + + + + + + + false + + + + + + ..\..\..\TechnitiumLibrary\bin\TechnitiumLibrary.dll + false + + + ..\..\..\TechnitiumLibrary\bin\TechnitiumLibrary.Net.dll + false + + + + + + PreserveNewest + + + + diff --git a/Apps/TyposquattingDetector/dnsApp.config b/Apps/TyposquattingDetector/dnsApp.config new file mode 100644 index 000000000..5d926e02d --- /dev/null +++ b/Apps/TyposquattingDetector/dnsApp.config @@ -0,0 +1,9 @@ +{ + "enable": true, + "url": "https://downloads.majestic.com/majestic_million.csv", + "disableTlsValidation": false, + "updateInterval": "30d", + "allowTxtBlockingReport": true, + "addExtendedDnsError": true, + "fuzzyMatchThreshold": 75 +} diff --git a/DnsServer.sln b/DnsServer.sln index 0a2a6756d..2e638b3ba 100644 --- a/DnsServer.sln +++ b/DnsServer.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.32014.148 +# Visual Studio Version 18 +VisualStudioVersion = 18.1.11312.151 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DnsServerApp", "DnsServerApp\DnsServerApp.csproj", "{ADE80805-9FA7-4F66-8A18-57B98F8C0B0F}" EndProject @@ -68,8 +68,11 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QueryLogsMySqlApp", "Apps\QueryLogsMySqlApp\QueryLogsMySqlApp.csproj", "{699E2A1D-D917-4825-939E-65CDB2B16A96}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MispConnectorApp", "Apps\MispConnectorApp\MispConnectorApp.csproj", "{83C8180A-0F86-F9A0-8F41-6FD61FAC41CB}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DnsServerCore.HttpApi", "DnsServerCore.HttpApi\DnsServerCore.HttpApi.csproj", "{1A49D371-D08C-475E-B7A2-6E8ECD181FD6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TyposquattingDetector", "Apps\TyposquattingDetector\TyposquattingDetector.csproj", "{FC71CB85-F69D-44E5-A447-52B39C7AB5C2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -208,6 +211,10 @@ Global {1A49D371-D08C-475E-B7A2-6E8ECD181FD6}.Debug|Any CPU.Build.0 = Debug|Any CPU {1A49D371-D08C-475E-B7A2-6E8ECD181FD6}.Release|Any CPU.ActiveCfg = Release|Any CPU {1A49D371-D08C-475E-B7A2-6E8ECD181FD6}.Release|Any CPU.Build.0 = Release|Any CPU + {FC71CB85-F69D-44E5-A447-52B39C7AB5C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FC71CB85-F69D-44E5-A447-52B39C7AB5C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FC71CB85-F69D-44E5-A447-52B39C7AB5C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FC71CB85-F69D-44E5-A447-52B39C7AB5C2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -240,6 +247,7 @@ Global {6F655C97-FD43-4FE1-B15A-6C783D2D91C9} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2} {699E2A1D-D917-4825-939E-65CDB2B16A96} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2} {83C8180A-0F86-F9A0-8F41-6FD61FAC41CB} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2} + {FC71CB85-F69D-44E5-A447-52B39C7AB5C2} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6747BB6D-2826-4356-A213-805FBCCF9201} diff --git a/README.md b/README.md index 84941ea11..3ae788b4b 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ Nobody really bothers about domain name resolution since it works automatically Be it a home network or an organization's network, having a locally running DNS server gives you more insights into your network and helps to understand it better using the DNS logs and stats. It improves overall performance since most queries are served from the DNS cache making web sites load faster by not having to wait for frequent DNS resolutions. It also gives you an additional control over your network allowing you to block domain names network wide and also allows you to route your DNS traffic securely using encrypted DNS protocols. +[![Quality gate](https://sonarcloud.io/api/project_badges/quality_gate?project=zbalkan_DnsServer)](https://sonarcloud.io/summary/new_code?id=zbalkan_DnsServer) + # Sponsored By

Altha Technology - Censorship Resistant Data Services From 109df5889bafce2fd323d73b1fd10003c891559c Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 30 Dec 2025 20:27:05 +0200 Subject: [PATCH 02/62] Added customlist capability --- Apps/TyposquattingDetector/App.cs | 12 ++-- Apps/TyposquattingDetector/Config.cs | 64 +++++++++++++++++-- .../TyposquattingDetector.cs | 22 ++++++- Apps/TyposquattingDetector/dnsApp.config | 2 +- 4 files changed, 84 insertions(+), 16 deletions(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index 175e9d5cd..2990593bf 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -49,6 +49,8 @@ public sealed partial class App : IDnsApplication, IDnsRequestBlockingHandler TyposquattingDetector _detector; CancellationTokenSource _appShutdownCts; + const string DefaultDomainListUrl = "https://downloads.technitium.com/dns/typosquatting/majestic_million.csv"; + #endregion variables #region IDisposable @@ -103,7 +105,7 @@ public async Task InitializeAsync(IDnsServer dnsServer, string config) { _dnsServer.WriteLog($"Typosquatting Detector: Started downloading domain list to path: '{_domainListFilePath}'."); - Uri domainList = new Uri(_config.Url); + Uri domainList = new Uri(DefaultDomainListUrl); _httpClient = CreateHttpClient(domainList, _config.DisableTlsValidation); await _httpClient.GetStreamAsync(domainList).ContinueWith(async t => { @@ -124,9 +126,6 @@ await _httpClient.GetStreamAsync(domainList).ContinueWith(async t => }).GetAwaiter().GetResult().ConfigureAwait(false); } _dnsServer.WriteLog($"Typosquatting Detector: Domain list saved to path: '{_domainListFilePath}'."); - _dnsServer.WriteLog($"Typosquatting Detector: Processing domain list..."); - _detector = new TyposquattingDetector(_domainListFilePath, _config.FuzzyMatchThreshold); - _dnsServer.WriteLog($"Typosquatting Detector: Processing completed."); // We do not await this, as it's designed to run for the lifetime of the app. _updateLoopTask = StartUpdateLoopAsync(_appShutdownCts.Token); @@ -312,9 +311,10 @@ private async Task UpdateDomainListAsync(CancellationToken cancellationToken) try { - var detector = new TyposquattingDetector(_domainListFilePath, _config.FuzzyMatchThreshold); + _dnsServer.WriteLog($"Typosquatting Detector: Processing domain list..."); + _detector = new TyposquattingDetector(_domainListFilePath, _config.Path, _config.FuzzyMatchThreshold); + _dnsServer.WriteLog($"Typosquatting Detector: Processing completed."); - _dnsServer.WriteLog($"Typosquatting Detector: Loaded Alexa Top 1M domains from file."); } catch (IOException ex) { diff --git a/Apps/TyposquattingDetector/Config.cs b/Apps/TyposquattingDetector/Config.cs index 59609a3fe..4508094b8 100644 --- a/Apps/TyposquattingDetector/Config.cs +++ b/Apps/TyposquattingDetector/Config.cs @@ -18,7 +18,9 @@ You should have received a copy of the GNU General Public License */ using System.ComponentModel.DataAnnotations; +using System.IO; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; namespace TyposquattingDetector { @@ -29,10 +31,10 @@ private class Config [JsonPropertyName("enable")] public bool Enable { get; set; } = true; - [JsonPropertyName("url")] - [Required(ErrorMessage = "url is a required configuration property.")] - [Url(ErrorMessage = "url must be a valid URL.")] - public string Url { get; set; } + [JsonPropertyName("customList")] + [RegularExpression("^(?:[a-zA-Z]:\\\\(?:[^\\\\\\/:*?\"<>|\\r\\n]+\\\\)*[^\\\\\\/:*?\"<>|\\r\\n]*|(?:\\/[^\\/\\0]+)+\\/?)$", ErrorMessage = "customList must be a valid file path with one domain per line.")] + [CustomValidation(typeof(FileContentValidator), nameof(FileContentValidator.ValidateDomainFile))] + public string? Path { get; set; } [JsonPropertyName("disableTlsValidation")] public bool DisableTlsValidation { get; set; } = false; @@ -40,7 +42,7 @@ private class Config [JsonPropertyName("updateInterval")] [Required(ErrorMessage = "updateInterval is a required configuration property.")] [RegularExpression(@"^\d+[mhd]$", ErrorMessage = "Invalid interval format. Use a number followed by 'm', 'h', or 'd' (e.g., '90m', '2h', '7d').", MatchTimeoutInMilliseconds = 3000)] - public string UpdateInterval { get; set; } + public string UpdateInterval { get; set; } = "30d"; [JsonPropertyName("allowTxtBlockingReport")] public bool AllowTxtBlockingReport { get; set; } = true; @@ -48,11 +50,61 @@ private class Config [JsonPropertyName("addExtendedDnsError")] public bool AddExtendedDnsError { get; set; } = true; - + [JsonPropertyName("fuzzyMatchThreshold")] [Range(75, 90, ErrorMessage = "fuzzyMatchThreshold must be between 75 and 90.")] [Required(ErrorMessage = "fuzzyMatchThreshold is a required configuration property. The lower threshold means more false positives.")] public int FuzzyMatchThreshold { get; set; } = 75; } + + private partial class FileContentValidator + { + // Optimized Regex: Compiled for performance during "Happy Path" scans + private static readonly Regex DomainRegex = FilePathPattern(); + + public static ValidationResult? ValidateDomainFile(string? path, ValidationContext context) + { + // 1. If path is null/empty, we assume validation is not required here + // (Use [Required] on the property if you want to force a path to be provided) + if (string.IsNullOrWhiteSpace(path)) return ValidationResult.Success; + + // 2. Existence Check + if (!File.Exists(path)) + return new ValidationResult($"File not found: {path}"); + + try + { + // 3. Stream through lines + // If the file is empty, this loop is simply skipped + foreach (string line in File.ReadLines(path)) + { + string trimmedLine = line.Trim(); + + // Skip truly empty lines (whitespace only) + if (string.IsNullOrEmpty(trimmedLine)) continue; + + // 4. Fail-Fast Logic + // If any content exists, it MUST follow the domain rules + if (trimmedLine.Contains("*") || !DomainRegex.IsMatch(trimmedLine)) + { + return new ValidationResult($"Invalid content: '{trimmedLine}'. Wildcards are not allowed."); + } + } + } + catch (IOException ex) + { + return new ValidationResult($"File access error: {ex.Message}"); + } + + // 5. Success Path + // Reached if the file was empty OR all lines passed validation + return ValidationResult.Success; + } + + [GeneratedRegex(@"^(?!-)[A-Za-z0-9-]+([\-\.]{1}[a-z0-9]+)*\.[A-Za-z]{2,63}$", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] + private static partial Regex FilePathPattern(); + } } + + } \ No newline at end of file diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index ce38402ff..6a7d7c2c1 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -55,7 +55,7 @@ public class TyposquattingDetector private readonly Dictionary> _lenBuckets = new(); private readonly int _threshold; - public TyposquattingDetector(string path, int threshold) + public TyposquattingDetector(string defaultPath, string customPath, int threshold) { _threshold = threshold; @@ -69,10 +69,10 @@ public TyposquattingDetector(string path, int threshold) _normalizer = new ThreadLocal(() => new DomainParser(_sharedRuleProvider, new Nager.PublicSuffix.DomainNormalizers.UriDomainNormalizer())); - LoadData(path); + LoadData(defaultPath, customPath); } - private void LoadData(string filePath) + private void LoadData(string filePath, string customPath) { _bloomFilter = FilterBuilder.Build(1_000_000, 0.01); @@ -94,6 +94,22 @@ private void LoadData(string filePath) } if (list.Count < 10000) list.Add(domain); } + + if (!string.IsNullOrEmpty(customPath) && File.Exists(customPath)) + { + foreach (var line in File.ReadLines(customPath)) + { + var domain = line.Trim(); + if (string.IsNullOrEmpty(domain)) continue; + _bloomFilter.Add(domain); + if (!_lenBuckets.TryGetValue(domain.Length, out var list)) + { + list = new List(); + _lenBuckets[domain.Length] = list; + } + if (list.Count < 10000) list.Add(domain); + } + } } public async Task FuzzyMatchAsync(string query) diff --git a/Apps/TyposquattingDetector/dnsApp.config b/Apps/TyposquattingDetector/dnsApp.config index 5d926e02d..92cd74654 100644 --- a/Apps/TyposquattingDetector/dnsApp.config +++ b/Apps/TyposquattingDetector/dnsApp.config @@ -1,6 +1,6 @@ { "enable": true, - "url": "https://downloads.majestic.com/majestic_million.csv", + "customList": "sample.txt", "disableTlsValidation": false, "updateInterval": "30d", "allowTxtBlockingReport": true, From 1ef77d6e73773d59d71eadd80a3ed7141b44be43 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 30 Dec 2025 21:01:51 +0200 Subject: [PATCH 03/62] Final version --- Apps/TyposquattingDetector/App.cs | 88 +++++----- Apps/TyposquattingDetector/Config.cs | 35 ++-- .../TyposquattingDetector.cs | 159 ++++++++++-------- 3 files changed, 149 insertions(+), 133 deletions(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index 2990593bf..10ed44118 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -39,17 +39,18 @@ namespace TyposquattingDetector public sealed partial class App : IDnsApplication, IDnsRequestBlockingHandler { #region variables - string _domainListFilePath; - Config _config; - IDnsServer _dnsServer; - HttpClient _httpClient; - DnsSOARecordData _soaRecord; - TimeSpan _updateInterval; - Task _updateLoopTask; - TyposquattingDetector _detector; - CancellationTokenSource _appShutdownCts; - - const string DefaultDomainListUrl = "https://downloads.technitium.com/dns/typosquatting/majestic_million.csv"; + + private const string DefaultDomainListUrl = "https://downloads.technitium.com/dns/typosquatting/majestic_million.csv"; + private CancellationTokenSource? _appShutdownCts; + private Config? _config; + private TyposquattingDetector? _detector; + private IDnsServer? _dnsServer; + private string? _domainListFilePath; + private HttpClient? _httpClient; + private DnsSOARecordData? _soaRecord; + private TimeSpan _updateInterval; + private Task? _updateLoopTask; + private static readonly JsonSerializerOptions _options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; #endregion variables @@ -85,9 +86,7 @@ public async Task InitializeAsync(IDnsServer dnsServer, string config) try { _soaRecord = new DnsSOARecordData(_dnsServer.ServerDomain, _dnsServer.ResponsiblePerson.Address, 1, 14400, 3600, 604800, 60); - - JsonSerializerOptions options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - _config = JsonSerializer.Deserialize(config, options); + _config = JsonSerializer.Deserialize(config, _options); Validator.ValidateObject(_config, new ValidationContext(_config), validateAllProperties: true); _updateInterval = ParseUpdateInterval(_config.UpdateInterval); @@ -157,14 +156,14 @@ public Task IsAllowedAsync(DnsDatagram request, IPEndPoint remoteEP) return null; } - // Download takes time. Let's nor break the app. + // Download takes time. Let's nor break the app. if (_detector is null) { return null; } DnsQuestionRecord question = request.Question[0]; - var res = await _detector.FuzzyMatchAsync(question.Name); + var res = await _detector.CheckAsync(question.Name); if (res.Status == DetectionStatus.Clean) { return null; @@ -175,12 +174,12 @@ public Task IsAllowedAsync(DnsDatagram request, IPEndPoint remoteEP) EDnsOption[]? options = null; if (_config.AddExtendedDnsError && request.EDNS is not null) { - options = new EDnsOption[] { new EDnsOption(EDnsOptionCode.EXTENDED_DNS_ERROR, new EDnsExtendedDnsErrorOptionData(EDnsExtendedDnsErrorCode.Blocked, string.Empty)) }; + options = new EDnsOption[] { new EDnsOption(EDnsOptionCode.EXTENDED_DNS_ERROR, new EDnsExtendedDnsErrorOptionData(EDnsExtendedDnsErrorCode.Blocked, blockingReport)) }; } if (_config.AllowTxtBlockingReport && question.Type == DnsResourceRecordType.TXT) { - DnsResourceRecord[] answer = new DnsResourceRecord[] { new DnsResourceRecord(question.Name, DnsResourceRecordType.TXT, question.Class, 60, new DnsTXTRecordData(string.Empty)) }; + DnsResourceRecord[] answer = new DnsResourceRecord[] { new DnsResourceRecord(question.Name, DnsResourceRecordType.TXT, question.Class, 60, new DnsTXTRecordData(blockingReport)) }; return new DnsDatagram( ID: request.Identifier, isResponse: true, @@ -227,32 +226,7 @@ public Task IsAllowedAsync(DnsDatagram request, IPEndPoint remoteEP) #endregion public #region private - private async Task StartUpdateLoopAsync(CancellationToken cancellationToken) - { - await Task.Delay(TimeSpan.FromSeconds(Random.Shared.Next(5, 30)), cancellationToken); - using (PeriodicTimer timer = new PeriodicTimer(_updateInterval)) - { - while (!cancellationToken.IsCancellationRequested) - { - try - { - await UpdateDomainListAsync(cancellationToken); - } - catch (OperationCanceledException) - { - _dnsServer.WriteLog("Update loop is shutting down gracefully."); - break; - } - catch (Exception ex) - { - _dnsServer.WriteLog($"FATAL: The Typosquatting Detector update task failed unexpectedly. Error: {ex.Message}"); - _dnsServer.WriteLog(ex); - } - await timer.WaitForNextTickAsync(cancellationToken); - } - } - } private static TimeSpan ParseUpdateInterval(string interval) { if (string.IsNullOrWhiteSpace(interval) || interval.Length < 2) @@ -278,8 +252,10 @@ private static TimeSpan ParseUpdateInterval(string interval) case "d": return TimeSpan.FromDays(value); + case "w": return TimeSpan.FromDays(value * 7); + default: throw new FormatException($"Invalid unit '{unit}' in update interval. Allowed units are 'm', 'h', 'd'. 'w'."); } @@ -305,6 +281,31 @@ private HttpClient CreateHttpClient(Uri serverUrl, bool disableTlsValidation) return new HttpClient(handler); } + private async Task StartUpdateLoopAsync(CancellationToken cancellationToken) + { + await Task.Delay(TimeSpan.FromSeconds(Random.Shared.Next(5, 30)), cancellationToken); + using PeriodicTimer timer = new PeriodicTimer(_updateInterval); + while (!cancellationToken.IsCancellationRequested) + { + try + { + await UpdateDomainListAsync(cancellationToken); + } + catch (OperationCanceledException) + { + _dnsServer.WriteLog("Update loop is shutting down gracefully."); + break; + } + catch (Exception ex) + { + _dnsServer.WriteLog($"FATAL: The Typosquatting Detector update task failed unexpectedly. Error: {ex.Message}"); + _dnsServer.WriteLog(ex); + } + + await timer.WaitForNextTickAsync(cancellationToken); + } + } + private async Task UpdateDomainListAsync(CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) return; @@ -314,7 +315,6 @@ private async Task UpdateDomainListAsync(CancellationToken cancellationToken) _dnsServer.WriteLog($"Typosquatting Detector: Processing domain list..."); _detector = new TyposquattingDetector(_domainListFilePath, _config.Path, _config.FuzzyMatchThreshold); _dnsServer.WriteLog($"Typosquatting Detector: Processing completed."); - } catch (IOException ex) { diff --git a/Apps/TyposquattingDetector/Config.cs b/Apps/TyposquattingDetector/Config.cs index 4508094b8..0786b3e7a 100644 --- a/Apps/TyposquattingDetector/Config.cs +++ b/Apps/TyposquattingDetector/Config.cs @@ -28,33 +28,32 @@ public sealed partial class App { private class Config { + [JsonPropertyName("addExtendedDnsError")] + public bool AddExtendedDnsError { get; set; } = true; + + [JsonPropertyName("allowTxtBlockingReport")] + public bool AllowTxtBlockingReport { get; set; } = true; + + [JsonPropertyName("disableTlsValidation")] + public bool DisableTlsValidation { get; set; } = false; + [JsonPropertyName("enable")] public bool Enable { get; set; } = true; + [JsonPropertyName("fuzzyMatchThreshold")] + [Range(75, 90, ErrorMessage = "fuzzyMatchThreshold must be between 75 and 90.")] + [Required(ErrorMessage = "fuzzyMatchThreshold is a required configuration property. The lower threshold means more false positives.")] + public int FuzzyMatchThreshold { get; set; } = 75; + [JsonPropertyName("customList")] [RegularExpression("^(?:[a-zA-Z]:\\\\(?:[^\\\\\\/:*?\"<>|\\r\\n]+\\\\)*[^\\\\\\/:*?\"<>|\\r\\n]*|(?:\\/[^\\/\\0]+)+\\/?)$", ErrorMessage = "customList must be a valid file path with one domain per line.")] [CustomValidation(typeof(FileContentValidator), nameof(FileContentValidator.ValidateDomainFile))] public string? Path { get; set; } - [JsonPropertyName("disableTlsValidation")] - public bool DisableTlsValidation { get; set; } = false; - [JsonPropertyName("updateInterval")] [Required(ErrorMessage = "updateInterval is a required configuration property.")] [RegularExpression(@"^\d+[mhd]$", ErrorMessage = "Invalid interval format. Use a number followed by 'm', 'h', or 'd' (e.g., '90m', '2h', '7d').", MatchTimeoutInMilliseconds = 3000)] - public string UpdateInterval { get; set; } = "30d"; - - [JsonPropertyName("allowTxtBlockingReport")] - public bool AllowTxtBlockingReport { get; set; } = true; - - - [JsonPropertyName("addExtendedDnsError")] - public bool AddExtendedDnsError { get; set; } = true; - - [JsonPropertyName("fuzzyMatchThreshold")] - [Range(75, 90, ErrorMessage = "fuzzyMatchThreshold must be between 75 and 90.")] - [Required(ErrorMessage = "fuzzyMatchThreshold is a required configuration property. The lower threshold means more false positives.")] - public int FuzzyMatchThreshold { get; set; } = 75; + public string UpdateInterval { get; set; } = "30d"; } private partial class FileContentValidator @@ -64,7 +63,7 @@ private partial class FileContentValidator public static ValidationResult? ValidateDomainFile(string? path, ValidationContext context) { - // 1. If path is null/empty, we assume validation is not required here + // 1. If path is null/empty, we assume validation is not required here // (Use [Required] on the property if you want to force a path to be provided) if (string.IsNullOrWhiteSpace(path)) return ValidationResult.Success; @@ -105,6 +104,4 @@ private partial class FileContentValidator private static partial Regex FilePathPattern(); } } - - } \ No newline at end of file diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index 6a7d7c2c1..17c4c8314 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -31,29 +31,34 @@ You should have received a copy of the GNU General Public License namespace TyposquattingDetector { - public enum DetectionStatus { Clean, Possible, Suspicious } - public enum Severity { NONE, LOW, MEDIUM, HIGH } - public enum Reason { BloomReject, Exact, Typosquatting, Medium, Low, NoCandidates } + public enum DetectionStatus + { Clean, Possible, Suspicious } + + public enum Reason + { BloomReject, Exact, Typosquatting, Medium, Low, NoCandidates } + + public enum Severity + { NONE, LOW, MEDIUM, HIGH } public class Result { - public string Query { get; } - public DetectionStatus Status { get; set; } - public Severity Severity { get; set; } - public Reason Reason { get; set; } + public Result(string query) => Query = query; + public string? BestMatch { get; set; } public int FuzzyScore { get; set; } - - public Result(string query) => Query = query; + public string Query { get; } + public Reason Reason { get; set; } + public Severity Severity { get; set; } + public DetectionStatus Status { get; set; } } public class TyposquattingDetector { - private static IRuleProvider _sharedRuleProvider; + private static CachedHttpRuleProvider? _sharedRuleProvider; + private readonly Dictionary> _lenBuckets = new Dictionary>(); private readonly ThreadLocal _normalizer; - private IBloomFilter _bloomFilter; - private readonly Dictionary> _lenBuckets = new(); private readonly int _threshold; + private IBloomFilter? _bloomFilter; public TyposquattingDetector(string defaultPath, string customPath, int threshold) { @@ -72,61 +77,23 @@ public TyposquattingDetector(string defaultPath, string customPath, int threshol LoadData(defaultPath, customPath); } - private void LoadData(string filePath, string customPath) - { - _bloomFilter = FilterBuilder.Build(1_000_000, 0.01); - - using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 65536); - using var reader = new StreamReader(fs); - reader.ReadLine(); - - while (reader.ReadLine() is { } line) - { - string? domain = ExtractDomain(line); - if (string.IsNullOrEmpty(domain)) continue; - - _bloomFilter.Add(domain); - - if (!_lenBuckets.TryGetValue(domain.Length, out var list)) - { - list = new List(); - _lenBuckets[domain.Length] = list; - } - if (list.Count < 10000) list.Add(domain); - } - - if (!string.IsNullOrEmpty(customPath) && File.Exists(customPath)) - { - foreach (var line in File.ReadLines(customPath)) - { - var domain = line.Trim(); - if (string.IsNullOrEmpty(domain)) continue; - _bloomFilter.Add(domain); - if (!_lenBuckets.TryGetValue(domain.Length, out var list)) - { - list = new List(); - _lenBuckets[domain.Length] = list; - } - if (list.Count < 10000) list.Add(domain); - } - } - } - - public async Task FuzzyMatchAsync(string query) + public async Task CheckAsync(string query) { - var q = Normalize(query); - var r = new Result(q); + var normalizedQuery = new Result(Normalize(query)); // GATE 1: Known Famous Site // If it's in the top 1M, it's 100% clean. - if (_bloomFilter.Contains(q)) + (bool flowControl, Result value) = Prefilter(Normalize(query), normalizedQuery); + if (!flowControl) { - r.Status = DetectionStatus.Clean; - r.Reason = Reason.Exact; - return r; + return value; } - // GATE 2: Fuzzy Similarity Check + return await FuzzyMatchAsync(Normalize(query), normalizedQuery); + } + + private async Task FuzzyMatchAsync(string q, Result r) + { return await Task.Run(() => { var candidates = new List(); @@ -160,20 +127,19 @@ public async Task FuzzyMatchAsync(string query) }); } - private string Normalize(string s) + private (bool flowControl, Result? value) Prefilter(string q, Result r) { - if (string.IsNullOrWhiteSpace(s)) return s; - try - { - return _normalizer!.Value!.Parse(s)!.RegistrableDomain ?? s; - } - catch + if (_bloomFilter.Contains(q)) { - return s.ToLowerInvariant().Trim().Replace("www.", ""); + r.Status = DetectionStatus.Clean; + r.Reason = Reason.Exact; + return (flowControl: false, value: r); } + + return (flowControl: true, value: null); } - private string? ExtractDomain(string line) + private static string? ExtractDomain(string line) { ReadOnlySpan span = line.AsSpan(); int firstComma = span.IndexOf(','); @@ -185,5 +151,58 @@ private string Normalize(string s) int thirdComma = afterSecond.IndexOf(','); return (thirdComma == -1 ? afterSecond : afterSecond.Slice(0, thirdComma)).ToString(); } + + private void LoadData(string filePath, string customPath) + { + _bloomFilter = FilterBuilder.Build(1_000_000, 0.01); + + using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 65536); + using var reader = new StreamReader(fs); + reader.ReadLine(); + + while (reader.ReadLine() is { } line) + { + string? domain = ExtractDomain(line); + if (string.IsNullOrEmpty(domain)) continue; + + _bloomFilter.Add(domain); + + if (!_lenBuckets.TryGetValue(domain.Length, out var list)) + { + list = new List(); + _lenBuckets[domain.Length] = list; + } + if (list.Count < 10000) list.Add(domain); + } + + if (!string.IsNullOrEmpty(customPath) && File.Exists(customPath)) + { + foreach (var line in File.ReadLines(customPath)) + { + var domain = line.Trim(); + if (string.IsNullOrEmpty(domain)) continue; + _bloomFilter.Add(domain); + if (!_lenBuckets.TryGetValue(domain.Length, out var list)) + { + list = new List(); + _lenBuckets[domain.Length] = list; + } + if (list.Count < 10000) list.Add(domain); + } + } + } + + private string Normalize(string s) + { + if (string.IsNullOrWhiteSpace(s)) return s; + try + { + return _normalizer!.Value!.Parse(s)!.RegistrableDomain ?? s; + } + catch + { + return s.ToLowerInvariant().Trim().Replace("www.", ""); + } + } } -} +} \ No newline at end of file From 3fde001c2b5b84d5e4ec886b48991e7e26be821e Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 30 Dec 2025 21:27:42 +0200 Subject: [PATCH 04/62] Foxed performance issue --- Apps/TyposquattingDetector/App.cs | 40 +++++++---- Apps/TyposquattingDetector/Config.cs | 2 +- .../TyposquattingDetector.cs | 69 +++++++++---------- Apps/TyposquattingDetector/dnsApp.config | 4 +- 4 files changed, 61 insertions(+), 54 deletions(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index 10ed44118..f3471dbe2 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -91,6 +91,7 @@ public async Task InitializeAsync(IDnsServer dnsServer, string config) Validator.ValidateObject(_config, new ValidationContext(_config), validateAllProperties: true); _updateInterval = ParseUpdateInterval(_config.UpdateInterval); _appShutdownCts = new CancellationTokenSource(); + await TryUpdate(_appShutdownCts.Token); string configDir = _dnsServer.ApplicationFolder; Directory.CreateDirectory(configDir); @@ -156,14 +157,14 @@ public Task IsAllowedAsync(DnsDatagram request, IPEndPoint remoteEP) return null; } - // Download takes time. Let's nor break the app. + // Download takes time. Let's not break the app. if (_detector is null) { return null; } DnsQuestionRecord question = request.Question[0]; - var res = await _detector.CheckAsync(question.Name); + var res = _detector.Check(question.Name); if (res.Status == DetectionStatus.Clean) { return null; @@ -283,27 +284,38 @@ private HttpClient CreateHttpClient(Uri serverUrl, bool disableTlsValidation) private async Task StartUpdateLoopAsync(CancellationToken cancellationToken) { - await Task.Delay(TimeSpan.FromSeconds(Random.Shared.Next(5, 30)), cancellationToken); using PeriodicTimer timer = new PeriodicTimer(_updateInterval); while (!cancellationToken.IsCancellationRequested) { - try - { - await UpdateDomainListAsync(cancellationToken); - } - catch (OperationCanceledException) + bool flowControl = await TryUpdate(cancellationToken); + if (!flowControl) { - _dnsServer.WriteLog("Update loop is shutting down gracefully."); break; } - catch (Exception ex) - { - _dnsServer.WriteLog($"FATAL: The Typosquatting Detector update task failed unexpectedly. Error: {ex.Message}"); - _dnsServer.WriteLog(ex); - } await timer.WaitForNextTickAsync(cancellationToken); } + await Task.Delay(TimeSpan.FromSeconds(Random.Shared.Next(5, 30)), cancellationToken); + } + + private async Task TryUpdate(CancellationToken cancellationToken) + { + try + { + await UpdateDomainListAsync(cancellationToken); + } + catch (OperationCanceledException) + { + _dnsServer.WriteLog("Update loop is shutting down gracefully."); + return false; + } + catch (Exception ex) + { + _dnsServer.WriteLog($"FATAL: The Typosquatting Detector update task failed unexpectedly. Error: {ex.Message}"); + _dnsServer.WriteLog(ex); + } + + return true; } private async Task UpdateDomainListAsync(CancellationToken cancellationToken) diff --git a/Apps/TyposquattingDetector/Config.cs b/Apps/TyposquattingDetector/Config.cs index 0786b3e7a..b15ee4b3c 100644 --- a/Apps/TyposquattingDetector/Config.cs +++ b/Apps/TyposquattingDetector/Config.cs @@ -56,7 +56,7 @@ private class Config public string UpdateInterval { get; set; } = "30d"; } - private partial class FileContentValidator + public partial class FileContentValidator { // Optimized Regex: Compiled for performance during "Happy Path" scans private static readonly Regex DomainRegex = FilePathPattern(); diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index 17c4c8314..b3d970d15 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -27,7 +27,6 @@ You should have received a copy of the GNU General Public License using System.Linq; using System.Net.Http; using System.Threading; -using System.Threading.Tasks; namespace TyposquattingDetector { @@ -77,54 +76,50 @@ public TyposquattingDetector(string defaultPath, string customPath, int threshol LoadData(defaultPath, customPath); } - public async Task CheckAsync(string query) + public Result Check(string query) { - var normalizedQuery = new Result(Normalize(query)); + var normalized = Normalize(query); + var result = new Result(normalized); // GATE 1: Known Famous Site - // If it's in the top 1M, it's 100% clean. - (bool flowControl, Result value) = Prefilter(Normalize(query), normalizedQuery); + (bool flowControl, Result? prefilterResult) = Prefilter(normalized, result); if (!flowControl) { - return value; + return prefilterResult!; } + // GATE 2: Fuzzy Similarity Check - return await FuzzyMatchAsync(Normalize(query), normalizedQuery); + return FuzzyMatch(normalized, result); } - private async Task FuzzyMatchAsync(string q, Result r) + private Result FuzzyMatch(string q, Result r) { - return await Task.Run(() => + // Remove Task.Run and the await lambda + var candidates = new List(); + for (int i = -1; i <= 1; i++) + if (_lenBuckets.TryGetValue(q.Length + i, out var bucket)) + candidates.AddRange(bucket); + + var best = candidates + .Select(d => new { d, score = Fuzz.WeightedRatio(q, d) }) + .OrderByDescending(x => x.score) + .FirstOrDefault(); + + if (best != null && best.score >= _threshold) { - var candidates = new List(); - for (int i = -1; i <= 1; i++) - if (_lenBuckets.TryGetValue(q.Length + i, out var bucket)) - candidates.AddRange(bucket); - - var best = candidates - .Select(d => new { d, score = Fuzz.WeightedRatio(q, d) }) - .OrderByDescending(x => x.score) - .FirstOrDefault(); - - // Logic: If score is [75-99], it's a suspicious lookalike. - // If score is < 75, it's just a random domain (Clean). - // Note: score of 100 would have been caught by the Bloom Filter. - if (best != null && best.score >= _threshold) - { - r.BestMatch = best.d; - r.FuzzyScore = best.score; - r.Status = DetectionStatus.Suspicious; - r.Severity = Severity.HIGH; - r.Reason = Reason.Typosquatting; - } - else - { - r.Status = DetectionStatus.Clean; - r.Reason = Reason.BloomReject; - } + r.BestMatch = best.d; + r.FuzzyScore = best.score; + r.Status = DetectionStatus.Suspicious; + r.Severity = Severity.HIGH; + r.Reason = Reason.Typosquatting; + } + else + { + r.Status = DetectionStatus.Clean; + r.Reason = Reason.BloomReject; + } - return r; - }); + return r; } private (bool flowControl, Result? value) Prefilter(string q, Result r) diff --git a/Apps/TyposquattingDetector/dnsApp.config b/Apps/TyposquattingDetector/dnsApp.config index 92cd74654..6a2e48095 100644 --- a/Apps/TyposquattingDetector/dnsApp.config +++ b/Apps/TyposquattingDetector/dnsApp.config @@ -1,9 +1,9 @@ { "enable": true, - "customList": "sample.txt", + "customList": "", "disableTlsValidation": false, "updateInterval": "30d", "allowTxtBlockingReport": true, "addExtendedDnsError": true, "fuzzyMatchThreshold": 75 -} +} \ No newline at end of file From c0eb10b807a14ef538f7c405eb504b323c7856a7 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 30 Dec 2025 21:35:19 +0200 Subject: [PATCH 05/62] Added path check --- Apps/TyposquattingDetector/App.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index f3471dbe2..d01842132 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -25,6 +25,7 @@ You should have received a copy of the GNU General Public License using System.Net; using System.Net.Http; using System.Net.Security; +using System.Security; using System.Security.Cryptography.X509Certificates; using System.Text.Json; using System.Threading; @@ -325,7 +326,14 @@ private async Task UpdateDomainListAsync(CancellationToken cancellationToken) try { _dnsServer.WriteLog($"Typosquatting Detector: Processing domain list..."); - _detector = new TyposquattingDetector(_domainListFilePath, _config.Path, _config.FuzzyMatchThreshold); + string safePath = string.Empty; + if (!string.IsNullOrEmpty(_config.Path)) + { + safePath = Path.GetFullPath(_config.Path); + if (!safePath.StartsWith(_dnsServer.ApplicationFolder)) throw new SecurityException("Access Denied"); + + } + _detector = new TyposquattingDetector(_domainListFilePath, safePath, _config.FuzzyMatchThreshold); _dnsServer.WriteLog($"Typosquatting Detector: Processing completed."); } catch (IOException ex) From 216c9ac802ffa57271cdcb31fd5787a8f623efa0 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 30 Dec 2025 21:39:18 +0200 Subject: [PATCH 06/62] Added exception handling for file parsing --- .../TyposquattingDetector/TyposquattingDetector.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index b3d970d15..71abd9086 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -157,11 +157,19 @@ private void LoadData(string filePath, string customPath) while (reader.ReadLine() is { } line) { - string? domain = ExtractDomain(line); - if (string.IsNullOrEmpty(domain)) continue; + string? domain = null; + try + { + domain = ExtractDomain(line); + if (string.IsNullOrEmpty(domain)) continue; - _bloomFilter.Add(domain); + _bloomFilter.Add(domain.ToLowerInvariant()); + } + catch (Exception) + { + continue; // skip corrupted lines + } if (!_lenBuckets.TryGetValue(domain.Length, out var list)) { list = new List(); From 234902381fc4c40733a8393fc65463600f364c93 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 30 Dec 2025 21:44:49 +0200 Subject: [PATCH 07/62] Disposed the threadlocal normalizer --- .../TyposquattingDetector.cs | 60 +++++++++++++++---- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index 71abd9086..77ba938dd 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -51,13 +51,20 @@ public class Result public DetectionStatus Status { get; set; } } - public class TyposquattingDetector + public class TyposquattingDetector : IDisposable { + #region variables + private static CachedHttpRuleProvider? _sharedRuleProvider; private readonly Dictionary> _lenBuckets = new Dictionary>(); private readonly ThreadLocal _normalizer; private readonly int _threshold; private IBloomFilter? _bloomFilter; + private bool disposedValue; + + #endregion variables + + #region constructor public TyposquattingDetector(string defaultPath, string customPath, int threshold) { @@ -76,6 +83,32 @@ public TyposquattingDetector(string defaultPath, string customPath, int threshol LoadData(defaultPath, customPath); } + + #endregion constructor + + #region Dispose + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + _normalizer.Dispose(); + } + disposedValue = true; + } + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + #endregion Dispose + + #region public public Result Check(string query) { var normalized = Normalize(query); @@ -91,35 +124,37 @@ public Result Check(string query) // GATE 2: Fuzzy Similarity Check return FuzzyMatch(normalized, result); } + #endregion public - private Result FuzzyMatch(string q, Result r) + #region private + private Result FuzzyMatch(string query, Result result) { // Remove Task.Run and the await lambda var candidates = new List(); for (int i = -1; i <= 1; i++) - if (_lenBuckets.TryGetValue(q.Length + i, out var bucket)) + if (_lenBuckets.TryGetValue(query.Length + i, out var bucket)) candidates.AddRange(bucket); var best = candidates - .Select(d => new { d, score = Fuzz.WeightedRatio(q, d) }) + .Select(d => new { d, score = Fuzz.WeightedRatio(query, d) }) .OrderByDescending(x => x.score) .FirstOrDefault(); if (best != null && best.score >= _threshold) { - r.BestMatch = best.d; - r.FuzzyScore = best.score; - r.Status = DetectionStatus.Suspicious; - r.Severity = Severity.HIGH; - r.Reason = Reason.Typosquatting; + result.BestMatch = best.d; + result.FuzzyScore = best.score; + result.Status = DetectionStatus.Suspicious; + result.Severity = Severity.HIGH; + result.Reason = Reason.Typosquatting; } else { - r.Status = DetectionStatus.Clean; - r.Reason = Reason.BloomReject; + result.Status = DetectionStatus.Clean; + result.Reason = Reason.BloomReject; } - return r; + return result; } private (bool flowControl, Result? value) Prefilter(string q, Result r) @@ -207,5 +242,6 @@ private string Normalize(string s) return s.ToLowerInvariant().Trim().Replace("www.", ""); } } + #endregion private } } \ No newline at end of file From d345ce2b3bbf9f39a0e332ca53ecfa676dba7762 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 30 Dec 2025 21:50:28 +0200 Subject: [PATCH 08/62] Memory management for detector --- Apps/TyposquattingDetector/App.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index d01842132..b1fa63a88 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -333,7 +333,9 @@ private async Task UpdateDomainListAsync(CancellationToken cancellationToken) if (!safePath.StartsWith(_dnsServer.ApplicationFolder)) throw new SecurityException("Access Denied"); } + var oldDetector = _detector; _detector = new TyposquattingDetector(_domainListFilePath, safePath, _config.FuzzyMatchThreshold); + oldDetector?.Dispose(); _dnsServer.WriteLog($"Typosquatting Detector: Processing completed."); } catch (IOException ex) From 9a9fc59aca5a1d47298bd99e5bc86e18e6de335a Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 30 Dec 2025 21:52:12 +0200 Subject: [PATCH 09/62] Marked detector as volatile --- Apps/TyposquattingDetector/App.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index b1fa63a88..b2719d404 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -44,7 +44,7 @@ public sealed partial class App : IDnsApplication, IDnsRequestBlockingHandler private const string DefaultDomainListUrl = "https://downloads.technitium.com/dns/typosquatting/majestic_million.csv"; private CancellationTokenSource? _appShutdownCts; private Config? _config; - private TyposquattingDetector? _detector; + private volatile TyposquattingDetector? _detector; private IDnsServer? _dnsServer; private string? _domainListFilePath; private HttpClient? _httpClient; From 62c97b407604be01e815dd4d0d676103b308862b Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 30 Dec 2025 21:54:53 +0200 Subject: [PATCH 10/62] Reused same httpclient --- Apps/TyposquattingDetector/TyposquattingDetector.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index 77ba938dd..7753414ed 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -60,6 +60,7 @@ public class TyposquattingDetector : IDisposable private readonly ThreadLocal _normalizer; private readonly int _threshold; private IBloomFilter? _bloomFilter; + private static readonly HttpClient _httpClient = new(); private bool disposedValue; #endregion variables @@ -73,7 +74,7 @@ public TyposquattingDetector(string defaultPath, string customPath, int threshol if (_sharedRuleProvider == null) { var cacheProvider = new Nager.PublicSuffix.RuleProviders.CacheProviders.LocalFileSystemCacheProvider(); - _sharedRuleProvider = new CachedHttpRuleProvider(cacheProvider, new HttpClient()); + _sharedRuleProvider = new CachedHttpRuleProvider(cacheProvider, _httpClient); _sharedRuleProvider.BuildAsync().GetAwaiter().GetResult(); } From ac3a4b94874a87ea0683652a8887998876c7ebec Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 30 Dec 2025 22:05:48 +0200 Subject: [PATCH 11/62] Fixed async query --- Apps/TyposquattingDetector/App.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index b2719d404..559090da6 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -151,24 +151,24 @@ public Task IsAllowedAsync(DnsDatagram request, IPEndPoint remoteEP) return Task.FromResult(false); } - public async Task ProcessRequestAsync(DnsDatagram request, IPEndPoint remoteEP) + public Task ProcessRequestAsync(DnsDatagram request, IPEndPoint remoteEP) { if (_config?.Enable != true) { - return null; + return Task.FromResult(null); } // Download takes time. Let's not break the app. if (_detector is null) { - return null; + return Task.FromResult(null); } DnsQuestionRecord question = request.Question[0]; var res = _detector.Check(question.Name); if (res.Status == DetectionStatus.Clean) { - return null; + return Task.FromResult(null); } string blockingReport = $"source=typosquatting-detector;domain={res.Query};severity={res.Severity};reason={res.Reason}"; @@ -182,7 +182,7 @@ public Task IsAllowedAsync(DnsDatagram request, IPEndPoint remoteEP) if (_config.AllowTxtBlockingReport && question.Type == DnsResourceRecordType.TXT) { DnsResourceRecord[] answer = new DnsResourceRecord[] { new DnsResourceRecord(question.Name, DnsResourceRecordType.TXT, question.Class, 60, new DnsTXTRecordData(blockingReport)) }; - return new DnsDatagram( + return Task.FromResult(new DnsDatagram( ID: request.Identifier, isResponse: true, OPCODE: DnsOpcode.StandardQuery, @@ -200,11 +200,11 @@ public Task IsAllowedAsync(DnsDatagram request, IPEndPoint remoteEP) udpPayloadSize: request.EDNS is null ? ushort.MinValue : _dnsServer.UdpPayloadSize, ednsFlags: EDnsHeaderFlags.None, options: options - ); + )); } DnsResourceRecord[] authority = { new DnsResourceRecord(question.Name, DnsResourceRecordType.SOA, question.Class, 60, _soaRecord) }; - return new DnsDatagram( + return Task.FromResult(new DnsDatagram( ID: request.Identifier, isResponse: true, OPCODE: DnsOpcode.StandardQuery, @@ -222,7 +222,7 @@ public Task IsAllowedAsync(DnsDatagram request, IPEndPoint remoteEP) udpPayloadSize: request.EDNS is null ? ushort.MinValue : _dnsServer.UdpPayloadSize, ednsFlags: EDnsHeaderFlags.None, options: options - ); + )); } #endregion public From d82deb5f4435382933381107e90ceb7c36720fab Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 30 Dec 2025 22:10:12 +0200 Subject: [PATCH 12/62] Fixed nullability attributes --- Apps/TyposquattingDetector/App.cs | 34 +++++++++++++++++++------------ 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index 559090da6..3e68ba40d 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -87,10 +87,18 @@ public async Task InitializeAsync(IDnsServer dnsServer, string config) try { _soaRecord = new DnsSOARecordData(_dnsServer.ServerDomain, _dnsServer.ResponsiblePerson.Address, 1, 14400, 3600, 604800, 60); - _config = JsonSerializer.Deserialize(config, _options); - Validator.ValidateObject(_config, new ValidationContext(_config), validateAllProperties: true); - _updateInterval = ParseUpdateInterval(_config.UpdateInterval); + try + { + _config = JsonSerializer.Deserialize(config, _options); + } + catch (Exception e) + { + throw new AggregateException("Invalid configuration for TyposquattingDetector app.", e); + } + + Validator.ValidateObject(_config!, new ValidationContext(_config!), validateAllProperties: true); + _updateInterval = ParseUpdateInterval(_config!.UpdateInterval); _appShutdownCts = new CancellationTokenSource(); await TryUpdate(_appShutdownCts.Token); @@ -197,7 +205,7 @@ public Task IsAllowedAsync(DnsDatagram request, IPEndPoint remoteEP) answer: answer, authority: null, additional: null, - udpPayloadSize: request.EDNS is null ? ushort.MinValue : _dnsServer.UdpPayloadSize, + udpPayloadSize: request.EDNS is null ? ushort.MinValue : _dnsServer!.UdpPayloadSize, ednsFlags: EDnsHeaderFlags.None, options: options )); @@ -219,7 +227,7 @@ public Task IsAllowedAsync(DnsDatagram request, IPEndPoint remoteEP) answer: null, authority: authority, additional: null, - udpPayloadSize: request.EDNS is null ? ushort.MinValue : _dnsServer.UdpPayloadSize, + udpPayloadSize: request.EDNS is null ? ushort.MinValue : _dnsServer!.UdpPayloadSize, ednsFlags: EDnsHeaderFlags.None, options: options )); @@ -266,13 +274,13 @@ private static TimeSpan ParseUpdateInterval(string interval) private HttpClient CreateHttpClient(Uri serverUrl, bool disableTlsValidation) { HttpClientNetworkHandler handler = new HttpClientNetworkHandler(); - handler.Proxy = _dnsServer.Proxy; + handler.Proxy = _dnsServer!.Proxy; handler.NetworkType = _dnsServer.PreferIPv6 ? HttpClientNetworkType.PreferIPv6 : HttpClientNetworkType.Default; handler.DnsClient = _dnsServer; if (disableTlsValidation) { - handler.InnerHandler.SslOptions.RemoteCertificateValidationCallback = delegate (object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) + handler.InnerHandler.SslOptions.RemoteCertificateValidationCallback = delegate (object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors) { return true; }; @@ -307,12 +315,12 @@ private async Task TryUpdate(CancellationToken cancellationToken) } catch (OperationCanceledException) { - _dnsServer.WriteLog("Update loop is shutting down gracefully."); + _dnsServer!.WriteLog("Update loop is shutting down gracefully."); return false; } catch (Exception ex) { - _dnsServer.WriteLog($"FATAL: The Typosquatting Detector update task failed unexpectedly. Error: {ex.Message}"); + _dnsServer!.WriteLog($"FATAL: The Typosquatting Detector update task failed unexpectedly. Error: {ex.Message}"); _dnsServer.WriteLog(ex); } @@ -325,22 +333,22 @@ private async Task UpdateDomainListAsync(CancellationToken cancellationToken) try { - _dnsServer.WriteLog($"Typosquatting Detector: Processing domain list..."); + _dnsServer!.WriteLog($"Typosquatting Detector: Processing domain list..."); string safePath = string.Empty; - if (!string.IsNullOrEmpty(_config.Path)) + if (!string.IsNullOrEmpty(_config!.Path)) { safePath = Path.GetFullPath(_config.Path); if (!safePath.StartsWith(_dnsServer.ApplicationFolder)) throw new SecurityException("Access Denied"); } var oldDetector = _detector; - _detector = new TyposquattingDetector(_domainListFilePath, safePath, _config.FuzzyMatchThreshold); + _detector = new TyposquattingDetector(_domainListFilePath!, safePath, _config.FuzzyMatchThreshold); oldDetector?.Dispose(); _dnsServer.WriteLog($"Typosquatting Detector: Processing completed."); } catch (IOException ex) { - _dnsServer.WriteLog($"ERROR: Failed to read cache file '{_domainListFilePath}'. Error: {ex.Message}"); + _dnsServer!.WriteLog($"ERROR: Failed to read cache file '{_domainListFilePath}'. Error: {ex.Message}"); } } From 381049f973ec53b63fa77bef895f57761076644b Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Wed, 31 Dec 2025 16:28:20 +0200 Subject: [PATCH 13/62] Minor fix for null bloomfilter --- Apps/TyposquattingDetector/TyposquattingDetector.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index 7753414ed..14eae13cf 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -75,7 +75,7 @@ public TyposquattingDetector(string defaultPath, string customPath, int threshol { var cacheProvider = new Nager.PublicSuffix.RuleProviders.CacheProviders.LocalFileSystemCacheProvider(); _sharedRuleProvider = new CachedHttpRuleProvider(cacheProvider, _httpClient); - _sharedRuleProvider.BuildAsync().GetAwaiter().GetResult(); + _sharedRuleProvider.BuildAsync().GetAwaiter().GetResult(); // Initialize synchronously, explicitly } _normalizer = new ThreadLocal(() => @@ -160,7 +160,7 @@ private Result FuzzyMatch(string query, Result result) private (bool flowControl, Result? value) Prefilter(string q, Result r) { - if (_bloomFilter.Contains(q)) + if (_bloomFilter is not null && _bloomFilter.Contains(q)) { r.Status = DetectionStatus.Clean; r.Reason = Reason.Exact; From 55be360dd83b19f0df9d3eed3a55a7dd93ab1dae Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Wed, 31 Dec 2025 17:49:20 +0200 Subject: [PATCH 14/62] Final touches --- Apps/TyposquattingDetector/App.cs | 77 ++++++++++++------- .../TyposquattingDetector.cs | 25 +++--- 2 files changed, 64 insertions(+), 38 deletions(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index 3e68ba40d..95a979ec3 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -22,10 +22,12 @@ You should have received a copy of the GNU General Public License using System.ComponentModel.DataAnnotations; using System.Globalization; using System.IO; +using System.Linq; using System.Net; using System.Net.Http; using System.Net.Security; using System.Security; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text.Json; using System.Threading; @@ -41,7 +43,7 @@ public sealed partial class App : IDnsApplication, IDnsRequestBlockingHandler { #region variables - private const string DefaultDomainListUrl = "https://downloads.technitium.com/dns/typosquatting/majestic_million.csv"; + private const string DefaultDomainListUrl = "https://downloads.majestic.com/majestic_million.csv"; private CancellationTokenSource? _appShutdownCts; private Config? _config; private volatile TyposquattingDetector? _detector; @@ -52,7 +54,7 @@ public sealed partial class App : IDnsApplication, IDnsRequestBlockingHandler private TimeSpan _updateInterval; private Task? _updateLoopTask; private static readonly JsonSerializerOptions _options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - + private bool _changed = false; #endregion variables #region IDisposable @@ -100,7 +102,6 @@ public async Task InitializeAsync(IDnsServer dnsServer, string config) Validator.ValidateObject(_config!, new ValidationContext(_config!), validateAllProperties: true); _updateInterval = ParseUpdateInterval(_config!.UpdateInterval); _appShutdownCts = new CancellationTokenSource(); - await TryUpdate(_appShutdownCts.Token); string configDir = _dnsServer.ApplicationFolder; Directory.CreateDirectory(configDir); @@ -120,10 +121,31 @@ await _httpClient.GetStreamAsync(domainList).ContinueWith(async t => { try { + var hashPath = Path.Combine(configDir, "majestic_million.csv.sha256"); using (Stream stream = await t) using (FileStream fs = new FileStream(_domainListFilePath, FileMode.Create, FileAccess.Write, FileShare.None)) { - await stream.CopyToAsync(fs); + await stream.CopyToAsync(fs, _appShutdownCts.Token); + } + + // Re-read file to calculate hash (or use a CryptoStream during download) + using (FileStream fs = new FileStream(_domainListFilePath, FileMode.Open, FileAccess.Read)) + { + string sha256 = Convert.ToHexString(await SHA256.HashDataAsync(fs)); + _dnsServer.WriteLog($"Typosquatting Detector: SHA256 hash of downloaded domain list: {sha256}"); + + if (File.Exists(hashPath) && File.ReadLines(hashPath).ToArray()[0] == sha256) + { + _changed = false; + _dnsServer.WriteLog($"Typosquatting Detector: Downloaded domain list is identical to the previous one. No changes made."); + } + else + { + using StreamWriter writer = new StreamWriter(new FileStream(hashPath, FileMode.Create, FileAccess.Write, FileShare.None)); + await writer.WriteAsync(sha256); + _changed = true; + _dnsServer.WriteLog($"Typosquatting Detector: Hash file is saved."); + } } _dnsServer.WriteLog($"Typosquatting Detector: Downloaded domain list from '{domainList}' to '{_domainListFilePath}'."); } @@ -134,18 +156,17 @@ await _httpClient.GetStreamAsync(domainList).ContinueWith(async t => } }).GetAwaiter().GetResult().ConfigureAwait(false); } - _dnsServer.WriteLog($"Typosquatting Detector: Domain list saved to path: '{_domainListFilePath}'."); // We do not await this, as it's designed to run for the lifetime of the app. _updateLoopTask = StartUpdateLoopAsync(_appShutdownCts.Token); _ = _updateLoopTask.ContinueWith(t => - { - if (t.IsFaulted) - { - _dnsServer.WriteLog($"FATAL: Update loop terminated unexpectedly: {t.Exception?.GetBaseException().Message}"); - _dnsServer.WriteLog(t.Exception); - } - }, TaskContinuationOptions.OnlyOnFaulted); + { + if (t.IsFaulted) + { + _dnsServer.WriteLog($"FATAL: Update loop terminated unexpectedly: {t.Exception?.GetBaseException().Message}"); + _dnsServer.WriteLog(t.Exception); + } + }, TaskContinuationOptions.OnlyOnFaulted); } catch (Exception ex) { @@ -241,7 +262,7 @@ private static TimeSpan ParseUpdateInterval(string interval) { if (string.IsNullOrWhiteSpace(interval) || interval.Length < 2) { - throw new FormatException("Update interval is not in a valid format (e.g., '60m', '2h', '7d')."); + throw new FormatException("Update interval is not in a valid format (e.g., '30m', '12h', '1d', '2w')."); } string unit = interval.Substring(interval.Length - 1).ToLowerInvariant(); @@ -293,18 +314,25 @@ private HttpClient CreateHttpClient(Uri serverUrl, bool disableTlsValidation) private async Task StartUpdateLoopAsync(CancellationToken cancellationToken) { - using PeriodicTimer timer = new PeriodicTimer(_updateInterval); - while (!cancellationToken.IsCancellationRequested) + if (!_changed) + { + // Nothing changed, skip first update + } + else { - bool flowControl = await TryUpdate(cancellationToken); - if (!flowControl) + using PeriodicTimer timer = new PeriodicTimer(_updateInterval); + while (!cancellationToken.IsCancellationRequested) { - break; - } + bool flowControl = await TryUpdate(cancellationToken); + if (!flowControl) + { + break; + } - await timer.WaitForNextTickAsync(cancellationToken); + await timer.WaitForNextTickAsync(cancellationToken); + } } - await Task.Delay(TimeSpan.FromSeconds(Random.Shared.Next(5, 30)), cancellationToken); + await Task.Delay(TimeSpan.FromSeconds(Random.Shared.Next(0, 60)), cancellationToken); } private async Task TryUpdate(CancellationToken cancellationToken) @@ -335,12 +363,9 @@ private async Task UpdateDomainListAsync(CancellationToken cancellationToken) { _dnsServer!.WriteLog($"Typosquatting Detector: Processing domain list..."); string safePath = string.Empty; - if (!string.IsNullOrEmpty(_config!.Path)) - { - safePath = Path.GetFullPath(_config.Path); - if (!safePath.StartsWith(_dnsServer.ApplicationFolder)) throw new SecurityException("Access Denied"); + safePath = Path.GetFullPath(_domainListFilePath!); + if (!safePath.StartsWith(_dnsServer.ApplicationFolder)) throw new SecurityException("Access Denied"); - } var oldDetector = _detector; _detector = new TyposquattingDetector(_domainListFilePath!, safePath, _config.FuzzyMatchThreshold); oldDetector?.Dispose(); diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index 14eae13cf..e2fd546c7 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -130,7 +130,6 @@ public Result Check(string query) #region private private Result FuzzyMatch(string query, Result result) { - // Remove Task.Run and the await lambda var candidates = new List(); for (int i = -1; i <= 1; i++) if (_lenBuckets.TryGetValue(query.Length + i, out var bucket)) @@ -214,20 +213,22 @@ private void LoadData(string filePath, string customPath) if (list.Count < 10000) list.Add(domain); } - if (!string.IsNullOrEmpty(customPath) && File.Exists(customPath)) + if (string.IsNullOrEmpty(customPath) || !File.Exists(customPath)) { - foreach (var line in File.ReadLines(customPath)) + return; + } + + foreach (var line in File.ReadLines(customPath)) + { + var domain = line.Trim(); + if (string.IsNullOrEmpty(domain)) continue; + _bloomFilter.Add(domain); + if (!_lenBuckets.TryGetValue(domain.Length, out var list)) { - var domain = line.Trim(); - if (string.IsNullOrEmpty(domain)) continue; - _bloomFilter.Add(domain); - if (!_lenBuckets.TryGetValue(domain.Length, out var list)) - { - list = new List(); - _lenBuckets[domain.Length] = list; - } - if (list.Count < 10000) list.Add(domain); + list = new List(); + _lenBuckets[domain.Length] = list; } + if (list.Count < 10000) list.Add(domain); } } From 43e2701d77b65e8b7cea27df7c4363983843eea0 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 17:50:02 +0200 Subject: [PATCH 15/62] Fixed sync-in-async issue --- Apps/TyposquattingDetector/App.cs | 68 ++++++++++++++++--------------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index 95a979ec3..5a63b03f6 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -115,50 +115,52 @@ public async Task InitializeAsync(IDnsServer dnsServer, string config) { _dnsServer.WriteLog($"Typosquatting Detector: Started downloading domain list to path: '{_domainListFilePath}'."); - Uri domainList = new Uri(DefaultDomainListUrl); - _httpClient = CreateHttpClient(domainList, _config.DisableTlsValidation); - await _httpClient.GetStreamAsync(domainList).ContinueWith(async t => + try { - try + Uri domainList = new Uri(DefaultDomainListUrl); + _httpClient = CreateHttpClient(domainList, _config.DisableTlsValidation); + + using (Stream stream = await _httpClient.GetStreamAsync(domainList)) + using (FileStream fs = new FileStream(_domainListFilePath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + await stream.CopyToAsync(fs, _appShutdownCts.Token); + } + + // Re-read file to calculate hash (or use a CryptoStream during download) + using (FileStream fs = new FileStream(_domainListFilePath, FileMode.Open, FileAccess.Read)) { + string sha256 = Convert.ToHexString(await SHA256.HashDataAsync(fs)); + _dnsServer.WriteLog($"Typosquatting Detector: SHA256 hash of downloaded domain list: {sha256}"); + var hashPath = Path.Combine(configDir, "majestic_million.csv.sha256"); - using (Stream stream = await t) - using (FileStream fs = new FileStream(_domainListFilePath, FileMode.Create, FileAccess.Write, FileShare.None)) + if (File.Exists(hashPath) && File.ReadLines(hashPath).ToArray()[0] == sha256) { - await stream.CopyToAsync(fs, _appShutdownCts.Token); + _changed = false; + _dnsServer.WriteLog($"Typosquatting Detector: Downloaded domain list is identical to the previous one. No changes made."); } - - // Re-read file to calculate hash (or use a CryptoStream during download) - using (FileStream fs = new FileStream(_domainListFilePath, FileMode.Open, FileAccess.Read)) + else { - string sha256 = Convert.ToHexString(await SHA256.HashDataAsync(fs)); - _dnsServer.WriteLog($"Typosquatting Detector: SHA256 hash of downloaded domain list: {sha256}"); - - if (File.Exists(hashPath) && File.ReadLines(hashPath).ToArray()[0] == sha256) - { - _changed = false; - _dnsServer.WriteLog($"Typosquatting Detector: Downloaded domain list is identical to the previous one. No changes made."); - } - else - { - using StreamWriter writer = new StreamWriter(new FileStream(hashPath, FileMode.Create, FileAccess.Write, FileShare.None)); - await writer.WriteAsync(sha256); - _changed = true; - _dnsServer.WriteLog($"Typosquatting Detector: Hash file is saved."); - } + await File.WriteAllTextAsync(hashPath, sha256, _appShutdownCts.Token); + _changed = true; + _dnsServer.WriteLog($"Typosquatting Detector: Hash file is saved."); } - _dnsServer.WriteLog($"Typosquatting Detector: Downloaded domain list from '{domainList}' to '{_domainListFilePath}'."); - } - catch (Exception ex) - { - _dnsServer.WriteLog($"FATAL: Failed to download domain list from '{domainList}'. Error: {ex.Message}"); - _dnsServer.WriteLog(ex); } - }).GetAwaiter().GetResult().ConfigureAwait(false); + _dnsServer.WriteLog($"Typosquatting Detector: Downloaded domain list from '{domainList}' to '{_domainListFilePath}'."); + } + catch (Exception ex) + { + _dnsServer.WriteLog($"FATAL: Failed to download domain list. Error: {ex.Message}"); + _dnsServer.WriteLog(ex); + } } - // We do not await this, as it's designed to run for the lifetime of the app. + + // We await this so InitializeAsync doesn't finish until the detector is ready. + await UpdateDomainListAsync(_appShutdownCts.Token); + + // Now that _detector is initiated, start the periodic update loop _updateLoopTask = StartUpdateLoopAsync(_appShutdownCts.Token); + _ = _updateLoopTask.ContinueWith(t => { if (t.IsFaulted) From 5ef1b49bb576dd10d59f83ab5bebd9dc7b08fca1 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 17:53:43 +0200 Subject: [PATCH 16/62] Fixed hash-check bug --- Apps/TyposquattingDetector/App.cs | 50 ++++++++++++++++--------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index 5a63b03f6..783c3bc73 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -107,11 +107,7 @@ public async Task InitializeAsync(IDnsServer dnsServer, string config) Directory.CreateDirectory(configDir); _domainListFilePath = Path.Combine(configDir, "majestic_million.csv"); - if (Path.Exists(_domainListFilePath)) - { - _dnsServer.WriteLog($"Typosquatting Detector: Domain list exists at path: '{_domainListFilePath}'."); - } - else + if (!Path.Exists(_domainListFilePath)) { _dnsServer.WriteLog($"Typosquatting Detector: Started downloading domain list to path: '{_domainListFilePath}'."); @@ -126,25 +122,6 @@ public async Task InitializeAsync(IDnsServer dnsServer, string config) await stream.CopyToAsync(fs, _appShutdownCts.Token); } - // Re-read file to calculate hash (or use a CryptoStream during download) - using (FileStream fs = new FileStream(_domainListFilePath, FileMode.Open, FileAccess.Read)) - { - string sha256 = Convert.ToHexString(await SHA256.HashDataAsync(fs)); - _dnsServer.WriteLog($"Typosquatting Detector: SHA256 hash of downloaded domain list: {sha256}"); - - var hashPath = Path.Combine(configDir, "majestic_million.csv.sha256"); - if (File.Exists(hashPath) && File.ReadLines(hashPath).ToArray()[0] == sha256) - { - _changed = false; - _dnsServer.WriteLog($"Typosquatting Detector: Downloaded domain list is identical to the previous one. No changes made."); - } - else - { - await File.WriteAllTextAsync(hashPath, sha256, _appShutdownCts.Token); - _changed = true; - _dnsServer.WriteLog($"Typosquatting Detector: Hash file is saved."); - } - } _dnsServer.WriteLog($"Typosquatting Detector: Downloaded domain list from '{domainList}' to '{_domainListFilePath}'."); } catch (Exception ex) @@ -153,6 +130,31 @@ public async Task InitializeAsync(IDnsServer dnsServer, string config) _dnsServer.WriteLog(ex); } } + else + { + _dnsServer.WriteLog($"Typosquatting Detector: Domain list exists at path: '{_domainListFilePath}'."); + } + + + // Re-read file to calculate hash (or use a CryptoStream during download) + using (FileStream fs = new FileStream(_domainListFilePath, FileMode.Open, FileAccess.Read)) + { + string sha256 = Convert.ToHexString(await SHA256.HashDataAsync(fs)); + _dnsServer.WriteLog($"Typosquatting Detector: SHA256 hash of downloaded domain list: {sha256}"); + + var hashPath = Path.Combine(configDir, "majestic_million.csv.sha256"); + if (File.Exists(hashPath) && File.ReadLines(hashPath).ToArray()[0] == sha256) + { + _changed = false; + _dnsServer.WriteLog($"Typosquatting Detector: Downloaded domain list is identical to the previous one. No changes made."); + } + else + { + await File.WriteAllTextAsync(hashPath, sha256, _appShutdownCts.Token); + _changed = true; + _dnsServer.WriteLog($"Typosquatting Detector: Hash file is saved."); + } + } // We await this so InitializeAsync doesn't finish until the detector is ready. From 3be8c4426404068d817d1d2c246a2b377f46c4dd Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 17:54:48 +0200 Subject: [PATCH 17/62] Fixed timer issue --- Apps/TyposquattingDetector/App.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index 783c3bc73..14ffd4b14 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -318,13 +318,14 @@ private HttpClient CreateHttpClient(Uri serverUrl, bool disableTlsValidation) private async Task StartUpdateLoopAsync(CancellationToken cancellationToken) { + using PeriodicTimer timer = new PeriodicTimer(_updateInterval); + if (!_changed) { // Nothing changed, skip first update } else { - using PeriodicTimer timer = new PeriodicTimer(_updateInterval); while (!cancellationToken.IsCancellationRequested) { bool flowControl = await TryUpdate(cancellationToken); From 4e819145a6b99ca8652e6c0f69e3265cc4ed759c Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 18:06:47 +0200 Subject: [PATCH 18/62] Prioritized user provided domain list --- .../TyposquattingDetector.cs | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index e2fd546c7..b243507cc 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -182,11 +182,32 @@ private Result FuzzyMatch(string query, Result result) return (thirdComma == -1 ? afterSecond : afterSecond.Slice(0, thirdComma)).ToString(); } - private void LoadData(string filePath, string customPath) + private void LoadData(string oneMilFilePath, string customPath) { _bloomFilter = FilterBuilder.Build(1_000_000, 0.01); - using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 65536); + // Load custom list first + if (string.IsNullOrEmpty(customPath) || !File.Exists(customPath)) + { + return; + } + + foreach (var line in File.ReadLines(customPath)) + { + var domain = line.Trim(); + if (string.IsNullOrEmpty(domain)) continue; + _bloomFilter.Add(domain); + if (!_lenBuckets.TryGetValue(domain.Length, out var list)) + { + list = new List(); + _lenBuckets[domain.Length] = list; + } + if (list.Count < 10000) list.Add(domain); + } + + + // Load majestic 1 million list later + using var fs = new FileStream(oneMilFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, 65536); using var reader = new StreamReader(fs); reader.ReadLine(); @@ -212,24 +233,6 @@ private void LoadData(string filePath, string customPath) } if (list.Count < 10000) list.Add(domain); } - - if (string.IsNullOrEmpty(customPath) || !File.Exists(customPath)) - { - return; - } - - foreach (var line in File.ReadLines(customPath)) - { - var domain = line.Trim(); - if (string.IsNullOrEmpty(domain)) continue; - _bloomFilter.Add(domain); - if (!_lenBuckets.TryGetValue(domain.Length, out var list)) - { - list = new List(); - _lenBuckets[domain.Length] = list; - } - if (list.Count < 10000) list.Add(domain); - } } private string Normalize(string s) From 16ee8982885a895dba28c32c7c6ef85705cb4955 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 18:09:14 +0200 Subject: [PATCH 19/62] Added "m" as a common subdomain name to cleanup --- Apps/TyposquattingDetector/TyposquattingDetector.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index b243507cc..aea0c717f 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -244,7 +244,10 @@ private string Normalize(string s) } catch { - return s.ToLowerInvariant().Trim().Replace("www.", ""); + var clean = s.ToLowerInvariant().Trim(); + if (clean.StartsWith("www.")) clean = clean.Substring(4); + if (clean.StartsWith("m.")) clean = clean.Substring(2); + return clean; } } #endregion private From a142f23c96b574509c41ed06583062c6ec94987c Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 18:23:01 +0200 Subject: [PATCH 20/62] Optimized fuzzy matching --- .../TyposquattingDetector.cs | 129 +++++++++--------- 1 file changed, 67 insertions(+), 62 deletions(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index aea0c717f..33bd60a09 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -27,6 +27,7 @@ You should have received a copy of the GNU General Public License using System.Linq; using System.Net.Http; using System.Threading; +using System.Threading.Tasks; namespace TyposquattingDetector { @@ -115,60 +116,75 @@ public Result Check(string query) var normalized = Normalize(query); var result = new Result(normalized); - // GATE 1: Known Famous Site - (bool flowControl, Result? prefilterResult) = Prefilter(normalized, result); - if (!flowControl) + // GATE 1: Bloom Filter Prefilter (O(1)) + if (_bloomFilter is not null && _bloomFilter.Contains(normalized)) { - return prefilterResult!; + result.Status = DetectionStatus.Clean; + result.Reason = Reason.Exact; + return result; } // GATE 2: Fuzzy Similarity Check return FuzzyMatch(normalized, result); } - #endregion public - #region private private Result FuzzyMatch(string query, Result result) { - var candidates = new List(); + string? bestDomain = null; + int maxScore = 0; + object lockObj = new object(); + + // Collect relevant buckets (Length +/- 1) + var targetBuckets = new List>(); for (int i = -1; i <= 1; i++) + { if (_lenBuckets.TryGetValue(query.Length + i, out var bucket)) - candidates.AddRange(bucket); + targetBuckets.Add(bucket); + } - var best = candidates - .Select(d => new { d, score = Fuzz.WeightedRatio(query, d) }) - .OrderByDescending(x => x.score) - .FirstOrDefault(); + // High-performance parallel search with adaptive pruning + foreach (var bucket in targetBuckets) + { + Parallel.ForEach(bucket, (domain, state) => + { + // Adaptive Pruning: If another thread found a near-perfect match, stop + if (maxScore >= 98) state.Stop(); + + int score = Fuzz.WeightedRatio(query, domain); + + if (score > _threshold) + { + lock (lockObj) + { + if (score > maxScore) + { + maxScore = score; + bestDomain = domain; + } + } + } + }); + + if (maxScore >= 98) break; // Optimization: Stop checking other buckets if we found a top match + } - if (best != null && best.score >= _threshold) + if (bestDomain != null) { - result.BestMatch = best.d; - result.FuzzyScore = best.score; + result.BestMatch = bestDomain; + result.FuzzyScore = maxScore; result.Status = DetectionStatus.Suspicious; - result.Severity = Severity.HIGH; + result.Severity = maxScore > 90 ? Severity.HIGH : Severity.MEDIUM; result.Reason = Reason.Typosquatting; } else { result.Status = DetectionStatus.Clean; - result.Reason = Reason.BloomReject; + result.Reason = Reason.NoCandidates; } return result; } - private (bool flowControl, Result? value) Prefilter(string q, Result r) - { - if (_bloomFilter is not null && _bloomFilter.Contains(q)) - { - r.Status = DetectionStatus.Clean; - r.Reason = Reason.Exact; - return (flowControl: false, value: r); - } - - return (flowControl: true, value: null); - } - private static string? ExtractDomain(string line) { ReadOnlySpan span = line.AsSpan(); @@ -184,54 +200,43 @@ private Result FuzzyMatch(string query, Result result) private void LoadData(string oneMilFilePath, string customPath) { - _bloomFilter = FilterBuilder.Build(1_000_000, 0.01); + // Capacity for 1M domains + custom list + _bloomFilter = FilterBuilder.Build(1_100_000, 0.001); - // Load custom list first - if (string.IsNullOrEmpty(customPath) || !File.Exists(customPath)) + // Helper to add domains to both Bloom and Buckets + void processDomain(string domain) { - return; - } - - foreach (var line in File.ReadLines(customPath)) - { - var domain = line.Trim(); - if (string.IsNullOrEmpty(domain)) continue; + if (string.IsNullOrWhiteSpace(domain)) return; + domain = domain.ToLowerInvariant(); _bloomFilter.Add(domain); if (!_lenBuckets.TryGetValue(domain.Length, out var list)) { list = new List(); _lenBuckets[domain.Length] = list; } - if (list.Count < 10000) list.Add(domain); + // Cap fuzzy search candidates per length to keep search times predictable + if (list.Count < 15000) list.Add(domain); } - - // Load majestic 1 million list later - using var fs = new FileStream(oneMilFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, 65536); - using var reader = new StreamReader(fs); - reader.ReadLine(); - - while (reader.ReadLine() is { } line) + // 1. Load custom list + if (!string.IsNullOrEmpty(customPath) && File.Exists(customPath)) { - string? domain = null; - try - { - domain = ExtractDomain(line); - if (string.IsNullOrEmpty(domain)) continue; + foreach (var line in File.ReadLines(customPath)) + processDomain(line.Trim()); + } - _bloomFilter.Add(domain.ToLowerInvariant()); + // 2. Load Majestic 1M + if (File.Exists(oneMilFilePath)) + { + using var fs = new FileStream(oneMilFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024); + using var reader = new StreamReader(fs); + reader.ReadLine(); // Skip header - } - catch (Exception) - { - continue; // skip corrupted lines - } - if (!_lenBuckets.TryGetValue(domain.Length, out var list)) + while (reader.ReadLine() is { } line) { - list = new List(); - _lenBuckets[domain.Length] = list; + var domain = ExtractDomain(line); + if (domain != null) processDomain(domain); } - if (list.Count < 10000) list.Add(domain); } } From 1ef772ea6a3d73e01ea87ecb87ac9a2069ef2900 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 18:35:44 +0200 Subject: [PATCH 21/62] Minor performance optimizations on hot path --- Apps/TyposquattingDetector/App.cs | 2 +- .../TyposquattingDetector.cs | 104 ++++++++++++++---- 2 files changed, 81 insertions(+), 25 deletions(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index 14ffd4b14..2c4d3f567 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -372,7 +372,7 @@ private async Task UpdateDomainListAsync(CancellationToken cancellationToken) if (!safePath.StartsWith(_dnsServer.ApplicationFolder)) throw new SecurityException("Access Denied"); var oldDetector = _detector; - _detector = new TyposquattingDetector(_domainListFilePath!, safePath, _config.FuzzyMatchThreshold); + _detector = new TyposquattingDetector(_domainListFilePath!, safePath, _config!.FuzzyMatchThreshold); oldDetector?.Dispose(); _dnsServer.WriteLog($"Typosquatting Detector: Processing completed."); } diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index 33bd60a09..51704885d 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -131,49 +131,105 @@ public Result Check(string query) private Result FuzzyMatch(string query, Result result) { string? bestDomain = null; - int maxScore = 0; - object lockObj = new object(); + int bestScore = 0; + + // Collect candidate buckets (Length ±1) + var buckets = new List?[3]; + int bi = 0; - // Collect relevant buckets (Length +/- 1) - var targetBuckets = new List>(); for (int i = -1; i <= 1; i++) { - if (_lenBuckets.TryGetValue(query.Length + i, out var bucket)) - targetBuckets.Add(bucket); + if (_lenBuckets.TryGetValue(query.Length + i, out var b)) + buckets[bi++] = b; } - // High-performance parallel search with adaptive pruning - foreach (var bucket in targetBuckets) + // --- cheap lexical + trigram prefilter --- + static bool PassesPrefilter(string q, string d, int threshold) { - Parallel.ForEach(bucket, (domain, state) => - { - // Adaptive Pruning: If another thread found a near-perfect match, stop - if (maxScore >= 98) state.Stop(); + int dl = d.Length; + int ql = q.Length; + + // reject far-length candidates + if (Math.Abs(dl - ql) > 2) + return false; + + // fast first-char rejection + if (q[0] != d[0]) + return false; + + // tiny strings → go straight to Fuzz() + if (ql < 4 || dl < 4) + return true; + + // small trigram overlap check (no alloc) + int hits = 0; + for (int i = 0; i < Math.Min(ql, dl) - 2; i++) + if (d.AsSpan().IndexOf(q.AsSpan(i, 3)) >= 0) hits++; + + // require minimal neighborhood similarity + return hits >= 1 || threshold <= 80; + } + + // --- shard scan with thread-local best --- + for (int i = 0; i < bi; i++) + { + var bucket = buckets[i]; + if (bucket is null) continue; + + var locals = new System.Collections.Concurrent.ConcurrentBag<(int score, string dom)>(); - int score = Fuzz.WeightedRatio(query, domain); + Parallel.ForEach( + bucket, + () => (score: 0, dom: (string?)null), - if (score > _threshold) + (domain, state, local) => { - lock (lockObj) + if (bestScore >= 98) { - if (score > maxScore) - { - maxScore = score; - bestDomain = domain; - } + state.Stop(); + return local; } + + if (!PassesPrefilter(query, domain, _threshold)) + return local; + + int score = Fuzz.WeightedRatio(query, domain); + + if (score > local.score) + local = (score, domain); + + if (score >= 95) + state.Stop(); + + return local; + }, + + local => + { + if (local.score > 0 && local.dom is not null) + locals.Add((local.score, local.dom)); } - }); + ); - if (maxScore >= 98) break; // Optimization: Stop checking other buckets if we found a top match + // serial reduction (no races) + foreach (var l in locals) + { + if (l.score > bestScore) + { + bestScore = l.score; + bestDomain = l.dom; + } + } + if (bestScore >= 98) + break; } if (bestDomain != null) { result.BestMatch = bestDomain; - result.FuzzyScore = maxScore; + result.FuzzyScore = bestScore; result.Status = DetectionStatus.Suspicious; - result.Severity = maxScore > 90 ? Severity.HIGH : Severity.MEDIUM; + result.Severity = bestScore > 90 ? Severity.HIGH : Severity.MEDIUM; result.Reason = Reason.Typosquatting; } else From ccc064e01153ac1ac4b44d67fcbf79a5387ae357 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 18:45:32 +0200 Subject: [PATCH 22/62] Added README --- Apps/TyposquattingDetector/README.md | 51 ++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 Apps/TyposquattingDetector/README.md diff --git a/Apps/TyposquattingDetector/README.md b/Apps/TyposquattingDetector/README.md new file mode 100644 index 000000000..b3ac1d2ea --- /dev/null +++ b/Apps/TyposquattingDetector/README.md @@ -0,0 +1,51 @@ +# Typosquatting Detector for Technitium DNS Server + +A DNS security plugin that detects and blocks look-alike domains associated with phishing and brand impersonation. The plugin evaluates similarity between queried domains and a high-reputation corpus and blocks near-miss variants before resolution. + +## Detection model + +The plugin builds a trusted corpus from the Majestic Million list plus an optional custom list. For each query it: + +1. Normalizes to the registrable domain using Public Suffix rules. +2. Performs an O(1) Bloom filter check for known legitimate domains. +3. Runs fuzzy similarity matching against length-adjacent candidates for unknown domains. + +Queries above the configured similarity threshold are classified as probable typosquats and blocked. + +## Enforcement behavior + +Suspicious domains receive an authoritative NXDOMAIN with SOA. Optional Extended DNS Error metadata and optional TXT blocking reports expose structured blocking details for logs and SIEM ingestion. Clean domains are not modified and resolve normally. + +## Configuration + +Example configuration: + +```json +{ + "enable": true, + "fuzzyMatchThreshold": 75, + "customList": "/path/to/custom-domains.txt", + "disableTlsValidation": false, + "updateInterval": "30d", + "allowTxtBlockingReport": true, + "addExtendedDnsError": true +} +``` + +Key options + +* fuzzyMatchThreshold (75–90): main sensitivity control. Lower values detect more variants but increase false positives. +* customList: one domain per line; add organization and brand domains you want treated as trusted. +* updateInterval: controls when the Majestic list is reprocessed; rebuilds are skipped when the file hash is unchanged. +* allowTxtBlockingReport / addExtendedDnsError: control operator visibility of blocking decisions. +* disableTlsValidation: test or lab use only. + +## Deployment and risk considerations + +Start with a conservative threshold (85–90) in production and observe blocks before lowering. False positives are most likely for domains visually similar to major brands but legitimate or newly emerging services. Mitigations include raising the threshold or adding the domain to the custom list. + +This plugin is intended for recursive resolvers operated by security teams where DNS blocking is an accepted control point. Communicate expected behavior to users and support staff to avoid confusion when NXDOMAIN is enforcement rather than resolution failure. + +## Acknowledgements + +Uses [Majestic Million dataset](https://majestic.com/reports/majestic-million), [Nager Public Suffix parser](https://github.com/nager/Nager.PublicSuffix), [BloomFilter.NetCore](https://github.com/vla/BloomFilter.NetCore) and [FuzzySharp](https://github.com/JakeBayer/FuzzySharp) libraries, and the Technitium DNS Server app framework. From 038d2551b3134b07490045523a9c9c9437a8ca84 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 18:47:07 +0200 Subject: [PATCH 23/62] Updated app description --- Apps/TyposquattingDetector/TyposquattingDetector.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.csproj b/Apps/TyposquattingDetector/TyposquattingDetector.csproj index 61b99f400..17acae3f0 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.csproj +++ b/Apps/TyposquattingDetector/TyposquattingDetector.csproj @@ -12,7 +12,7 @@ TyposquattingDetector https://technitium.com/dns/ https://github.com/TechnitiumSoftware/DnsServer - Detects if a queried domainname MIGHT be a typosquatting attempt or not. + Evaluates queried domains against a trusted corpus and flags visually similar near-matches as potential typosquatting. Allows blocking of suspicious queries and exposes structured detection details. The fuzzy-match threshold and optional custom domain list are operator-tunable; adjust cautiously to reduce false-positive impact. false Library true From 4340bebdde650f62aefecbd266e43ce88ecae3e8 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 18:56:35 +0200 Subject: [PATCH 24/62] Fixed regions --- Apps/TyposquattingDetector/TyposquattingDetector.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index 51704885d..6f5e850e1 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -127,7 +127,9 @@ public Result Check(string query) // GATE 2: Fuzzy Similarity Check return FuzzyMatch(normalized, result); } + #endregion public + #region private private Result FuzzyMatch(string query, Result result) { string? bestDomain = null; From c27794eb29e435347fc32596037dff5a7c984d6f Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 19:07:46 +0200 Subject: [PATCH 25/62] Improved null handlng --- Apps/TyposquattingDetector/TyposquattingDetector.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index 6f5e850e1..dad114bfc 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -303,7 +303,8 @@ private string Normalize(string s) if (string.IsNullOrWhiteSpace(s)) return s; try { - return _normalizer!.Value!.Parse(s)!.RegistrableDomain ?? s; + var registrableDomain = _normalizer?.Value?.Parse(s)?.RegistrableDomain; + return registrableDomain ?? s; } catch { From f38c308d099d7a58e8fd06a67c94c3dd5ed40dea Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 19:11:25 +0200 Subject: [PATCH 26/62] Simplified suspicious check --- Apps/TyposquattingDetector/App.cs | 2 +- Apps/TyposquattingDetector/TyposquattingDetector.cs | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index 2c4d3f567..f8d14b09b 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -199,7 +199,7 @@ public Task IsAllowedAsync(DnsDatagram request, IPEndPoint remoteEP) DnsQuestionRecord question = request.Question[0]; var res = _detector.Check(question.Name); - if (res.Status == DetectionStatus.Clean) + if (res.IsSuspicious == false) { return Task.FromResult(null); } diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index dad114bfc..c85504455 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -31,9 +31,6 @@ You should have received a copy of the GNU General Public License namespace TyposquattingDetector { - public enum DetectionStatus - { Clean, Possible, Suspicious } - public enum Reason { BloomReject, Exact, Typosquatting, Medium, Low, NoCandidates } @@ -49,7 +46,7 @@ public class Result public string Query { get; } public Reason Reason { get; set; } public Severity Severity { get; set; } - public DetectionStatus Status { get; set; } + public bool IsSuspicious { get; set; } } public class TyposquattingDetector : IDisposable @@ -119,7 +116,7 @@ public Result Check(string query) // GATE 1: Bloom Filter Prefilter (O(1)) if (_bloomFilter is not null && _bloomFilter.Contains(normalized)) { - result.Status = DetectionStatus.Clean; + result.IsSuspicious = false; result.Reason = Reason.Exact; return result; } @@ -230,13 +227,13 @@ static bool PassesPrefilter(string q, string d, int threshold) { result.BestMatch = bestDomain; result.FuzzyScore = bestScore; - result.Status = DetectionStatus.Suspicious; + result.IsSuspicious = true; result.Severity = bestScore > 90 ? Severity.HIGH : Severity.MEDIUM; result.Reason = Reason.Typosquatting; } else { - result.Status = DetectionStatus.Clean; + result.IsSuspicious = false; result.Reason = Reason.NoCandidates; } From ae1d7ebcd6450e0a66ffca4f4cc5be13754c8746 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 19:12:48 +0200 Subject: [PATCH 27/62] Simplified Reason enum --- Apps/TyposquattingDetector/TyposquattingDetector.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index c85504455..334fbda21 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -32,7 +32,7 @@ You should have received a copy of the GNU General Public License namespace TyposquattingDetector { public enum Reason - { BloomReject, Exact, Typosquatting, Medium, Low, NoCandidates } + { Exact, Typosquatting, NoCandidates } public enum Severity { NONE, LOW, MEDIUM, HIGH } From ea267e069dd682cfa3d660a9b568f1f3aa93fb18 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 19:17:28 +0200 Subject: [PATCH 28/62] Added empty or corrupted hash file edge case handling --- Apps/TyposquattingDetector/App.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index f8d14b09b..fa2a6482b 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -143,7 +143,13 @@ public async Task InitializeAsync(IDnsServer dnsServer, string config) _dnsServer.WriteLog($"Typosquatting Detector: SHA256 hash of downloaded domain list: {sha256}"); var hashPath = Path.Combine(configDir, "majestic_million.csv.sha256"); - if (File.Exists(hashPath) && File.ReadLines(hashPath).ToArray()[0] == sha256) + string? previousHash = null; + if (File.Exists(hashPath)) + { + // Safely read the first line; handle empty or corrupted hash file + previousHash = File.ReadLines(hashPath).FirstOrDefault()?.Trim(); + } + if (!string.IsNullOrEmpty(previousHash) && string.Equals(previousHash, sha256, StringComparison.OrdinalIgnoreCase)) { _changed = false; _dnsServer.WriteLog($"Typosquatting Detector: Downloaded domain list is identical to the previous one. No changes made."); From cf26711971a5e50bdadcca90b7340ae1e558b405 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 19:20:17 +0200 Subject: [PATCH 29/62] Guard clause for httpClient leak edge case --- Apps/TyposquattingDetector/App.cs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index fa2a6482b..1cc2ebbd6 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -114,13 +114,10 @@ public async Task InitializeAsync(IDnsServer dnsServer, string config) try { Uri domainList = new Uri(DefaultDomainListUrl); - _httpClient = CreateHttpClient(domainList, _config.DisableTlsValidation); - - using (Stream stream = await _httpClient.GetStreamAsync(domainList)) - using (FileStream fs = new FileStream(_domainListFilePath, FileMode.Create, FileAccess.Write, FileShare.None)) - { - await stream.CopyToAsync(fs, _appShutdownCts.Token); - } + using HttpClient httpClient = CreateHttpClient(domainList, _config.DisableTlsValidation); + using Stream stream = await httpClient.GetStreamAsync(domainList); + using FileStream fs = new FileStream(_domainListFilePath, FileMode.Create, FileAccess.Write, FileShare.None); + await stream.CopyToAsync(fs, _appShutdownCts.Token); _dnsServer.WriteLog($"Typosquatting Detector: Downloaded domain list from '{domainList}' to '{_domainListFilePath}'."); } From 9cc367a4519ea0eac80b474ead96a7c3f4ad3142 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 19:24:10 +0200 Subject: [PATCH 30/62] Added default severity. --- Apps/TyposquattingDetector/TyposquattingDetector.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index 334fbda21..abebb9ee2 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -45,7 +45,7 @@ public class Result public int FuzzyScore { get; set; } public string Query { get; } public Reason Reason { get; set; } - public Severity Severity { get; set; } + public Severity Severity { get; set; } = Severity.NONE; public bool IsSuspicious { get; set; } } From d46419625605f00c6f59f4041d59bb7392cdacb8 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 19:26:00 +0200 Subject: [PATCH 31/62] Updated description --- Apps/TyposquattingDetector/App.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index 1cc2ebbd6..c2b641ae9 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -393,7 +393,7 @@ public string Description { get { - return "Downloads Alexa toip 1 million domains, runs a fuzzy logic, and if the match is high but not 100, it may be a typosquatting attempt."; + return "Evaluates queried domains against a trusted corpus and flags visually similar near-matches as potential typosquatting. Allows blocking of suspicious queries and exposes structured detection details. The fuzzy-match threshold and optional custom domain list are operator-tunable; adjust cautiously to reduce false-positive impact."; } } From 8fb46f39285a2f5bee18e54a28b2a2557bf63649 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 19:29:06 +0200 Subject: [PATCH 32/62] Made httpclient non-static --- Apps/TyposquattingDetector/TyposquattingDetector.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index abebb9ee2..4808605a8 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -58,7 +58,7 @@ public class TyposquattingDetector : IDisposable private readonly ThreadLocal _normalizer; private readonly int _threshold; private IBloomFilter? _bloomFilter; - private static readonly HttpClient _httpClient = new(); + private readonly HttpClient _httpClient = new HttpClient(); private bool disposedValue; #endregion variables @@ -92,7 +92,8 @@ protected virtual void Dispose(bool disposing) { if (disposing) { - _normalizer.Dispose(); + _httpClient?.Dispose(); + _normalizer?.Dispose(); } disposedValue = true; } From 6b724e3323ee53cef75cd5bde4db3a7aea8b13fc Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 19:34:41 +0200 Subject: [PATCH 33/62] Used regular for loop for small instances to minimize overload --- .../TyposquattingDetector.cs | 140 +++++++++++++----- 1 file changed, 99 insertions(+), 41 deletions(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index 4808605a8..ffd8cd686 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -176,52 +176,23 @@ static bool PassesPrefilter(string q, string d, int threshold) var bucket = buckets[i]; if (bucket is null) continue; - var locals = new System.Collections.Concurrent.ConcurrentBag<(int score, string dom)>(); + // Tuneable knobs + const int SequentialCutoff = 256; + int maxDop = Math.Max(1, Environment.ProcessorCount / 2); - Parallel.ForEach( - bucket, - () => (score: 0, dom: (string?)null), - - (domain, state, local) => - { - if (bestScore >= 98) - { - state.Stop(); - return local; - } - - if (!PassesPrefilter(query, domain, _threshold)) - return local; - - int score = Fuzz.WeightedRatio(query, domain); - - if (score > local.score) - local = (score, domain); - - if (score >= 95) - state.Stop(); - - return local; - }, - - local => - { - if (local.score > 0 && local.dom is not null) - locals.Add((local.score, local.dom)); - } - ); - - // serial reduction (no races) - foreach (var l in locals) + if (bucket.Count <= SequentialCutoff) + { + // Sequential fast-path for small buckets + SequentialMatch(query, ref bestDomain, ref bestScore, bucket); + } + else { - if (l.score > bestScore) + (bool flowControl, (bestDomain, bestScore)) = ParallelMatch(query, bestDomain, bestScore, bucket, maxDop); + if (!flowControl) { - bestScore = l.score; - bestDomain = l.dom; + break; } } - if (bestScore >= 98) - break; } if (bestDomain != null) @@ -239,6 +210,93 @@ static bool PassesPrefilter(string q, string d, int threshold) } return result; + + (bool flowControl, (string? bestDomain, int bestScore) value) ParallelMatch(string query, string? bestDomain, int bestScore, List bucket, int maxDop) + { + { + // Bounded parallelism for large buckets + var locals = new System.Collections.Concurrent.ConcurrentBag<(int score, string dom)>(); + var po = new ParallelOptions { MaxDegreeOfParallelism = maxDop }; + + Parallel.ForEach( + bucket, + po, + () => (score: 0, dom: (string?)null), + + (domain, state, local) => + { + if (bestScore >= 98) + { + state.Stop(); + return local; + } + + if (!PassesPrefilter(query, domain, _threshold)) + return local; + + int score = Fuzz.WeightedRatio(query, domain); + + if (score > local.score) + local = (score, domain); + + if (score >= 95) + state.Stop(); + + return local; + }, + + local => + { + if (local.score > 0 && local.dom is not null) + locals.Add((local.score, local.dom)); + } + ); + + foreach (var l in locals) + if (l.score > bestScore) + { + bestScore = l.score; + bestDomain = l.dom; + } + + + // serial reduction (no races) + foreach (var l in locals) + { + if (l.score > bestScore) + { + bestScore = l.score; + bestDomain = l.dom; + } + } + if (bestScore >= 98) + return (flowControl: false, value: default); + } + + return (flowControl: true, value: default); + } + + void SequentialMatch(string query, ref string? bestDomain, ref int bestScore, List bucket) + { + foreach (var domain in bucket) + { + if (bestScore >= 98) break; + + if (!PassesPrefilter(query, domain, _threshold)) + continue; + + int score = Fuzz.WeightedRatio(query, domain); + + if (score > bestScore) + { + bestScore = score; + bestDomain = domain; + } + + if (score >= 95) + break; + } + } } private static string? ExtractDomain(string line) From 94aed97119dfbc2fd5dcedb036c9562d328e8b50 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 19:48:34 +0200 Subject: [PATCH 34/62] Removed unused httpclient --- Apps/TyposquattingDetector/App.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index c2b641ae9..40a25ba77 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -49,7 +49,6 @@ public sealed partial class App : IDnsApplication, IDnsRequestBlockingHandler private volatile TyposquattingDetector? _detector; private IDnsServer? _dnsServer; private string? _domainListFilePath; - private HttpClient? _httpClient; private DnsSOARecordData? _soaRecord; private TimeSpan _updateInterval; private Task? _updateLoopTask; @@ -75,7 +74,6 @@ public void Dispose() finally { _appShutdownCts?.Dispose(); - _httpClient?.Dispose(); } } From 8375608cb33f2cf5d0b821a1e64700f8fee91ae8 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 20:31:49 +0200 Subject: [PATCH 35/62] Solving race condition in Parallel.Foreach --- .../TyposquattingDetector.cs | 224 ++++++++---------- 1 file changed, 100 insertions(+), 124 deletions(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index ffd8cd686..9af6d222b 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -59,7 +59,14 @@ public class TyposquattingDetector : IDisposable private readonly int _threshold; private IBloomFilter? _bloomFilter; private readonly HttpClient _httpClient = new HttpClient(); - private bool disposedValue; + private readonly object _reductionLock = new object(); + private static readonly object _staticInitLock = new object(); + private bool _disposedValue; + private class MatchState + { + public string? BestDomain; + public int BestScore; + } #endregion variables @@ -69,11 +76,15 @@ public TyposquattingDetector(string defaultPath, string customPath, int threshol { _threshold = threshold; - if (_sharedRuleProvider == null) + // Thread-safe static initialization + lock (_staticInitLock) { - var cacheProvider = new Nager.PublicSuffix.RuleProviders.CacheProviders.LocalFileSystemCacheProvider(); - _sharedRuleProvider = new CachedHttpRuleProvider(cacheProvider, _httpClient); - _sharedRuleProvider.BuildAsync().GetAwaiter().GetResult(); // Initialize synchronously, explicitly + if (_sharedRuleProvider == null) + { + var cacheProvider = new Nager.PublicSuffix.RuleProviders.CacheProviders.LocalFileSystemCacheProvider(); + _sharedRuleProvider = new CachedHttpRuleProvider(cacheProvider, _httpClient); + _sharedRuleProvider.BuildAsync().GetAwaiter().GetResult(); + } } _normalizer = new ThreadLocal(() => @@ -88,14 +99,14 @@ public TyposquattingDetector(string defaultPath, string customPath, int threshol #region Dispose protected virtual void Dispose(bool disposing) { - if (!disposedValue) + if (!_disposedValue) { if (disposing) { _httpClient?.Dispose(); _normalizer?.Dispose(); } - disposedValue = true; + _disposedValue = true; } } @@ -130,77 +141,35 @@ public Result Check(string query) #region private private Result FuzzyMatch(string query, Result result) { - string? bestDomain = null; - int bestScore = 0; - - // Collect candidate buckets (Length ±1) - var buckets = new List?[3]; - int bi = 0; - - for (int i = -1; i <= 1; i++) - { - if (_lenBuckets.TryGetValue(query.Length + i, out var b)) - buckets[bi++] = b; - } - - // --- cheap lexical + trigram prefilter --- - static bool PassesPrefilter(string q, string d, int threshold) - { - int dl = d.Length; - int ql = q.Length; - - // reject far-length candidates - if (Math.Abs(dl - ql) > 2) - return false; - - // fast first-char rejection - if (q[0] != d[0]) - return false; - - // tiny strings → go straight to Fuzz() - if (ql < 4 || dl < 4) - return true; + var globalState = new MatchState { BestDomain = null, BestScore = 0 }; - // small trigram overlap check (no alloc) - int hits = 0; - for (int i = 0; i < Math.Min(ql, dl) - 2; i++) - if (d.AsSpan().IndexOf(q.AsSpan(i, 3)) >= 0) hits++; + var bucketIndices = new[] { query.Length - 1, query.Length, query.Length + 1 }; - // require minimal neighborhood similarity - return hits >= 1 || threshold <= 80; - } - - // --- shard scan with thread-local best --- - for (int i = 0; i < bi; i++) + foreach (int len in bucketIndices) { - var bucket = buckets[i]; - if (bucket is null) continue; + if (!_lenBuckets.TryGetValue(len, out var bucket)) continue; - // Tuneable knobs const int SequentialCutoff = 256; int maxDop = Math.Max(1, Environment.ProcessorCount / 2); if (bucket.Count <= SequentialCutoff) { - // Sequential fast-path for small buckets - SequentialMatch(query, ref bestDomain, ref bestScore, bucket); + SequentialMatch(query, globalState, bucket); } else { - (bool flowControl, (bestDomain, bestScore)) = ParallelMatch(query, bestDomain, bestScore, bucket, maxDop); - if (!flowControl) - { - break; - } + ParallelMatch(query, globalState, bucket, maxDop); } + + if (globalState.BestScore >= 98) break; } - if (bestDomain != null) + if (globalState.BestDomain != null) { - result.BestMatch = bestDomain; - result.FuzzyScore = bestScore; + result.BestMatch = globalState.BestDomain; + result.FuzzyScore = globalState.BestScore; result.IsSuspicious = true; - result.Severity = bestScore > 90 ? Severity.HIGH : Severity.MEDIUM; + result.Severity = globalState.BestScore > 90 ? Severity.HIGH : Severity.MEDIUM; result.Reason = Reason.Typosquatting; } else @@ -210,93 +179,100 @@ static bool PassesPrefilter(string q, string d, int threshold) } return result; + } - (bool flowControl, (string? bestDomain, int bestScore) value) ParallelMatch(string query, string? bestDomain, int bestScore, List bucket, int maxDop) + private void SequentialMatch(string query, MatchState state, List bucket) + { + foreach (var domain in bucket) { - { - // Bounded parallelism for large buckets - var locals = new System.Collections.Concurrent.ConcurrentBag<(int score, string dom)>(); - var po = new ParallelOptions { MaxDegreeOfParallelism = maxDop }; + if (state.BestScore >= 98) break; - Parallel.ForEach( - bucket, - po, - () => (score: 0, dom: (string?)null), + if (!PassesPrefilter(query, domain, _threshold)) + continue; - (domain, state, local) => - { - if (bestScore >= 98) - { - state.Stop(); - return local; - } + int score = Fuzz.WeightedRatio(query, domain); - if (!PassesPrefilter(query, domain, _threshold)) - return local; + if (score > state.BestScore) + { + state.BestScore = score; + state.BestDomain = domain; + } - int score = Fuzz.WeightedRatio(query, domain); + if (score >= 95) break; + } + } - if (score > local.score) - local = (score, domain); + private void ParallelMatch(string query, MatchState globalState, List bucket, int maxDop) + { + var po = new ParallelOptions { MaxDegreeOfParallelism = maxDop }; - if (score >= 95) - state.Stop(); + Parallel.ForEach( + bucket, + po, + () => (score: 0, dom: (string?)null), // Thread-local state + (domain, state, local) => + { + // Volatile check for early exit + if (Volatile.Read(ref globalState.BestScore) >= 98) + { + state.Stop(); + return local; + } - return local; - }, + if (!PassesPrefilter(query, domain, _threshold)) + return local; - local => - { - if (local.score > 0 && local.dom is not null) - locals.Add((local.score, local.dom)); - } - ); + int score = Fuzz.WeightedRatio(query, domain); - foreach (var l in locals) - if (l.score > bestScore) - { - bestScore = l.score; - bestDomain = l.dom; - } + if (score > local.score) + local = (score, domain); + if (score >= 95) state.Stop(); - // serial reduction (no races) - foreach (var l in locals) + return local; + }, + local => + { + // Reduction phase: Merge thread-local winner into global state + if (local.dom != null) { - if (l.score > bestScore) + lock (globalState) // Lock on the state object { - bestScore = l.score; - bestDomain = l.dom; + if (local.score > globalState.BestScore) + { + globalState.BestScore = local.score; + globalState.BestDomain = local.dom; + } } } - if (bestScore >= 98) - return (flowControl: false, value: default); } + ); + } - return (flowControl: true, value: default); - } + private static bool PassesPrefilter(string q, string d, int threshold) + { + int dl = d.Length; + int ql = q.Length; - void SequentialMatch(string query, ref string? bestDomain, ref int bestScore, List bucket) - { - foreach (var domain in bucket) - { - if (bestScore >= 98) break; + // reject far-length candidates + if (Math.Abs(dl - ql) > 2) + return false; - if (!PassesPrefilter(query, domain, _threshold)) - continue; + // fast first-char rejection + if (q[0] != d[0]) + return false; - int score = Fuzz.WeightedRatio(query, domain); + // tiny strings → go straight to Fuzz() + if (ql < 4 || dl < 4) + return true; - if (score > bestScore) - { - bestScore = score; - bestDomain = domain; - } + // small trigram overlap check (no alloc) + int hits = 0; + for (int i = 0; i < Math.Min(ql, dl) - 2; i++) + if (d.AsSpan().IndexOf(q.AsSpan(i, 3)) >= 0) hits++; - if (score >= 95) - break; - } - } + // require minimal neighborhood similarity + return hits >= 1 || threshold <= 80; } private static string? ExtractDomain(string line) From c57e475f2d5e92be08dbbee6640bbad5c1d9cf09 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 20:33:51 +0200 Subject: [PATCH 36/62] Fixrd concurrency issue in swapping detector --- Apps/TyposquattingDetector/App.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index 40a25ba77..969939c7d 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -293,7 +293,7 @@ private static TimeSpan ParseUpdateInterval(string interval) return TimeSpan.FromDays(value * 7); default: - throw new FormatException($"Invalid unit '{unit}' in update interval. Allowed units are 'm', 'h', 'd'. 'w'."); + throw new FormatException($"Invalid unit '{unit}' in update interval. Allowed units are 'm', 'h', 'd', 'w'."); } } @@ -371,9 +371,8 @@ private async Task UpdateDomainListAsync(CancellationToken cancellationToken) string safePath = string.Empty; safePath = Path.GetFullPath(_domainListFilePath!); if (!safePath.StartsWith(_dnsServer.ApplicationFolder)) throw new SecurityException("Access Denied"); - - var oldDetector = _detector; - _detector = new TyposquattingDetector(_domainListFilePath!, safePath, _config!.FuzzyMatchThreshold); + var newDetector = new TyposquattingDetector(_domainListFilePath!, safePath, _config!.FuzzyMatchThreshold); + var oldDetector = Interlocked.Exchange(ref _detector, newDetector); oldDetector?.Dispose(); _dnsServer.WriteLog($"Typosquatting Detector: Processing completed."); } From b8fbcf928246f88aadb81aa7b452b2e369626612 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 21:07:56 +0200 Subject: [PATCH 37/62] Volarile _change --- Apps/TyposquattingDetector/App.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index 969939c7d..010c681ee 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -321,7 +321,7 @@ private async Task StartUpdateLoopAsync(CancellationToken cancellationToken) { using PeriodicTimer timer = new PeriodicTimer(_updateInterval); - if (!_changed) + if (!Volatile.Read(ref _changed)) { // Nothing changed, skip first update } From f4e59686c60eabc7c0c9347e21beb209d379d0c6 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 21:12:29 +0200 Subject: [PATCH 38/62] Concurrency issues, again --- Apps/TyposquattingDetector/TyposquattingDetector.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index 9af6d222b..b385a285f 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -58,7 +58,7 @@ public class TyposquattingDetector : IDisposable private readonly ThreadLocal _normalizer; private readonly int _threshold; private IBloomFilter? _bloomFilter; - private readonly HttpClient _httpClient = new HttpClient(); + private readonly static HttpClient _httpClient = new HttpClient(); private readonly object _reductionLock = new object(); private static readonly object _staticInitLock = new object(); private bool _disposedValue; @@ -103,7 +103,6 @@ protected virtual void Dispose(bool disposing) { if (disposing) { - _httpClient?.Dispose(); _normalizer?.Dispose(); } _disposedValue = true; @@ -236,7 +235,7 @@ private void ParallelMatch(string query, MatchState globalState, List bu // Reduction phase: Merge thread-local winner into global state if (local.dom != null) { - lock (globalState) // Lock on the state object + lock (_reductionLock) { if (local.score > globalState.BestScore) { @@ -251,6 +250,9 @@ private void ParallelMatch(string query, MatchState globalState, List bu private static bool PassesPrefilter(string q, string d, int threshold) { + if (string.IsNullOrEmpty(q) || string.IsNullOrEmpty(d)) + return false; + int dl = d.Length; int ql = q.Length; From c111cd1c83ea2accd9a31e005922c5c507821999 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 21:15:49 +0200 Subject: [PATCH 39/62] Fixed regex --- Apps/TyposquattingDetector/Config.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Apps/TyposquattingDetector/Config.cs b/Apps/TyposquattingDetector/Config.cs index b15ee4b3c..4764bde3e 100644 --- a/Apps/TyposquattingDetector/Config.cs +++ b/Apps/TyposquattingDetector/Config.cs @@ -52,7 +52,7 @@ private class Config [JsonPropertyName("updateInterval")] [Required(ErrorMessage = "updateInterval is a required configuration property.")] - [RegularExpression(@"^\d+[mhd]$", ErrorMessage = "Invalid interval format. Use a number followed by 'm', 'h', or 'd' (e.g., '90m', '2h', '7d').", MatchTimeoutInMilliseconds = 3000)] + [RegularExpression(@"^\d+[mhdw]$", ErrorMessage = "Invalid interval format. Use a number followed by 'm', 'h', or 'd' (e.g., '90m', '2h', '7d').", MatchTimeoutInMilliseconds = 3000)] public string UpdateInterval { get; set; } = "30d"; } @@ -84,7 +84,7 @@ public partial class FileContentValidator // 4. Fail-Fast Logic // If any content exists, it MUST follow the domain rules - if (trimmedLine.Contains("*") || !DomainRegex.IsMatch(trimmedLine)) + if (trimmedLine.Contains('*') || !DomainRegex.IsMatch(trimmedLine)) { return new ValidationResult($"Invalid content: '{trimmedLine}'. Wildcards are not allowed."); } From 79cf25bbad3233a5f78a1bd9bc5e8af720fd5db1 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 21:18:33 +0200 Subject: [PATCH 40/62] Added null check --- .../TyposquattingDetector.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index b385a285f..4ff1fef09 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -122,6 +122,15 @@ public void Dispose() public Result Check(string query) { var normalized = Normalize(query); + if (normalized == null) + { + return new Result(query) + { + IsSuspicious = false, + Reason = Reason.NoCandidates + }; + } + var result = new Result(normalized); // GATE 1: Bloom Filter Prefilter (O(1)) @@ -298,7 +307,8 @@ private void LoadData(string oneMilFilePath, string customPath) // Helper to add domains to both Bloom and Buckets void processDomain(string domain) { - if (string.IsNullOrWhiteSpace(domain)) return; + if (string.IsNullOrWhiteSpace(domain) || string.IsNullOrEmpty(domain)) return; + domain = domain.ToLowerInvariant(); _bloomFilter.Add(domain); if (!_lenBuckets.TryGetValue(domain.Length, out var list)) @@ -332,9 +342,10 @@ void processDomain(string domain) } } - private string Normalize(string s) + private string? Normalize(string s) { - if (string.IsNullOrWhiteSpace(s)) return s; + if (string.IsNullOrWhiteSpace(s)|| string.IsNullOrEmpty(s)) return null; + try { var registrableDomain = _normalizer?.Value?.Parse(s)?.RegistrableDomain; From 2af0ae547cb97ad255144a1be150d370fcaab840 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 21:20:57 +0200 Subject: [PATCH 41/62] Shared lock issue fixed --- .../TyposquattingDetector.cs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index 4ff1fef09..b93a9671e 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -59,8 +59,7 @@ public class TyposquattingDetector : IDisposable private readonly int _threshold; private IBloomFilter? _bloomFilter; private readonly static HttpClient _httpClient = new HttpClient(); - private readonly object _reductionLock = new object(); - private static readonly object _staticInitLock = new object(); + private static readonly Lock _staticInitLock = new Lock(); private bool _disposedValue; private class MatchState { @@ -242,16 +241,18 @@ private void ParallelMatch(string query, MatchState globalState, List bu local => { // Reduction phase: Merge thread-local winner into global state - if (local.dom != null) + if (local.dom == null) { - lock (_reductionLock) + return; + } + lock (globalState) + { + if (local.score <= globalState.BestScore) { - if (local.score > globalState.BestScore) - { - globalState.BestScore = local.score; - globalState.BestDomain = local.dom; - } + return; } + globalState.BestScore = local.score; + globalState.BestDomain = local.dom; } } ); From e05fbc3219c04a07298f37ed282891afb85e439e Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 21:24:11 +0200 Subject: [PATCH 42/62] Normalizatipn function fixed --- Apps/TyposquattingDetector/TyposquattingDetector.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index b93a9671e..d45399d30 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -345,16 +345,17 @@ void processDomain(string domain) private string? Normalize(string s) { - if (string.IsNullOrWhiteSpace(s)|| string.IsNullOrEmpty(s)) return null; + if (string.IsNullOrWhiteSpace(s)) return null; try { - var registrableDomain = _normalizer?.Value?.Parse(s)?.RegistrableDomain; - return registrableDomain ?? s; + var rd = _normalizer.Value?.Parse(s)?.RegistrableDomain; + if (string.IsNullOrWhiteSpace(rd)) rd = s; + return rd.TrimEnd('.').ToLowerInvariant(); } catch { - var clean = s.ToLowerInvariant().Trim(); + var clean = s.Trim().TrimEnd('.').ToLowerInvariant(); if (clean.StartsWith("www.")) clean = clean.Substring(4); if (clean.StartsWith("m.")) clean = clean.Substring(2); return clean; From 5b7695e1f5491279bf6f72256551fd67c250232c Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 21:29:11 +0200 Subject: [PATCH 43/62] Simplification --- Apps/TyposquattingDetector/TyposquattingDetector.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index d45399d30..6e8e0abb8 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -60,6 +60,7 @@ public class TyposquattingDetector : IDisposable private IBloomFilter? _bloomFilter; private readonly static HttpClient _httpClient = new HttpClient(); private static readonly Lock _staticInitLock = new Lock(); + private readonly ParallelOptions _po; private bool _disposedValue; private class MatchState { @@ -74,6 +75,7 @@ private class MatchState public TyposquattingDetector(string defaultPath, string customPath, int threshold) { _threshold = threshold; + _po = new ParallelOptions { MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount / 2) }; // Thread-safe static initialization lock (_staticInitLock) @@ -157,7 +159,6 @@ private Result FuzzyMatch(string query, Result result) if (!_lenBuckets.TryGetValue(len, out var bucket)) continue; const int SequentialCutoff = 256; - int maxDop = Math.Max(1, Environment.ProcessorCount / 2); if (bucket.Count <= SequentialCutoff) { @@ -165,7 +166,7 @@ private Result FuzzyMatch(string query, Result result) } else { - ParallelMatch(query, globalState, bucket, maxDop); + ParallelMatch(query, globalState, bucket); } if (globalState.BestScore >= 98) break; @@ -209,13 +210,11 @@ private void SequentialMatch(string query, MatchState state, List bucket } } - private void ParallelMatch(string query, MatchState globalState, List bucket, int maxDop) + private void ParallelMatch(string query, MatchState globalState, List bucket) { - var po = new ParallelOptions { MaxDegreeOfParallelism = maxDop }; - Parallel.ForEach( bucket, - po, + _po, () => (score: 0, dom: (string?)null), // Thread-local state (domain, state, local) => { From ce401946e2da0ddcb18048460101b008c9274345 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 21:30:42 +0200 Subject: [PATCH 44/62] Optimizations --- Apps/TyposquattingDetector/TyposquattingDetector.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index 6e8e0abb8..0fb2ef054 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -279,7 +279,8 @@ private static bool PassesPrefilter(string q, string d, int threshold) // small trigram overlap check (no alloc) int hits = 0; - for (int i = 0; i < Math.Min(ql, dl) - 2; i++) + int maxTrigrams = Math.Min(10, Math.Min(ql, dl) - 2); + for (int i = 0; i < maxTrigrams; i++) if (d.AsSpan().IndexOf(q.AsSpan(i, 3)) >= 0) hits++; // require minimal neighborhood similarity From 576be77bfbada3400c2a94ac2d821ad4b9836025 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 21:41:36 +0200 Subject: [PATCH 45/62] Used Lazy static HTTP client issues --- .../TyposquattingDetector.cs | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index 0fb2ef054..e27e23961 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -53,15 +53,27 @@ public class TyposquattingDetector : IDisposable { #region variables - private static CachedHttpRuleProvider? _sharedRuleProvider; private readonly Dictionary> _lenBuckets = new Dictionary>(); private readonly ThreadLocal _normalizer; private readonly int _threshold; private IBloomFilter? _bloomFilter; - private readonly static HttpClient _httpClient = new HttpClient(); + private readonly static HttpClient _pslHttpClient = new HttpClient(); private static readonly Lock _staticInitLock = new Lock(); private readonly ParallelOptions _po; private bool _disposedValue; + + + private static readonly Lazy _sharedRuleProvider = + new Lazy(() => + { + var cacheProvider = + new Nager.PublicSuffix.RuleProviders.CacheProviders.LocalFileSystemCacheProvider(); + + var rp = new CachedHttpRuleProvider(cacheProvider, _pslHttpClient); + rp.BuildAsync().GetAwaiter().GetResult(); + return rp; + }, isThreadSafe: true); + private class MatchState { public string? BestDomain; @@ -77,19 +89,9 @@ public TyposquattingDetector(string defaultPath, string customPath, int threshol _threshold = threshold; _po = new ParallelOptions { MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount / 2) }; - // Thread-safe static initialization - lock (_staticInitLock) - { - if (_sharedRuleProvider == null) - { - var cacheProvider = new Nager.PublicSuffix.RuleProviders.CacheProviders.LocalFileSystemCacheProvider(); - _sharedRuleProvider = new CachedHttpRuleProvider(cacheProvider, _httpClient); - _sharedRuleProvider.BuildAsync().GetAwaiter().GetResult(); - } - } - _normalizer = new ThreadLocal(() => - new DomainParser(_sharedRuleProvider, new Nager.PublicSuffix.DomainNormalizers.UriDomainNormalizer())); + new DomainParser(_sharedRuleProvider.Value, new Nager.PublicSuffix.DomainNormalizers.UriDomainNormalizer())); + LoadData(defaultPath, customPath); } From e82225b8876ed773b910c609ebc04bb3eb9e9e69 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 21:47:10 +0200 Subject: [PATCH 46/62] Fixed path issues --- Apps/TyposquattingDetector/App.cs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index 010c681ee..406b3aef4 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -368,10 +368,23 @@ private async Task UpdateDomainListAsync(CancellationToken cancellationToken) try { _dnsServer!.WriteLog($"Typosquatting Detector: Processing domain list..."); - string safePath = string.Empty; - safePath = Path.GetFullPath(_domainListFilePath!); - if (!safePath.StartsWith(_dnsServer.ApplicationFolder)) throw new SecurityException("Access Denied"); - var newDetector = new TyposquattingDetector(_domainListFilePath!, safePath, _config!.FuzzyMatchThreshold); + // Validate Majestic list path (must be inside app folder) + string majesticPath = Path.GetFullPath(_domainListFilePath!); + if (!majesticPath.StartsWith(_dnsServer.ApplicationFolder, StringComparison.OrdinalIgnoreCase)) + throw new SecurityException("Access Denied"); + + // Resolve custom list path from config (optional) + string customListPath = string.Empty; + if (!string.IsNullOrWhiteSpace(_config!.Path)) + { + customListPath = Path.GetFullPath(_config.Path); + + // Keep policy consistent: restrict custom list to app folder. + // If you want to allow arbitrary paths, remove this check. + if (!customListPath.StartsWith(_dnsServer.ApplicationFolder, StringComparison.OrdinalIgnoreCase)) + throw new SecurityException("Access Denied"); + } + var newDetector = new TyposquattingDetector(majesticPath, customListPath, _config!.FuzzyMatchThreshold); var oldDetector = Interlocked.Exchange(ref _detector, newDetector); oldDetector?.Dispose(); _dnsServer.WriteLog($"Typosquatting Detector: Processing completed."); From 2369713b28a0047775a4d3b01f73430abc4298de Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 21:54:06 +0200 Subject: [PATCH 47/62] Concurrency is a headache --- Apps/TyposquattingDetector/App.cs | 126 +++++++++++++++++++++++------- 1 file changed, 99 insertions(+), 27 deletions(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index 406b3aef4..b7054c059 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -321,78 +321,150 @@ private async Task StartUpdateLoopAsync(CancellationToken cancellationToken) { using PeriodicTimer timer = new PeriodicTimer(_updateInterval); - if (!Volatile.Read(ref _changed)) - { - // Nothing changed, skip first update - } - else + // If init already checked hash and found no change, you can skip the *first* interval check. + bool skipFirst = !Volatile.Read(ref _changed); + + // Jitter to avoid stampede after restart + await Task.Delay(TimeSpan.FromSeconds(Random.Shared.Next(0, 60)), cancellationToken); + + while (!cancellationToken.IsCancellationRequested) { - while (!cancellationToken.IsCancellationRequested) + if (skipFirst) + { + skipFirst = false; + } + else { bool flowControl = await TryUpdate(cancellationToken); if (!flowControl) - { break; - } - - await timer.WaitForNextTickAsync(cancellationToken); } + + await timer.WaitForNextTickAsync(cancellationToken); } - await Task.Delay(TimeSpan.FromSeconds(Random.Shared.Next(0, 60)), cancellationToken); } private async Task TryUpdate(CancellationToken cancellationToken) { try { - await UpdateDomainListAsync(cancellationToken); + bool changed = await DownloadIfChangedAndReloadAsync(cancellationToken); + if (changed) + _dnsServer!.WriteLog("Typosquatting Detector: Domain list updated and detector reloaded."); } catch (OperationCanceledException) { - _dnsServer!.WriteLog("Update loop is shutting down gracefully."); + _dnsServer!.WriteLog("Typosquatting Detector: Update loop is shutting down gracefully."); return false; } catch (Exception ex) { - _dnsServer!.WriteLog($"FATAL: The Typosquatting Detector update task failed unexpectedly. Error: {ex.Message}"); - _dnsServer.WriteLog(ex); + _dnsServer!.WriteLog($"ERROR: Typosquatting Detector update failed. {ex.Message}"); + _dnsServer!.WriteLog(ex); + } + + return true; + } + + private async Task DownloadIfChangedAndReloadAsync(CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) return false; + + string configDir = _dnsServer!.ApplicationFolder; + string majesticPath = Path.GetFullPath(_domainListFilePath!); + + if (!majesticPath.StartsWith(configDir, StringComparison.OrdinalIgnoreCase)) + throw new SecurityException("Access Denied"); + + string hashPath = Path.Combine(configDir, "majestic_million.csv.sha256"); + string tempPath = Path.Combine(configDir, "majestic_million.csv.tmp"); + + // Avoid concurrent temp collisions (paranoia) + if (File.Exists(tempPath)) + { + try { File.Delete(tempPath); } catch { /* ignore */ } + } + + _dnsServer.WriteLog("Typosquatting Detector: Checking for updated domain list..."); + + // Download to temp and compute hash while writing (single pass) + string newHash; + Uri domainList = new Uri(DefaultDomainListUrl); + + using (HttpClient httpClient = CreateHttpClient(domainList, _config!.DisableTlsValidation)) + using (Stream netStream = await httpClient.GetStreamAsync(domainList, cancellationToken)) + using (FileStream fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None, 128 * 1024, useAsync: true)) + using (var sha = SHA256.Create()) + using (var crypto = new CryptoStream(fs, sha, CryptoStreamMode.Write, leaveOpen: true)) + { + await netStream.CopyToAsync(crypto, 128 * 1024, cancellationToken); + await crypto.FlushAsync(cancellationToken); + crypto.FlushFinalBlock(); + + newHash = Convert.ToHexString(sha.Hash!); } + // Read old hash (if any) + string? oldHash = null; + if (File.Exists(hashPath)) + oldHash = File.ReadLines(hashPath).FirstOrDefault()?.Trim(); + + if (!string.IsNullOrEmpty(oldHash) && string.Equals(oldHash, newHash, StringComparison.OrdinalIgnoreCase)) + { + // No change → delete temp + try { File.Delete(tempPath); } catch { /* ignore */ } + Volatile.Write(ref _changed, false); + _dnsServer.WriteLog("Typosquatting Detector: No change in domain list."); + return false; + } + + // Changed → replace live file atomically (temp is in same directory) + // File.Move(tempPath, majesticPath, overwrite: true) is supported on modern .NET. + File.Move(tempPath, majesticPath, overwrite: true); + + await File.WriteAllTextAsync(hashPath, newHash, cancellationToken); + Volatile.Write(ref _changed, true); + + // Reload detector from the updated file + await UpdateDomainListAsync(cancellationToken); + return true; } - private async Task UpdateDomainListAsync(CancellationToken cancellationToken) + private Task UpdateDomainListAsync(CancellationToken cancellationToken) { - if (cancellationToken.IsCancellationRequested) return; + if (cancellationToken.IsCancellationRequested) return Task.CompletedTask; try { - _dnsServer!.WriteLog($"Typosquatting Detector: Processing domain list..."); - // Validate Majestic list path (must be inside app folder) + _dnsServer!.WriteLog("Typosquatting Detector: Processing domain list..."); + + string configDir = _dnsServer.ApplicationFolder; + string majesticPath = Path.GetFullPath(_domainListFilePath!); - if (!majesticPath.StartsWith(_dnsServer.ApplicationFolder, StringComparison.OrdinalIgnoreCase)) + if (!majesticPath.StartsWith(configDir, StringComparison.OrdinalIgnoreCase)) throw new SecurityException("Access Denied"); - // Resolve custom list path from config (optional) string customListPath = string.Empty; if (!string.IsNullOrWhiteSpace(_config!.Path)) { customListPath = Path.GetFullPath(_config.Path); - - // Keep policy consistent: restrict custom list to app folder. - // If you want to allow arbitrary paths, remove this check. - if (!customListPath.StartsWith(_dnsServer.ApplicationFolder, StringComparison.OrdinalIgnoreCase)) + if (!customListPath.StartsWith(configDir, StringComparison.OrdinalIgnoreCase)) throw new SecurityException("Access Denied"); } - var newDetector = new TyposquattingDetector(majesticPath, customListPath, _config!.FuzzyMatchThreshold); + + var newDetector = new TyposquattingDetector(majesticPath, customListPath, _config.FuzzyMatchThreshold); var oldDetector = Interlocked.Exchange(ref _detector, newDetector); oldDetector?.Dispose(); - _dnsServer.WriteLog($"Typosquatting Detector: Processing completed."); + + _dnsServer.WriteLog("Typosquatting Detector: Processing completed."); } catch (IOException ex) { _dnsServer!.WriteLog($"ERROR: Failed to read cache file '{_domainListFilePath}'. Error: {ex.Message}"); } + + return Task.CompletedTask; } #endregion private From a22f5992f47dd3daa3ad4a6ce96a417b83882311 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 1 Jan 2026 21:59:06 +0200 Subject: [PATCH 48/62] Used explicit type instead of var everywhere --- Apps/TyposquattingDetector/App.cs | 12 +++---- .../TyposquattingDetector.cs | 35 +++++++++---------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index b7054c059..12f2a43ec 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -137,7 +137,7 @@ public async Task InitializeAsync(IDnsServer dnsServer, string config) string sha256 = Convert.ToHexString(await SHA256.HashDataAsync(fs)); _dnsServer.WriteLog($"Typosquatting Detector: SHA256 hash of downloaded domain list: {sha256}"); - var hashPath = Path.Combine(configDir, "majestic_million.csv.sha256"); + string hashPath = Path.Combine(configDir, "majestic_million.csv.sha256"); string? previousHash = null; if (File.Exists(hashPath)) { @@ -199,7 +199,7 @@ public Task IsAllowedAsync(DnsDatagram request, IPEndPoint remoteEP) } DnsQuestionRecord question = request.Question[0]; - var res = _detector.Check(question.Name); + Result res = _detector.Check(question.Name); if (res.IsSuspicious == false) { return Task.FromResult(null); @@ -394,8 +394,8 @@ private async Task DownloadIfChangedAndReloadAsync(CancellationToken cance using (HttpClient httpClient = CreateHttpClient(domainList, _config!.DisableTlsValidation)) using (Stream netStream = await httpClient.GetStreamAsync(domainList, cancellationToken)) using (FileStream fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None, 128 * 1024, useAsync: true)) - using (var sha = SHA256.Create()) - using (var crypto = new CryptoStream(fs, sha, CryptoStreamMode.Write, leaveOpen: true)) + using (SHA256 sha = SHA256.Create()) + using (CryptoStream crypto = new CryptoStream(fs, sha, CryptoStreamMode.Write, leaveOpen: true)) { await netStream.CopyToAsync(crypto, 128 * 1024, cancellationToken); await crypto.FlushAsync(cancellationToken); @@ -453,8 +453,8 @@ private Task UpdateDomainListAsync(CancellationToken cancellationToken) throw new SecurityException("Access Denied"); } - var newDetector = new TyposquattingDetector(majesticPath, customListPath, _config.FuzzyMatchThreshold); - var oldDetector = Interlocked.Exchange(ref _detector, newDetector); + TyposquattingDetector newDetector = new TyposquattingDetector(majesticPath, customListPath, _config.FuzzyMatchThreshold); + TyposquattingDetector? oldDetector = Interlocked.Exchange(ref _detector, newDetector); oldDetector?.Dispose(); _dnsServer.WriteLog("Typosquatting Detector: Processing completed."); diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index e27e23961..a2638420e 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -21,6 +21,7 @@ You should have received a copy of the GNU General Public License using FuzzySharp; using Nager.PublicSuffix; using Nager.PublicSuffix.RuleProviders; +using Nager.PublicSuffix.RuleProviders.CacheProviders; using System; using System.Collections.Generic; using System.IO; @@ -64,12 +65,10 @@ public class TyposquattingDetector : IDisposable private static readonly Lazy _sharedRuleProvider = - new Lazy(() => + new Lazy(static () => { - var cacheProvider = - new Nager.PublicSuffix.RuleProviders.CacheProviders.LocalFileSystemCacheProvider(); - - var rp = new CachedHttpRuleProvider(cacheProvider, _pslHttpClient); + LocalFileSystemCacheProvider cacheProvider = new LocalFileSystemCacheProvider(); + CachedHttpRuleProvider rp = new CachedHttpRuleProvider(cacheProvider, _pslHttpClient); rp.BuildAsync().GetAwaiter().GetResult(); return rp; }, isThreadSafe: true); @@ -124,7 +123,7 @@ public void Dispose() #region public public Result Check(string query) { - var normalized = Normalize(query); + string? normalized = Normalize(query); if (normalized == null) { return new Result(query) @@ -134,7 +133,7 @@ public Result Check(string query) }; } - var result = new Result(normalized); + Result result = new Result(normalized); // GATE 1: Bloom Filter Prefilter (O(1)) if (_bloomFilter is not null && _bloomFilter.Contains(normalized)) @@ -152,13 +151,13 @@ public Result Check(string query) #region private private Result FuzzyMatch(string query, Result result) { - var globalState = new MatchState { BestDomain = null, BestScore = 0 }; + MatchState globalState = new MatchState { BestDomain = null, BestScore = 0 }; - var bucketIndices = new[] { query.Length - 1, query.Length, query.Length + 1 }; + int[] bucketIndices = new[] { query.Length - 1, query.Length, query.Length + 1 }; foreach (int len in bucketIndices) { - if (!_lenBuckets.TryGetValue(len, out var bucket)) continue; + if (!_lenBuckets.TryGetValue(len, out List? bucket)) continue; const int SequentialCutoff = 256; @@ -193,7 +192,7 @@ private Result FuzzyMatch(string query, Result result) private void SequentialMatch(string query, MatchState state, List bucket) { - foreach (var domain in bucket) + foreach (string domain in bucket) { if (state.BestScore >= 98) break; @@ -314,7 +313,7 @@ void processDomain(string domain) domain = domain.ToLowerInvariant(); _bloomFilter.Add(domain); - if (!_lenBuckets.TryGetValue(domain.Length, out var list)) + if (!_lenBuckets.TryGetValue(domain.Length, out List? list)) { list = new List(); _lenBuckets[domain.Length] = list; @@ -326,20 +325,20 @@ void processDomain(string domain) // 1. Load custom list if (!string.IsNullOrEmpty(customPath) && File.Exists(customPath)) { - foreach (var line in File.ReadLines(customPath)) + foreach (string line in File.ReadLines(customPath)) processDomain(line.Trim()); } // 2. Load Majestic 1M if (File.Exists(oneMilFilePath)) { - using var fs = new FileStream(oneMilFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024); - using var reader = new StreamReader(fs); + using FileStream fs = new FileStream(oneMilFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024); + using StreamReader reader = new StreamReader(fs); reader.ReadLine(); // Skip header while (reader.ReadLine() is { } line) { - var domain = ExtractDomain(line); + string? domain = ExtractDomain(line); if (domain != null) processDomain(domain); } } @@ -351,13 +350,13 @@ void processDomain(string domain) try { - var rd = _normalizer.Value?.Parse(s)?.RegistrableDomain; + string? rd = _normalizer.Value?.Parse(s)?.RegistrableDomain; if (string.IsNullOrWhiteSpace(rd)) rd = s; return rd.TrimEnd('.').ToLowerInvariant(); } catch { - var clean = s.Trim().TrimEnd('.').ToLowerInvariant(); + string? clean = s.Trim().TrimEnd('.').ToLowerInvariant(); if (clean.StartsWith("www.")) clean = clean.Substring(4); if (clean.StartsWith("m.")) clean = clean.Substring(2); return clean; From b934b24db853c9ad74b6182a416b111497e8c0fd Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 2 Jan 2026 12:52:18 +0200 Subject: [PATCH 49/62] Fixed regex issue --- Apps/TyposquattingDetector/Config.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Apps/TyposquattingDetector/Config.cs b/Apps/TyposquattingDetector/Config.cs index 4764bde3e..58c1b4e8f 100644 --- a/Apps/TyposquattingDetector/Config.cs +++ b/Apps/TyposquattingDetector/Config.cs @@ -46,7 +46,6 @@ private class Config public int FuzzyMatchThreshold { get; set; } = 75; [JsonPropertyName("customList")] - [RegularExpression("^(?:[a-zA-Z]:\\\\(?:[^\\\\\\/:*?\"<>|\\r\\n]+\\\\)*[^\\\\\\/:*?\"<>|\\r\\n]*|(?:\\/[^\\/\\0]+)+\\/?)$", ErrorMessage = "customList must be a valid file path with one domain per line.")] [CustomValidation(typeof(FileContentValidator), nameof(FileContentValidator.ValidateDomainFile))] public string? Path { get; set; } @@ -59,7 +58,7 @@ private class Config public partial class FileContentValidator { // Optimized Regex: Compiled for performance during "Happy Path" scans - private static readonly Regex DomainRegex = FilePathPattern(); + private static readonly Regex DomainRegex = DomainPattern(); public static ValidationResult? ValidateDomainFile(string? path, ValidationContext context) { @@ -100,8 +99,8 @@ public partial class FileContentValidator return ValidationResult.Success; } - [GeneratedRegex(@"^(?!-)[A-Za-z0-9-]+([\-\.]{1}[a-z0-9]+)*\.[A-Za-z]{2,63}$", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] - private static partial Regex FilePathPattern(); + [GeneratedRegex(@"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] + private static partial Regex DomainPattern(); } } } \ No newline at end of file From c4502fcca0f2c1f583b7c69fb1aadf0af1a0c7b2 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 2 Jan 2026 12:55:14 +0200 Subject: [PATCH 50/62] Formatting --- .../TyposquattingDetector.cs | 268 +++++++++--------- 1 file changed, 141 insertions(+), 127 deletions(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index a2638420e..cd7d85ca3 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -33,36 +33,40 @@ You should have received a copy of the GNU General Public License namespace TyposquattingDetector { public enum Reason - { Exact, Typosquatting, NoCandidates } + { + Exact, + Typosquatting, + NoCandidates + } public enum Severity - { NONE, LOW, MEDIUM, HIGH } + { + NONE, + LOW, + MEDIUM, + HIGH + } public class Result { - public Result(string query) => Query = query; + public Result(string query) + { + Query = query; + } public string? BestMatch { get; set; } public int FuzzyScore { get; set; } + public bool IsSuspicious { get; set; } public string Query { get; } public Reason Reason { get; set; } public Severity Severity { get; set; } = Severity.NONE; - public bool IsSuspicious { get; set; } } - public class TyposquattingDetector : IDisposable + public class TyposquattingDetector : IDisposable { #region variables - private readonly Dictionary> _lenBuckets = new Dictionary>(); - private readonly ThreadLocal _normalizer; - private readonly int _threshold; - private IBloomFilter? _bloomFilter; - private readonly static HttpClient _pslHttpClient = new HttpClient(); - private static readonly Lock _staticInitLock = new Lock(); - private readonly ParallelOptions _po; - private bool _disposedValue; - + private static readonly HttpClient _pslHttpClient = new HttpClient(); private static readonly Lazy _sharedRuleProvider = new Lazy(static () => @@ -73,6 +77,13 @@ public class TyposquattingDetector : IDisposable return rp; }, isThreadSafe: true); + private readonly Dictionary> _lenBuckets = new Dictionary>(); + private readonly ThreadLocal _normalizer; + private readonly ParallelOptions _po; + private readonly int _threshold; + private IBloomFilter? _bloomFilter; + private bool _disposedValue; + private class MatchState { public string? BestDomain; @@ -91,14 +102,20 @@ public TyposquattingDetector(string defaultPath, string customPath, int threshol _normalizer = new ThreadLocal(() => new DomainParser(_sharedRuleProvider.Value, new Nager.PublicSuffix.DomainNormalizers.UriDomainNormalizer())); - LoadData(defaultPath, customPath); } - #endregion constructor #region Dispose + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + protected virtual void Dispose(bool disposing) { if (!_disposedValue) @@ -111,16 +128,10 @@ protected virtual void Dispose(bool disposing) } } - public void Dispose() - { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose(disposing: true); - GC.SuppressFinalize(this); - } - #endregion Dispose #region public + public Result Check(string query) { string? normalized = Normalize(query); @@ -146,9 +157,54 @@ public Result Check(string query) // GATE 2: Fuzzy Similarity Check return FuzzyMatch(normalized, result); } + #endregion public #region private + + private static string? ExtractDomain(string line) + { + ReadOnlySpan span = line.AsSpan(); + int firstComma = span.IndexOf(','); + if (firstComma == -1) return null; + ReadOnlySpan afterFirst = span.Slice(firstComma + 1); + int secondComma = afterFirst.IndexOf(','); + if (secondComma == -1) return null; + ReadOnlySpan afterSecond = afterFirst.Slice(secondComma + 1); + int thirdComma = afterSecond.IndexOf(','); + return (thirdComma == -1 ? afterSecond : afterSecond.Slice(0, thirdComma)).ToString(); + } + + private static bool PassesPrefilter(string q, string d, int threshold) + { + if (string.IsNullOrEmpty(q) || string.IsNullOrEmpty(d)) + return false; + + int dl = d.Length; + int ql = q.Length; + + // reject far-length candidates + if (Math.Abs(dl - ql) > 2) + return false; + + // fast first-char rejection + if (q[0] != d[0]) + return false; + + // tiny strings → go straight to Fuzz() + if (ql < 4 || dl < 4) + return true; + + // small trigram overlap check (no alloc) + int hits = 0; + int maxTrigrams = Math.Min(10, Math.Min(ql, dl) - 2); + for (int i = 0; i < maxTrigrams; i++) + if (d.AsSpan().IndexOf(q.AsSpan(i, 3)) >= 0) hits++; + + // require minimal neighborhood similarity + return hits >= 1 || threshold <= 80; + } + private Result FuzzyMatch(string query, Result result) { MatchState globalState = new MatchState { BestDomain = null, BestScore = 0 }; @@ -190,24 +246,65 @@ private Result FuzzyMatch(string query, Result result) return result; } - private void SequentialMatch(string query, MatchState state, List bucket) + private void LoadData(string oneMilFilePath, string customPath) { - foreach (string domain in bucket) + // Capacity for 1M domains + custom list + _bloomFilter = FilterBuilder.Build(1_100_000, 0.001); + + // Helper to add domains to both Bloom and Buckets + void processDomain(string domain) { - if (state.BestScore >= 98) break; + if (string.IsNullOrWhiteSpace(domain) || string.IsNullOrEmpty(domain)) return; - if (!PassesPrefilter(query, domain, _threshold)) - continue; + domain = domain.ToLowerInvariant(); + _bloomFilter.Add(domain); + if (!_lenBuckets.TryGetValue(domain.Length, out List? list)) + { + list = new List(); + _lenBuckets[domain.Length] = list; + } + // Cap fuzzy search candidates per length to keep search times predictable + if (list.Count < 15000) list.Add(domain); + } - int score = Fuzz.WeightedRatio(query, domain); + // 1. Load custom list + if (!string.IsNullOrEmpty(customPath) && File.Exists(customPath)) + { + foreach (string line in File.ReadLines(customPath)) + processDomain(line.Trim()); + } - if (score > state.BestScore) + // 2. Load Majestic 1M + if (File.Exists(oneMilFilePath)) + { + using FileStream fs = new FileStream(oneMilFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024); + using StreamReader reader = new StreamReader(fs); + reader.ReadLine(); // Skip header + + while (reader.ReadLine() is { } line) { - state.BestScore = score; - state.BestDomain = domain; + string? domain = ExtractDomain(line); + if (domain != null) processDomain(domain); } + } + } - if (score >= 95) break; + private string? Normalize(string s) + { + if (string.IsNullOrWhiteSpace(s)) return null; + + try + { + string? rd = _normalizer.Value?.Parse(s)?.RegistrableDomain; + if (string.IsNullOrWhiteSpace(rd)) rd = s; + return rd.TrimEnd('.').ToLowerInvariant(); + } + catch + { + string? clean = s.Trim().TrimEnd('.').ToLowerInvariant(); + if (clean.StartsWith("www.")) clean = clean.Substring(4); + if (clean.StartsWith("m.")) clean = clean.Substring(2); + return clean; } } @@ -258,110 +355,27 @@ private void ParallelMatch(string query, MatchState globalState, List bu ); } - private static bool PassesPrefilter(string q, string d, int threshold) - { - if (string.IsNullOrEmpty(q) || string.IsNullOrEmpty(d)) - return false; - - int dl = d.Length; - int ql = q.Length; - - // reject far-length candidates - if (Math.Abs(dl - ql) > 2) - return false; - - // fast first-char rejection - if (q[0] != d[0]) - return false; - - // tiny strings → go straight to Fuzz() - if (ql < 4 || dl < 4) - return true; - - // small trigram overlap check (no alloc) - int hits = 0; - int maxTrigrams = Math.Min(10, Math.Min(ql, dl) - 2); - for (int i = 0; i < maxTrigrams; i++) - if (d.AsSpan().IndexOf(q.AsSpan(i, 3)) >= 0) hits++; - - // require minimal neighborhood similarity - return hits >= 1 || threshold <= 80; - } - - private static string? ExtractDomain(string line) - { - ReadOnlySpan span = line.AsSpan(); - int firstComma = span.IndexOf(','); - if (firstComma == -1) return null; - ReadOnlySpan afterFirst = span.Slice(firstComma + 1); - int secondComma = afterFirst.IndexOf(','); - if (secondComma == -1) return null; - ReadOnlySpan afterSecond = afterFirst.Slice(secondComma + 1); - int thirdComma = afterSecond.IndexOf(','); - return (thirdComma == -1 ? afterSecond : afterSecond.Slice(0, thirdComma)).ToString(); - } - - private void LoadData(string oneMilFilePath, string customPath) + private void SequentialMatch(string query, MatchState state, List bucket) { - // Capacity for 1M domains + custom list - _bloomFilter = FilterBuilder.Build(1_100_000, 0.001); - - // Helper to add domains to both Bloom and Buckets - void processDomain(string domain) + foreach (string domain in bucket) { - if (string.IsNullOrWhiteSpace(domain) || string.IsNullOrEmpty(domain)) return; - - domain = domain.ToLowerInvariant(); - _bloomFilter.Add(domain); - if (!_lenBuckets.TryGetValue(domain.Length, out List? list)) - { - list = new List(); - _lenBuckets[domain.Length] = list; - } - // Cap fuzzy search candidates per length to keep search times predictable - if (list.Count < 15000) list.Add(domain); - } + if (state.BestScore >= 98) break; - // 1. Load custom list - if (!string.IsNullOrEmpty(customPath) && File.Exists(customPath)) - { - foreach (string line in File.ReadLines(customPath)) - processDomain(line.Trim()); - } + if (!PassesPrefilter(query, domain, _threshold)) + continue; - // 2. Load Majestic 1M - if (File.Exists(oneMilFilePath)) - { - using FileStream fs = new FileStream(oneMilFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024); - using StreamReader reader = new StreamReader(fs); - reader.ReadLine(); // Skip header + int score = Fuzz.WeightedRatio(query, domain); - while (reader.ReadLine() is { } line) + if (score > state.BestScore) { - string? domain = ExtractDomain(line); - if (domain != null) processDomain(domain); + state.BestScore = score; + state.BestDomain = domain; } - } - } - private string? Normalize(string s) - { - if (string.IsNullOrWhiteSpace(s)) return null; - - try - { - string? rd = _normalizer.Value?.Parse(s)?.RegistrableDomain; - if (string.IsNullOrWhiteSpace(rd)) rd = s; - return rd.TrimEnd('.').ToLowerInvariant(); - } - catch - { - string? clean = s.Trim().TrimEnd('.').ToLowerInvariant(); - if (clean.StartsWith("www.")) clean = clean.Substring(4); - if (clean.StartsWith("m.")) clean = clean.Substring(2); - return clean; + if (score >= 95) break; } } + #endregion private } } \ No newline at end of file From 5ac62ffd856ac371f7cce8dabecb1b8471e8b644 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 2 Jan 2026 12:58:02 +0200 Subject: [PATCH 51/62] Refactor --- .../TyposquattingDetector.cs | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index cd7d85ca3..f6be3af87 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -209,43 +209,49 @@ private Result FuzzyMatch(string query, Result result) { MatchState globalState = new MatchState { BestDomain = null, BestScore = 0 }; - int[] bucketIndices = new[] { query.Length - 1, query.Length, query.Length + 1 }; - - foreach (int len in bucketIndices) + for (int delta = -1; delta <= 1; delta++) { - if (!_lenBuckets.TryGetValue(len, out List? bucket)) continue; + int len = query.Length + delta; + if (!_lenBuckets.TryGetValue(len, out var bucket)) continue; const int SequentialCutoff = 256; if (bucket.Count <= SequentialCutoff) - { SequentialMatch(query, globalState, bucket); - } else - { ParallelMatch(query, globalState, bucket); - } if (globalState.BestScore >= 98) break; } + if (globalState.BestDomain != null) { - result.BestMatch = globalState.BestDomain; - result.FuzzyScore = globalState.BestScore; - result.IsSuspicious = true; - result.Severity = globalState.BestScore > 90 ? Severity.HIGH : Severity.MEDIUM; - result.Reason = Reason.Typosquatting; + GetSuspiciousResult(result, globalState); } else { - result.IsSuspicious = false; - result.Reason = Reason.NoCandidates; + GetNormalResult(result); } return result; } + private static void GetNormalResult(Result result) + { + result.IsSuspicious = false; + result.Reason = Reason.NoCandidates; + } + + private static void GetSuspiciousResult(Result result, MatchState globalState) + { + result.BestMatch = globalState.BestDomain; + result.FuzzyScore = globalState.BestScore; + result.IsSuspicious = true; + result.Severity = globalState.BestScore > 90 ? Severity.HIGH : Severity.MEDIUM; + result.Reason = Reason.Typosquatting; + } + private void LoadData(string oneMilFilePath, string customPath) { // Capacity for 1M domains + custom list From 0ceae8f936558864529e9423f56816e1b20908d1 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 2 Jan 2026 13:10:55 +0200 Subject: [PATCH 52/62] Created statepool for allocation issues --- .../TyposquattingDetector.MatchState.cs | 55 +++++++++++++++++++ .../TyposquattingDetector.cs | 16 +++--- 2 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 Apps/TyposquattingDetector/TyposquattingDetector.MatchState.cs diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.MatchState.cs b/Apps/TyposquattingDetector/TyposquattingDetector.MatchState.cs new file mode 100644 index 000000000..62a8ff2d5 --- /dev/null +++ b/Apps/TyposquattingDetector/TyposquattingDetector.MatchState.cs @@ -0,0 +1,55 @@ +/* +Technitium DNS Server +Copyright (C) 2025 Shreyas Zare (shreyas@technitium.com) +Copyright (C) 2025 Zafer Balkan (zafer@zaferbalkan.com) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System.Collections.Concurrent; + +namespace TyposquattingDetector +{ + public partial class TyposquattingDetector + { + // Define the state as a class to allow locking + private class MatchState + { + public string? BestDomain; + public int BestScore; + + // Reset method for reuse + public void Reset() + { + BestDomain = null; + BestScore = 0; + } + } + + // Simple thread-safe pool + private readonly ConcurrentQueue _statePool = new ConcurrentQueue(); + + private MatchState GetState() + { + if (_statePool.TryDequeue(out var state)) return state; + return new MatchState(); + } + + private void ReturnState(MatchState state) + { + state.Reset(); + _statePool.Enqueue(state); + } + } +} \ No newline at end of file diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index f6be3af87..e48b0d8ad 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -62,7 +62,7 @@ public Result(string query) public Severity Severity { get; set; } = Severity.NONE; } - public class TyposquattingDetector : IDisposable + public partial class TyposquattingDetector : IDisposable { #region variables @@ -84,12 +84,6 @@ public class TyposquattingDetector : IDisposable private IBloomFilter? _bloomFilter; private bool _disposedValue; - private class MatchState - { - public string? BestDomain; - public int BestScore; - } - #endregion variables #region constructor @@ -207,7 +201,11 @@ private static bool PassesPrefilter(string q, string d, int threshold) private Result FuzzyMatch(string query, Result result) { - MatchState globalState = new MatchState { BestDomain = null, BestScore = 0 }; + + //MatchState globalState = new MatchState { BestDomain = null, BestScore = 0 }; + MatchState globalState = GetState(); + globalState.BestDomain = null; + globalState.BestScore = 0; for (int delta = -1; delta <= 1; delta++) { @@ -233,7 +231,7 @@ private Result FuzzyMatch(string query, Result result) { GetNormalResult(result); } - + ReturnState(globalState); return result; } From 24a45a89f7a67ef339478a05b3dceef9cca86831 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 2 Jan 2026 13:44:44 +0200 Subject: [PATCH 53/62] Added second-level sharding (length + prefix2), with a prefix1 fallback to avoid missing second-character typos --- .../TyposquattingDetector.cs | 107 ++++++++++++++---- 1 file changed, 85 insertions(+), 22 deletions(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index e48b0d8ad..2b74f6c6e 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -62,7 +62,7 @@ public Result(string query) public Severity Severity { get; set; } = Severity.NONE; } - public partial class TyposquattingDetector : IDisposable + public partial class TyposquattingDetector : IDisposable { #region variables @@ -76,12 +76,20 @@ public partial class TyposquattingDetector : IDisposable rp.BuildAsync().GetAwaiter().GetResult(); return rp; }, isThreadSafe: true); + // Length -> (prefixKey -> candidates) + private readonly Dictionary>> _lenPrefixBuckets = new Dictionary>>(); + private const int MaxCandidatesPerPrefix2Bucket = 2000; // Tune caps to bound worst-case CPU per query + private const int MaxCandidatesPerPrefix1Bucket = 8000; - private readonly Dictionary> _lenBuckets = new Dictionary>(); private readonly ThreadLocal _normalizer; private readonly ParallelOptions _po; private readonly int _threshold; private IBloomFilter? _bloomFilter; + + // Use sequential processing for smaller buckets; benchmarks showed that below ~256 + // candidates, the overhead of parallelism outweighs its benefits. + const int SequentialCutoff = 256; + private bool _disposedValue; #endregion variables @@ -201,27 +209,42 @@ private static bool PassesPrefilter(string q, string d, int threshold) private Result FuzzyMatch(string query, Result result) { - - //MatchState globalState = new MatchState { BestDomain = null, BestScore = 0 }; MatchState globalState = GetState(); globalState.BestDomain = null; globalState.BestScore = 0; + uint q2 = Prefix2Key(query); + uint q1 = Prefix1Key(query); + for (int delta = -1; delta <= 1; delta++) { int len = query.Length + delta; - if (!_lenBuckets.TryGetValue(len, out var bucket)) continue; - const int SequentialCutoff = 256; + if (!_lenPrefixBuckets.TryGetValue(len, out var shardMap)) + continue; + + // 1) Exact prefix2 shard first (fastest / smallest) + if (shardMap.TryGetValue(q2, out var bucket2)) + { + if (bucket2.Count <= SequentialCutoff) + SequentialMatch(query, globalState, bucket2); + else + ParallelMatch(query, globalState, bucket2); - if (bucket.Count <= SequentialCutoff) - SequentialMatch(query, globalState, bucket); - else - ParallelMatch(query, globalState, bucket); + if (globalState.BestScore >= 98) break; + } - if (globalState.BestScore >= 98) break; - } + // 2) Prefix1 fallback shard (covers second-character differences) + if (q1 != q2 && shardMap.TryGetValue(q1, out var bucket1)) + { + if (bucket1.Count <= SequentialCutoff) + SequentialMatch(query, globalState, bucket1); + else + ParallelMatch(query, globalState, bucket1); + if (globalState.BestScore >= 98) break; + } + } if (globalState.BestDomain != null) { @@ -235,6 +258,41 @@ private Result FuzzyMatch(string query, Result result) return result; } + private static uint Prefix2Key(string s) + { + if (string.IsNullOrEmpty(s)) return 0; + + char c0 = s[0]; + char c1 = s.Length > 1 ? s[1] : '\0'; + return (uint)c0 | ((uint)c1 << 16); + } + + private static uint Prefix1Key(string s) + { + if (string.IsNullOrEmpty(s)) return 0; + + char c0 = s[0]; + return (uint)c0; // equivalent to (uint)c0 | (0u << 16) + } + + private void AddToBucket(int len, uint key, string domain, int cap) + { + if (!_lenPrefixBuckets.TryGetValue(len, out var shardMap)) + { + shardMap = new Dictionary>(capacity: 128); + _lenPrefixBuckets[len] = shardMap; + } + + if (!shardMap.TryGetValue(key, out var list)) + { + // Small initial capacity; grows if needed but capped by `cap` + list = new List(capacity: Math.Min(256, cap)); + shardMap[key] = list; + } + + if (list.Count < cap) + list.Add(domain); + } private static void GetNormalResult(Result result) { result.IsSuspicious = false; @@ -262,13 +320,17 @@ void processDomain(string domain) domain = domain.ToLowerInvariant(); _bloomFilter.Add(domain); - if (!_lenBuckets.TryGetValue(domain.Length, out List? list)) - { - list = new List(); - _lenBuckets[domain.Length] = list; - } - // Cap fuzzy search candidates per length to keep search times predictable - if (list.Count < 15000) list.Add(domain); + + int len = domain.Length; + + // Primary shard: prefix2 + uint p2 = Prefix2Key(domain); + AddToBucket(len, p2, domain, MaxCandidatesPerPrefix2Bucket); + + // Fallback shard: prefix1 (helps if the 2nd character differs) + uint p1 = Prefix1Key(domain); + if (p1 != p2) + AddToBucket(len, p1, domain, MaxCandidatesPerPrefix1Bucket); } // 1. Load custom list @@ -306,9 +368,10 @@ void processDomain(string domain) catch { string? clean = s.Trim().TrimEnd('.').ToLowerInvariant(); - if (clean.StartsWith("www.")) clean = clean.Substring(4); - if (clean.StartsWith("m.")) clean = clean.Substring(2); - return clean; + ReadOnlySpan span = clean.AsSpan(); + if (span.StartsWith("www.".AsSpan())) span = span[4..]; + if (span.StartsWith("m.".AsSpan())) span = span[2..]; + return new string(span); } } From 3d2bab519ed5d336d86f171f674b848019e79710 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 2 Jan 2026 13:49:39 +0200 Subject: [PATCH 54/62] Optimized score --- Apps/TyposquattingDetector/TyposquattingDetector.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index 2b74f6c6e..8d7e5d656 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -304,7 +304,7 @@ private static void GetSuspiciousResult(Result result, MatchState globalState) result.BestMatch = globalState.BestDomain; result.FuzzyScore = globalState.BestScore; result.IsSuspicious = true; - result.Severity = globalState.BestScore > 90 ? Severity.HIGH : Severity.MEDIUM; + result.Severity = globalState.BestScore > 85 ? Severity.HIGH : Severity.MEDIUM; result.Reason = Reason.Typosquatting; } From 22f012c853a8933436a23215dda65c7094bb3057 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 2 Jan 2026 13:52:52 +0200 Subject: [PATCH 55/62] Null check --- Apps/TyposquattingDetector/TyposquattingDetector.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index 8d7e5d656..b100cf83f 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -89,7 +89,7 @@ public partial class TyposquattingDetector : IDisposable // Use sequential processing for smaller buckets; benchmarks showed that below ~256 // candidates, the overhead of parallelism outweighs its benefits. const int SequentialCutoff = 256; - + private bool _disposedValue; #endregion variables @@ -368,6 +368,7 @@ void processDomain(string domain) catch { string? clean = s.Trim().TrimEnd('.').ToLowerInvariant(); + if (string.IsNullOrEmpty(clean)) return null; ReadOnlySpan span = clean.AsSpan(); if (span.StartsWith("www.".AsSpan())) span = span[4..]; if (span.StartsWith("m.".AsSpan())) span = span[2..]; From 67fe1b6d8d4eab9ba86b2e5700495910524676ee Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 2 Jan 2026 14:09:23 +0200 Subject: [PATCH 56/62] Used DomainCache for domain name normalization optimization --- Apps/TyposquattingDetector/DomainCache.cs | 275 ++++++++++++++++++ .../TyposquattingDetector.cs | 22 +- 2 files changed, 280 insertions(+), 17 deletions(-) create mode 100644 Apps/TyposquattingDetector/DomainCache.cs diff --git a/Apps/TyposquattingDetector/DomainCache.cs b/Apps/TyposquattingDetector/DomainCache.cs new file mode 100644 index 000000000..a1856007c --- /dev/null +++ b/Apps/TyposquattingDetector/DomainCache.cs @@ -0,0 +1,275 @@ +/* +Technitium DNS Server +Copyright (C) 2025 Shreyas Zare (shreyas@technitium.com) +Copyright (C) 2025 Zafer Balkan (zafer@zaferbalkan.com) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using Nager.PublicSuffix; +using Nager.PublicSuffix.RuleProviders; +using Nager.PublicSuffix.RuleProviders.CacheProviders; +using System; +using System.Collections.Concurrent; +using System.Net.Http; +using System.Threading; + +namespace TyposquattingDetector +{ + + + ///

+ /// Thread-safe cache for parsed domain information using the SIEVE eviction algorithm. + /// SIEVE provides better scan resistance than LRU, making it ideal for DNS workloads + /// where one-time queries (typos, DGA domains) are common. + /// + /// Reference: "SIEVE is Simpler than LRU: an Efficient Turn-Key Eviction Algorithm for + /// Web Caches" (NSDI '24) + /// + internal sealed class DomainCache + { + #region variables + + private const int MaxSize = 10000; + private const int StringPoolMaxSize = 10000; + private static readonly DomainInfo Empty = new DomainInfo(); + + // ADR: Loading the PSL must not block or fail plugin startup. We defer + // initialization and make it best-effort to avoid network dependencies. + private static readonly Lazy _parser = new Lazy(InitializeParser); + + private static readonly HttpClient _pslHttpClient = new HttpClient(); + + private static readonly Lazy _sharedRuleProvider = + new Lazy(static () => + { + LocalFileSystemCacheProvider cacheProvider = new LocalFileSystemCacheProvider(); + CachedHttpRuleProvider rp = new CachedHttpRuleProvider(cacheProvider, _pslHttpClient); + rp.BuildAsync().GetAwaiter().GetResult(); + return rp; + }, isThreadSafe: true); + + private readonly ConcurrentDictionary _cache = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _stringPool = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + private readonly Lock _evictionLock = new Lock(); + + // SIEVE data structures + private CacheNode? _head; + private CacheNode? _tail; + private CacheNode? _hand; + #endregion + + #region public + public DomainInfo GetOrAdd(string domainName) + { + if (string.IsNullOrWhiteSpace(domainName)) + return Empty; + + // Fast path: try cache lookup with original name first (case-insensitive) + if (_cache.TryGetValue(domainName, out CacheNode? node)) + { + node.Visited = true; + return node.Domain; + } + + // NormalizeConfig only if needed, using string pool to reduce allocations + string normalizedName = GetPooledNormalizedName(domainName); + + // Check cache again with normalized name (may differ from original) + if (!ReferenceEquals(normalizedName, domainName) && + _cache.TryGetValue(normalizedName, out node)) + { + node.Visited = true; + return node.Domain; + } + + DomainInfo domain = Parse(domainName); + AddToCache(normalizedName, domain); + return domain; + } + + + public void Clear() + { + _cache.Clear(); + _stringPool.Clear(); + } + + #endregion + + #region private + + /// + /// Returns a pooled, normalized version of the domain name to reduce allocations. + /// If the name is already normalized, returns the original string. + /// + private string GetPooledNormalizedName(string name) + { + if (!NeedsNormalization(name)) + return name; + + string normalized = name.ToLowerInvariant().TrimEnd('.'); + + // Try to get from pool, or add if not present + if (_stringPool.TryGetValue(normalized, out string? pooled)) + return pooled; + + // Limit pool size to prevent unbounded growth + if (_stringPool.Count < StringPoolMaxSize) + { + _stringPool.TryAdd(normalized, normalized); + } + + return normalized; + } + + /// + /// Checks if the domain name needs normalization (has uppercase or trailing dot). + /// + private static bool NeedsNormalization(string name) + { + if (name.Length > 0 && name[^1] == '.') + return true; + + foreach (char c in name) + { + if (c >= 'A' && c <= 'Z') + return true; + } + + return false; + } + + private static DomainInfo Parse(string name) + { + DomainParser? parser = _parser.Value; + if (parser == null) + return Empty; + + try + { + return parser.Parse(name) ?? Empty; + } + catch + { + // Parsing errors are intentionally ignored because PSL is optional. + return Empty; + } + } + + private static DomainParser? InitializeParser() + { + // ADR: The PSL download via SimpleHttpRuleProvider performs outbound HTTP. + // Relying on external network connectivity at plugin startup is unsafe in + // production DNS environments (offline appliances, firewalled networks, + // corporate proxies). Initialization must never block or fail due to PSL + // retrieval. We therefore treat PSL availability as optional: + // - If the download succeeds, domain parsing is enriched. + // - If it fails, we return null and logging continues without PSL data. + try + { + return new DomainParser(_sharedRuleProvider.Value); + } + catch + { + return null; + } + } + + private void AddToCache(string key, DomainInfo domain) + { + lock (_evictionLock) + { + if (_cache.ContainsKey(key)) + return; + + while (_cache.Count >= MaxSize) + Evict(); + + CacheNode newNode = new CacheNode(key, domain); + InsertAtHead(newNode); + _cache[key] = newNode; + } + } + + private void InsertAtHead(CacheNode node) + { + node.Next = _head; + node.Prev = null; + + if (_head != null) + _head.Prev = node; + + _head = node; + + _tail ??= node; + + _hand ??= node; + } + + private void Evict() + { + _hand ??= _tail; + + while (_hand != null) + { + if (!_hand.Visited) + { + CacheNode victim = _hand; + _hand = _hand.Prev ?? _tail; + RemoveNode(victim); + _cache.TryRemove(victim.Key, out _); + return; + } + + _hand.Visited = false; + _hand = _hand.Prev ?? _tail; + } + } + + private void RemoveNode(CacheNode node) + { + if (node.Prev != null) + node.Prev.Next = node.Next; + else + _head = node.Next; + + if (node.Next != null) + node.Next.Prev = node.Prev; + else + _tail = node.Prev; + + if (_hand == node) + _hand = node.Prev ?? _tail; + } + + private class CacheNode + { + public readonly string Key; + public readonly DomainInfo Domain; + public volatile bool Visited; + public CacheNode? Next; + public CacheNode? Prev; + + public CacheNode(string key, DomainInfo domain) + { + Key = key; + Domain = domain; + } + } + #endregion + } +} \ No newline at end of file diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index b100cf83f..b11c7cc55 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -66,22 +66,12 @@ public partial class TyposquattingDetector : IDisposable { #region variables - private static readonly HttpClient _pslHttpClient = new HttpClient(); - - private static readonly Lazy _sharedRuleProvider = - new Lazy(static () => - { - LocalFileSystemCacheProvider cacheProvider = new LocalFileSystemCacheProvider(); - CachedHttpRuleProvider rp = new CachedHttpRuleProvider(cacheProvider, _pslHttpClient); - rp.BuildAsync().GetAwaiter().GetResult(); - return rp; - }, isThreadSafe: true); // Length -> (prefixKey -> candidates) private readonly Dictionary>> _lenPrefixBuckets = new Dictionary>>(); private const int MaxCandidatesPerPrefix2Bucket = 2000; // Tune caps to bound worst-case CPU per query private const int MaxCandidatesPerPrefix1Bucket = 8000; - private readonly ThreadLocal _normalizer; + readonly DomainCache _domainCache; private readonly ParallelOptions _po; private readonly int _threshold; private IBloomFilter? _bloomFilter; @@ -90,6 +80,7 @@ public partial class TyposquattingDetector : IDisposable // candidates, the overhead of parallelism outweighs its benefits. const int SequentialCutoff = 256; + private bool _disposedValue; #endregion variables @@ -99,11 +90,8 @@ public partial class TyposquattingDetector : IDisposable public TyposquattingDetector(string defaultPath, string customPath, int threshold) { _threshold = threshold; + _domainCache = new DomainCache(); _po = new ParallelOptions { MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount / 2) }; - - _normalizer = new ThreadLocal(() => - new DomainParser(_sharedRuleProvider.Value, new Nager.PublicSuffix.DomainNormalizers.UriDomainNormalizer())); - LoadData(defaultPath, customPath); } @@ -124,7 +112,7 @@ protected virtual void Dispose(bool disposing) { if (disposing) { - _normalizer?.Dispose(); + _domainCache.Clear(); } _disposedValue = true; } @@ -361,7 +349,7 @@ void processDomain(string domain) try { - string? rd = _normalizer.Value?.Parse(s)?.RegistrableDomain; + string? rd = _domainCache.GetOrAdd(s)?.RegistrableDomain; if (string.IsNullOrWhiteSpace(rd)) rd = s; return rd.TrimEnd('.').ToLowerInvariant(); } From b37f08ae2de1a68e798e07f03810945b7cbcaec7 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 2 Jan 2026 14:28:42 +0200 Subject: [PATCH 57/62] Optimize Bloom filter shards --- .../TyposquattingDetector/TyposquattingDetector.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs index b11c7cc55..a52bd17e2 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -69,7 +69,12 @@ public partial class TyposquattingDetector : IDisposable // Length -> (prefixKey -> candidates) private readonly Dictionary>> _lenPrefixBuckets = new Dictionary>>(); private const int MaxCandidatesPerPrefix2Bucket = 2000; // Tune caps to bound worst-case CPU per query - private const int MaxCandidatesPerPrefix1Bucket = 8000; + + // Prefix1 shards are broad (all domains sharing first char). Keep them small to save memory. + private const int MaxCandidatesPerPrefix1Bucket = 2000; + + // Only consult prefix1 when prefix2 didn't yield a strong candidate. + private const int Prefix1FallbackMinScore = 88; readonly DomainCache _domainCache; private readonly ParallelOptions _po; @@ -223,7 +228,10 @@ private Result FuzzyMatch(string query, Result result) } // 2) Prefix1 fallback shard (covers second-character differences) - if (q1 != q2 && shardMap.TryGetValue(q1, out var bucket1)) + // Only pay this cost if prefix2 didn't already produce a decent match. + if (globalState.BestScore < Prefix1FallbackMinScore && + q1 != q2 && + shardMap.TryGetValue(q1, out var bucket1)) { if (bucket1.Count <= SequentialCutoff) SequentialMatch(query, globalState, bucket1); @@ -316,9 +324,11 @@ void processDomain(string domain) AddToBucket(len, p2, domain, MaxCandidatesPerPrefix2Bucket); // Fallback shard: prefix1 (helps if the 2nd character differs) + // Note: capped low to bound memory + fallback is gated on score. uint p1 = Prefix1Key(domain); if (p1 != p2) AddToBucket(len, p1, domain, MaxCandidatesPerPrefix1Bucket); + } // 1. Load custom list From 78ee5a96c6e42b85193de8ab735eec04d319fe67 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 2 Jan 2026 14:31:34 +0200 Subject: [PATCH 58/62] Used bounded statepool to prevent memory allocation issues --- .../TyposquattingDetector.MatchState.cs | 54 +++++++++++++------ 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.MatchState.cs b/Apps/TyposquattingDetector/TyposquattingDetector.MatchState.cs index 62a8ff2d5..7ed4192dd 100644 --- a/Apps/TyposquattingDetector/TyposquattingDetector.MatchState.cs +++ b/Apps/TyposquattingDetector/TyposquattingDetector.MatchState.cs @@ -17,39 +17,59 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ +using System; using System.Collections.Concurrent; +using System.Threading; namespace TyposquattingDetector { - public partial class TyposquattingDetector + public partial class TyposquattingDetector { - // Define the state as a class to allow locking - private class MatchState - { - public string? BestDomain; - public int BestScore; - - // Reset method for reuse - public void Reset() - { - BestDomain = null; - BestScore = 0; - } - } + // Bound the pool so burst traffic cannot cause permanent memory growth. + // Size heuristic: a few multiples of CPU is enough to cover typical concurrency. + private static readonly int MaxStatePoolSize = Math.Max(16, Environment.ProcessorCount * 4); - // Simple thread-safe pool private readonly ConcurrentQueue _statePool = new ConcurrentQueue(); + private int _statePoolCount; + private MatchState GetState() { - if (_statePool.TryDequeue(out var state)) return state; + if (_statePool.TryDequeue(out MatchState? state)) + { + Interlocked.Decrement(ref _statePoolCount); + return state; + } + return new MatchState(); } private void ReturnState(MatchState state) { state.Reset(); - _statePool.Enqueue(state); + + int newCount = Interlocked.Increment(ref _statePoolCount); + if (newCount <= MaxStatePoolSize) + { + _statePool.Enqueue(state); + return; + } + + // Over cap: undo the count and let GC reclaim this instance. + Interlocked.Decrement(ref _statePoolCount); + } + + // Define the state as a class to allow locking + private sealed class MatchState + { + public string? BestDomain; + public int BestScore; + + public void Reset() + { + BestDomain = null; + BestScore = 0; + } } } } \ No newline at end of file From 7cf90cf0502589c12dad8b68b4368842aff35ec9 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 2 Jan 2026 14:45:48 +0200 Subject: [PATCH 59/62] Rolled back accidental changes Signed-off-by: Zafer Balkan --- DnsServer.sln | 4 ++-- README.md | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/DnsServer.sln b/DnsServer.sln index 2e638b3ba..d8bf9890e 100644 --- a/DnsServer.sln +++ b/DnsServer.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 18 -VisualStudioVersion = 18.1.11312.151 d18.0 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31912.275 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DnsServerApp", "DnsServerApp\DnsServerApp.csproj", "{ADE80805-9FA7-4F66-8A18-57B98F8C0B0F}" EndProject diff --git a/README.md b/README.md index 3ae788b4b..3716235ba 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,7 @@ Nobody really bothers about domain name resolution since it works automatically Be it a home network or an organization's network, having a locally running DNS server gives you more insights into your network and helps to understand it better using the DNS logs and stats. It improves overall performance since most queries are served from the DNS cache making web sites load faster by not having to wait for frequent DNS resolutions. It also gives you an additional control over your network allowing you to block domain names network wide and also allows you to route your DNS traffic securely using encrypted DNS protocols. -[![Quality gate](https://sonarcloud.io/api/project_badges/quality_gate?project=zbalkan_DnsServer)](https://sonarcloud.io/summary/new_code?id=zbalkan_DnsServer) - -# Sponsored By +WW# Sponsored By

Altha Technology - Censorship Resistant Data Services

From 95fb88cff16ed139b338abe051101357e37047d1 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 2 Jan 2026 14:50:51 +0200 Subject: [PATCH 60/62] Added lock check in Clear --- Apps/TyposquattingDetector/DomainCache.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Apps/TyposquattingDetector/DomainCache.cs b/Apps/TyposquattingDetector/DomainCache.cs index a1856007c..a7b9d6c02 100644 --- a/Apps/TyposquattingDetector/DomainCache.cs +++ b/Apps/TyposquattingDetector/DomainCache.cs @@ -104,8 +104,14 @@ public DomainInfo GetOrAdd(string domainName) public void Clear() { - _cache.Clear(); - _stringPool.Clear(); + lock (_evictionLock) + { + _cache.Clear(); + _stringPool.Clear(); + _head = null; + _tail = null; + _hand = null; + } } #endregion From 2866ee802423a56ec59255c12c7aceb4491b2718 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 2 Jan 2026 15:13:17 +0200 Subject: [PATCH 61/62] Application config guards added --- Apps/TyposquattingDetector/App.cs | 37 ++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs index 12f2a43ec..e79204ce7 100644 --- a/Apps/TyposquattingDetector/App.cs +++ b/Apps/TyposquattingDetector/App.cs @@ -439,18 +439,16 @@ private Task UpdateDomainListAsync(CancellationToken cancellationToken) { _dnsServer!.WriteLog("Typosquatting Detector: Processing domain list..."); - string configDir = _dnsServer.ApplicationFolder; + string configDirFullPath = Path.GetFullPath(_dnsServer.ApplicationFolder); string majesticPath = Path.GetFullPath(_domainListFilePath!); - if (!majesticPath.StartsWith(configDir, StringComparison.OrdinalIgnoreCase)) - throw new SecurityException("Access Denied"); + EnsureUnderBaseSymlinkSafe(configDirFullPath, majesticPath); string customListPath = string.Empty; if (!string.IsNullOrWhiteSpace(_config!.Path)) { customListPath = Path.GetFullPath(_config.Path); - if (!customListPath.StartsWith(configDir, StringComparison.OrdinalIgnoreCase)) - throw new SecurityException("Access Denied"); + EnsureUnderBaseSymlinkSafe(configDirFullPath, customListPath); } TyposquattingDetector newDetector = new TyposquattingDetector(majesticPath, customListPath, _config.FuzzyMatchThreshold); @@ -467,6 +465,35 @@ private Task UpdateDomainListAsync(CancellationToken cancellationToken) return Task.CompletedTask; } + private static void EnsureUnderBaseSymlinkSafe(string baseDirFullPath, string candidateFullPath) + { + baseDirFullPath = Path.GetFullPath(baseDirFullPath); + candidateFullPath = Path.GetFullPath(candidateFullPath); + + // First: lexical traversal guard + string rel = Path.GetRelativePath(baseDirFullPath, candidateFullPath); + if (rel == ".." || + rel.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) || + rel.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal)) + throw new SecurityException("Access Denied"); + + // Second: resolve each component and block symlink escape + var current = new DirectoryInfo(candidateFullPath); + while (current != null && + !current.FullName.Equals(baseDirFullPath, StringComparison.OrdinalIgnoreCase)) + { + // If any component is a symlink → reject + if ((current.Attributes & FileAttributes.ReparsePoint) != 0) + throw new SecurityException("Access Denied"); + + current = current.Parent; + } + + // If we walked to filesystem root without hitting base folder → reject + if (current == null) + throw new SecurityException("Access Denied"); + } + #endregion private #region properties From 0f26f9d15633d993258c4bc47d43df01ab023c78 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Mon, 5 Jan 2026 22:32:52 +0300 Subject: [PATCH 62/62] Fixed typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3716235ba..84941ea11 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Nobody really bothers about domain name resolution since it works automatically Be it a home network or an organization's network, having a locally running DNS server gives you more insights into your network and helps to understand it better using the DNS logs and stats. It improves overall performance since most queries are served from the DNS cache making web sites load faster by not having to wait for frequent DNS resolutions. It also gives you an additional control over your network allowing you to block domain names network wide and also allows you to route your DNS traffic securely using encrypted DNS protocols. -WW# Sponsored By +# Sponsored By

Altha Technology - Censorship Resistant Data Services