From 8246534162a94a3a4ad4c8caa351c92c3fa6a3ce Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Wed, 18 Feb 2026 13:53:37 +0200 Subject: [PATCH 01/37] Added basic DNS cookie support --- DnsServerCore/Dns/DnsServer.cs | 249 ++++++++++++++++++ .../Dns/Security/DnsCookieSecretManager.cs | 165 ++++++++++++ .../Dns/Security/DnsCookieValidator.cs | 192 ++++++++++++++ 3 files changed, 606 insertions(+) create mode 100644 DnsServerCore/Dns/Security/DnsCookieSecretManager.cs create mode 100644 DnsServerCore/Dns/Security/DnsCookieValidator.cs diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index c031cbe9e..7f534c6a0 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -49,6 +49,7 @@ You should have received a copy of the GNU General Public License using System.Text; using System.Threading; using System.Threading.Tasks; +using DnsServerCore.Dns.Security; using TechnitiumLibrary; using TechnitiumLibrary.IO; using TechnitiumLibrary.Net; @@ -272,6 +273,23 @@ enum ServiceState readonly Timer _saveTimer; const int SAVE_TIMER_INITIAL_INTERVAL = 5000; + // DNS Cookies (RFC 7873) + readonly bool _dnsCookiesEnabled = true; + readonly string _dnsCookiesSecretFile = "dns.cookies.state"; + readonly int _dnsCookiesRotationPeriodHours = 1; + readonly bool _dnsCookiesSetTcOnBadCookie = true; + readonly bool _dnsCookiesAlwaysEcho = true; + + Security.DnsCookieSecretManager _cookieSecrets; + Security.DnsCookieValidator _cookieValidator; + Timer _cookieRotationTimer; + + // Optional observability counters + long _cookieValid; + long _cookieInvalid; + long _cookieMissing; + long _cookieBadcookieSent; + #endregion #region constructor @@ -434,6 +452,9 @@ public async ValueTask DisposeAsync() } } + _cookieRotationTimer?.Dispose(); + _cookieRotationTimer = null; + _disposed = true; GC.SuppressFinalize(this); } @@ -1125,6 +1146,14 @@ private void ReadConfigFrom(Stream s, bool isConfigTransfer) int maxStatFileDays = bR.ReadInt32(); if (!isConfigTransfer) _statsManager.MaxStatFileDays = maxStatFileDays; + + if (!isConfigTransfer) + { + _cookieRotationTimer?.Dispose(); + _cookieRotationTimer = null; + + InitDnsCookiesIfEnabled(); + } } private void WriteConfigTo(Stream s) @@ -1409,6 +1438,13 @@ private void WriteConfigTo(Stream s) bW.Write(_queryLog is not null); //log all queries bW.Write(_statsManager.EnableInMemoryStats); bW.Write(_statsManager.MaxStatFileDays); + + // DNS cookies + bW.Write(_dnsCookiesEnabled); + bW.Write(_dnsCookiesSecretFile ?? "dns.cookies.state"); + bW.Write(_dnsCookiesRotationPeriodHours); + bW.Write(_dnsCookiesSetTcOnBadCookie); + bW.Write(_dnsCookiesAlwaysEcho); } #endregion @@ -1576,6 +1612,146 @@ private string ConvertToAbsolutePath(string path) return Path.Combine(_configFolder, path); } + private void InitDnsCookiesIfEnabled() + { + if (!_dnsCookiesEnabled) + return; + + string secretPath = Path.IsPathRooted(_dnsCookiesSecretFile) + ? _dnsCookiesSecretFile + : Path.Combine(_configFolder, _dnsCookiesSecretFile); + + _cookieSecrets = new Security.DnsCookieSecretManager(secretPath); + _cookieValidator = new Security.DnsCookieValidator(_cookieSecrets); + + _cookieRotationTimer?.Dispose(); + if (_dnsCookiesRotationPeriodHours > 0) + { + _cookieRotationTimer = new Timer( + _ => + { + try { _cookieSecrets.Rotate(); } + catch (Exception ex) { _log.Write(ex); } + }, + null, + dueTime: TimeSpan.FromMinutes(5), + period: TimeSpan.FromHours(_dnsCookiesRotationPeriodHours)); + } + } + + private static EDnsCookieOptionData TryGetCookieOption(DnsDatagram request) + { + DnsDatagramEdns edns = request.EDNS; + if (edns is null) + return null; + + foreach (EDnsOption opt in edns.Options) + { + if (opt.Code == EDnsOptionCode.COOKIE && opt.Data is EDnsCookieOptionData c) + return c; + } + + return null; + } + + private DnsDatagram BuildBadCookieResponse( + DnsDatagram request, + IPEndPoint remoteEP, + bool isRecursionAllowed, + EDnsCookieOptionData responseCookie) + { + IReadOnlyList options = + MergeCookieOption(request.EDNS?.Options, responseCookie); + + ushort udpPayload = request.EDNS?.UdpPayloadSize ?? 512; + EDnsHeaderFlags flags = request.EDNS?.Flags ?? EDnsHeaderFlags.None; + + return new DnsDatagram( + request.Identifier, + true, + request.OPCODE, + false, + truncation: true, // REQUIRED by RFC 7873 §5.2.3 + recursionDesired: request.RecursionDesired, + recursionAvailable: isRecursionAllowed, + authenticData: false, + checkingDisabled: request.CheckingDisabled, + DnsResponseCode.BADCOOKIE, + request.Question, + null, + null, + null, + udpPayload, + flags, + options + ) + { + Tag = DnsServerResponseType.Authoritative + }; + } + + private static IReadOnlyList MergeCookieOption( + IReadOnlyList existing, + EDnsCookieOptionData cookie) + { + List list; + + if (existing == null) + list = new List(1); + else + list = new List(existing.Count + 1); + + if (existing != null) + { + foreach (var opt in existing) + { + if (opt.Code != EDnsOptionCode.COOKIE) + list.Add(opt); + } + } + + list.Add(new EDnsOption(EDnsOptionCode.COOKIE, cookie)); + + return list; + } + + private static IReadOnlyList UpsertOptRecord( + IReadOnlyList existingAdditional, + DnsDatagram request, + DnsDatagram response, + IReadOnlyList options) + { + var baseEdns = response.EDNS ?? request.EDNS; + + ushort udp = baseEdns?.UdpPayloadSize ?? 512; + var flags = baseEdns?.Flags ?? EDnsHeaderFlags.None; + + var opt = DnsDatagramEdns.GetOPTFor( + udpPayloadSize: udp, + extendedRCODE: 0, + version: 0, + flags: flags, + options: options); + + List list = + existingAdditional == null + ? new List(1) + : new List(existingAdditional.Count + 1); + + if (existingAdditional != null) + { + foreach (var rr in existingAdditional) + { + if (rr.Type != DnsResourceRecordType.OPT) + list.Add(rr); + } + } + + list.Add(opt); + + return list; + } + #endregion #region private @@ -2643,10 +2819,83 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo return new DnsDatagram(request.Identifier, true, request.OPCODE, false, false, request.RecursionDesired, isRecursionAllowed, false, request.CheckingDisabled, DnsResponseCode.BADVERS, request.Question, null, null, null, _udpPayloadSize, request.DnssecOk ? EDnsHeaderFlags.DNSSEC_OK : EDnsHeaderFlags.None) { Tag = DnsServerResponseType.Authoritative }; } + // DNS Cookies (RFC 7873) + if (_dnsCookiesEnabled && + request.EDNS != null && + _cookieValidator != null) + { + EDnsCookieOptionData cookie = + TryGetCookieOption(request); + + if (cookie == null) + { + Interlocked.Increment(ref _cookieMissing); + } + else + { + if (cookie.ServerCookie != null && cookie.ServerCookie.Length > 0) + { + if (!_cookieValidator.Validate( + remoteEP.Address, + cookie)) + { + Interlocked.Increment(ref _cookieInvalid); + + var respCookie = + _cookieValidator.CreateResponseCookie( + remoteEP.Address, + cookie); + + Interlocked.Increment( + ref _cookieBadcookieSent); + + return BuildBadCookieResponse( + request, + remoteEP, + isRecursionAllowed, + respCookie); + } + + Interlocked.Increment(ref _cookieValid); + } + } + } + DnsDatagram response = await ProcessQueryAsync(request, remoteEP, protocol, isRecursionAllowed, false, _clientTimeout, null); if (response is null) return null; + // Attach cookie to response if needed + if (_dnsCookiesEnabled && _cookieValidator != null && request.EDNS != null) + { + EDnsCookieOptionData requestCookie = TryGetCookieOption(request); + if (requestCookie != null) + { + bool shouldSendServerCookie = + _dnsCookiesAlwaysEcho || + requestCookie.ServerCookie == null || + requestCookie.ServerCookie.Length == 0; + + if (shouldSendServerCookie) + { + EDnsCookieOptionData responseCookie = + _cookieValidator.CreateResponseCookie( + remoteEP.Address, + requestCookie); + + IReadOnlyList mergedOptions = + MergeCookieOption(response.EDNS?.Options, responseCookie); + + response = response.Clone( + additional: UpsertOptRecord( + response.Additional, + request, + response, + mergedOptions)); + } + } + } + return await PostProcessQueryAsync(request, remoteEP, protocol, response); } diff --git a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs new file mode 100644 index 000000000..b196d4668 --- /dev/null +++ b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs @@ -0,0 +1,165 @@ +/* +Technitium DNS Server +Copyright (C) 2026 Shreyas Zare (shreyas@technitium.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; +using System.IO; +using System.Security.Cryptography; + +namespace DnsServerCore.Dns.Security +{ + public class DnsCookieSecretManager + { + #region variables + + readonly string _secretFilePath; + readonly object _lock = new object(); + + byte[] _currentSecret; + byte[] _previousSecret; + DateTime _currentSecretCreated; + + #endregion + + #region constructor + + public DnsCookieSecretManager(string secretFilePath) + { + _secretFilePath = secretFilePath; + Load(); + } + + #endregion + + #region private + + private void Load() + { + lock (_lock) + { + if (File.Exists(_secretFilePath)) + { + try + { + byte[] data = File.ReadAllBytes(_secretFilePath); + using (MemoryStream ms = new MemoryStream(data)) + using (BinaryReader br = new BinaryReader(ms)) + { + int version = br.ReadInt32(); + if (version == 1) + { + _currentSecretCreated = new DateTime(br.ReadInt64(), DateTimeKind.Utc); + + int currentLen = br.ReadInt32(); + _currentSecret = br.ReadBytes(currentLen); + + int previousLen = br.ReadInt32(); + if (previousLen > 0) + _previousSecret = br.ReadBytes(previousLen); + } + } + } + catch + { + // If loading fails, generate new secrets + GenerateNewSecrets(); + } + } + else + { + GenerateNewSecrets(); + } + } + } + + private void Save() + { + lock (_lock) + { + using (MemoryStream ms = new MemoryStream()) + { + using (BinaryWriter bw = new BinaryWriter(ms)) + { + bw.Write(1); // version + bw.Write(_currentSecretCreated.Ticks); + + bw.Write(_currentSecret.Length); + bw.Write(_currentSecret); + + if (_previousSecret != null) + { + bw.Write(_previousSecret.Length); + bw.Write(_previousSecret); + } + else + { + bw.Write(0); + } + } + + string directory = Path.GetDirectoryName(_secretFilePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + Directory.CreateDirectory(directory); + + File.WriteAllBytes(_secretFilePath, ms.ToArray()); + } + } + } + + private void GenerateNewSecrets() + { + _currentSecret = RandomNumberGenerator.GetBytes(32); + _currentSecretCreated = DateTime.UtcNow; + _previousSecret = null; + Save(); + } + + #endregion + + #region public + + public void Rotate() + { + lock (_lock) + { + _previousSecret = _currentSecret; + _currentSecret = RandomNumberGenerator.GetBytes(32); + _currentSecretCreated = DateTime.UtcNow; + Save(); + } + } + + public byte[] GetCurrentSecret() + { + lock (_lock) + { + return _currentSecret; + } + } + + public byte[] GetPreviousSecret() + { + lock (_lock) + { + return _previousSecret; + } + } + + #endregion + } +} diff --git a/DnsServerCore/Dns/Security/DnsCookieValidator.cs b/DnsServerCore/Dns/Security/DnsCookieValidator.cs new file mode 100644 index 000000000..f6a8d6b83 --- /dev/null +++ b/DnsServerCore/Dns/Security/DnsCookieValidator.cs @@ -0,0 +1,192 @@ +/* +Technitium DNS Server +Copyright (C) 2026 Shreyas Zare (shreyas@technitium.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; +using System.IO; +using System.Net; +using System.Security.Cryptography; +using TechnitiumLibrary.Net.Dns.EDnsOptions; + +namespace DnsServerCore.Dns.Security +{ + public class DnsCookieValidator + { + #region variables + + readonly DnsCookieSecretManager _secretManager; + // RFC 9018 server cookie structure: Version(1) + Reserved(1) + Timestamp(4) + Hash(8) = 14 bytes + + #endregion + + #region constructor + + public DnsCookieValidator(DnsCookieSecretManager secretManager) + { + _secretManager = secretManager ?? throw new ArgumentNullException(nameof(secretManager)); + } + + #endregion + + #region private + + private static byte[] ComputeServerCookie(IPAddress clientAddress, ReadOnlySpan clientCookie, byte[] secret) + { + // RFC 9018 server cookie structure: + // Version (1 byte) | Reserved (1 byte) | Timestamp (4 bytes) | Hash (8 bytes) + + using (MemoryStream ms = new MemoryStream()) + { + using (BinaryWriter bw = new BinaryWriter(ms)) + { + // Version 1 + bw.Write((byte)1); + + // Reserved (0) + bw.Write((byte)0); + + // Timestamp (Unix time in seconds) + uint timestamp = (uint)(DateTimeOffset.UtcNow.ToUnixTimeSeconds() & 0xFFFFFFFF); + bw.Write(timestamp); + + // Compute HMAC-SHA256 hash + byte[] hashInput; + using (MemoryStream hashMs = new MemoryStream()) + { + using (BinaryWriter hashBw = new BinaryWriter(hashMs)) + { + // Hash input: version | reserved | timestamp | client_cookie | client_ip + hashBw.Write((byte)1); + hashBw.Write((byte)0); + hashBw.Write(timestamp); + hashBw.Write(clientCookie); + hashBw.Write(clientAddress.GetAddressBytes()); + } + hashInput = hashMs.ToArray(); + } + + using (HMACSHA256 hmac = new HMACSHA256(secret)) + { + byte[] hash = hmac.ComputeHash(hashInput); + // Take first 8 bytes (64 bits) as per RFC 9018 + bw.Write(hash, 0, 8); + } + } + + return ms.ToArray(); + } + } + + private static bool ValidateServerCookieWithSecret(IPAddress clientAddress, ReadOnlySpan clientCookie, ReadOnlySpan serverCookie, byte[] secret) + { + if (serverCookie.Length < 8 || serverCookie.Length > 32) + return false; + + // Extract timestamp from server cookie + if (serverCookie.Length < 6) + return false; + + byte version = serverCookie[0]; + if (version != 1) + return false; + + uint cookieTimestamp = BitConverter.ToUInt32(serverCookie.ToArray(), 2); + uint currentTimestamp = (uint)(DateTimeOffset.UtcNow.ToUnixTimeSeconds() & 0xFFFFFFFF); + + // Check timestamp is within 5 minutes (300 seconds) - RFC 9018 recommendation + uint timeDiff = currentTimestamp > cookieTimestamp + ? currentTimestamp - cookieTimestamp + : cookieTimestamp - currentTimestamp; + + if (timeDiff > 300) + return false; + + // Recompute hash with the same timestamp + using (MemoryStream hashMs = new MemoryStream()) + { + using (BinaryWriter hashBw = new BinaryWriter(hashMs)) + { + hashBw.Write((byte)1); + hashBw.Write((byte)0); + hashBw.Write(cookieTimestamp); + hashBw.Write(clientCookie); + hashBw.Write(clientAddress.GetAddressBytes()); + } + + using (HMACSHA256 hmac = new HMACSHA256(secret)) + { + byte[] hash = hmac.ComputeHash(hashMs.ToArray()); + + // Compare first 8 bytes + if (serverCookie.Length >= 14) + { + for (int i = 0; i < 8; i++) + { + if (serverCookie[6 + i] != hash[i]) + return false; + } + return true; + } + } + } + + return false; + } + + #endregion + + #region public + + public bool Validate(IPAddress clientAddress, EDnsCookieOptionData cookie) + { + if (cookie == null || cookie.ClientCookie.IsEmpty || cookie.ServerCookie.IsEmpty) + return false; + + if (cookie.ClientCookie.Length != 8) + return false; + + // Try current secret first + byte[] currentSecret = _secretManager.GetCurrentSecret(); + if (currentSecret != null && ValidateServerCookieWithSecret(clientAddress, cookie.ClientCookie, cookie.ServerCookie, currentSecret)) + return true; + + // Try previous secret for rotation grace period + byte[] previousSecret = _secretManager.GetPreviousSecret(); + if (previousSecret != null && ValidateServerCookieWithSecret(clientAddress, cookie.ClientCookie, cookie.ServerCookie, previousSecret)) + return true; + + return false; + } + + public EDnsCookieOptionData CreateResponseCookie(IPAddress clientAddress, EDnsCookieOptionData requestCookie) + { + if (requestCookie == null || requestCookie.ClientCookie.IsEmpty) + throw new ArgumentException("Request cookie must have a client cookie"); + + if (requestCookie.ClientCookie.Length != 8) + throw new ArgumentException("Client cookie must be 8 bytes"); + + byte[] currentSecret = _secretManager.GetCurrentSecret(); + byte[] serverCookie = ComputeServerCookie(clientAddress, requestCookie.ClientCookie, currentSecret); + + return new EDnsCookieOptionData(requestCookie.ClientCookie.ToArray(), serverCookie); + } + + #endregion + } +} From dd5813de7492f1c1d864b8c284db07d4c29f193d Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 19 Feb 2026 10:47:25 +0200 Subject: [PATCH 02/37] Added cookie initialization to path when no config file exists --- DnsServerCore/Dns/DnsServer.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index 7f534c6a0..c3d7a5d57 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -49,7 +49,6 @@ You should have received a copy of the GNU General Public License using System.Text; using System.Threading; using System.Threading.Tasks; -using DnsServerCore.Dns.Security; using TechnitiumLibrary; using TechnitiumLibrary.IO; using TechnitiumLibrary.Net; @@ -599,6 +598,8 @@ public void LoadConfigFile() _statsManager.MaxStatFileDays = 365; SaveConfigFileInternal(); + + InitDnsCookiesIfEnabled(); } catch (Exception ex) { From 69241ab0659c40cfe3040499628f7ae4c37c14bc Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 19 Feb 2026 10:49:18 +0200 Subject: [PATCH 03/37] Merged null check in MergeCookieOption --- DnsServerCore/Dns/DnsServer.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index c3d7a5d57..b63aa24c4 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -1698,12 +1698,12 @@ private static IReadOnlyList MergeCookieOption( List list; if (existing == null) + { list = new List(1); + } else - list = new List(existing.Count + 1); - - if (existing != null) { + list = new List(existing.Count + 1); foreach (var opt in existing) { if (opt.Code != EDnsOptionCode.COOKIE) From 7da12a197f689f491eb1d9074a7b597ad40c18e1 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 19 Feb 2026 10:57:14 +0200 Subject: [PATCH 04/37] Fixed endianness of timestamp --- DnsServerCore/Dns/Security/DnsCookieValidator.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/DnsServerCore/Dns/Security/DnsCookieValidator.cs b/DnsServerCore/Dns/Security/DnsCookieValidator.cs index f6a8d6b83..360c4148c 100644 --- a/DnsServerCore/Dns/Security/DnsCookieValidator.cs +++ b/DnsServerCore/Dns/Security/DnsCookieValidator.cs @@ -61,8 +61,12 @@ private static byte[] ComputeServerCookie(IPAddress clientAddress, ReadOnlySpan< bw.Write((byte)0); // Timestamp (Unix time in seconds) - uint timestamp = (uint)(DateTimeOffset.UtcNow.ToUnixTimeSeconds() & 0xFFFFFFFF); - bw.Write(timestamp); + // RFC 9018 Section 4.1 specifies that the timestamp should be in network byte order (big-endian). + uint timestamp = unchecked((uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + bw.Write((byte)(timestamp >> 24)); + bw.Write((byte)(timestamp >> 16)); + bw.Write((byte)(timestamp >> 8)); + bw.Write((byte)(timestamp >> 0)); // Compute HMAC-SHA256 hash byte[] hashInput; From dd41d22d9d2fb3d55a4ee03d449623dd721ecd80 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 19 Feb 2026 11:05:36 +0200 Subject: [PATCH 05/37] Hardened ComputeServerCookie --- .../Dns/Security/DnsCookieValidator.cs | 110 +++++++++++------- 1 file changed, 65 insertions(+), 45 deletions(-) diff --git a/DnsServerCore/Dns/Security/DnsCookieValidator.cs b/DnsServerCore/Dns/Security/DnsCookieValidator.cs index 360c4148c..5f331c1cb 100644 --- a/DnsServerCore/Dns/Security/DnsCookieValidator.cs +++ b/DnsServerCore/Dns/Security/DnsCookieValidator.cs @@ -18,8 +18,10 @@ You should have received a copy of the GNU General Public License */ using System; +using System.Buffers.Binary; using System.IO; using System.Net; +using System.Net.Sockets; using System.Security.Cryptography; using TechnitiumLibrary.Net.Dns.EDnsOptions; @@ -49,53 +51,71 @@ private static byte[] ComputeServerCookie(IPAddress clientAddress, ReadOnlySpan< { // RFC 9018 server cookie structure: // Version (1 byte) | Reserved (1 byte) | Timestamp (4 bytes) | Hash (8 bytes) + const int CookieLen = 14; - using (MemoryStream ms = new MemoryStream()) - { - using (BinaryWriter bw = new BinaryWriter(ms)) - { - // Version 1 - bw.Write((byte)1); - - // Reserved (0) - bw.Write((byte)0); - - // Timestamp (Unix time in seconds) - // RFC 9018 Section 4.1 specifies that the timestamp should be in network byte order (big-endian). - uint timestamp = unchecked((uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds()); - bw.Write((byte)(timestamp >> 24)); - bw.Write((byte)(timestamp >> 16)); - bw.Write((byte)(timestamp >> 8)); - bw.Write((byte)(timestamp >> 0)); - - // Compute HMAC-SHA256 hash - byte[] hashInput; - using (MemoryStream hashMs = new MemoryStream()) - { - using (BinaryWriter hashBw = new BinaryWriter(hashMs)) - { - // Hash input: version | reserved | timestamp | client_cookie | client_ip - hashBw.Write((byte)1); - hashBw.Write((byte)0); - hashBw.Write(timestamp); - hashBw.Write(clientCookie); - hashBw.Write(clientAddress.GetAddressBytes()); - } - hashInput = hashMs.ToArray(); - } + if (clientAddress is null) + throw new ArgumentNullException(nameof(clientAddress)); - using (HMACSHA256 hmac = new HMACSHA256(secret)) - { - byte[] hash = hmac.ComputeHash(hashInput); - // Take first 8 bytes (64 bits) as per RFC 9018 - bw.Write(hash, 0, 8); - } - } + if (secret is null) + throw new ArgumentNullException(nameof(secret)); - return ms.ToArray(); - } + // Operationally sane minimum (adjust to your key management policy) + if (secret.Length < 16) + throw new ArgumentException("Secret must be at least 16 bytes.", nameof(secret)); + + // COOKIE option client cookie is commonly 8 bytes; RFC allows a range. + // Keep bounds strict enough to prevent abuse but aligned to what you support. + const int MinClientCookieLen = 8; + const int MaxClientCookieLen = 32; // adjust if your implementation supports a different maximum + + if (clientCookie.Length < MinClientCookieLen || clientCookie.Length > MaxClientCookieLen) + throw new ArgumentOutOfRangeException( + nameof(clientCookie), + $"Client cookie length must be between {MinClientCookieLen} and {MaxClientCookieLen} bytes."); + + // Only IPv4/IPv6 are meaningful here + if (clientAddress.AddressFamily != AddressFamily.InterNetwork && + clientAddress.AddressFamily != AddressFamily.InterNetworkV6) + throw new ArgumentException("Client address must be IPv4 or IPv6.", nameof(clientAddress)); + + // Canonicalize IPv4-mapped IPv6 to IPv4 to avoid representation-dependent MACs + if (clientAddress.IsIPv4MappedToIPv6) + clientAddress = clientAddress.MapToIPv4(); + + Span cookie = stackalloc byte[CookieLen]; + cookie[0] = 1; // Version + cookie[1] = 0; // Reserved + + uint ts = unchecked((uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + BinaryPrimitives.WriteUInt32BigEndian(cookie.Slice(2, 4), ts); + + // Build HMAC input + byte[] ipBytes = clientAddress.GetAddressBytes(); + int inputLen = 1 + 1 + 4 + clientCookie.Length + ipBytes.Length; + + // Defensive ceiling against pathological sizes (should never trigger with sane bounds) + const int MaxInputLen = 1 + 1 + 4 + MaxClientCookieLen + 16; // v6 worst case + if (inputLen > MaxInputLen) + throw new InvalidOperationException("Computed hash input length exceeds expected maximum."); + + byte[] input = GC.AllocateUninitializedArray(inputLen); + input[0] = 1; + input[1] = 0; + cookie.Slice(2, 4).CopyTo(input.AsSpan(2, 4)); + clientCookie.CopyTo(input.AsSpan(6)); + ipBytes.AsSpan().CopyTo(input.AsSpan(6 + clientCookie.Length)); + + // Compute MAC + Span fullMac = stackalloc byte[32]; + HMACSHA256.HashData(secret, input, fullMac); + + // First 8 bytes (64 bits) + fullMac.Slice(0, 8).CopyTo(cookie.Slice(6, 8)); + + return cookie.ToArray(); } + private static bool ValidateServerCookieWithSecret(IPAddress clientAddress, ReadOnlySpan clientCookie, ReadOnlySpan serverCookie, byte[] secret) { if (serverCookie.Length < 8 || serverCookie.Length > 32) @@ -113,8 +133,8 @@ private static bool ValidateServerCookieWithSecret(IPAddress clientAddress, Read uint currentTimestamp = (uint)(DateTimeOffset.UtcNow.ToUnixTimeSeconds() & 0xFFFFFFFF); // Check timestamp is within 5 minutes (300 seconds) - RFC 9018 recommendation - uint timeDiff = currentTimestamp > cookieTimestamp - ? currentTimestamp - cookieTimestamp + uint timeDiff = currentTimestamp > cookieTimestamp + ? currentTimestamp - cookieTimestamp : cookieTimestamp - currentTimestamp; if (timeDiff > 300) @@ -135,7 +155,7 @@ private static bool ValidateServerCookieWithSecret(IPAddress clientAddress, Read using (HMACSHA256 hmac = new HMACSHA256(secret)) { byte[] hash = hmac.ComputeHash(hashMs.ToArray()); - + // Compare first 8 bytes if (serverCookie.Length >= 14) { From 44282812095280e22e686788990cb67ec35d0c14 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 19 Feb 2026 11:08:46 +0200 Subject: [PATCH 06/37] Hardened ValidateServerCookieWithSecret, and removed nested streams --- .../Dns/Security/DnsCookieValidator.cs | 92 ++++++++++--------- 1 file changed, 49 insertions(+), 43 deletions(-) diff --git a/DnsServerCore/Dns/Security/DnsCookieValidator.cs b/DnsServerCore/Dns/Security/DnsCookieValidator.cs index 5f331c1cb..6a3d98723 100644 --- a/DnsServerCore/Dns/Security/DnsCookieValidator.cs +++ b/DnsServerCore/Dns/Security/DnsCookieValidator.cs @@ -115,61 +115,67 @@ private static byte[] ComputeServerCookie(IPAddress clientAddress, ReadOnlySpan< return cookie.ToArray(); } - - private static bool ValidateServerCookieWithSecret(IPAddress clientAddress, ReadOnlySpan clientCookie, ReadOnlySpan serverCookie, byte[] secret) + private static bool ValidateServerCookieWithSecret( + IPAddress clientAddress, + ReadOnlySpan clientCookie, + ReadOnlySpan serverCookie, + byte[] secret) { - if (serverCookie.Length < 8 || serverCookie.Length > 32) + if (clientAddress is null || secret is null) return false; - // Extract timestamp from server cookie - if (serverCookie.Length < 6) + // Server cookie v1 structure is fixed 14 bytes in this implementation. + if (serverCookie.Length != 14) return false; - byte version = serverCookie[0]; - if (version != 1) + // Client cookie is fixed 8 bytes by your public Validate() already, + // but keep it defensive here too. + if (clientCookie.Length != 8) return false; - uint cookieTimestamp = BitConverter.ToUInt32(serverCookie.ToArray(), 2); - uint currentTimestamp = (uint)(DateTimeOffset.UtcNow.ToUnixTimeSeconds() & 0xFFFFFFFF); + // Version / reserved + if (serverCookie[0] != 1) + return false; - // Check timestamp is within 5 minutes (300 seconds) - RFC 9018 recommendation - uint timeDiff = currentTimestamp > cookieTimestamp - ? currentTimestamp - cookieTimestamp - : cookieTimestamp - currentTimestamp; + // enforce reserved == 0 (you always emit 0) + if (serverCookie[1] != 0) + return false; - if (timeDiff > 300) + // Only IPv4/IPv6; canonicalize IPv4-mapped IPv6 to IPv4 (must match compute policy) + if (clientAddress.AddressFamily != AddressFamily.InterNetwork && + clientAddress.AddressFamily != AddressFamily.InterNetworkV6) return false; - // Recompute hash with the same timestamp - using (MemoryStream hashMs = new MemoryStream()) - { - using (BinaryWriter hashBw = new BinaryWriter(hashMs)) - { - hashBw.Write((byte)1); - hashBw.Write((byte)0); - hashBw.Write(cookieTimestamp); - hashBw.Write(clientCookie); - hashBw.Write(clientAddress.GetAddressBytes()); - } - - using (HMACSHA256 hmac = new HMACSHA256(secret)) - { - byte[] hash = hmac.ComputeHash(hashMs.ToArray()); - - // Compare first 8 bytes - if (serverCookie.Length >= 14) - { - for (int i = 0; i < 8; i++) - { - if (serverCookie[6 + i] != hash[i]) - return false; - } - return true; - } - } - } + if (clientAddress.IsIPv4MappedToIPv6) + clientAddress = clientAddress.MapToIPv4(); - return false; + // Timestamp validation (big-endian) + uint cookieTs = BinaryPrimitives.ReadUInt32BigEndian(serverCookie.Slice(2, 4)); + uint nowTs = unchecked((uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + + uint diff = nowTs >= cookieTs ? (nowTs - cookieTs) : (cookieTs - nowTs); + if (diff > 300) // 5 minutes + return false; + + // Recompute MAC over exact bytes: version|reserved|timestampBytes|clientCookie|clientIP + byte[] ipBytes = clientAddress.GetAddressBytes(); + int inputLen = 1 + 1 + 4 + clientCookie.Length + ipBytes.Length; + + byte[] input = GC.AllocateUninitializedArray(inputLen); + input[0] = serverCookie[0]; + input[1] = serverCookie[1]; + serverCookie.Slice(2, 4).CopyTo(input.AsSpan(2, 4)); + clientCookie.CopyTo(input.AsSpan(6)); + ipBytes.AsSpan().CopyTo(input.AsSpan(6 + clientCookie.Length)); + + Span fullMac = stackalloc byte[32]; + HMACSHA256.HashData(secret, input, fullMac); + + // Compare first 8 bytes in constant time + Span expected = fullMac.Slice(0, 8); + ReadOnlySpan provided = serverCookie.Slice(6, 8); + + return CryptographicOperations.FixedTimeEquals(expected, provided); } #endregion From eecb4221f17ec8dc1f78fe12627f8bb9c67aaf12 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 19 Feb 2026 11:09:24 +0200 Subject: [PATCH 07/37] Hardened CreateResponseCookie --- DnsServerCore/Dns/Security/DnsCookieValidator.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/DnsServerCore/Dns/Security/DnsCookieValidator.cs b/DnsServerCore/Dns/Security/DnsCookieValidator.cs index 6a3d98723..df4abb565 100644 --- a/DnsServerCore/Dns/Security/DnsCookieValidator.cs +++ b/DnsServerCore/Dns/Security/DnsCookieValidator.cs @@ -212,6 +212,8 @@ public EDnsCookieOptionData CreateResponseCookie(IPAddress clientAddress, EDnsCo throw new ArgumentException("Client cookie must be 8 bytes"); byte[] currentSecret = _secretManager.GetCurrentSecret(); + if (currentSecret is null || currentSecret.Length < 16) + throw new InvalidOperationException("Server cookie secret is not available or too short."); byte[] serverCookie = ComputeServerCookie(clientAddress, requestCookie.ClientCookie, currentSecret); return new EDnsCookieOptionData(requestCookie.ClientCookie.ToArray(), serverCookie); From 76dc2cdc87d607f5eb96b38b288ca8871f11fe6b Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 19 Feb 2026 11:14:50 +0200 Subject: [PATCH 08/37] Rewrite of DnsCookieValidator --- .../Dns/Security/DnsCookieValidator.cs | 179 ++++++++++-------- 1 file changed, 95 insertions(+), 84 deletions(-) diff --git a/DnsServerCore/Dns/Security/DnsCookieValidator.cs b/DnsServerCore/Dns/Security/DnsCookieValidator.cs index df4abb565..912c2d646 100644 --- a/DnsServerCore/Dns/Security/DnsCookieValidator.cs +++ b/DnsServerCore/Dns/Security/DnsCookieValidator.cs @@ -19,7 +19,6 @@ You should have received a copy of the GNU General Public License using System; using System.Buffers.Binary; -using System.IO; using System.Net; using System.Net.Sockets; using System.Security.Cryptography; @@ -27,12 +26,29 @@ You should have received a copy of the GNU General Public License namespace DnsServerCore.Dns.Security { - public class DnsCookieValidator + public sealed class DnsCookieValidator { + #region constants + + // RFC 9018 v1 server cookie structure: Version(1) + Reserved(1) + Timestamp(4) + Hash(8) = 14 bytes + const int ClientCookieLen = 8; + const int ServerCookieLen = 14; + const int TimestampOffset = 2; + const int TimestampLen = 4; + const int MacOffset = 6; + const int MacLen = 8; + + // RFC 9018 recommends a short lifetime; 5 minutes is commonly used. + const uint MaxSkewSeconds = 300; + + // Operational minimum; adjust to your key-management policy. + const int MinSecretLen = 16; + + #endregion + #region variables readonly DnsCookieSecretManager _secretManager; - // RFC 9018 server cookie structure: Version(1) + Reserved(1) + Timestamp(4) + Hash(8) = 14 bytes #endregion @@ -45,74 +61,65 @@ public DnsCookieValidator(DnsCookieSecretManager secretManager) #endregion - #region private + #region private helpers - private static byte[] ComputeServerCookie(IPAddress clientAddress, ReadOnlySpan clientCookie, byte[] secret) + private static IPAddress CanonicalizeClientAddress(IPAddress clientAddress) { - // RFC 9018 server cookie structure: - // Version (1 byte) | Reserved (1 byte) | Timestamp (4 bytes) | Hash (8 bytes) - const int CookieLen = 14; - if (clientAddress is null) throw new ArgumentNullException(nameof(clientAddress)); - if (secret is null) - throw new ArgumentNullException(nameof(secret)); - - // Operationally sane minimum (adjust to your key management policy) - if (secret.Length < 16) - throw new ArgumentException("Secret must be at least 16 bytes.", nameof(secret)); - - // COOKIE option client cookie is commonly 8 bytes; RFC allows a range. - // Keep bounds strict enough to prevent abuse but aligned to what you support. - const int MinClientCookieLen = 8; - const int MaxClientCookieLen = 32; // adjust if your implementation supports a different maximum - - if (clientCookie.Length < MinClientCookieLen || clientCookie.Length > MaxClientCookieLen) - throw new ArgumentOutOfRangeException( - nameof(clientCookie), - $"Client cookie length must be between {MinClientCookieLen} and {MaxClientCookieLen} bytes."); - - // Only IPv4/IPv6 are meaningful here if (clientAddress.AddressFamily != AddressFamily.InterNetwork && clientAddress.AddressFamily != AddressFamily.InterNetworkV6) throw new ArgumentException("Client address must be IPv4 or IPv6.", nameof(clientAddress)); - // Canonicalize IPv4-mapped IPv6 to IPv4 to avoid representation-dependent MACs + // Avoid representation-dependent MACs. if (clientAddress.IsIPv4MappedToIPv6) - clientAddress = clientAddress.MapToIPv4(); + return clientAddress.MapToIPv4(); + + return clientAddress; + } + + private static void ValidateSecret(byte[] secret) + { + if (secret is null) + throw new ArgumentNullException(nameof(secret)); + + if (secret.Length < MinSecretLen) + throw new ArgumentException($"Secret must be at least {MinSecretLen} bytes.", nameof(secret)); + } - Span cookie = stackalloc byte[CookieLen]; + private static byte[] ComputeServerCookie(IPAddress clientAddress, ReadOnlySpan clientCookie, byte[] secret) + { + clientAddress = CanonicalizeClientAddress(clientAddress); + ValidateSecret(secret); + + if (clientCookie.Length != ClientCookieLen) + throw new ArgumentException($"Client cookie must be {ClientCookieLen} bytes.", nameof(clientCookie)); + + // We must return a heap array anyway, so build directly into it. + byte[] cookie = new byte[ServerCookieLen]; cookie[0] = 1; // Version cookie[1] = 0; // Reserved uint ts = unchecked((uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds()); - BinaryPrimitives.WriteUInt32BigEndian(cookie.Slice(2, 4), ts); + BinaryPrimitives.WriteUInt32BigEndian(cookie.AsSpan(TimestampOffset, TimestampLen), ts); - // Build HMAC input + // HMAC input: version | reserved | timestamp(4) | client_cookie(8) | client_ip(4/16) byte[] ipBytes = clientAddress.GetAddressBytes(); - int inputLen = 1 + 1 + 4 + clientCookie.Length + ipBytes.Length; - - // Defensive ceiling against pathological sizes (should never trigger with sane bounds) - const int MaxInputLen = 1 + 1 + 4 + MaxClientCookieLen + 16; // v6 worst case - if (inputLen > MaxInputLen) - throw new InvalidOperationException("Computed hash input length exceeds expected maximum."); + int inputLen = 1 + 1 + TimestampLen + ClientCookieLen + ipBytes.Length; byte[] input = GC.AllocateUninitializedArray(inputLen); - input[0] = 1; - input[1] = 0; - cookie.Slice(2, 4).CopyTo(input.AsSpan(2, 4)); - clientCookie.CopyTo(input.AsSpan(6)); - ipBytes.AsSpan().CopyTo(input.AsSpan(6 + clientCookie.Length)); + input[0] = cookie[0]; + input[1] = cookie[1]; + cookie.AsSpan(TimestampOffset, TimestampLen).CopyTo(input.AsSpan(2, TimestampLen)); + clientCookie.CopyTo(input.AsSpan(2 + TimestampLen)); + ipBytes.AsSpan().CopyTo(input.AsSpan(2 + TimestampLen + ClientCookieLen)); - // Compute MAC Span fullMac = stackalloc byte[32]; HMACSHA256.HashData(secret, input, fullMac); - // First 8 bytes (64 bits) - fullMac.Slice(0, 8).CopyTo(cookie.Slice(6, 8)); - - return cookie.ToArray(); + fullMac.Slice(0, MacLen).CopyTo(cookie.AsSpan(MacOffset, MacLen)); + return cookie; } private static bool ValidateServerCookieWithSecret( @@ -124,24 +131,16 @@ private static bool ValidateServerCookieWithSecret( if (clientAddress is null || secret is null) return false; - // Server cookie v1 structure is fixed 14 bytes in this implementation. - if (serverCookie.Length != 14) + if (secret.Length < MinSecretLen) return false; - // Client cookie is fixed 8 bytes by your public Validate() already, - // but keep it defensive here too. - if (clientCookie.Length != 8) + if (clientCookie.Length != ClientCookieLen) return false; - // Version / reserved - if (serverCookie[0] != 1) - return false; - - // enforce reserved == 0 (you always emit 0) - if (serverCookie[1] != 0) + if (serverCookie.Length != ServerCookieLen) return false; - // Only IPv4/IPv6; canonicalize IPv4-mapped IPv6 to IPv4 (must match compute policy) + // Canonicalize must match ComputeServerCookie policy if (clientAddress.AddressFamily != AddressFamily.InterNetwork && clientAddress.AddressFamily != AddressFamily.InterNetworkV6) return false; @@ -149,31 +148,37 @@ private static bool ValidateServerCookieWithSecret( if (clientAddress.IsIPv4MappedToIPv6) clientAddress = clientAddress.MapToIPv4(); - // Timestamp validation (big-endian) - uint cookieTs = BinaryPrimitives.ReadUInt32BigEndian(serverCookie.Slice(2, 4)); + // Version must match v1 + if (serverCookie[0] != 1) + return false; + + // Reserved; strict since we always emit 0 (change to "ignore" if you want forward-compat) + if (serverCookie[1] != 0) + return false; + + uint cookieTs = BinaryPrimitives.ReadUInt32BigEndian(serverCookie.Slice(TimestampOffset, TimestampLen)); uint nowTs = unchecked((uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds()); uint diff = nowTs >= cookieTs ? (nowTs - cookieTs) : (cookieTs - nowTs); - if (diff > 300) // 5 minutes + if (diff > MaxSkewSeconds) return false; - // Recompute MAC over exact bytes: version|reserved|timestampBytes|clientCookie|clientIP + // Recompute expected MAC over exact bytes: version|reserved|timestampBytes|clientCookie|clientIP byte[] ipBytes = clientAddress.GetAddressBytes(); - int inputLen = 1 + 1 + 4 + clientCookie.Length + ipBytes.Length; + int inputLen = 1 + 1 + TimestampLen + ClientCookieLen + ipBytes.Length; byte[] input = GC.AllocateUninitializedArray(inputLen); input[0] = serverCookie[0]; input[1] = serverCookie[1]; - serverCookie.Slice(2, 4).CopyTo(input.AsSpan(2, 4)); - clientCookie.CopyTo(input.AsSpan(6)); - ipBytes.AsSpan().CopyTo(input.AsSpan(6 + clientCookie.Length)); + serverCookie.Slice(TimestampOffset, TimestampLen).CopyTo(input.AsSpan(2, TimestampLen)); + clientCookie.CopyTo(input.AsSpan(2 + TimestampLen)); + ipBytes.AsSpan().CopyTo(input.AsSpan(2 + TimestampLen + ClientCookieLen)); Span fullMac = stackalloc byte[32]; HMACSHA256.HashData(secret, input, fullMac); - // Compare first 8 bytes in constant time - Span expected = fullMac.Slice(0, 8); - ReadOnlySpan provided = serverCookie.Slice(6, 8); + ReadOnlySpan provided = serverCookie.Slice(MacOffset, MacLen); + Span expected = fullMac.Slice(0, MacLen); return CryptographicOperations.FixedTimeEquals(expected, provided); } @@ -184,20 +189,24 @@ private static bool ValidateServerCookieWithSecret( public bool Validate(IPAddress clientAddress, EDnsCookieOptionData cookie) { - if (cookie == null || cookie.ClientCookie.IsEmpty || cookie.ServerCookie.IsEmpty) + if (clientAddress is null || cookie is null) + return false; + + // This validator is specifically for validating presence of BOTH CC and SC. + if (cookie.ClientCookie.IsEmpty || cookie.ServerCookie.IsEmpty) return false; - if (cookie.ClientCookie.Length != 8) + if (cookie.ClientCookie.Length != ClientCookieLen) return false; - // Try current secret first byte[] currentSecret = _secretManager.GetCurrentSecret(); - if (currentSecret != null && ValidateServerCookieWithSecret(clientAddress, cookie.ClientCookie, cookie.ServerCookie, currentSecret)) + if (currentSecret != null && + ValidateServerCookieWithSecret(clientAddress, cookie.ClientCookie, cookie.ServerCookie, currentSecret)) return true; - // Try previous secret for rotation grace period byte[] previousSecret = _secretManager.GetPreviousSecret(); - if (previousSecret != null && ValidateServerCookieWithSecret(clientAddress, cookie.ClientCookie, cookie.ServerCookie, previousSecret)) + if (previousSecret != null && + ValidateServerCookieWithSecret(clientAddress, cookie.ClientCookie, cookie.ServerCookie, previousSecret)) return true; return false; @@ -205,17 +214,19 @@ public bool Validate(IPAddress clientAddress, EDnsCookieOptionData cookie) public EDnsCookieOptionData CreateResponseCookie(IPAddress clientAddress, EDnsCookieOptionData requestCookie) { - if (requestCookie == null || requestCookie.ClientCookie.IsEmpty) - throw new ArgumentException("Request cookie must have a client cookie"); + if (clientAddress is null) + throw new ArgumentNullException(nameof(clientAddress)); + + if (requestCookie is null || requestCookie.ClientCookie.IsEmpty) + throw new ArgumentException("Request cookie must include a client cookie.", nameof(requestCookie)); - if (requestCookie.ClientCookie.Length != 8) - throw new ArgumentException("Client cookie must be 8 bytes"); + if (requestCookie.ClientCookie.Length != ClientCookieLen) + throw new ArgumentException($"Client cookie must be {ClientCookieLen} bytes.", nameof(requestCookie)); byte[] currentSecret = _secretManager.GetCurrentSecret(); - if (currentSecret is null || currentSecret.Length < 16) - throw new InvalidOperationException("Server cookie secret is not available or too short."); - byte[] serverCookie = ComputeServerCookie(clientAddress, requestCookie.ClientCookie, currentSecret); + ValidateSecret(currentSecret); + byte[] serverCookie = ComputeServerCookie(clientAddress, requestCookie.ClientCookie, currentSecret); return new EDnsCookieOptionData(requestCookie.ClientCookie.ToArray(), serverCookie); } From 9cf4346d3422b2112599454f89666c8b65ff48ec Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 19 Feb 2026 11:16:06 +0200 Subject: [PATCH 09/37] Cloned current secret in rotation --- DnsServerCore/Dns/Security/DnsCookieSecretManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs index b196d4668..d36042bb2 100644 --- a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs +++ b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs @@ -137,7 +137,7 @@ public void Rotate() { lock (_lock) { - _previousSecret = _currentSecret; + _previousSecret = _currentSecret != null ? (byte[])_currentSecret.Clone() : null; _currentSecret = RandomNumberGenerator.GetBytes(32); _currentSecretCreated = DateTime.UtcNow; Save(); From 2ecb87d3e2a170c2ca7f82841ca8d745e1a8d034 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 19 Feb 2026 11:21:44 +0200 Subject: [PATCH 10/37] Used System.Threading.Lock instead of object --- DnsServerCore/Dns/Security/DnsCookieSecretManager.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs index d36042bb2..ce92aab4f 100644 --- a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs +++ b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs @@ -20,6 +20,7 @@ You should have received a copy of the GNU General Public License using System; using System.IO; using System.Security.Cryptography; +using System.Threading; namespace DnsServerCore.Dns.Security { @@ -28,7 +29,7 @@ public class DnsCookieSecretManager #region variables readonly string _secretFilePath; - readonly object _lock = new object(); + readonly Lock _lock = new Lock(); byte[] _currentSecret; byte[] _previousSecret; From c14c3a6734155df7609d848ba6cc2d80f666f319 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 19 Feb 2026 11:42:48 +0200 Subject: [PATCH 11/37] TC is necessary now; removed _dnsCookiesSetTcOnBadCookie setting from config --- DnsServerCore/Dns/DnsServer.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index b63aa24c4..766246494 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -276,7 +276,6 @@ enum ServiceState readonly bool _dnsCookiesEnabled = true; readonly string _dnsCookiesSecretFile = "dns.cookies.state"; readonly int _dnsCookiesRotationPeriodHours = 1; - readonly bool _dnsCookiesSetTcOnBadCookie = true; readonly bool _dnsCookiesAlwaysEcho = true; Security.DnsCookieSecretManager _cookieSecrets; @@ -1444,7 +1443,6 @@ private void WriteConfigTo(Stream s) bW.Write(_dnsCookiesEnabled); bW.Write(_dnsCookiesSecretFile ?? "dns.cookies.state"); bW.Write(_dnsCookiesRotationPeriodHours); - bW.Write(_dnsCookiesSetTcOnBadCookie); bW.Write(_dnsCookiesAlwaysEcho); } From a0676b7dbd85485488ab7cdf429727262d48c451 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 19 Feb 2026 11:54:00 +0200 Subject: [PATCH 12/37] Used ReadOnlySpaninstead of mutable byte arrays --- DnsServerCore/Dns/DnsServer.cs | 4 ++-- .../Dns/Security/DnsCookieSecretManager.cs | 9 +++++---- .../Dns/Security/DnsCookieValidator.cs | 20 +++++++++---------- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index 766246494..70e874ee4 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -2832,7 +2832,7 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo } else { - if (cookie.ServerCookie != null && cookie.ServerCookie.Length > 0) + if (!cookie.ServerCookie.IsEmpty && cookie.ServerCookie.Length > 0) { if (!_cookieValidator.Validate( remoteEP.Address, @@ -2872,7 +2872,7 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo { bool shouldSendServerCookie = _dnsCookiesAlwaysEcho || - requestCookie.ServerCookie == null || + requestCookie.ServerCookie.IsEmpty || requestCookie.ServerCookie.Length == 0; if (shouldSendServerCookie) diff --git a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs index ce92aab4f..2307950bd 100644 --- a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs +++ b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs @@ -18,6 +18,7 @@ You should have received a copy of the GNU General Public License */ using System; +using System.Collections.Generic; using System.IO; using System.Security.Cryptography; using System.Threading; @@ -145,19 +146,19 @@ public void Rotate() } } - public byte[] GetCurrentSecret() + public ReadOnlySpan GetCurrentSecret() { lock (_lock) { - return _currentSecret; + return new ReadOnlySpan(_currentSecret); } } - public byte[] GetPreviousSecret() + public ReadOnlySpan GetPreviousSecret() { lock (_lock) { - return _previousSecret; + return new ReadOnlySpan(_previousSecret); } } diff --git a/DnsServerCore/Dns/Security/DnsCookieValidator.cs b/DnsServerCore/Dns/Security/DnsCookieValidator.cs index 912c2d646..f52686d58 100644 --- a/DnsServerCore/Dns/Security/DnsCookieValidator.cs +++ b/DnsServerCore/Dns/Security/DnsCookieValidator.cs @@ -79,16 +79,16 @@ private static IPAddress CanonicalizeClientAddress(IPAddress clientAddress) return clientAddress; } - private static void ValidateSecret(byte[] secret) + private static void ValidateSecret(ReadOnlySpan secret) { - if (secret is null) + if (secret.IsEmpty) throw new ArgumentNullException(nameof(secret)); if (secret.Length < MinSecretLen) throw new ArgumentException($"Secret must be at least {MinSecretLen} bytes.", nameof(secret)); } - private static byte[] ComputeServerCookie(IPAddress clientAddress, ReadOnlySpan clientCookie, byte[] secret) + private static byte[] ComputeServerCookie(IPAddress clientAddress, ReadOnlySpan clientCookie, ReadOnlySpan secret) { clientAddress = CanonicalizeClientAddress(clientAddress); ValidateSecret(secret); @@ -126,9 +126,9 @@ private static bool ValidateServerCookieWithSecret( IPAddress clientAddress, ReadOnlySpan clientCookie, ReadOnlySpan serverCookie, - byte[] secret) + ReadOnlySpan secret) { - if (clientAddress is null || secret is null) + if (clientAddress is null || secret.IsEmpty) return false; if (secret.Length < MinSecretLen) @@ -199,13 +199,13 @@ public bool Validate(IPAddress clientAddress, EDnsCookieOptionData cookie) if (cookie.ClientCookie.Length != ClientCookieLen) return false; - byte[] currentSecret = _secretManager.GetCurrentSecret(); - if (currentSecret != null && + ReadOnlySpan currentSecret = _secretManager.GetCurrentSecret(); + if (!currentSecret.IsEmpty && ValidateServerCookieWithSecret(clientAddress, cookie.ClientCookie, cookie.ServerCookie, currentSecret)) return true; - byte[] previousSecret = _secretManager.GetPreviousSecret(); - if (previousSecret != null && + ReadOnlySpan previousSecret = _secretManager.GetPreviousSecret(); + if (!previousSecret.IsEmpty && ValidateServerCookieWithSecret(clientAddress, cookie.ClientCookie, cookie.ServerCookie, previousSecret)) return true; @@ -223,7 +223,7 @@ public EDnsCookieOptionData CreateResponseCookie(IPAddress clientAddress, EDnsCo if (requestCookie.ClientCookie.Length != ClientCookieLen) throw new ArgumentException($"Client cookie must be {ClientCookieLen} bytes.", nameof(requestCookie)); - byte[] currentSecret = _secretManager.GetCurrentSecret(); + ReadOnlySpan currentSecret = _secretManager.GetCurrentSecret(); ValidateSecret(currentSecret); byte[] serverCookie = ComputeServerCookie(clientAddress, requestCookie.ClientCookie, currentSecret); From fb6192c0218156eb02d0689e1889d819351e820c Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 19 Feb 2026 12:09:12 +0200 Subject: [PATCH 13/37] Made cookies enabled by default and hardcoded --- DnsServerCore/Dns/DnsServer.cs | 54 +++++++++++----------------------- 1 file changed, 17 insertions(+), 37 deletions(-) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index 70e874ee4..3c16d33c2 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -273,10 +273,8 @@ enum ServiceState const int SAVE_TIMER_INITIAL_INTERVAL = 5000; // DNS Cookies (RFC 7873) - readonly bool _dnsCookiesEnabled = true; readonly string _dnsCookiesSecretFile = "dns.cookies.state"; readonly int _dnsCookiesRotationPeriodHours = 1; - readonly bool _dnsCookiesAlwaysEcho = true; Security.DnsCookieSecretManager _cookieSecrets; Security.DnsCookieValidator _cookieValidator; @@ -598,7 +596,7 @@ public void LoadConfigFile() SaveConfigFileInternal(); - InitDnsCookiesIfEnabled(); + InitDnsCookies(); } catch (Exception ex) { @@ -1152,7 +1150,7 @@ private void ReadConfigFrom(Stream s, bool isConfigTransfer) _cookieRotationTimer?.Dispose(); _cookieRotationTimer = null; - InitDnsCookiesIfEnabled(); + InitDnsCookies(); } } @@ -1438,12 +1436,6 @@ private void WriteConfigTo(Stream s) bW.Write(_queryLog is not null); //log all queries bW.Write(_statsManager.EnableInMemoryStats); bW.Write(_statsManager.MaxStatFileDays); - - // DNS cookies - bW.Write(_dnsCookiesEnabled); - bW.Write(_dnsCookiesSecretFile ?? "dns.cookies.state"); - bW.Write(_dnsCookiesRotationPeriodHours); - bW.Write(_dnsCookiesAlwaysEcho); } #endregion @@ -1611,11 +1603,8 @@ private string ConvertToAbsolutePath(string path) return Path.Combine(_configFolder, path); } - private void InitDnsCookiesIfEnabled() + private void InitDnsCookies() { - if (!_dnsCookiesEnabled) - return; - string secretPath = Path.IsPathRooted(_dnsCookiesSecretFile) ? _dnsCookiesSecretFile : Path.Combine(_configFolder, _dnsCookiesSecretFile); @@ -2819,8 +2808,7 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo } // DNS Cookies (RFC 7873) - if (_dnsCookiesEnabled && - request.EDNS != null && + if (request.EDNS != null && _cookieValidator != null) { EDnsCookieOptionData cookie = @@ -2865,33 +2853,25 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo return null; // Attach cookie to response if needed - if (_dnsCookiesEnabled && _cookieValidator != null && request.EDNS != null) + if (_cookieValidator != null && request.EDNS != null) { EDnsCookieOptionData requestCookie = TryGetCookieOption(request); if (requestCookie != null) { - bool shouldSendServerCookie = - _dnsCookiesAlwaysEcho || - requestCookie.ServerCookie.IsEmpty || - requestCookie.ServerCookie.Length == 0; - - if (shouldSendServerCookie) - { - EDnsCookieOptionData responseCookie = - _cookieValidator.CreateResponseCookie( - remoteEP.Address, - requestCookie); + EDnsCookieOptionData responseCookie = + _cookieValidator.CreateResponseCookie( + remoteEP.Address, + requestCookie); - IReadOnlyList mergedOptions = - MergeCookieOption(response.EDNS?.Options, responseCookie); + IReadOnlyList mergedOptions = + MergeCookieOption(response.EDNS?.Options, responseCookie); - response = response.Clone( - additional: UpsertOptRecord( - response.Additional, - request, - response, - mergedOptions)); - } + response = response.Clone( + additional: UpsertOptRecord( + response.Additional, + request, + response, + mergedOptions)); } } From a4aec0f6714bdea0f6414d5f33a39c98ef020e98 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 19 Feb 2026 12:17:22 +0200 Subject: [PATCH 14/37] Added guard clauses for secret file loading --- .../Dns/Security/DnsCookieSecretManager.cs | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs index 2307950bd..410ed12ab 100644 --- a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs +++ b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs @@ -63,17 +63,22 @@ private void Load() using (BinaryReader br = new BinaryReader(ms)) { int version = br.ReadInt32(); - if (version == 1) - { - _currentSecretCreated = new DateTime(br.ReadInt64(), DateTimeKind.Utc); - - int currentLen = br.ReadInt32(); - _currentSecret = br.ReadBytes(currentLen); - - int previousLen = br.ReadInt32(); - if (previousLen > 0) - _previousSecret = br.ReadBytes(previousLen); - } + if (version != 1) + throw new InvalidDataException("Unsupported secret file version."); + + _currentSecretCreated = new DateTime(br.ReadInt64(), DateTimeKind.Utc); + + int currentLen = br.ReadInt32(); + if (currentLen < 8 || currentLen > 256) + throw new InvalidDataException("Invalid current secret length."); + + _currentSecret = br.ReadBytes(currentLen); + + int previousLen = br.ReadInt32(); + if (previousLen < 8 || previousLen > 256) + throw new InvalidDataException("Invalid previous secret length."); + + _previousSecret = br.ReadBytes(previousLen); } } catch @@ -99,7 +104,7 @@ private void Save() { bw.Write(1); // version bw.Write(_currentSecretCreated.Ticks); - + bw.Write(_currentSecret.Length); bw.Write(_currentSecret); From 761554cc5f0bf107797cc1d7a34ab38f5d5b796c Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 19 Feb 2026 12:30:36 +0200 Subject: [PATCH 15/37] Rewrite locking in DnsCookieSecretManager --- .../Dns/Security/DnsCookieSecretManager.cs | 181 ++++++++++-------- 1 file changed, 105 insertions(+), 76 deletions(-) diff --git a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs index 410ed12ab..713ba3ae1 100644 --- a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs +++ b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs @@ -18,7 +18,6 @@ You should have received a copy of the GNU General Public License */ using System; -using System.Collections.Generic; using System.IO; using System.Security.Cryptography; using System.Threading; @@ -27,6 +26,19 @@ namespace DnsServerCore.Dns.Security { public class DnsCookieSecretManager { + #region constants + + private const int FileVersion = 1; + + // Operational bounds; keep aligned with validator policy. + private const int MinSecretLen = 16; + private const int MaxSecretLen = 256; + + // Default secret size (256-bit) + private const int DefaultSecretLen = 32; + + #endregion + #region variables readonly string _secretFilePath; @@ -42,98 +54,113 @@ public class DnsCookieSecretManager public DnsCookieSecretManager(string secretFilePath) { + if (string.IsNullOrWhiteSpace(secretFilePath)) + throw new ArgumentException("Secret file path must not be null or empty.", nameof(secretFilePath)); + _secretFilePath = secretFilePath; - Load(); + + lock (_lock) + { + LoadLocked(); + } } #endregion #region private - private void Load() + private void LoadLocked() { - lock (_lock) + // Caller must hold _lock + if (!File.Exists(_secretFilePath)) { - if (File.Exists(_secretFilePath)) - { - try - { - byte[] data = File.ReadAllBytes(_secretFilePath); - using (MemoryStream ms = new MemoryStream(data)) - using (BinaryReader br = new BinaryReader(ms)) - { - int version = br.ReadInt32(); - if (version != 1) - throw new InvalidDataException("Unsupported secret file version."); - - _currentSecretCreated = new DateTime(br.ReadInt64(), DateTimeKind.Utc); - - int currentLen = br.ReadInt32(); - if (currentLen < 8 || currentLen > 256) - throw new InvalidDataException("Invalid current secret length."); - - _currentSecret = br.ReadBytes(currentLen); - - int previousLen = br.ReadInt32(); - if (previousLen < 8 || previousLen > 256) - throw new InvalidDataException("Invalid previous secret length."); - - _previousSecret = br.ReadBytes(previousLen); - } - } - catch - { - // If loading fails, generate new secrets - GenerateNewSecrets(); - } - } - else + GenerateNewSecretsLocked(); + return; + } + + try + { + byte[] data = File.ReadAllBytes(_secretFilePath); + using MemoryStream ms = new MemoryStream(data, writable: false); + using BinaryReader br = new BinaryReader(ms); + + int version = br.ReadInt32(); + if (version != FileVersion) + throw new InvalidDataException("Unsupported secret file version."); + + _currentSecretCreated = new DateTime(br.ReadInt64(), DateTimeKind.Utc); + + int currentLen = br.ReadInt32(); + if (currentLen < MinSecretLen || currentLen > MaxSecretLen) + throw new InvalidDataException("Invalid current secret length."); + + byte[] current = br.ReadBytes(currentLen); + if (current.Length != currentLen) + throw new EndOfStreamException("Unexpected end of secret file (current secret)."); + + int previousLen = br.ReadInt32(); + byte[] previous = null; + + if (previousLen != 0) { - GenerateNewSecrets(); + if (previousLen < MinSecretLen || previousLen > MaxSecretLen) + throw new InvalidDataException("Invalid previous secret length."); + + previous = br.ReadBytes(previousLen); + if (previous.Length != previousLen) + throw new EndOfStreamException("Unexpected end of secret file (previous secret)."); } + + _currentSecret = current; + _previousSecret = previous; + } + catch + { + GenerateNewSecretsLocked(); } } - private void Save() + private void SaveLocked() { - lock (_lock) + // Caller must hold _lock + if (_currentSecret is null || _currentSecret.Length < MinSecretLen) + throw new InvalidOperationException("Current secret is missing or too short."); + + using MemoryStream ms = new MemoryStream(); + using (BinaryWriter bw = new BinaryWriter(ms)) { - using (MemoryStream ms = new MemoryStream()) + bw.Write(FileVersion); + bw.Write(_currentSecretCreated.Ticks); + + bw.Write(_currentSecret.Length); + bw.Write(_currentSecret); + + if (_previousSecret is { Length: >= MinSecretLen and <= MaxSecretLen }) + { + bw.Write(_previousSecret.Length); + bw.Write(_previousSecret); + } + else { - using (BinaryWriter bw = new BinaryWriter(ms)) - { - bw.Write(1); // version - bw.Write(_currentSecretCreated.Ticks); - - bw.Write(_currentSecret.Length); - bw.Write(_currentSecret); - - if (_previousSecret != null) - { - bw.Write(_previousSecret.Length); - bw.Write(_previousSecret); - } - else - { - bw.Write(0); - } - } - - string directory = Path.GetDirectoryName(_secretFilePath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - Directory.CreateDirectory(directory); - - File.WriteAllBytes(_secretFilePath, ms.ToArray()); + bw.Write(0); } } + + string directory = Path.GetDirectoryName(_secretFilePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + Directory.CreateDirectory(directory); + + File.WriteAllBytes(_secretFilePath, ms.ToArray()); } - private void GenerateNewSecrets() + private void GenerateNewSecretsLocked() { - _currentSecret = RandomNumberGenerator.GetBytes(32); + // Caller must hold _lock + _currentSecret = RandomNumberGenerator.GetBytes(DefaultSecretLen); _currentSecretCreated = DateTime.UtcNow; _previousSecret = null; - Save(); + + SaveLocked(); } #endregion @@ -144,26 +171,28 @@ public void Rotate() { lock (_lock) { - _previousSecret = _currentSecret != null ? (byte[])_currentSecret.Clone() : null; - _currentSecret = RandomNumberGenerator.GetBytes(32); + _previousSecret = _currentSecret is null ? null : (byte[])_currentSecret.Clone(); + _currentSecret = RandomNumberGenerator.GetBytes(DefaultSecretLen); _currentSecretCreated = DateTime.UtcNow; - Save(); + + SaveLocked(); } } - public ReadOnlySpan GetCurrentSecret() + // Returning spans here is unsafe once the lock is released; return a clone instead. + public byte[] GetCurrentSecret() { lock (_lock) { - return new ReadOnlySpan(_currentSecret); + return _currentSecret is null ? null : (byte[])_currentSecret.Clone(); } } - public ReadOnlySpan GetPreviousSecret() + public byte[] GetPreviousSecret() { lock (_lock) { - return new ReadOnlySpan(_previousSecret); + return _previousSecret is null ? null : (byte[])_previousSecret.Clone(); } } From 4055dc664ef44f86c16cc0f3f66e6615ff38b2af Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 19 Feb 2026 14:03:30 +0200 Subject: [PATCH 16/37] Moved cookie code under separate region --- DnsServerCore/Dns/DnsServer.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index 3c16d33c2..2fb1c7b88 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -1602,6 +1602,9 @@ private string ConvertToAbsolutePath(string path) return Path.Combine(_configFolder, path); } + #endregion + + #region cookie private void InitDnsCookies() { From 1e76c1d9da97e6abb3f604ed433e2363d54c617a Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 19 Feb 2026 14:05:56 +0200 Subject: [PATCH 17/37] Added UDP check for cookies --- DnsServerCore/Dns/DnsServer.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index 2fb1c7b88..8f9552acc 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -2811,7 +2811,8 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo } // DNS Cookies (RFC 7873) - if (request.EDNS != null && + if (protocol == DnsTransportProtocol.Udp && + request.EDNS != null && _cookieValidator != null) { EDnsCookieOptionData cookie = From 45f1ee39874d7a9ef9c8bb549981b2bc3a16294f Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 19 Feb 2026 14:07:54 +0200 Subject: [PATCH 18/37] Made a difference between a parsing error and a bad cookie --- DnsServerCore/Dns/DnsServer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index 8f9552acc..66f25e77f 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -2824,7 +2824,7 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo } else { - if (!cookie.ServerCookie.IsEmpty && cookie.ServerCookie.Length > 0) + if (!cookie.ServerCookie.IsEmpty && cookie.ServerCookie.Length > 8) { if (!_cookieValidator.Validate( remoteEP.Address, From 22355e9cb2e06d0bc942e40482b92ceed55d4107 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Thu, 19 Feb 2026 14:09:54 +0200 Subject: [PATCH 19/37] Removed unnecessary allocations --- DnsServerCore/Dns/Security/DnsCookieSecretManager.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs index 713ba3ae1..be22ed9f1 100644 --- a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs +++ b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs @@ -180,19 +180,19 @@ public void Rotate() } // Returning spans here is unsafe once the lock is released; return a clone instead. - public byte[] GetCurrentSecret() + public ReadOnlySpan GetCurrentSecret() { lock (_lock) { - return _currentSecret is null ? null : (byte[])_currentSecret.Clone(); + return _currentSecret is null ? null : new ReadOnlySpan(_currentSecret); } } - public byte[] GetPreviousSecret() + public ReadOnlySpan GetPreviousSecret() { lock (_lock) { - return _previousSecret is null ? null : (byte[])_previousSecret.Clone(); + return _previousSecret is null ? null : new ReadOnlySpan(_previousSecret); } } From 08e68e67809d37691e0b3d8065ea3b34e942e3e9 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 20 Feb 2026 09:51:40 +0200 Subject: [PATCH 20/37] Used an internal snapshot to solve concurrency issues on rotation --- .../Dns/Security/DnsCookieSecretManager.cs | 98 +++++++++++-------- 1 file changed, 56 insertions(+), 42 deletions(-) diff --git a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs index be22ed9f1..1e49be14a 100644 --- a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs +++ b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs @@ -44,9 +44,8 @@ public class DnsCookieSecretManager readonly string _secretFilePath; readonly Lock _lock = new Lock(); - byte[] _currentSecret; - byte[] _previousSecret; - DateTime _currentSecretCreated; + // Immutable snapshot published atomically for lock-free hot-path reads. + private Snapshot _snapshot; #endregion @@ -61,7 +60,12 @@ public DnsCookieSecretManager(string secretFilePath) lock (_lock) { - LoadLocked(); + Snapshot loaded = LoadLocked(); + if (loaded is null) + loaded = GenerateNewSnapshot(previousSecret: null); + + SaveLocked(loaded); + Volatile.Write(ref _snapshot, loaded); } } @@ -69,14 +73,11 @@ public DnsCookieSecretManager(string secretFilePath) #region private - private void LoadLocked() + private Snapshot LoadLocked() { // Caller must hold _lock if (!File.Exists(_secretFilePath)) - { - GenerateNewSecretsLocked(); - return; - } + return null; try { @@ -88,7 +89,7 @@ private void LoadLocked() if (version != FileVersion) throw new InvalidDataException("Unsupported secret file version."); - _currentSecretCreated = new DateTime(br.ReadInt64(), DateTimeKind.Utc); + DateTime createdUtc = new DateTime(br.ReadInt64(), DateTimeKind.Utc); int currentLen = br.ReadInt32(); if (currentLen < MinSecretLen || currentLen > MaxSecretLen) @@ -111,34 +112,36 @@ private void LoadLocked() throw new EndOfStreamException("Unexpected end of secret file (previous secret)."); } - _currentSecret = current; - _previousSecret = previous; + return new Snapshot(current, previous, createdUtc); } catch { - GenerateNewSecretsLocked(); + return null; } } - private void SaveLocked() + private void SaveLocked(Snapshot snapshot) { // Caller must hold _lock - if (_currentSecret is null || _currentSecret.Length < MinSecretLen) + if (snapshot is null) + throw new ArgumentNullException(nameof(snapshot)); + + if (snapshot.CurrentSecret is null || snapshot.CurrentSecret.Length < MinSecretLen) throw new InvalidOperationException("Current secret is missing or too short."); using MemoryStream ms = new MemoryStream(); using (BinaryWriter bw = new BinaryWriter(ms)) { bw.Write(FileVersion); - bw.Write(_currentSecretCreated.Ticks); + bw.Write(snapshot.CurrentSecretCreatedUtc.Ticks); - bw.Write(_currentSecret.Length); - bw.Write(_currentSecret); + bw.Write(snapshot.CurrentSecret.Length); + bw.Write(snapshot.CurrentSecret); - if (_previousSecret is { Length: >= MinSecretLen and <= MaxSecretLen }) + if (snapshot.PreviousSecret is { Length: >= MinSecretLen and <= MaxSecretLen }) { - bw.Write(_previousSecret.Length); - bw.Write(_previousSecret); + bw.Write(snapshot.PreviousSecret.Length); + bw.Write(snapshot.PreviousSecret); } else { @@ -153,14 +156,14 @@ private void SaveLocked() File.WriteAllBytes(_secretFilePath, ms.ToArray()); } - private void GenerateNewSecretsLocked() + private Snapshot GenerateNewSnapshot(byte[] previousSecret) { // Caller must hold _lock - _currentSecret = RandomNumberGenerator.GetBytes(DefaultSecretLen); - _currentSecretCreated = DateTime.UtcNow; - _previousSecret = null; + byte[] currentSecret = RandomNumberGenerator.GetBytes(DefaultSecretLen); + DateTime createdUtc = DateTime.UtcNow; - SaveLocked(); + // previousSecret is expected to be immutable once published; we pass it through as-is. + return new Snapshot(currentSecret, previousSecret, createdUtc); } #endregion @@ -171,31 +174,42 @@ public void Rotate() { lock (_lock) { - _previousSecret = _currentSecret is null ? null : (byte[])_currentSecret.Clone(); - _currentSecret = RandomNumberGenerator.GetBytes(DefaultSecretLen); - _currentSecretCreated = DateTime.UtcNow; + Snapshot currentSnapshot = Volatile.Read(ref _snapshot); - SaveLocked(); + byte[] previous = currentSnapshot is null ? null : currentSnapshot.CurrentSecret; + Snapshot nextSnapshot = GenerateNewSnapshot(previous); + + SaveLocked(nextSnapshot); + Volatile.Write(ref _snapshot, nextSnapshot); } } - // Returning spans here is unsafe once the lock is released; return a clone instead. - public ReadOnlySpan GetCurrentSecret() + // Hot path: lock-free, allocation-free. Returned arrays must be treated as read-only by callers. + public byte[] GetCurrentSecret() { - lock (_lock) - { - return _currentSecret is null ? null : new ReadOnlySpan(_currentSecret); - } + Snapshot snapshot = Volatile.Read(ref _snapshot); + return snapshot is null ? null : snapshot.CurrentSecret; } - public ReadOnlySpan GetPreviousSecret() + public byte[] GetPreviousSecret() { - lock (_lock) - { - return _previousSecret is null ? null : new ReadOnlySpan(_previousSecret); - } + Snapshot snapshot = Volatile.Read(ref _snapshot); + return snapshot is null ? null : snapshot.PreviousSecret; } #endregion + private sealed class Snapshot + { + internal readonly byte[] CurrentSecret; + internal readonly byte[] PreviousSecret; // may be null + internal readonly DateTime CurrentSecretCreatedUtc; + + internal Snapshot(byte[] currentSecret, byte[] previousSecret, DateTime currentSecretCreatedUtc) + { + CurrentSecret = currentSecret; + PreviousSecret = previousSecret; + CurrentSecretCreatedUtc = currentSecretCreatedUtc; + } + } } -} +} \ No newline at end of file From b6200031b8925fa95fd82734bb88fcfc4cc0007e Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 20 Feb 2026 10:25:21 +0200 Subject: [PATCH 21/37] Used siphash again --- .../Dns/Security/DnsCookieValidator.cs | 205 +++++++++++++----- 1 file changed, 154 insertions(+), 51 deletions(-) diff --git a/DnsServerCore/Dns/Security/DnsCookieValidator.cs b/DnsServerCore/Dns/Security/DnsCookieValidator.cs index f52686d58..2957367d9 100644 --- a/DnsServerCore/Dns/Security/DnsCookieValidator.cs +++ b/DnsServerCore/Dns/Security/DnsCookieValidator.cs @@ -30,16 +30,23 @@ public sealed class DnsCookieValidator { #region constants - // RFC 9018 v1 server cookie structure: Version(1) + Reserved(1) + Timestamp(4) + Hash(8) = 14 bytes + // RFC 9018 v1 server cookie structure: Version(1) + Reserved(3) + Timestamp(4) + Hash(8) = 16 bytes const int ClientCookieLen = 8; - const int ServerCookieLen = 14; - const int TimestampOffset = 2; + const int ServerCookieLen = 16; + + const int VersionOffset = 0; + const int ReservedOffset = 1; + const int ReservedLen = 3; + + const int TimestampOffset = 4; const int TimestampLen = 4; - const int MacOffset = 6; + + const int MacOffset = 8; const int MacLen = 8; - // RFC 9018 recommends a short lifetime; 5 minutes is commonly used. - const uint MaxSkewSeconds = 300; + // RFC 9018 recommended acceptance: <= 1 hour past, <= 5 minutes future + const uint MaxPastSeconds = 3600; + const uint MaxFutureSeconds = 300; // Operational minimum; adjust to your key-management policy. const int MinSecretLen = 16; @@ -82,7 +89,7 @@ private static IPAddress CanonicalizeClientAddress(IPAddress clientAddress) private static void ValidateSecret(ReadOnlySpan secret) { if (secret.IsEmpty) - throw new ArgumentNullException(nameof(secret)); + throw new ArgumentException("Secret must not be empty.", nameof(secret)); if (secret.Length < MinSecretLen) throw new ArgumentException($"Secret must be at least {MinSecretLen} bytes.", nameof(secret)); @@ -96,37 +103,42 @@ private static byte[] ComputeServerCookie(IPAddress clientAddress, ReadOnlySpan< if (clientCookie.Length != ClientCookieLen) throw new ArgumentException($"Client cookie must be {ClientCookieLen} bytes.", nameof(clientCookie)); - // We must return a heap array anyway, so build directly into it. byte[] cookie = new byte[ServerCookieLen]; - cookie[0] = 1; // Version - cookie[1] = 0; // Reserved + + cookie[VersionOffset] = 1; + + // Reserved MUST be set to zero on construction (RFC 9018) + cookie.AsSpan(ReservedOffset, ReservedLen).Clear(); uint ts = unchecked((uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds()); BinaryPrimitives.WriteUInt32BigEndian(cookie.AsSpan(TimestampOffset, TimestampLen), ts); - // HMAC input: version | reserved | timestamp(4) | client_cookie(8) | client_ip(4/16) + // SipHash input: clientCookie(8) | version(1) | reserved(3) | timestamp(4) | clientIP(4/16) byte[] ipBytes = clientAddress.GetAddressBytes(); - int inputLen = 1 + 1 + TimestampLen + ClientCookieLen + ipBytes.Length; + int inputLen = ClientCookieLen + 1 + ReservedLen + TimestampLen + ipBytes.Length; + + Span input = inputLen <= 64 ? stackalloc byte[inputLen] : new byte[inputLen]; + int o = 0; + clientCookie.CopyTo(input.Slice(o, ClientCookieLen)); o += ClientCookieLen; + input[o++] = cookie[VersionOffset]; + cookie.AsSpan(ReservedOffset, ReservedLen).CopyTo(input.Slice(o, ReservedLen)); o += ReservedLen; + cookie.AsSpan(TimestampOffset, TimestampLen).CopyTo(input.Slice(o, TimestampLen)); o += TimestampLen; + ipBytes.AsSpan().CopyTo(input.Slice(o, ipBytes.Length)); - byte[] input = GC.AllocateUninitializedArray(inputLen); - input[0] = cookie[0]; - input[1] = cookie[1]; - cookie.AsSpan(TimestampOffset, TimestampLen).CopyTo(input.AsSpan(2, TimestampLen)); - clientCookie.CopyTo(input.AsSpan(2 + TimestampLen)); - ipBytes.AsSpan().CopyTo(input.AsSpan(2 + TimestampLen + ClientCookieLen)); + ReadOnlySpan key16 = secret.Slice(0, 16); // acceptable if secret is uniformly random + ulong tag = SipHash24.Compute(key16, input); - Span fullMac = stackalloc byte[32]; - HMACSHA256.HashData(secret, input, fullMac); + // Store tag in network order for deterministic on-wire representation + BinaryPrimitives.WriteUInt64BigEndian(cookie.AsSpan(MacOffset, MacLen), tag); - fullMac.Slice(0, MacLen).CopyTo(cookie.AsSpan(MacOffset, MacLen)); return cookie; } private static bool ValidateServerCookieWithSecret( - IPAddress clientAddress, - ReadOnlySpan clientCookie, - ReadOnlySpan serverCookie, - ReadOnlySpan secret) + IPAddress clientAddress, + ReadOnlySpan clientCookie, + ReadOnlySpan serverCookie, + ReadOnlySpan secret) { if (clientAddress is null || secret.IsEmpty) return false; @@ -148,39 +160,54 @@ private static bool ValidateServerCookieWithSecret( if (clientAddress.IsIPv4MappedToIPv6) clientAddress = clientAddress.MapToIPv4(); - // Version must match v1 - if (serverCookie[0] != 1) + if (serverCookie[VersionOffset] != 1) return false; - // Reserved; strict since we always emit 0 (change to "ignore" if you want forward-compat) - if (serverCookie[1] != 0) - return false; + // IMPORTANT (RFC 9018): do NOT enforce Reserved==0 on verification. + // Include received reserved bytes in the MAC input. uint cookieTs = BinaryPrimitives.ReadUInt32BigEndian(serverCookie.Slice(TimestampOffset, TimestampLen)); uint nowTs = unchecked((uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds()); - uint diff = nowTs >= cookieTs ? (nowTs - cookieTs) : (cookieTs - nowTs); - if (diff > MaxSkewSeconds) - return false; - - // Recompute expected MAC over exact bytes: version|reserved|timestampBytes|clientCookie|clientIP + // RFC 1982 serial arithmetic + static bool SerialLessThan(uint a, uint b) => a != b && (uint)(b - a) < 0x8000_0000u; + static uint SerialDistance(uint a, uint b) => (uint)(b - a); + + if (SerialLessThan(nowTs, cookieTs)) + { + uint future = SerialDistance(nowTs, cookieTs); + if (future > MaxFutureSeconds) + return false; + } + else + { + uint past = SerialDistance(cookieTs, nowTs); + if (past > MaxPastSeconds) + return false; + } + + // SipHash input: clientCookie(8) | version(1) | reserved(3) | timestamp(4) | clientIP(4/16) byte[] ipBytes = clientAddress.GetAddressBytes(); - int inputLen = 1 + 1 + TimestampLen + ClientCookieLen + ipBytes.Length; - - byte[] input = GC.AllocateUninitializedArray(inputLen); - input[0] = serverCookie[0]; - input[1] = serverCookie[1]; - serverCookie.Slice(TimestampOffset, TimestampLen).CopyTo(input.AsSpan(2, TimestampLen)); - clientCookie.CopyTo(input.AsSpan(2 + TimestampLen)); - ipBytes.AsSpan().CopyTo(input.AsSpan(2 + TimestampLen + ClientCookieLen)); - - Span fullMac = stackalloc byte[32]; - HMACSHA256.HashData(secret, input, fullMac); - - ReadOnlySpan provided = serverCookie.Slice(MacOffset, MacLen); - Span expected = fullMac.Slice(0, MacLen); - - return CryptographicOperations.FixedTimeEquals(expected, provided); + int inputLen = ClientCookieLen + 1 + ReservedLen + TimestampLen + ipBytes.Length; + + Span input = inputLen <= 64 ? stackalloc byte[inputLen] : new byte[inputLen]; + int o = 0; + clientCookie.CopyTo(input.Slice(o, ClientCookieLen)); o += ClientCookieLen; + input[o++] = serverCookie[VersionOffset]; + serverCookie.Slice(ReservedOffset, ReservedLen).CopyTo(input.Slice(o, ReservedLen)); o += ReservedLen; + serverCookie.Slice(TimestampOffset, TimestampLen).CopyTo(input.Slice(o, TimestampLen)); o += TimestampLen; + ipBytes.AsSpan().CopyTo(input.Slice(o, ipBytes.Length)); + + ReadOnlySpan key16 = secret.Slice(0, 16); + ulong expectedTag = SipHash24.Compute(key16, input); + + ulong providedTag = BinaryPrimitives.ReadUInt64BigEndian(serverCookie.Slice(MacOffset, MacLen)); + + // Constant-time compare without allocating: + // compare tags by bytes, not by ulong equality (avoids timing artifacts) + Span expectedBytes = stackalloc byte[8]; + BinaryPrimitives.WriteUInt64BigEndian(expectedBytes, expectedTag); + return CryptographicOperations.FixedTimeEquals(expectedBytes, serverCookie.Slice(MacOffset, MacLen)); } #endregion @@ -231,5 +258,81 @@ public EDnsCookieOptionData CreateResponseCookie(IPAddress clientAddress, EDnsCo } #endregion + + internal static class SipHash24 + { + // SipHash-2-4 with 128-bit key (16 bytes), returns 64-bit tag. + public static ulong Compute(ReadOnlySpan key16, ReadOnlySpan msg) + { + if (key16.Length != 16) + throw new ArgumentException("SipHash key must be 16 bytes.", nameof(key16)); + + ulong k0 = ReadU64LE(key16.Slice(0, 8)); + ulong k1 = ReadU64LE(key16.Slice(8, 8)); + + ulong v0 = 0x736f6d6570736575UL ^ k0; + ulong v1 = 0x646f72616e646f6dUL ^ k1; + ulong v2 = 0x6c7967656e657261UL ^ k0; + ulong v3 = 0x7465646279746573UL ^ k1; + + int len = msg.Length; + int end = len & ~7; + + for (int i = 0; i < end; i += 8) + { + ulong m = ReadU64LE(msg.Slice(i, 8)); + v3 ^= m; + SipRound(ref v0, ref v1, ref v2, ref v3); + SipRound(ref v0, ref v1, ref v2, ref v3); + v0 ^= m; + } + + ulong b = (ulong)len << 56; + int rem = len - end; + if (rem != 0) + { + ReadOnlySpan tail = msg.Slice(end, rem); + for (int i = 0; i < rem; i++) + b |= (ulong)tail[i] << (8 * i); + } + + v3 ^= b; + SipRound(ref v0, ref v1, ref v2, ref v3); + SipRound(ref v0, ref v1, ref v2, ref v3); + v0 ^= b; + + v2 ^= 0xff; + SipRound(ref v0, ref v1, ref v2, ref v3); + SipRound(ref v0, ref v1, ref v2, ref v3); + SipRound(ref v0, ref v1, ref v2, ref v3); + SipRound(ref v0, ref v1, ref v2, ref v3); + + return v0 ^ v1 ^ v2 ^ v3; + } + + private static void SipRound(ref ulong v0, ref ulong v1, ref ulong v2, ref ulong v3) + { + v0 += v1; v1 = RotL(v1, 13); v1 ^= v0; v0 = RotL(v0, 32); + v2 += v3; v3 = RotL(v3, 16); v3 ^= v2; + v0 += v3; v3 = RotL(v3, 21); v3 ^= v0; + v2 += v1; v1 = RotL(v1, 17); v1 ^= v2; v2 = RotL(v2, 32); + } + + private static ulong RotL(ulong x, int b) => (x << b) | (x >> (64 - b)); + + private static ulong ReadU64LE(ReadOnlySpan s) + { + // SipHash spec uses little-endian loads for message words. + return + ((ulong)s[0]) | + ((ulong)s[1] << 8) | + ((ulong)s[2] << 16) | + ((ulong)s[3] << 24) | + ((ulong)s[4] << 32) | + ((ulong)s[5] << 40) | + ((ulong)s[6] << 48) | + ((ulong)s[7] << 56); + } + } } } From 1919362c5f7a40c591eb8fccf4d36b517de693f0 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 20 Feb 2026 10:27:03 +0200 Subject: [PATCH 22/37] USed tmp file during writes --- DnsServerCore/Dns/Security/DnsCookieSecretManager.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs index 1e49be14a..5974254a2 100644 --- a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs +++ b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs @@ -153,7 +153,14 @@ private void SaveLocked(Snapshot snapshot) if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) Directory.CreateDirectory(directory); - File.WriteAllBytes(_secretFilePath, ms.ToArray()); + string tmpPath = _secretFilePath + ".tmp"; + File.WriteAllBytes(tmpPath, ms.ToArray()); + + // Atomic replace where supported + if (File.Exists(_secretFilePath)) + File.Replace(tmpPath, _secretFilePath, destinationBackupFileName: null); + else + File.Move(tmpPath, _secretFilePath); } private Snapshot GenerateNewSnapshot(byte[] previousSecret) From 1cc46512d9738591cdffa427371214f376f6822b Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 20 Feb 2026 10:49:29 +0200 Subject: [PATCH 23/37] Improved cookie handling --- DnsServerCore/Dns/DnsServer.cs | 116 ++++++++++++++++++++++++++------- 1 file changed, 91 insertions(+), 25 deletions(-) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index 66f25e77f..a8e2c1317 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -285,6 +285,7 @@ enum ServiceState long _cookieInvalid; long _cookieMissing; long _cookieBadcookieSent; + long _cookieClientOnly; #endregion @@ -1719,7 +1720,7 @@ private static IReadOnlyList UpsertOptRecord( var opt = DnsDatagramEdns.GetOPTFor( udpPayloadSize: udp, - extendedRCODE: 0, + extendedRCODE: response.RCODE, version: 0, flags: flags, options: options); @@ -2810,13 +2811,12 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo return new DnsDatagram(request.Identifier, true, request.OPCODE, false, false, request.RecursionDesired, isRecursionAllowed, false, request.CheckingDisabled, DnsResponseCode.BADVERS, request.Question, null, null, null, _udpPayloadSize, request.DnssecOk ? EDnsHeaderFlags.DNSSEC_OK : EDnsHeaderFlags.None) { Tag = DnsServerResponseType.Authoritative }; } - // DNS Cookies (RFC 7873) + // DNS Cookies (RFC 7873 / RFC 9018 v1) if (protocol == DnsTransportProtocol.Udp && request.EDNS != null && _cookieValidator != null) { - EDnsCookieOptionData cookie = - TryGetCookieOption(request); + EDnsCookieOptionData cookie = TryGetCookieOption(request); if (cookie == null) { @@ -2824,30 +2824,98 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo } else { - if (!cookie.ServerCookie.IsEmpty && cookie.ServerCookie.Length > 8) + // RFC 7873: CC MUST be 8 bytes. Total length MUST be 8 OR 16..40. + int ccLen = cookie.ClientCookie.Length; + int scLen = cookie.ServerCookie.Length; + int totalLen = ccLen + scLen; + + bool lengthOk = + (ccLen == 8) && + ( + (scLen == 0) || // totalLen == 8 + (scLen >= 8 && scLen <= 32) // totalLen 16..40 + ); + + if (!lengthOk) + { + // Malformed COOKIE option => FORMERR + Interlocked.Increment(ref _cookieInvalid); + + return new DnsDatagram( + request.Identifier, + true, + request.OPCODE, + false, + truncation: false, + recursionDesired: request.RecursionDesired, + recursionAvailable: isRecursionAllowed, + authenticData: false, + checkingDisabled: request.CheckingDisabled, + DnsResponseCode.FormatError, + request.Question, + null, + null, + null, + request.EDNS?.UdpPayloadSize ?? _udpPayloadSize, + request.EDNS?.Flags ?? EDnsHeaderFlags.None) + { Tag = DnsServerResponseType.Authoritative }; + } + + // CC-only (totalLen == 8): valid request; we’ll attach SC to the normal response later (no extra RTT). + if (totalLen == 8) + { + Interlocked.Increment(ref _cookieClientOnly); + } + else { - if (!_cookieValidator.Validate( - remoteEP.Address, - cookie)) + // CC+SC present. If RFC 9018 v1 is in use, enforce v1 structural expectations: + // version==1 => SC MUST be 16 bytes (totalLen must be 24). + // If you want to allow non-v1 cookies, relax this branch. + if (scLen > 0 && cookie.ServerCookie[0] == 1 && scLen != 16) { Interlocked.Increment(ref _cookieInvalid); - var respCookie = - _cookieValidator.CreateResponseCookie( - remoteEP.Address, - cookie); + return new DnsDatagram( + request.Identifier, + true, + request.OPCODE, + false, + truncation: false, + recursionDesired: request.RecursionDesired, + recursionAvailable: isRecursionAllowed, + authenticData: false, + checkingDisabled: request.CheckingDisabled, + DnsResponseCode.FormatError, + request.Question, + null, + null, + null, + _udpPayloadSize, + request.DnssecOk ? EDnsHeaderFlags.DNSSEC_OK : EDnsHeaderFlags.None) + { Tag = DnsServerResponseType.Authoritative }; + } + + // Only attempt validation when a server cookie is present (RFC 7873: 8..32) + if (scLen >= 8) + { + if (!_cookieValidator.Validate(remoteEP.Address, cookie)) + { + Interlocked.Increment(ref _cookieInvalid); + + EDnsCookieOptionData respCookie = + _cookieValidator.CreateResponseCookie(remoteEP.Address, cookie); - Interlocked.Increment( - ref _cookieBadcookieSent); + Interlocked.Increment(ref _cookieBadcookieSent); - return BuildBadCookieResponse( - request, - remoteEP, - isRecursionAllowed, - respCookie); - } + return BuildBadCookieResponse( + request, + remoteEP, + isRecursionAllowed, + respCookie); + } - Interlocked.Increment(ref _cookieValid); + Interlocked.Increment(ref _cookieValid); + } } } } @@ -2860,12 +2928,10 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo if (_cookieValidator != null && request.EDNS != null) { EDnsCookieOptionData requestCookie = TryGetCookieOption(request); - if (requestCookie != null) + if (requestCookie != null && requestCookie.ClientCookie.Length == 8) { EDnsCookieOptionData responseCookie = - _cookieValidator.CreateResponseCookie( - remoteEP.Address, - requestCookie); + _cookieValidator.CreateResponseCookie(remoteEP.Address, requestCookie); IReadOnlyList mergedOptions = MergeCookieOption(response.EDNS?.Options, responseCookie); From 8c49aa14630d4f2d5a1d06e78da094445f593a69 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 20 Feb 2026 11:03:33 +0200 Subject: [PATCH 24/37] Improved cookie validation --- DnsServerCore/Dns/DnsServer.cs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index a8e2c1317..512ddf713 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -2894,10 +2894,37 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo request.DnssecOk ? EDnsHeaderFlags.DNSSEC_OK : EDnsHeaderFlags.None) { Tag = DnsServerResponseType.Authoritative }; } - - // Only attempt validation when a server cookie is present (RFC 7873: 8..32) + // CC+SC present if (scLen >= 8) { + // v1 requires totalLen == 24 (CC 8 + SC 16). Anything else is “not a valid server cookie”. + bool looksLikeV1 = (cookie.ServerCookie[0] == 1); + if (looksLikeV1 && scLen != 16) + { + Interlocked.Increment(ref _cookieInvalid); + + EDnsCookieOptionData respCookie = + _cookieValidator.CreateResponseCookie(remoteEP.Address, cookie); + + Interlocked.Increment(ref _cookieBadcookieSent); + + return BuildBadCookieResponse(request, remoteEP, isRecursionAllowed, respCookie); + } + + // If non-v1 versions are unsupported, you can also BADCOOKIE them: + if (!looksLikeV1) + { + Interlocked.Increment(ref _cookieInvalid); + + EDnsCookieOptionData respCookie = + _cookieValidator.CreateResponseCookie(remoteEP.Address, cookie); + + Interlocked.Increment(ref _cookieBadcookieSent); + + return BuildBadCookieResponse(request, remoteEP, isRecursionAllowed, respCookie); + } + + // v1: validate cryptographically if (!_cookieValidator.Validate(remoteEP.Address, cookie)) { Interlocked.Increment(ref _cookieInvalid); From f5e076c93f011a1328eb46158b80323377c97e75 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 20 Feb 2026 11:31:59 +0200 Subject: [PATCH 25/37] Fixed RFC non-compliant logic --- DnsServerCore/Dns/DnsServer.cs | 149 ++++++++++++++++----------------- 1 file changed, 71 insertions(+), 78 deletions(-) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index 512ddf713..fa35f3fc5 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -2808,15 +2808,37 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo if (request.EDNS is not null) { if (request.EDNS.Version != 0) - return new DnsDatagram(request.Identifier, true, request.OPCODE, false, false, request.RecursionDesired, isRecursionAllowed, false, request.CheckingDisabled, DnsResponseCode.BADVERS, request.Question, null, null, null, _udpPayloadSize, request.DnssecOk ? EDnsHeaderFlags.DNSSEC_OK : EDnsHeaderFlags.None) { Tag = DnsServerResponseType.Authoritative }; + { + ushort udpPayload = request.EDNS?.UdpPayloadSize ?? _udpPayloadSize; + EDnsHeaderFlags flags = request.EDNS?.Flags ?? EDnsHeaderFlags.None; + return new DnsDatagram( + request.Identifier, + true, + request.OPCODE, + false, + false, + request.RecursionDesired, + isRecursionAllowed, + false, + request.CheckingDisabled, + DnsResponseCode.BADVERS, + request.Question, + null, + null, + null, + udpPayload, + flags) + { Tag = DnsServerResponseType.Authoritative }; + } } // DNS Cookies (RFC 7873 / RFC 9018 v1) + EDnsCookieOptionData cookie = null; if (protocol == DnsTransportProtocol.Udp && request.EDNS != null && _cookieValidator != null) { - EDnsCookieOptionData cookie = TryGetCookieOption(request); + cookie = TryGetCookieOption(request); if (cookie == null) { @@ -2827,8 +2849,6 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo // RFC 7873: CC MUST be 8 bytes. Total length MUST be 8 OR 16..40. int ccLen = cookie.ClientCookie.Length; int scLen = cookie.ServerCookie.Length; - int totalLen = ccLen + scLen; - bool lengthOk = (ccLen == 8) && ( @@ -2840,109 +2860,82 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo { // Malformed COOKIE option => FORMERR Interlocked.Increment(ref _cookieInvalid); + ushort udpPayload = request.EDNS?.UdpPayloadSize ?? _udpPayloadSize; + EDnsHeaderFlags flags = request.EDNS?.Flags ?? EDnsHeaderFlags.None; return new DnsDatagram( request.Identifier, true, request.OPCODE, false, - truncation: false, - recursionDesired: request.RecursionDesired, - recursionAvailable: isRecursionAllowed, - authenticData: false, - checkingDisabled: request.CheckingDisabled, + false, + request.RecursionDesired, + isRecursionAllowed, + false, + request.CheckingDisabled, DnsResponseCode.FormatError, request.Question, null, null, null, - request.EDNS?.UdpPayloadSize ?? _udpPayloadSize, - request.EDNS?.Flags ?? EDnsHeaderFlags.None) + request.EDNS is null ? ushort.MinValue : udpPayload, + flags) { Tag = DnsServerResponseType.Authoritative }; } - // CC-only (totalLen == 8): valid request; we’ll attach SC to the normal response later (no extra RTT). - if (totalLen == 8) + // CC-only: valid request; we'll attach SC to the normal response later (no extra RTT). + if (scLen == 0) { Interlocked.Increment(ref _cookieClientOnly); } else { - // CC+SC present. If RFC 9018 v1 is in use, enforce v1 structural expectations: - // version==1 => SC MUST be 16 bytes (totalLen must be 24). - // If you want to allow non-v1 cookies, relax this branch. - if (scLen > 0 && cookie.ServerCookie[0] == 1 && scLen != 16) - { - Interlocked.Increment(ref _cookieInvalid); - - return new DnsDatagram( - request.Identifier, - true, - request.OPCODE, - false, - truncation: false, - recursionDesired: request.RecursionDesired, - recursionAvailable: isRecursionAllowed, - authenticData: false, - checkingDisabled: request.CheckingDisabled, - DnsResponseCode.FormatError, - request.Question, - null, - null, - null, - _udpPayloadSize, - request.DnssecOk ? EDnsHeaderFlags.DNSSEC_OK : EDnsHeaderFlags.None) - { Tag = DnsServerResponseType.Authoritative }; - } // CC+SC present - if (scLen >= 8) + // v1 requires totalLen == 24 (CC 8 + SC 16). Anything else is “not a valid server cookie”. + bool looksLikeV1 = cookie.ServerCookie[0] == 1; + if (looksLikeV1 && scLen != 16) { - // v1 requires totalLen == 24 (CC 8 + SC 16). Anything else is “not a valid server cookie”. - bool looksLikeV1 = (cookie.ServerCookie[0] == 1); - if (looksLikeV1 && scLen != 16) - { - Interlocked.Increment(ref _cookieInvalid); - - EDnsCookieOptionData respCookie = - _cookieValidator.CreateResponseCookie(remoteEP.Address, cookie); + Interlocked.Increment(ref _cookieInvalid); - Interlocked.Increment(ref _cookieBadcookieSent); + EDnsCookieOptionData respCookie = + _cookieValidator.CreateResponseCookie(remoteEP.Address, cookie); - return BuildBadCookieResponse(request, remoteEP, isRecursionAllowed, respCookie); - } + Interlocked.Increment(ref _cookieBadcookieSent); - // If non-v1 versions are unsupported, you can also BADCOOKIE them: - if (!looksLikeV1) - { - Interlocked.Increment(ref _cookieInvalid); + return BuildBadCookieResponse(request, remoteEP, isRecursionAllowed, respCookie); + } - EDnsCookieOptionData respCookie = - _cookieValidator.CreateResponseCookie(remoteEP.Address, cookie); + // If non-v1 versions are unsupported, you can also BADCOOKIE them: + if (!looksLikeV1) + { + Interlocked.Increment(ref _cookieInvalid); - Interlocked.Increment(ref _cookieBadcookieSent); + EDnsCookieOptionData respCookie = + _cookieValidator.CreateResponseCookie(remoteEP.Address, cookie); - return BuildBadCookieResponse(request, remoteEP, isRecursionAllowed, respCookie); - } + Interlocked.Increment(ref _cookieBadcookieSent); - // v1: validate cryptographically - if (!_cookieValidator.Validate(remoteEP.Address, cookie)) - { - Interlocked.Increment(ref _cookieInvalid); + return BuildBadCookieResponse(request, remoteEP, isRecursionAllowed, respCookie); + } - EDnsCookieOptionData respCookie = - _cookieValidator.CreateResponseCookie(remoteEP.Address, cookie); + // v1: validate cryptographically + if (!_cookieValidator.Validate(remoteEP.Address, cookie)) + { + Interlocked.Increment(ref _cookieInvalid); - Interlocked.Increment(ref _cookieBadcookieSent); + EDnsCookieOptionData respCookie = + _cookieValidator.CreateResponseCookie(remoteEP.Address, cookie); - return BuildBadCookieResponse( - request, - remoteEP, - isRecursionAllowed, - respCookie); - } + Interlocked.Increment(ref _cookieBadcookieSent); - Interlocked.Increment(ref _cookieValid); + return BuildBadCookieResponse( + request, + remoteEP, + isRecursionAllowed, + respCookie); } + + Interlocked.Increment(ref _cookieValid); } } } @@ -2952,13 +2945,13 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo return null; // Attach cookie to response if needed - if (_cookieValidator != null && request.EDNS != null) + if (protocol == DnsTransportProtocol.Udp && _cookieValidator != null && request.EDNS != null) { - EDnsCookieOptionData requestCookie = TryGetCookieOption(request); - if (requestCookie != null && requestCookie.ClientCookie.Length == 8) + + if (cookie != null && cookie.ClientCookie.Length == 8) { EDnsCookieOptionData responseCookie = - _cookieValidator.CreateResponseCookie(remoteEP.Address, requestCookie); + _cookieValidator.CreateResponseCookie(remoteEP.Address, cookie); IReadOnlyList mergedOptions = MergeCookieOption(response.EDNS?.Options, responseCookie); From 4e3c798ef91ce951f01fb46140d603d196376cc1 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 20 Feb 2026 11:34:14 +0200 Subject: [PATCH 26/37] Standardized FORMERR --- DnsServerCore/Dns/DnsServer.cs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index fa35f3fc5..efdf4e83f 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -2774,7 +2774,25 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo _log.Write(remoteEP, protocol, request.ParsingException); //format error response - return new DnsDatagram(request.Identifier, true, request.OPCODE, false, false, request.RecursionDesired, isRecursionAllowed, false, request.CheckingDisabled, DnsResponseCode.FormatError, request.Question, null, null, null, request.EDNS is null ? ushort.MinValue : _udpPayloadSize, request.DnssecOk ? EDnsHeaderFlags.DNSSEC_OK : EDnsHeaderFlags.None) { Tag = DnsServerResponseType.Authoritative }; + ushort udpPayload = request.EDNS?.UdpPayloadSize ?? _udpPayloadSize; + EDnsHeaderFlags flags = request.EDNS?.Flags ?? EDnsHeaderFlags.None; + return new DnsDatagram(request.Identifier, + true, + request.OPCODE, + false, + false, + request.RecursionDesired, + isRecursionAllowed, + false, + request.CheckingDisabled, + DnsResponseCode.FormatError, + request.Question, + null, + null, + null, + request.EDNS is null ? ushort.MinValue : udpPayload, + flags) + { Tag = DnsServerResponseType.Authoritative }; } //check for invalid domain name From f4993c6687e9ef2d671fc96f94a2499f70c4d2ae Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 20 Feb 2026 13:19:14 +0200 Subject: [PATCH 27/37] Used lock for DNS cookie initialization --- DnsServerCore/Dns/DnsServer.cs | 41 ++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index efdf4e83f..d159cd38d 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -1609,25 +1609,29 @@ private string ConvertToAbsolutePath(string path) private void InitDnsCookies() { - string secretPath = Path.IsPathRooted(_dnsCookiesSecretFile) - ? _dnsCookiesSecretFile - : Path.Combine(_configFolder, _dnsCookiesSecretFile); + lock (_saveLock) + { + string secretPath = Path.IsPathRooted(_dnsCookiesSecretFile) + ? _dnsCookiesSecretFile + : Path.Combine(_configFolder, _dnsCookiesSecretFile); - _cookieSecrets = new Security.DnsCookieSecretManager(secretPath); - _cookieValidator = new Security.DnsCookieValidator(_cookieSecrets); + _cookieSecrets = new Security.DnsCookieSecretManager(secretPath); + _cookieValidator = new Security.DnsCookieValidator(_cookieSecrets); + + _cookieRotationTimer?.Dispose(); + if (_dnsCookiesRotationPeriodHours > 0) + { + _cookieRotationTimer = new Timer( + _ => + { + try { _cookieSecrets.Rotate(); } + catch (Exception ex) { _log.Write(ex); } + }, + null, + dueTime: TimeSpan.FromMinutes(5), + period: TimeSpan.FromHours(_dnsCookiesRotationPeriodHours)); + } - _cookieRotationTimer?.Dispose(); - if (_dnsCookiesRotationPeriodHours > 0) - { - _cookieRotationTimer = new Timer( - _ => - { - try { _cookieSecrets.Rotate(); } - catch (Exception ex) { _log.Write(ex); } - }, - null, - dueTime: TimeSpan.FromMinutes(5), - period: TimeSpan.FromHours(_dnsCookiesRotationPeriodHours)); } } @@ -2964,8 +2968,7 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo // Attach cookie to response if needed if (protocol == DnsTransportProtocol.Udp && _cookieValidator != null && request.EDNS != null) - { - + { if (cookie != null && cookie.ClientCookie.Length == 8) { EDnsCookieOptionData responseCookie = From b40326da6829c987c3de09365539dfbaa707da57 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 20 Feb 2026 13:22:47 +0200 Subject: [PATCH 28/37] Refactored UpsertOptRecord --- DnsServerCore/Dns/DnsServer.cs | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index d159cd38d..e5cb937f0 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -1699,7 +1699,7 @@ private static IReadOnlyList MergeCookieOption( else { list = new List(existing.Count + 1); - foreach (var opt in existing) + foreach (EDnsOption opt in existing) { if (opt.Code != EDnsOptionCode.COOKIE) list.Add(opt); @@ -1717,30 +1717,24 @@ private static IReadOnlyList UpsertOptRecord( DnsDatagram response, IReadOnlyList options) { - var baseEdns = response.EDNS ?? request.EDNS; + DnsDatagramEdns baseEdns = response.EDNS ?? request.EDNS; ushort udp = baseEdns?.UdpPayloadSize ?? 512; - var flags = baseEdns?.Flags ?? EDnsHeaderFlags.None; + EDnsHeaderFlags flags = baseEdns?.Flags ?? EDnsHeaderFlags.None; - var opt = DnsDatagramEdns.GetOPTFor( + DnsResourceRecord opt = DnsDatagramEdns.GetOPTFor( udpPayloadSize: udp, extendedRCODE: response.RCODE, version: 0, flags: flags, options: options); - List list = - existingAdditional == null - ? new List(1) - : new List(existingAdditional.Count + 1); - - if (existingAdditional != null) + int capacity = (existingAdditional?.Count ?? 0) + 1; + List list = new List(capacity); + foreach (DnsResourceRecord rr in existingAdditional ?? Array.Empty()) { - foreach (var rr in existingAdditional) - { - if (rr.Type != DnsResourceRecordType.OPT) - list.Add(rr); - } + if (rr.Type != DnsResourceRecordType.OPT) + list.Add(rr); } list.Add(opt); From fa34152d8894824066493e86b0fee4d5eba0c280 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 20 Feb 2026 13:30:04 +0200 Subject: [PATCH 29/37] Reused cookie variable for clarity --- DnsServerCore/Dns/DnsServer.cs | 43 +++++++++++++++++----------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index e5cb937f0..6183530a1 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -2849,22 +2849,23 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo } // DNS Cookies (RFC 7873 / RFC 9018 v1) - EDnsCookieOptionData cookie = null; + EDnsCookieOptionData requestCookie = null; + EDnsCookieOptionData responseCookie; if (protocol == DnsTransportProtocol.Udp && request.EDNS != null && _cookieValidator != null) { - cookie = TryGetCookieOption(request); + requestCookie = TryGetCookieOption(request); - if (cookie == null) + if (requestCookie == null) { Interlocked.Increment(ref _cookieMissing); } else { // RFC 7873: CC MUST be 8 bytes. Total length MUST be 8 OR 16..40. - int ccLen = cookie.ClientCookie.Length; - int scLen = cookie.ServerCookie.Length; + int ccLen = requestCookie.ClientCookie.Length; + int scLen = requestCookie.ServerCookie.Length; bool lengthOk = (ccLen == 8) && ( @@ -2907,18 +2908,18 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo else { // CC+SC present - // v1 requires totalLen == 24 (CC 8 + SC 16). Anything else is “not a valid server cookie”. - bool looksLikeV1 = cookie.ServerCookie[0] == 1; + // v1 requires totalLen == 24 (CC 8 + SC 16). Anything else is “not a valid server requestCookie”. + bool looksLikeV1 = requestCookie.ServerCookie[0] == 1; if (looksLikeV1 && scLen != 16) { Interlocked.Increment(ref _cookieInvalid); - EDnsCookieOptionData respCookie = - _cookieValidator.CreateResponseCookie(remoteEP.Address, cookie); + responseCookie = + _cookieValidator.CreateResponseCookie(remoteEP.Address, requestCookie); Interlocked.Increment(ref _cookieBadcookieSent); - return BuildBadCookieResponse(request, remoteEP, isRecursionAllowed, respCookie); + return BuildBadCookieResponse(request, remoteEP, isRecursionAllowed, responseCookie); } // If non-v1 versions are unsupported, you can also BADCOOKIE them: @@ -2926,21 +2927,21 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo { Interlocked.Increment(ref _cookieInvalid); - EDnsCookieOptionData respCookie = - _cookieValidator.CreateResponseCookie(remoteEP.Address, cookie); + responseCookie = + _cookieValidator.CreateResponseCookie(remoteEP.Address, requestCookie); Interlocked.Increment(ref _cookieBadcookieSent); - return BuildBadCookieResponse(request, remoteEP, isRecursionAllowed, respCookie); + return BuildBadCookieResponse(request, remoteEP, isRecursionAllowed, responseCookie); } // v1: validate cryptographically - if (!_cookieValidator.Validate(remoteEP.Address, cookie)) + if (!_cookieValidator.Validate(remoteEP.Address, requestCookie)) { Interlocked.Increment(ref _cookieInvalid); - EDnsCookieOptionData respCookie = - _cookieValidator.CreateResponseCookie(remoteEP.Address, cookie); + responseCookie = + _cookieValidator.CreateResponseCookie(remoteEP.Address, requestCookie); Interlocked.Increment(ref _cookieBadcookieSent); @@ -2948,7 +2949,7 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo request, remoteEP, isRecursionAllowed, - respCookie); + responseCookie); } Interlocked.Increment(ref _cookieValid); @@ -2960,13 +2961,13 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo if (response is null) return null; - // Attach cookie to response if needed + // Attach requestCookie to response if needed if (protocol == DnsTransportProtocol.Udp && _cookieValidator != null && request.EDNS != null) { - if (cookie != null && cookie.ClientCookie.Length == 8) + if (requestCookie != null && requestCookie.ClientCookie.Length == 8) { - EDnsCookieOptionData responseCookie = - _cookieValidator.CreateResponseCookie(remoteEP.Address, cookie); + responseCookie = + _cookieValidator.CreateResponseCookie(remoteEP.Address, requestCookie); IReadOnlyList mergedOptions = MergeCookieOption(response.EDNS?.Options, responseCookie); From d4870c54dcdc4a79ab047eec7e88f3c1e1299267 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 20 Feb 2026 13:49:50 +0200 Subject: [PATCH 30/37] Optimized IPv6 handling in cookie calculation hot path --- .../Dns/Security/DnsCookieValidator.cs | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/DnsServerCore/Dns/Security/DnsCookieValidator.cs b/DnsServerCore/Dns/Security/DnsCookieValidator.cs index 2957367d9..5f6a66707 100644 --- a/DnsServerCore/Dns/Security/DnsCookieValidator.cs +++ b/DnsServerCore/Dns/Security/DnsCookieValidator.cs @@ -157,9 +157,6 @@ private static bool ValidateServerCookieWithSecret( clientAddress.AddressFamily != AddressFamily.InterNetworkV6) return false; - if (clientAddress.IsIPv4MappedToIPv6) - clientAddress = clientAddress.MapToIPv4(); - if (serverCookie[VersionOffset] != 1) return false; @@ -187,22 +184,52 @@ private static bool ValidateServerCookieWithSecret( } // SipHash input: clientCookie(8) | version(1) | reserved(3) | timestamp(4) | clientIP(4/16) - byte[] ipBytes = clientAddress.GetAddressBytes(); - int inputLen = ClientCookieLen + 1 + ReservedLen + TimestampLen + ipBytes.Length; + Span ip = stackalloc byte[16]; + int ipLen = 0; - Span input = inputLen <= 64 ? stackalloc byte[inputLen] : new byte[inputLen]; + // Avoid MapToIPv4() allocation: handle IPv4-mapped via bytes. + if (clientAddress.AddressFamily == AddressFamily.InterNetwork) + { + ipLen = 4; + clientAddress.TryWriteBytes(ip.Slice(0, 4), out _); + } + else + { + // IPv6 + clientAddress.TryWriteBytes(ip, out int written); + if (written != 16) + return false; // or throw in compute path + + // If v4-mapped (::ffff:a.b.c.d), canonicalize to 4 bytes (last 4 bytes) + bool isV4Mapped = + ip[0] == 0 && ip[1] == 0 && ip[2] == 0 && ip[3] == 0 && + ip[4] == 0 && ip[5] == 0 && ip[6] == 0 && ip[7] == 0 && + ip[8] == 0 && ip[9] == 0 && + ip[10] == 0xff && ip[11] == 0xff; + + if (isV4Mapped) + { + ipLen = 4; + ip.Slice(12, 4).CopyTo(ip.Slice(0, 4)); + } + else + { + ipLen = 16; + } + } + + int inputLen = ClientCookieLen + 1 + ReservedLen + TimestampLen + ipLen; + Span input = stackalloc byte[inputLen]; // always <= 32 here int o = 0; clientCookie.CopyTo(input.Slice(o, ClientCookieLen)); o += ClientCookieLen; input[o++] = serverCookie[VersionOffset]; serverCookie.Slice(ReservedOffset, ReservedLen).CopyTo(input.Slice(o, ReservedLen)); o += ReservedLen; serverCookie.Slice(TimestampOffset, TimestampLen).CopyTo(input.Slice(o, TimestampLen)); o += TimestampLen; - ipBytes.AsSpan().CopyTo(input.Slice(o, ipBytes.Length)); + ip.Slice(0, ipLen).CopyTo(input.Slice(o, ipLen)); ReadOnlySpan key16 = secret.Slice(0, 16); ulong expectedTag = SipHash24.Compute(key16, input); - ulong providedTag = BinaryPrimitives.ReadUInt64BigEndian(serverCookie.Slice(MacOffset, MacLen)); - // Constant-time compare without allocating: // compare tags by bytes, not by ulong equality (avoids timing artifacts) Span expectedBytes = stackalloc byte[8]; From 1eec19276b7c7823b2cca08dc0b48bbd98bd8748 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 20 Feb 2026 14:08:46 +0200 Subject: [PATCH 31/37] Added null checks --- DnsServerCore/Dns/Security/DnsCookieSecretManager.cs | 6 +++--- DnsServerCore/Dns/Security/DnsCookieValidator.cs | 11 ++++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs index 5974254a2..601c22344 100644 --- a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs +++ b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs @@ -183,7 +183,7 @@ public void Rotate() { Snapshot currentSnapshot = Volatile.Read(ref _snapshot); - byte[] previous = currentSnapshot is null ? null : currentSnapshot.CurrentSecret; + byte[] previous = currentSnapshot?.CurrentSecret; Snapshot nextSnapshot = GenerateNewSnapshot(previous); SaveLocked(nextSnapshot); @@ -195,13 +195,13 @@ public void Rotate() public byte[] GetCurrentSecret() { Snapshot snapshot = Volatile.Read(ref _snapshot); - return snapshot is null ? null : snapshot.CurrentSecret; + return snapshot?.CurrentSecret; } public byte[] GetPreviousSecret() { Snapshot snapshot = Volatile.Read(ref _snapshot); - return snapshot is null ? null : snapshot.PreviousSecret; + return snapshot?.PreviousSecret; } #endregion diff --git a/DnsServerCore/Dns/Security/DnsCookieValidator.cs b/DnsServerCore/Dns/Security/DnsCookieValidator.cs index 5f6a66707..33fba1865 100644 --- a/DnsServerCore/Dns/Security/DnsCookieValidator.cs +++ b/DnsServerCore/Dns/Security/DnsCookieValidator.cs @@ -253,13 +253,14 @@ public bool Validate(IPAddress clientAddress, EDnsCookieOptionData cookie) if (cookie.ClientCookie.Length != ClientCookieLen) return false; - ReadOnlySpan currentSecret = _secretManager.GetCurrentSecret(); - if (!currentSecret.IsEmpty && + byte[] currentSecret = _secretManager.GetCurrentSecret(); + + if (currentSecret != null && currentSecret.Length > 0 && ValidateServerCookieWithSecret(clientAddress, cookie.ClientCookie, cookie.ServerCookie, currentSecret)) return true; - ReadOnlySpan previousSecret = _secretManager.GetPreviousSecret(); - if (!previousSecret.IsEmpty && + byte[] previousSecret = _secretManager.GetPreviousSecret(); + if (previousSecret != null && previousSecret.Length > 0 && ValidateServerCookieWithSecret(clientAddress, cookie.ClientCookie, cookie.ServerCookie, previousSecret)) return true; @@ -277,7 +278,7 @@ public EDnsCookieOptionData CreateResponseCookie(IPAddress clientAddress, EDnsCo if (requestCookie.ClientCookie.Length != ClientCookieLen) throw new ArgumentException($"Client cookie must be {ClientCookieLen} bytes.", nameof(requestCookie)); - ReadOnlySpan currentSecret = _secretManager.GetCurrentSecret(); + byte[] currentSecret = _secretManager.GetCurrentSecret(); ValidateSecret(currentSecret); byte[] serverCookie = ComputeServerCookie(clientAddress, requestCookie.ClientCookie, currentSecret); From 5bccec5799ee1733c05d8c419a9a731c94a5a5d9 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 20 Feb 2026 14:11:35 +0200 Subject: [PATCH 32/37] Used ArgumentNullException.ThrowIfNull where applicable --- DnsServerCore/Dns/Security/DnsCookieValidator.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/DnsServerCore/Dns/Security/DnsCookieValidator.cs b/DnsServerCore/Dns/Security/DnsCookieValidator.cs index 33fba1865..244008ca4 100644 --- a/DnsServerCore/Dns/Security/DnsCookieValidator.cs +++ b/DnsServerCore/Dns/Security/DnsCookieValidator.cs @@ -72,8 +72,7 @@ public DnsCookieValidator(DnsCookieSecretManager secretManager) private static IPAddress CanonicalizeClientAddress(IPAddress clientAddress) { - if (clientAddress is null) - throw new ArgumentNullException(nameof(clientAddress)); + ArgumentNullException.ThrowIfNull(clientAddress); if (clientAddress.AddressFamily != AddressFamily.InterNetwork && clientAddress.AddressFamily != AddressFamily.InterNetworkV6) @@ -269,8 +268,7 @@ public bool Validate(IPAddress clientAddress, EDnsCookieOptionData cookie) public EDnsCookieOptionData CreateResponseCookie(IPAddress clientAddress, EDnsCookieOptionData requestCookie) { - if (clientAddress is null) - throw new ArgumentNullException(nameof(clientAddress)); + ArgumentNullException.ThrowIfNull(clientAddress); if (requestCookie is null || requestCookie.ClientCookie.IsEmpty) throw new ArgumentException("Request cookie must include a client cookie.", nameof(requestCookie)); From 448bd5d5bdb4064429d0ac7f00633598ee9f01b5 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 20 Feb 2026 15:06:01 +0200 Subject: [PATCH 33/37] Formatting --- DnsServerCore/Dns/DnsServer.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index 6183530a1..9301f1dd9 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -1631,7 +1631,6 @@ private void InitDnsCookies() dueTime: TimeSpan.FromMinutes(5), period: TimeSpan.FromHours(_dnsCookiesRotationPeriodHours)); } - } } @@ -2908,7 +2907,7 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo else { // CC+SC present - // v1 requires totalLen == 24 (CC 8 + SC 16). Anything else is “not a valid server requestCookie”. + // v1 requires totalLen == 24 (CC 8 + SC 16). Anything else is “not a valid server cookie”. bool looksLikeV1 = requestCookie.ServerCookie[0] == 1; if (looksLikeV1 && scLen != 16) { From 4a5750ea2bbc3e6284a40aed61776179795bb9ab Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 24 Apr 2026 21:33:31 +0300 Subject: [PATCH 34/37] Hardened RFC7873/9018 COOKIE handling (robust parse/rebuild, reliable response attach, malformed FORMERR, abuse throttling, and runtime toggle) --- DnsServerCore/Dns/DnsServer.cs | 457 ++++++++++++++++-- .../Dns/Security/DnsCookieValidator.cs | 27 +- DnsServerCore/WebServiceSettingsApi.cs | 10 + DnsServerCore/www/index.html | 14 + DnsServerCore/www/js/main.js | 23 +- 5 files changed, 474 insertions(+), 57 deletions(-) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index 9301f1dd9..f4a340736 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -186,6 +186,8 @@ enum ServiceState bool _enableDnsOverHttps; bool _enableDnsOverHttp3; bool _enableDnsOverQuic; + bool _useDnsCookies = true; + IReadOnlyCollection _reverseProxyNetworkACL; int _dnsOverUdpProxyPort = 538; int _dnsOverTcpProxyPort = 538; int _dnsOverHttpPort = 80; @@ -286,6 +288,17 @@ enum ServiceState long _cookieMissing; long _cookieBadcookieSent; long _cookieClientOnly; + long _cookieInvalidDropped; + + // Hard-coded initial policy for invalid DNS cookie abuse handling. + // Iteration 2 can make these values configurable. + const int COOKIE_FAILURE_WINDOW_SECONDS = 60; + const int COOKIE_FAILURE_SOFT_THRESHOLD = 20; + const int COOKIE_FAILURE_HARD_THRESHOLD = 100; + const int COOKIE_FAILURE_RETENTION_SECONDS = 300; + + readonly Dictionary _cookieFailureByClient = new(); + readonly object _cookieFailureLock = new(); #endregion @@ -1146,6 +1159,18 @@ private void ReadConfigFrom(Stream s, bool isConfigTransfer) if (!isConfigTransfer) _statsManager.MaxStatFileDays = maxStatFileDays; + if (s.Position < s.Length) + { + // Backward-compatible read: older config files won't have this trailing flag. + bool useDnsCookies = bR.ReadBoolean(); + if (!isConfigTransfer) + _useDnsCookies = useDnsCookies; + } + else if (!isConfigTransfer) + { + _useDnsCookies = true; + } + if (!isConfigTransfer) { _cookieRotationTimer?.Dispose(); @@ -1437,6 +1462,7 @@ private void WriteConfigTo(Stream s) bW.Write(_queryLog is not null); //log all queries bW.Write(_statsManager.EnableInMemoryStats); bW.Write(_statsManager.MaxStatFileDays); + bW.Write(_useDnsCookies); } #endregion @@ -1611,6 +1637,20 @@ private void InitDnsCookies() { lock (_saveLock) { + if (!_useDnsCookies) + { + // Operational compatibility switch: when disabled, RFC 7873/9018 cookie handling is bypassed. + _cookieRotationTimer?.Dispose(); + _cookieRotationTimer = null; + _cookieSecrets = null; + _cookieValidator = null; + + lock (_cookieFailureLock) + _cookieFailureByClient.Clear(); + + return; + } + string secretPath = Path.IsPathRooted(_dnsCookiesSecretFile) ? _dnsCookiesSecretFile : Path.Combine(_configFolder, _dnsCookiesSecretFile); @@ -1634,7 +1674,199 @@ private void InitDnsCookies() } } - private static EDnsCookieOptionData TryGetCookieOption(DnsDatagram request) + private sealed class CookieOptionData + { + public byte[] ClientCookie { get; } + public byte[] ServerCookie { get; } + public Type RawOptionDataType { get; } + + public CookieOptionData(byte[] clientCookie, byte[] serverCookie, Type rawOptionDataType) + { + ClientCookie = clientCookie ?? Array.Empty(); + ServerCookie = serverCookie ?? Array.Empty(); + RawOptionDataType = rawOptionDataType; + } + } + + private static bool TryReadCookiePart(object value, out byte[] bytes) + { + switch (value) + { + case byte[] b: + bytes = b; + return true; + + case ReadOnlyMemory rom: + bytes = rom.ToArray(); + return true; + + case Memory mem: + bytes = mem.ToArray(); + return true; + + case ArraySegment seg: + bytes = seg.Array is null ? Array.Empty() : seg.AsSpan().ToArray(); + return true; + + case IEnumerable enumerable: + bytes = enumerable is byte[] arr ? arr : new List(enumerable).ToArray(); + return true; + + case null: + bytes = null; + return false; + + default: + Type valueType = value.GetType(); + + System.Reflection.MethodInfo toArrayMethod = valueType.GetMethod("ToArray", Type.EmptyTypes); + if (toArrayMethod?.ReturnType == typeof(byte[])) + { + bytes = (byte[])toArrayMethod.Invoke(value, null); + return true; + } + + foreach (string methodName in new[] { "GetBytes", "AsBytes" }) + { + System.Reflection.MethodInfo m = valueType.GetMethod(methodName, Type.EmptyTypes); + if (m?.ReturnType == typeof(byte[])) + { + bytes = (byte[])m.Invoke(value, null); + return true; + } + } + + foreach (string propertyName in new[] { "Bytes", "Byte", "Buffer", "Data", "Value" }) + { + System.Reflection.PropertyInfo p = valueType.GetProperty(propertyName); + if ((p is null) || !p.CanRead) + continue; + + if (TryReadCookiePart(p.GetValue(value), out bytes)) + return true; + } + + System.Reflection.PropertyInfo lengthProp = valueType.GetProperty("Length") ?? valueType.GetProperty("Count"); + System.Reflection.PropertyInfo indexer = valueType.GetProperty("Item", [typeof(int)]); + if ((lengthProp is not null) && (indexer is not null)) + { + object lenObj = lengthProp.GetValue(value); + if ((lenObj is int len) && (len >= 0) && (len <= 4096)) + { + byte[] tmp = new byte[len]; + for (int i = 0; i < len; i++) + { + object item = indexer.GetValue(value, [i]); + if (item is byte b) + { + tmp[i] = b; + } + else + { + bytes = null; + return false; + } + } + + bytes = tmp; + return true; + } + } + + bytes = null; + return false; + } + } + + private static bool HasCookieOption(DnsDatagram request) + { + if (request.EDNS is null) + return false; + + foreach (EDnsOption opt in request.EDNS.Options) + { + if (opt.Code == EDnsOptionCode.COOKIE) + return true; + } + + return false; + } + + private static object TryCreateCookieOptionDataInstance(Type dataType, params object[] args) + { + try + { + return Activator.CreateInstance( + dataType, + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic, + binder: null, + args: args, + culture: null) as EDnsOptionData; + } + catch + { + return null; + } + } + + private static object CreateCookieOptionData(Type dataType, byte[] clientCookie, byte[] serverCookie) + { + if (dataType is null) + return null; + + byte[] rawCookieData = new byte[clientCookie.Length + serverCookie.Length]; + Buffer.BlockCopy(clientCookie, 0, rawCookieData, 0, clientCookie.Length); + Buffer.BlockCopy(serverCookie, 0, rawCookieData, clientCookie.Length, serverCookie.Length); + + // Keep this path intentionally small and deterministic; only try the commonly used constructor shapes. + return + TryCreateCookieOptionDataInstance(dataType, clientCookie, serverCookie) ?? + TryCreateCookieOptionDataInstance(dataType, new ReadOnlyMemory(clientCookie), new ReadOnlyMemory(serverCookie)) ?? + TryCreateCookieOptionDataInstance(dataType, rawCookieData) ?? + TryCreateCookieOptionDataInstance(dataType, new ReadOnlyMemory(rawCookieData)); + } + + private static EDnsOption TryCreateCookieOption(object cookieData, byte[] rawCookieData) + { + if (cookieData is EDnsOptionData typedCookie) + return new EDnsOption(EDnsOptionCode.COOKIE, typedCookie); + + foreach (object payload in new object[] { cookieData, rawCookieData, rawCookieData is null ? null : new ReadOnlyMemory(rawCookieData) }) + { + if (payload is null) + continue; + + try + { + EDnsOption option = Activator.CreateInstance( + typeof(EDnsOption), + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic, + binder: null, + args: new object[] { EDnsOptionCode.COOKIE, payload }, + culture: null) as EDnsOption; + if (option is not null) + return option; + } + catch { } + + try + { + EDnsOption option = Activator.CreateInstance( + typeof(EDnsOption), + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic, + binder: null, + args: new object[] { (ushort)EDnsOptionCode.COOKIE, payload }, + culture: null) as EDnsOption; + if (option is not null) + return option; + } + catch { } + } + + return null; + } + + private static CookieOptionData TryGetCookieOption(DnsDatagram request) { DnsDatagramEdns edns = request.EDNS; if (edns is null) @@ -1642,8 +1874,36 @@ private static EDnsCookieOptionData TryGetCookieOption(DnsDatagram request) foreach (EDnsOption opt in edns.Options) { - if (opt.Code == EDnsOptionCode.COOKIE && opt.Data is EDnsCookieOptionData c) - return c; + if (opt.Code != EDnsOptionCode.COOKIE || opt.Data is null) + continue; + + if (TryReadCookiePart(opt.Data, out byte[] rawCookieData)) + { + if (rawCookieData.Length < 8) + { + // Return short raw cookie as-is so downstream RFC length checks can emit FORMERR. + return new CookieOptionData(rawCookieData, Array.Empty(), opt.Data.GetType()); + } + + byte[] parsedClientCookie = rawCookieData.AsSpan(0, 8).ToArray(); + byte[] parsedServerCookie = rawCookieData.AsSpan(8).ToArray(); + return new CookieOptionData(parsedClientCookie, parsedServerCookie, opt.Data.GetType()); + } + + Type dataType = opt.Data.GetType(); + System.Reflection.PropertyInfo clientProp = dataType.GetProperty("ClientCookie"); + System.Reflection.PropertyInfo serverProp = dataType.GetProperty("ServerCookie"); + + if ((clientProp is null) || (serverProp is null)) + continue; + + if (!TryReadCookiePart(clientProp.GetValue(opt.Data), out byte[] clientCookie)) + continue; + + if (!TryReadCookiePart(serverProp.GetValue(opt.Data), out byte[] serverCookie)) + continue; + + return new CookieOptionData(clientCookie, serverCookie, opt.Data.GetType()); } return null; @@ -1653,10 +1913,11 @@ private DnsDatagram BuildBadCookieResponse( DnsDatagram request, IPEndPoint remoteEP, bool isRecursionAllowed, - EDnsCookieOptionData responseCookie) + object responseCookie, + byte[] responseCookieRawData) { IReadOnlyList options = - MergeCookieOption(request.EDNS?.Options, responseCookie); + MergeCookieOption(request.EDNS?.Options, responseCookie, responseCookieRawData); ushort udpPayload = request.EDNS?.UdpPayloadSize ?? 512; EDnsHeaderFlags flags = request.EDNS?.Flags ?? EDnsHeaderFlags.None; @@ -1687,13 +1948,14 @@ private DnsDatagram BuildBadCookieResponse( private static IReadOnlyList MergeCookieOption( IReadOnlyList existing, - EDnsCookieOptionData cookie) + object cookieData, + byte[] cookieRawData) { List list; if (existing == null) { - list = new List(1); + list = new List(cookieData is null && cookieRawData is null ? 0 : 1); } else { @@ -1705,7 +1967,9 @@ private static IReadOnlyList MergeCookieOption( } } - list.Add(new EDnsOption(EDnsOptionCode.COOKIE, cookie)); + EDnsOption newCookie = TryCreateCookieOption(cookieData, cookieRawData); + if (newCookie is not null) + list.Add(newCookie); return list; } @@ -1716,7 +1980,9 @@ private static IReadOnlyList UpsertOptRecord( DnsDatagram response, IReadOnlyList options) { - DnsDatagramEdns baseEdns = response.EDNS ?? request.EDNS; + // Build downstream OPT from request EDNS first so advertised UDP payload reflects + // what the client can receive (do not up-advertise from upstream recursive response EDNS). + DnsDatagramEdns baseEdns = request.EDNS ?? response.EDNS; ushort udp = baseEdns?.UdpPayloadSize ?? 512; EDnsHeaderFlags flags = baseEdns?.Flags ?? EDnsHeaderFlags.None; @@ -1741,6 +2007,85 @@ private static IReadOnlyList UpsertOptRecord( return list; } + private bool ShouldDropInvalidCookie(IPAddress clientAddress, out int failureCount) + { + long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + lock (_cookieFailureLock) + { + if (!_cookieFailureByClient.TryGetValue(clientAddress, out (long WindowStart, int Count) state) || ((now - state.WindowStart) >= COOKIE_FAILURE_WINDOW_SECONDS)) + { + state = (now, 0); + } + + failureCount = state.Count + 1; + _cookieFailureByClient[clientAddress] = (state.WindowStart, failureCount); + + if ((_cookieFailureByClient.Count > 4096) || ((now % 31) == 0)) + { + // Keep bounded in-memory state so abusive source churn cannot grow memory unbounded. + long staleBefore = now - COOKIE_FAILURE_RETENTION_SECONDS; + List staleKeys = new List(); + foreach (KeyValuePair item in _cookieFailureByClient) + { + if (item.Value.WindowStart < staleBefore) + staleKeys.Add(item.Key); + } + + foreach (IPAddress staleKey in staleKeys) + { + _cookieFailureByClient.Remove(staleKey); + } + } + } + + return failureCount > COOKIE_FAILURE_HARD_THRESHOLD; + } + + private DnsDatagram HandleInvalidCookieRequest( + DnsDatagram request, + IPEndPoint remoteEP, + bool isRecursionAllowed, + CookieOptionData requestCookie, + string reason) + { + Interlocked.Increment(ref _cookieInvalid); + + bool drop = ShouldDropInvalidCookie(remoteEP.Address, out int failuresInWindow); + + if (failuresInWindow == COOKIE_FAILURE_SOFT_THRESHOLD) + { + _log.Write($"Client '{remoteEP.Address}' hit DNS cookie invalid soft threshold ({COOKIE_FAILURE_SOFT_THRESHOLD}/{COOKIE_FAILURE_WINDOW_SECONDS}s)."); + } + + if (drop) + { + Interlocked.Increment(ref _cookieInvalidDropped); + + if (failuresInWindow == (COOKIE_FAILURE_HARD_THRESHOLD + 1)) + { + _log.Write($"Client '{remoteEP.Address}' exceeded DNS cookie invalid hard threshold ({COOKIE_FAILURE_HARD_THRESHOLD}/{COOKIE_FAILURE_WINDOW_SECONDS}s); dropping invalid-cookie requests."); + } + + return null; // drop abusive invalid-cookie traffic; caller treats null as "no response" + } + + byte[] serverCookie = _cookieValidator.CreateResponseCookie(remoteEP.Address, requestCookie.ClientCookie); + object responseCookie = CreateCookieOptionData(requestCookie.RawOptionDataType, requestCookie.ClientCookie, serverCookie); + byte[] responseCookieRawData = new byte[requestCookie.ClientCookie.Length + serverCookie.Length]; + Buffer.BlockCopy(requestCookie.ClientCookie, 0, responseCookieRawData, 0, requestCookie.ClientCookie.Length); + Buffer.BlockCopy(serverCookie, 0, responseCookieRawData, requestCookie.ClientCookie.Length, serverCookie.Length); + + Interlocked.Increment(ref _cookieBadcookieSent); + + if (failuresInWindow == COOKIE_FAILURE_SOFT_THRESHOLD) + { + _log.Write($"Returning BADCOOKIE to '{remoteEP.Address}' for reason='{reason}'."); + } + + return BuildBadCookieResponse(request, remoteEP, isRecursionAllowed, responseCookie, responseCookieRawData); + } + #endregion #region private @@ -2848,8 +3193,8 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo } // DNS Cookies (RFC 7873 / RFC 9018 v1) - EDnsCookieOptionData requestCookie = null; - EDnsCookieOptionData responseCookie; + CookieOptionData requestCookie = null; + object responseCookie; if (protocol == DnsTransportProtocol.Udp && request.EDNS != null && _cookieValidator != null) @@ -2911,44 +3256,34 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo bool looksLikeV1 = requestCookie.ServerCookie[0] == 1; if (looksLikeV1 && scLen != 16) { - Interlocked.Increment(ref _cookieInvalid); - - responseCookie = - _cookieValidator.CreateResponseCookie(remoteEP.Address, requestCookie); - - Interlocked.Increment(ref _cookieBadcookieSent); - - return BuildBadCookieResponse(request, remoteEP, isRecursionAllowed, responseCookie); + return HandleInvalidCookieRequest( + request, + remoteEP, + isRecursionAllowed, + requestCookie, + "v1-length"); } // If non-v1 versions are unsupported, you can also BADCOOKIE them: if (!looksLikeV1) { - Interlocked.Increment(ref _cookieInvalid); - - responseCookie = - _cookieValidator.CreateResponseCookie(remoteEP.Address, requestCookie); - - Interlocked.Increment(ref _cookieBadcookieSent); - - return BuildBadCookieResponse(request, remoteEP, isRecursionAllowed, responseCookie); + return HandleInvalidCookieRequest( + request, + remoteEP, + isRecursionAllowed, + requestCookie, + "unsupported-version"); } // v1: validate cryptographically - if (!_cookieValidator.Validate(remoteEP.Address, requestCookie)) + if (!_cookieValidator.Validate(remoteEP.Address, requestCookie.ClientCookie, requestCookie.ServerCookie)) { - Interlocked.Increment(ref _cookieInvalid); - - responseCookie = - _cookieValidator.CreateResponseCookie(remoteEP.Address, requestCookie); - - Interlocked.Increment(ref _cookieBadcookieSent); - - return BuildBadCookieResponse( + return HandleInvalidCookieRequest( request, remoteEP, isRecursionAllowed, - responseCookie); + requestCookie, + "crypto-validate"); } Interlocked.Increment(ref _cookieValid); @@ -2965,11 +3300,26 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo { if (requestCookie != null && requestCookie.ClientCookie.Length == 8) { - responseCookie = - _cookieValidator.CreateResponseCookie(remoteEP.Address, requestCookie); + byte[] serverCookie = _cookieValidator.CreateResponseCookie(remoteEP.Address, requestCookie.ClientCookie); + responseCookie = CreateCookieOptionData(requestCookie.RawOptionDataType, requestCookie.ClientCookie, serverCookie); + byte[] responseCookieRawData = new byte[requestCookie.ClientCookie.Length + serverCookie.Length]; + Buffer.BlockCopy(requestCookie.ClientCookie, 0, responseCookieRawData, 0, requestCookie.ClientCookie.Length); + Buffer.BlockCopy(serverCookie, 0, responseCookieRawData, requestCookie.ClientCookie.Length, serverCookie.Length); IReadOnlyList mergedOptions = - MergeCookieOption(response.EDNS?.Options, responseCookie); + MergeCookieOption(response.EDNS?.Options ?? request.EDNS?.Options, responseCookie, responseCookieRawData); + + response = response.Clone( + additional: UpsertOptRecord( + response.Additional, + request, + response, + mergedOptions)); + } + else if (requestCookie is null && HasCookieOption(request)) + { + IReadOnlyList mergedOptions = + MergeCookieOption(response.EDNS?.Options ?? request.EDNS?.Options, null, null); response = response.Clone( additional: UpsertOptRecord( @@ -7866,6 +8216,33 @@ public bool EnableDnsOverQuic set { _enableDnsOverQuic = value; } } + public bool UseDnsCookies + { + get { return _useDnsCookies; } + set + { + if (_useDnsCookies == value) + return; + + _useDnsCookies = value; + InitDnsCookies(); + } + } + + public IReadOnlyCollection ReverseProxyNetworkACL + { + get { return _reverseProxyNetworkACL; } + set + { + if ((value is null) || (value.Count == 0)) + _reverseProxyNetworkACL = null; + else if (value.Count > byte.MaxValue) + throw new ArgumentOutOfRangeException(nameof(ReverseProxyNetworkACL), "Network Access Control List cannot have more than 255 entries."); + else + _reverseProxyNetworkACL = value; + } + } + public int DnsOverUdpProxyPort { get { return _dnsOverUdpProxyPort; } diff --git a/DnsServerCore/Dns/Security/DnsCookieValidator.cs b/DnsServerCore/Dns/Security/DnsCookieValidator.cs index 244008ca4..7f183312f 100644 --- a/DnsServerCore/Dns/Security/DnsCookieValidator.cs +++ b/DnsServerCore/Dns/Security/DnsCookieValidator.cs @@ -22,8 +22,6 @@ You should have received a copy of the GNU General Public License using System.Net; using System.Net.Sockets; using System.Security.Cryptography; -using TechnitiumLibrary.Net.Dns.EDnsOptions; - namespace DnsServerCore.Dns.Security { public sealed class DnsCookieValidator @@ -240,47 +238,46 @@ private static bool ValidateServerCookieWithSecret( #region public - public bool Validate(IPAddress clientAddress, EDnsCookieOptionData cookie) + public bool Validate(IPAddress clientAddress, ReadOnlySpan clientCookie, ReadOnlySpan serverCookie) { - if (clientAddress is null || cookie is null) + if (clientAddress is null) return false; // This validator is specifically for validating presence of BOTH CC and SC. - if (cookie.ClientCookie.IsEmpty || cookie.ServerCookie.IsEmpty) + if (clientCookie.IsEmpty || serverCookie.IsEmpty) return false; - if (cookie.ClientCookie.Length != ClientCookieLen) + if (clientCookie.Length != ClientCookieLen) return false; byte[] currentSecret = _secretManager.GetCurrentSecret(); if (currentSecret != null && currentSecret.Length > 0 && - ValidateServerCookieWithSecret(clientAddress, cookie.ClientCookie, cookie.ServerCookie, currentSecret)) + ValidateServerCookieWithSecret(clientAddress, clientCookie, serverCookie, currentSecret)) return true; byte[] previousSecret = _secretManager.GetPreviousSecret(); if (previousSecret != null && previousSecret.Length > 0 && - ValidateServerCookieWithSecret(clientAddress, cookie.ClientCookie, cookie.ServerCookie, previousSecret)) + ValidateServerCookieWithSecret(clientAddress, clientCookie, serverCookie, previousSecret)) return true; return false; } - public EDnsCookieOptionData CreateResponseCookie(IPAddress clientAddress, EDnsCookieOptionData requestCookie) + public byte[] CreateResponseCookie(IPAddress clientAddress, ReadOnlySpan clientCookie) { ArgumentNullException.ThrowIfNull(clientAddress); - if (requestCookie is null || requestCookie.ClientCookie.IsEmpty) - throw new ArgumentException("Request cookie must include a client cookie.", nameof(requestCookie)); + if (clientCookie.IsEmpty) + throw new ArgumentException("Request cookie must include a client cookie.", nameof(clientCookie)); - if (requestCookie.ClientCookie.Length != ClientCookieLen) - throw new ArgumentException($"Client cookie must be {ClientCookieLen} bytes.", nameof(requestCookie)); + if (clientCookie.Length != ClientCookieLen) + throw new ArgumentException($"Client cookie must be {ClientCookieLen} bytes.", nameof(clientCookie)); byte[] currentSecret = _secretManager.GetCurrentSecret(); ValidateSecret(currentSecret); - byte[] serverCookie = ComputeServerCookie(clientAddress, requestCookie.ClientCookie, currentSecret); - return new EDnsCookieOptionData(requestCookie.ClientCookie.ToArray(), serverCookie); + return ComputeServerCookie(clientAddress, clientCookie, currentSecret); } #endregion diff --git a/DnsServerCore/WebServiceSettingsApi.cs b/DnsServerCore/WebServiceSettingsApi.cs index 5f0786fa9..7d2e719e4 100644 --- a/DnsServerCore/WebServiceSettingsApi.cs +++ b/DnsServerCore/WebServiceSettingsApi.cs @@ -224,6 +224,7 @@ private void WriteDnsSettings(Utf8JsonWriter jsonWriter) jsonWriter.WriteBoolean("enableDnsOverHttps", _dnsWebService._dnsServer.EnableDnsOverHttps); jsonWriter.WriteBoolean("enableDnsOverHttp3", _dnsWebService._dnsServer.EnableDnsOverHttp3); jsonWriter.WriteBoolean("enableDnsOverQuic", _dnsWebService._dnsServer.EnableDnsOverQuic); + jsonWriter.WriteBoolean("useDnsCookies", _dnsWebService._dnsServer.UseDnsCookies); jsonWriter.WriteNumber("dnsOverUdpProxyPort", _dnsWebService._dnsServer.DnsOverUdpProxyPort); jsonWriter.WriteNumber("dnsOverTcpProxyPort", _dnsWebService._dnsServer.DnsOverTcpProxyPort); jsonWriter.WriteNumber("dnsOverHttpPort", _dnsWebService._dnsServer.DnsOverHttpPort); @@ -1064,6 +1065,15 @@ public async Task SetDnsSettingsAsync(HttpContext context) } } + if (request.TryGetQueryOrForm("useDnsCookies", bool.Parse, out bool useDnsCookies)) + { + if (_dnsWebService._dnsServer.UseDnsCookies != useDnsCookies) + { + _dnsWebService._dnsServer.UseDnsCookies = useDnsCookies; + restartDnsService = true; + } + } + if (request.TryGetQueryOrForm("dnsOverUdpProxyPort", int.Parse, out int dnsOverUdpProxyPort)) { if (_dnsWebService._dnsServer.DnsOverUdpProxyPort != dnsOverUdpProxyPort) diff --git a/DnsServerCore/www/index.html b/DnsServerCore/www/index.html index c42237b83..7418b2a27 100644 --- a/DnsServerCore/www/index.html +++ b/DnsServerCore/www/index.html @@ -1149,6 +1149,20 @@

Note! Enabling UDP socket pool provides port randomization for all outbound DNS-over-UDP requests to mitigate spoofing attacks. It is recommended to enable UDP socket pool on Windows platform. On Linux, ports are fairly random and thus socket pool may be enabled if more randomization is desired. The DNS Server can detect DNS spoofing attack attempts based on ID mismatch and switch to TCP protocol automatically.
+
+
+ +
+
+ +
+
+
+
Enable this option to use DNS Cookies for UDP queries. Disable this option to support older DNS clients that may not interoperate well with DNS Cookie behavior.
+
+
diff --git a/DnsServerCore/www/js/main.js b/DnsServerCore/www/js/main.js index a8b65bfa5..e83c841b9 100644 --- a/DnsServerCore/www/js/main.js +++ b/DnsServerCore/www/js/main.js @@ -1184,6 +1184,7 @@ function loadDnsSettings(responseJSON) { $("#chkEnableDnsOverHttp3").prop("disabled", !responseJSON.response.enableDnsOverHttps); $("#chkEnableDnsOverHttp3").prop("checked", responseJSON.response.enableDnsOverHttp3); $("#chkEnableDnsOverQuic").prop("checked", responseJSON.response.enableDnsOverQuic); + $("#chkUseDnsCookies").prop("checked", responseJSON.response.useDnsCookies); $("#txtDnsOverUdpProxyPort").prop("disabled", !responseJSON.response.enableDnsOverUdpProxy); $("#txtDnsOverTcpProxyPort").prop("disabled", !responseJSON.response.enableDnsOverTcpProxy); @@ -1731,6 +1732,7 @@ function saveDnsSettings(objBtn) { var enableDnsOverHttps = $("#chkEnableDnsOverHttps").prop("checked"); var enableDnsOverHttp3 = $("#chkEnableDnsOverHttp3").prop("checked"); var enableDnsOverQuic = $("#chkEnableDnsOverQuic").prop("checked"); + var useDnsCookies = $("#chkUseDnsCookies").prop("checked"); var dnsOverUdpProxyPort = $("#txtDnsOverUdpProxyPort").val(); if ((dnsOverUdpProxyPort == null) || (dnsOverUdpProxyPort === "")) { @@ -1786,8 +1788,25 @@ function saveDnsSettings(objBtn) { var dnsTlsCertificatePath = $("#txtDnsTlsCertificatePath").val(); var dnsTlsCertificatePassword = $("#txtDnsTlsCertificatePassword").val(); - formData += "&enableEDnsClientSubnetSourceAddress=" + enableEDnsClientSubnetSourceAddress + "&enableDnsOverUdpProxy=" + enableDnsOverUdpProxy + "&enableDnsOverTcpProxy=" + enableDnsOverTcpProxy + "&enableDnsOverHttp=" + enableDnsOverHttp + "&enableDnsOverTls=" + enableDnsOverTls + "&enableDnsOverHttps=" + enableDnsOverHttps + "&enableDnsOverHttp3=" + enableDnsOverHttp3 + "&enableDnsOverQuic=" + enableDnsOverQuic + "&dnsOverUdpProxyPort=" + dnsOverUdpProxyPort + "&dnsOverTcpProxyPort=" + dnsOverTcpProxyPort + "&dnsOverHttpPort=" + dnsOverHttpPort + "&dnsOverTlsPort=" + dnsOverTlsPort + "&dnsOverHttpsPort=" + dnsOverHttpsPort + "&dnsOverQuicPort=" + dnsOverQuicPort + "&dnsReverseProxyNetworkACL=" + encodeURIComponent(dnsReverseProxyNetworkACL) + "&dnsOverHttpRealIpHeader=" + encodeURIComponent(dnsOverHttpRealIpHeader) + "&dnsTlsCertificatePath=" + encodeURIComponent(dnsTlsCertificatePath) + "&dnsTlsCertificatePassword=" + encodeURIComponent(dnsTlsCertificatePassword); - } + formData += "&enableEDnsClientSubnetSourceAddress=" + enableEDnsClientSubnetSourceAddress + + "&enableDnsOverUdpProxy=" + enableDnsOverUdpProxy + + "&enableDnsOverTcpProxy=" + enableDnsOverTcpProxy + + "&enableDnsOverHttp=" + enableDnsOverHttp + + "&enableDnsOverTls=" + enableDnsOverTls + + "&enableDnsOverHttps=" + enableDnsOverHttps + + "&enableDnsOverHttp3=" + enableDnsOverHttp3 + + "&enableDnsOverQuic=" + enableDnsOverQuic + + "&useDnsCookies=" + useDnsCookies + // Added from feat branch + "&dnsOverUdpProxyPort=" + dnsOverUdpProxyPort + + "&dnsOverTcpProxyPort=" + dnsOverTcpProxyPort + + "&dnsOverHttpPort=" + dnsOverHttpPort + + "&dnsOverTlsPort=" + dnsOverTlsPort + + "&dnsOverHttpsPort=" + dnsOverHttpsPort + + "&dnsOverQuicPort=" + dnsOverQuicPort + + "&dnsReverseProxyNetworkACL=" + encodeURIComponent(dnsReverseProxyNetworkACL) + // Kept master naming + "&dnsOverHttpRealIpHeader=" + encodeURIComponent(dnsOverHttpRealIpHeader) + + "&dnsTlsCertificatePath=" + encodeURIComponent(dnsTlsCertificatePath) + + "&dnsTlsCertificatePassword=" + encodeURIComponent(dnsTlsCertificatePassword); } //tsig if (includeClusterParameters) { From f052d00bb21de7d320e3688df8be2726c1f3cd0f Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 24 Apr 2026 22:35:56 +0300 Subject: [PATCH 35/37] Implemented DNS Cookie based rate limiter with performance tricks, i.e. drop without responding beyond a threshold --- DnsServerCore/Dns/DnsServer.cs | 171 ++++++++++++++++++++++++++++++++- 1 file changed, 170 insertions(+), 1 deletion(-) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index f4a340736..41e9ff6bd 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -289,16 +289,27 @@ enum ServiceState long _cookieBadcookieSent; long _cookieClientOnly; long _cookieInvalidDropped; + long _cookieRateLimited; // Hard-coded initial policy for invalid DNS cookie abuse handling. // Iteration 2 can make these values configurable. const int COOKIE_FAILURE_WINDOW_SECONDS = 60; const int COOKIE_FAILURE_SOFT_THRESHOLD = 20; + const int COOKIE_FAILURE_BADCOOKIE_SLIP_THRESHOLD = 40; + const int COOKIE_FAILURE_BADCOOKIE_SLIP_FACTOR = 4; // send BADCOOKIE for every Nth request after slip threshold const int COOKIE_FAILURE_HARD_THRESHOLD = 100; const int COOKIE_FAILURE_RETENTION_SECONDS = 300; + const int COOKIE_BOOTSTRAP_WINDOW_SECONDS = 10; + const int COOKIE_BOOTSTRAP_HARD_THRESHOLD = 30; + const int COOKIE_BOOTSTRAP_REFILL_RATE_PER_SECOND = 3; // 30 tokens per 10 seconds + const int COOKIE_BOOTSTRAP_IDLE_EVICT_SECONDS = 120; + const int COOKIE_BOOTSTRAP_BUCKET_COUNT = 16384; // power-of-two for fast masking + const int COOKIE_BOOTSTRAP_MAX_PROBES = 8; readonly Dictionary _cookieFailureByClient = new(); readonly object _cookieFailureLock = new(); + readonly object _cookieBootstrapLock = new(); + readonly CookieBootstrapBucket[] _cookieBootstrapBuckets = new CookieBootstrapBucket[COOKIE_BOOTSTRAP_BUCKET_COUNT]; #endregion @@ -1633,6 +1644,14 @@ private string ConvertToAbsolutePath(string path) #region cookie + private struct CookieBootstrapBucket + { + public ulong ClientHash; + public uint LastRefillSecond; + public uint LastSeenSecond; + public int Tokens; + } + private void InitDnsCookies() { lock (_saveLock) @@ -1647,6 +1666,10 @@ private void InitDnsCookies() lock (_cookieFailureLock) _cookieFailureByClient.Clear(); + lock (_cookieBootstrapLock) + { + Array.Clear(_cookieBootstrapBuckets, 0, _cookieBootstrapBuckets.Length); + } return; } @@ -2039,7 +2062,124 @@ private bool ShouldDropInvalidCookie(IPAddress clientAddress, out int failureCou } } - return failureCount > COOKIE_FAILURE_HARD_THRESHOLD; + if (failureCount > COOKIE_FAILURE_HARD_THRESHOLD) + return true; + + if (failureCount > COOKIE_FAILURE_BADCOOKIE_SLIP_THRESHOLD) + return (failureCount % COOKIE_FAILURE_BADCOOKIE_SLIP_FACTOR) != 0; + + return false; + } + + private static ulong GetCookieBootstrapClientHash(IPAddress clientAddress) + { + Span addressBytes = stackalloc byte[16]; + if (!clientAddress.TryWriteBytes(addressBytes, out int written)) + written = 0; + + int offset = 0; + if (written == 16) + { + bool isV4Mapped = + addressBytes[0] == 0 && addressBytes[1] == 0 && addressBytes[2] == 0 && addressBytes[3] == 0 && + addressBytes[4] == 0 && addressBytes[5] == 0 && addressBytes[6] == 0 && addressBytes[7] == 0 && + addressBytes[8] == 0 && addressBytes[9] == 0 && + addressBytes[10] == 0xff && addressBytes[11] == 0xff; + + if (isV4Mapped) + { + offset = 12; + written = 4; + } + } + + // FNV-1a 64-bit. + ulong hash = 1469598103934665603UL; + for (int i = 0; i < written; i++) + { + hash ^= addressBytes[offset + i]; + hash *= 1099511628211UL; + } + + // Reserve zero as "empty bucket" marker. + return hash == 0 ? 1UL : hash; + } + + private bool ShouldRateLimitCookieBootstrap(IPAddress clientAddress, out int requestCount) + { + uint now = unchecked((uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + ulong clientHash = GetCookieBootstrapClientHash(clientAddress); + int mask = COOKIE_BOOTSTRAP_BUCKET_COUNT - 1; + int start = (int)(clientHash & (uint)mask); + + lock (_cookieBootstrapLock) + { + int selectedIndex = -1; + int staleIndex = -1; + + for (int probe = 0; probe < COOKIE_BOOTSTRAP_MAX_PROBES; probe++) + { + int index = (start + probe) & mask; + ref CookieBootstrapBucket bucket = ref _cookieBootstrapBuckets[index]; + + if (bucket.ClientHash == clientHash) + { + selectedIndex = index; + break; + } + + if (bucket.ClientHash == 0) + { + selectedIndex = index; + break; + } + + if ((now - bucket.LastSeenSecond) >= COOKIE_BOOTSTRAP_IDLE_EVICT_SECONDS && staleIndex < 0) + staleIndex = index; + } + + if (selectedIndex < 0) + selectedIndex = staleIndex; + + if (selectedIndex < 0) + { + requestCount = int.MaxValue; // table pressure fail-closed + return true; + } + + ref CookieBootstrapBucket state = ref _cookieBootstrapBuckets[selectedIndex]; + + if (state.ClientHash != clientHash) + { + state.ClientHash = clientHash; + state.LastRefillSecond = now; + state.LastSeenSecond = now; + state.Tokens = COOKIE_BOOTSTRAP_HARD_THRESHOLD; + } + else + { + uint elapsed = now - state.LastRefillSecond; + if (elapsed > 0) + { + int refill = (int)Math.Min(int.MaxValue, elapsed * (uint)COOKIE_BOOTSTRAP_REFILL_RATE_PER_SECOND); + state.Tokens = Math.Min(COOKIE_BOOTSTRAP_HARD_THRESHOLD, state.Tokens + refill); + state.LastRefillSecond = now; + } + + state.LastSeenSecond = now; + } + + if (state.Tokens <= 0) + { + requestCount = COOKIE_BOOTSTRAP_HARD_THRESHOLD + 1; + return true; + } + + state.Tokens--; + } + + requestCount = 0; + return false; } private DnsDatagram HandleInvalidCookieRequest( @@ -2062,6 +2202,11 @@ private DnsDatagram HandleInvalidCookieRequest( { Interlocked.Increment(ref _cookieInvalidDropped); + if (failuresInWindow == (COOKIE_FAILURE_BADCOOKIE_SLIP_THRESHOLD + 1)) + { + _log.Write($"Client '{remoteEP.Address}' crossed DNS cookie invalid slip threshold ({COOKIE_FAILURE_BADCOOKIE_SLIP_THRESHOLD}/{COOKIE_FAILURE_WINDOW_SECONDS}s); BADCOOKIE responses are now throttled to 1/{COOKIE_FAILURE_BADCOOKIE_SLIP_FACTOR}."); + } + if (failuresInWindow == (COOKIE_FAILURE_HARD_THRESHOLD + 1)) { _log.Write($"Client '{remoteEP.Address}' exceeded DNS cookie invalid hard threshold ({COOKIE_FAILURE_HARD_THRESHOLD}/{COOKIE_FAILURE_WINDOW_SECONDS}s); dropping invalid-cookie requests."); @@ -3204,6 +3349,18 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo if (requestCookie == null) { Interlocked.Increment(ref _cookieMissing); + + if (ShouldRateLimitCookieBootstrap(remoteEP.Address, out int requestsInWindow)) + { + Interlocked.Increment(ref _cookieRateLimited); + + if (requestsInWindow == (COOKIE_BOOTSTRAP_HARD_THRESHOLD + 1)) + { + _log.Write($"Client '{remoteEP.Address}' exceeded DNS cookie bootstrap threshold ({COOKIE_BOOTSTRAP_HARD_THRESHOLD}/{COOKIE_BOOTSTRAP_WINDOW_SECONDS}s); dropping UDP requests without cookie."); + } + + return null; + } } else { @@ -3248,6 +3405,18 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo if (scLen == 0) { Interlocked.Increment(ref _cookieClientOnly); + + if (ShouldRateLimitCookieBootstrap(remoteEP.Address, out int requestsInWindow)) + { + Interlocked.Increment(ref _cookieRateLimited); + + if (requestsInWindow == (COOKIE_BOOTSTRAP_HARD_THRESHOLD + 1)) + { + _log.Write($"Client '{remoteEP.Address}' exceeded DNS cookie bootstrap threshold ({COOKIE_BOOTSTRAP_HARD_THRESHOLD}/{COOKIE_BOOTSTRAP_WINDOW_SECONDS}s); dropping client-cookie-only requests."); + } + + return null; + } } else { From d71e2f810ed9bb288449e7ae55aad8e4d28aea8e Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Mon, 11 May 2026 23:00:07 +0300 Subject: [PATCH 36/37] Fixed conflicts --- DnsServerCore/Dns/DnsServer.cs | 5 +++-- DnsServerCore/www/js/main.js | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index 41e9ff6bd..2ff864e6b 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -301,7 +301,7 @@ enum ServiceState const int COOKIE_FAILURE_RETENTION_SECONDS = 300; const int COOKIE_BOOTSTRAP_WINDOW_SECONDS = 10; const int COOKIE_BOOTSTRAP_HARD_THRESHOLD = 30; - const int COOKIE_BOOTSTRAP_REFILL_RATE_PER_SECOND = 3; // 30 tokens per 10 seconds + const int COOKIE_BOOTSTRAP_REFILL_RATE_PER_SECOND = COOKIE_BOOTSTRAP_HARD_THRESHOLD / COOKIE_BOOTSTRAP_WINDOW_SECONDS; // 30 tokens per 10 seconds const int COOKIE_BOOTSTRAP_IDLE_EVICT_SECONDS = 120; const int COOKIE_BOOTSTRAP_IDLE_EVICT_SECONDS = 120; const int COOKIE_BOOTSTRAP_BUCKET_COUNT = 16384; // power-of-two for fast masking const int COOKIE_BOOTSTRAP_MAX_PROBES = 8; @@ -3340,9 +3340,10 @@ private async Task ProcessRequestAsync(DnsDatagram request, IPEndPo // DNS Cookies (RFC 7873 / RFC 9018 v1) CookieOptionData requestCookie = null; object responseCookie; + var cookieValidator = _cookieValidator; if (protocol == DnsTransportProtocol.Udp && request.EDNS != null && - _cookieValidator != null) + cookieValidator != null) { requestCookie = TryGetCookieOption(request); diff --git a/DnsServerCore/www/js/main.js b/DnsServerCore/www/js/main.js index e83c841b9..e1954d698 100644 --- a/DnsServerCore/www/js/main.js +++ b/DnsServerCore/www/js/main.js @@ -1796,17 +1796,18 @@ function saveDnsSettings(objBtn) { "&enableDnsOverHttps=" + enableDnsOverHttps + "&enableDnsOverHttp3=" + enableDnsOverHttp3 + "&enableDnsOverQuic=" + enableDnsOverQuic + - "&useDnsCookies=" + useDnsCookies + // Added from feat branch + "&useDnsCookies=" + useDnsCookies + "&dnsOverUdpProxyPort=" + dnsOverUdpProxyPort + "&dnsOverTcpProxyPort=" + dnsOverTcpProxyPort + "&dnsOverHttpPort=" + dnsOverHttpPort + "&dnsOverTlsPort=" + dnsOverTlsPort + "&dnsOverHttpsPort=" + dnsOverHttpsPort + "&dnsOverQuicPort=" + dnsOverQuicPort + - "&dnsReverseProxyNetworkACL=" + encodeURIComponent(dnsReverseProxyNetworkACL) + // Kept master naming + "&dnsReverseProxyNetworkACL=" + encodeURIComponent(dnsReverseProxyNetworkACL) + "&dnsOverHttpRealIpHeader=" + encodeURIComponent(dnsOverHttpRealIpHeader) + "&dnsTlsCertificatePath=" + encodeURIComponent(dnsTlsCertificatePath) + - "&dnsTlsCertificatePassword=" + encodeURIComponent(dnsTlsCertificatePassword); } + "&dnsTlsCertificatePassword=" + encodeURIComponent(dnsTlsCertificatePassword); + } //tsig if (includeClusterParameters) { From d68132836a339e82e5989086b14dafef535b58c5 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Mon, 11 May 2026 23:05:03 +0300 Subject: [PATCH 37/37] Hardened file write permission on Unix systems --- .../Dns/Security/DnsCookieSecretManager.cs | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs index 601c22344..ef6796d38 100644 --- a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs +++ b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs @@ -154,13 +154,38 @@ private void SaveLocked(Snapshot snapshot) Directory.CreateDirectory(directory); string tmpPath = _secretFilePath + ".tmp"; - File.WriteAllBytes(tmpPath, ms.ToArray()); + WriteSecretFile(tmpPath, ms.ToArray()); // Atomic replace where supported if (File.Exists(_secretFilePath)) File.Replace(tmpPath, _secretFilePath, destinationBackupFileName: null); else File.Move(tmpPath, _secretFilePath); + + RestrictSecretFilePermissions(_secretFilePath); + } + + private static void WriteSecretFile(string path, byte[] contents) + { + if (OperatingSystem.IsWindows()) + { + File.WriteAllBytes(path, contents); + return; + } + FileStreamOptions options = new FileStreamOptions + { + Mode = FileMode.Create, + Access = FileAccess.Write, + Share = FileShare.None, + UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite + }; + using FileStream stream = new FileStream(path, options); + stream.Write(contents, 0, contents.Length); + } + private static void RestrictSecretFilePermissions(string path) + { + if (!OperatingSystem.IsWindows() && File.Exists(path)) + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); } private Snapshot GenerateNewSnapshot(byte[] previousSecret)