diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index ffbfbf8db..c2fb47521 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -183,6 +183,7 @@ enum ServiceState bool _enableDnsOverHttps; bool _enableDnsOverHttp3; bool _enableDnsOverQuic; + bool _useDnsCookies = true; IReadOnlyCollection _reverseProxyNetworkACL; int _dnsOverUdpProxyPort = 538; int _dnsOverTcpProxyPort = 538; @@ -269,6 +270,43 @@ enum ServiceState readonly Timer _saveTimer; const int SAVE_TIMER_INITIAL_INTERVAL = 5000; + // DNS Cookies (RFC 7873) + readonly string _dnsCookiesSecretFile = "dns.cookies.state"; + readonly int _dnsCookiesRotationPeriodHours = 1; + + Security.DnsCookieSecretManager _cookieSecrets; + Security.DnsCookieValidator _cookieValidator; + Timer _cookieRotationTimer; + + // Optional observability counters + long _cookieValid; + long _cookieInvalid; + long _cookieMissing; + 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 #region constructor @@ -431,6 +469,9 @@ public async ValueTask DisposeAsync() } } + _cookieRotationTimer?.Dispose(); + _cookieRotationTimer = null; + _disposed = true; GC.SuppressFinalize(this); } @@ -566,6 +607,8 @@ public void LoadConfigFile() _statsManager.MaxStatFileDays = 365; SaveConfigFileInternal(); + + InitDnsCookies(); } catch (Exception ex) { @@ -1078,6 +1121,26 @@ private void ReadConfigFrom(Stream s, bool isConfigTransfer) int maxStatFileDays = bR.ReadInt32(); 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(); + _cookieRotationTimer = null; + + InitDnsCookies(); + } } private void WriteConfigTo(Stream s) @@ -1359,6 +1422,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 @@ -1525,6 +1589,596 @@ private string ConvertToAbsolutePath(string path) return Path.Combine(_configFolder, path); } + #endregion + + #region cookie + + private struct CookieBootstrapBucket + { + public ulong ClientHash; + public uint LastRefillSecond; + public uint LastSeenSecond; + public int Tokens; + } + + 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(); + lock (_cookieBootstrapLock) + { + Array.Clear(_cookieBootstrapBuckets, 0, _cookieBootstrapBuckets.Length); + } + + 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 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) + return null; + + foreach (EDnsOption opt in edns.Options) + { + 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; + } + + private DnsDatagram BuildBadCookieResponse( + DnsDatagram request, + IPEndPoint remoteEP, + bool isRecursionAllowed, + object responseCookie, + byte[] responseCookieRawData) + { + IReadOnlyList options = + MergeCookieOption(request.EDNS?.Options, responseCookie, responseCookieRawData); + + 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, + object cookieData, + byte[] cookieRawData) + { + List list; + + if (existing == null) + { + list = new List(cookieData is null && cookieRawData is null ? 0 : 1); + } + else + { + list = new List(existing.Count + 1); + foreach (EDnsOption opt in existing) + { + if (opt.Code != EDnsOptionCode.COOKIE) + list.Add(opt); + } + } + + EDnsOption newCookie = TryCreateCookieOption(cookieData, cookieRawData); + if (newCookie is not null) + list.Add(newCookie); + + return list; + } + + private static IReadOnlyList UpsertOptRecord( + IReadOnlyList existingAdditional, + DnsDatagram request, + DnsDatagram response, + IReadOnlyList options) + { + // 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; + + DnsResourceRecord opt = DnsDatagramEdns.GetOPTFor( + udpPayloadSize: udp, + extendedRCODE: response.RCODE, + version: 0, + flags: flags, + options: options); + + int capacity = (existingAdditional?.Count ?? 0) + 1; + List list = new List(capacity); + foreach (DnsResourceRecord rr in existingAdditional ?? Array.Empty()) + { + if (rr.Type != DnsResourceRecordType.OPT) + list.Add(rr); + } + + list.Add(opt); + + 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); + } + } + } + + 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( + 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_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."); + } + + 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 @@ -2366,7 +3020,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 }; } if (request.IsSigned) @@ -2393,13 +3065,192 @@ 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) + CookieOptionData requestCookie = null; + object responseCookie; + if (protocol == DnsTransportProtocol.Udp && + request.EDNS != null && + _cookieValidator != null) + { + requestCookie = TryGetCookieOption(request); + + 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 + { + // RFC 7873: CC MUST be 8 bytes. Total length MUST be 8 OR 16..40. + int ccLen = requestCookie.ClientCookie.Length; + int scLen = requestCookie.ServerCookie.Length; + 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); + 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 }; + } + + // CC-only: valid request; we'll attach SC to the normal response later (no extra RTT). + 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 + { + // CC+SC present + // 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) + { + return HandleInvalidCookieRequest( + request, + remoteEP, + isRecursionAllowed, + requestCookie, + "v1-length"); + } + + // If non-v1 versions are unsupported, you can also BADCOOKIE them: + if (!looksLikeV1) + { + return HandleInvalidCookieRequest( + request, + remoteEP, + isRecursionAllowed, + requestCookie, + "unsupported-version"); + } + + // v1: validate cryptographically + if (!_cookieValidator.Validate(remoteEP.Address, requestCookie.ClientCookie, requestCookie.ServerCookie)) + { + return HandleInvalidCookieRequest( + request, + remoteEP, + isRecursionAllowed, + requestCookie, + "crypto-validate"); + } + + Interlocked.Increment(ref _cookieValid); + } + } } DnsDatagram response = await ProcessQueryAsync(request, remoteEP, protocol, isRecursionAllowed, false, _clientTimeout, null); if (response is null) return null; + // Attach requestCookie to response if needed + if (protocol == DnsTransportProtocol.Udp && _cookieValidator != null && request.EDNS != null) + { + if (requestCookie != null && requestCookie.ClientCookie.Length == 8) + { + 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 ?? 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( + response.Additional, + request, + response, + mergedOptions)); + } + } + return await PostProcessQueryAsync(request, remoteEP, protocol, response); } @@ -7108,6 +7959,19 @@ 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; } diff --git a/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs new file mode 100644 index 000000000..601c22344 --- /dev/null +++ b/DnsServerCore/Dns/Security/DnsCookieSecretManager.cs @@ -0,0 +1,222 @@ +/* +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; +using System.Threading; + +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; + readonly Lock _lock = new Lock(); + + // Immutable snapshot published atomically for lock-free hot-path reads. + private Snapshot _snapshot; + + #endregion + + #region constructor + + public DnsCookieSecretManager(string secretFilePath) + { + if (string.IsNullOrWhiteSpace(secretFilePath)) + throw new ArgumentException("Secret file path must not be null or empty.", nameof(secretFilePath)); + + _secretFilePath = secretFilePath; + + lock (_lock) + { + Snapshot loaded = LoadLocked(); + if (loaded is null) + loaded = GenerateNewSnapshot(previousSecret: null); + + SaveLocked(loaded); + Volatile.Write(ref _snapshot, loaded); + } + } + + #endregion + + #region private + + private Snapshot LoadLocked() + { + // Caller must hold _lock + if (!File.Exists(_secretFilePath)) + return null; + + 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."); + + DateTime createdUtc = 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) + { + 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)."); + } + + return new Snapshot(current, previous, createdUtc); + } + catch + { + return null; + } + } + + private void SaveLocked(Snapshot snapshot) + { + // Caller must hold _lock + 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(snapshot.CurrentSecretCreatedUtc.Ticks); + + bw.Write(snapshot.CurrentSecret.Length); + bw.Write(snapshot.CurrentSecret); + + if (snapshot.PreviousSecret is { Length: >= MinSecretLen and <= MaxSecretLen }) + { + bw.Write(snapshot.PreviousSecret.Length); + bw.Write(snapshot.PreviousSecret); + } + else + { + bw.Write(0); + } + } + + string directory = Path.GetDirectoryName(_secretFilePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + Directory.CreateDirectory(directory); + + 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) + { + // Caller must hold _lock + byte[] currentSecret = RandomNumberGenerator.GetBytes(DefaultSecretLen); + DateTime createdUtc = DateTime.UtcNow; + + // previousSecret is expected to be immutable once published; we pass it through as-is. + return new Snapshot(currentSecret, previousSecret, createdUtc); + } + + #endregion + + #region public + + public void Rotate() + { + lock (_lock) + { + Snapshot currentSnapshot = Volatile.Read(ref _snapshot); + + byte[] previous = currentSnapshot?.CurrentSecret; + Snapshot nextSnapshot = GenerateNewSnapshot(previous); + + SaveLocked(nextSnapshot); + Volatile.Write(ref _snapshot, nextSnapshot); + } + } + + // Hot path: lock-free, allocation-free. Returned arrays must be treated as read-only by callers. + public byte[] GetCurrentSecret() + { + Snapshot snapshot = Volatile.Read(ref _snapshot); + return snapshot?.CurrentSecret; + } + + public byte[] GetPreviousSecret() + { + Snapshot snapshot = Volatile.Read(ref _snapshot); + return 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 diff --git a/DnsServerCore/Dns/Security/DnsCookieValidator.cs b/DnsServerCore/Dns/Security/DnsCookieValidator.cs new file mode 100644 index 000000000..7f183312f --- /dev/null +++ b/DnsServerCore/Dns/Security/DnsCookieValidator.cs @@ -0,0 +1,361 @@ +/* +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.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +namespace DnsServerCore.Dns.Security +{ + public sealed class DnsCookieValidator + { + #region constants + + // RFC 9018 v1 server cookie structure: Version(1) + Reserved(3) + Timestamp(4) + Hash(8) = 16 bytes + const int ClientCookieLen = 8; + 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 = 8; + const int MacLen = 8; + + // 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; + + #endregion + + #region variables + + readonly DnsCookieSecretManager _secretManager; + + #endregion + + #region constructor + + public DnsCookieValidator(DnsCookieSecretManager secretManager) + { + _secretManager = secretManager ?? throw new ArgumentNullException(nameof(secretManager)); + } + + #endregion + + #region private helpers + + private static IPAddress CanonicalizeClientAddress(IPAddress clientAddress) + { + ArgumentNullException.ThrowIfNull(clientAddress); + + if (clientAddress.AddressFamily != AddressFamily.InterNetwork && + clientAddress.AddressFamily != AddressFamily.InterNetworkV6) + throw new ArgumentException("Client address must be IPv4 or IPv6.", nameof(clientAddress)); + + // Avoid representation-dependent MACs. + if (clientAddress.IsIPv4MappedToIPv6) + return clientAddress.MapToIPv4(); + + return clientAddress; + } + + private static void ValidateSecret(ReadOnlySpan secret) + { + if (secret.IsEmpty) + 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)); + } + + private static byte[] ComputeServerCookie(IPAddress clientAddress, ReadOnlySpan clientCookie, ReadOnlySpan secret) + { + clientAddress = CanonicalizeClientAddress(clientAddress); + ValidateSecret(secret); + + if (clientCookie.Length != ClientCookieLen) + throw new ArgumentException($"Client cookie must be {ClientCookieLen} bytes.", nameof(clientCookie)); + + byte[] cookie = new byte[ServerCookieLen]; + + 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); + + // 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 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)); + + ReadOnlySpan key16 = secret.Slice(0, 16); // acceptable if secret is uniformly random + ulong tag = SipHash24.Compute(key16, input); + + // Store tag in network order for deterministic on-wire representation + BinaryPrimitives.WriteUInt64BigEndian(cookie.AsSpan(MacOffset, MacLen), tag); + + return cookie; + } + + private static bool ValidateServerCookieWithSecret( + IPAddress clientAddress, + ReadOnlySpan clientCookie, + ReadOnlySpan serverCookie, + ReadOnlySpan secret) + { + if (clientAddress is null || secret.IsEmpty) + return false; + + if (secret.Length < MinSecretLen) + return false; + + if (clientCookie.Length != ClientCookieLen) + return false; + + if (serverCookie.Length != ServerCookieLen) + return false; + + // Canonicalize must match ComputeServerCookie policy + if (clientAddress.AddressFamily != AddressFamily.InterNetwork && + clientAddress.AddressFamily != AddressFamily.InterNetworkV6) + return false; + + if (serverCookie[VersionOffset] != 1) + 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()); + + // 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) + Span ip = stackalloc byte[16]; + int ipLen = 0; + + // 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; + ip.Slice(0, ipLen).CopyTo(input.Slice(o, ipLen)); + + ReadOnlySpan key16 = secret.Slice(0, 16); + ulong expectedTag = SipHash24.Compute(key16, input); + + // 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 + + #region public + + public bool Validate(IPAddress clientAddress, ReadOnlySpan clientCookie, ReadOnlySpan serverCookie) + { + if (clientAddress is null) + return false; + + // This validator is specifically for validating presence of BOTH CC and SC. + if (clientCookie.IsEmpty || serverCookie.IsEmpty) + return false; + + if (clientCookie.Length != ClientCookieLen) + return false; + + byte[] currentSecret = _secretManager.GetCurrentSecret(); + + if (currentSecret != null && currentSecret.Length > 0 && + ValidateServerCookieWithSecret(clientAddress, clientCookie, serverCookie, currentSecret)) + return true; + + byte[] previousSecret = _secretManager.GetPreviousSecret(); + if (previousSecret != null && previousSecret.Length > 0 && + ValidateServerCookieWithSecret(clientAddress, clientCookie, serverCookie, previousSecret)) + return true; + + return false; + } + + public byte[] CreateResponseCookie(IPAddress clientAddress, ReadOnlySpan clientCookie) + { + ArgumentNullException.ThrowIfNull(clientAddress); + + if (clientCookie.IsEmpty) + throw new ArgumentException("Request cookie must include a client cookie.", nameof(clientCookie)); + + if (clientCookie.Length != ClientCookieLen) + throw new ArgumentException($"Client cookie must be {ClientCookieLen} bytes.", nameof(clientCookie)); + + byte[] currentSecret = _secretManager.GetCurrentSecret(); + ValidateSecret(currentSecret); + + return ComputeServerCookie(clientAddress, clientCookie, currentSecret); + } + + #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); + } + } + } +} diff --git a/DnsServerCore/WebServiceSettingsApi.cs b/DnsServerCore/WebServiceSettingsApi.cs index 5dbc7cb26..5883b33da 100644 --- a/DnsServerCore/WebServiceSettingsApi.cs +++ b/DnsServerCore/WebServiceSettingsApi.cs @@ -260,6 +260,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); @@ -1063,6 +1064,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 5f5b75e2b..dbbbf81a4 100644 --- a/DnsServerCore/www/index.html +++ b/DnsServerCore/www/index.html @@ -1105,6 +1105,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 8518f1d52..7148dde80 100644 --- a/DnsServerCore/www/js/main.js +++ b/DnsServerCore/www/js/main.js @@ -1121,6 +1121,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); @@ -1644,6 +1645,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 === "")) { @@ -1699,7 +1701,7 @@ function saveDnsSettings(objBtn) { var dnsOverHttpRealIpHeader = $("#txtDnsOverHttpRealIpHeader").val(); - formData += "&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 + "&reverseProxyNetworkACL=" + encodeURIComponent(reverseProxyNetworkACL) + "&dnsTlsCertificatePath=" + encodeURIComponent(dnsTlsCertificatePath) + "&dnsTlsCertificatePassword=" + encodeURIComponent(dnsTlsCertificatePassword) + "&dnsOverHttpRealIpHeader=" + encodeURIComponent(dnsOverHttpRealIpHeader); + formData += "&enableDnsOverUdpProxy=" + enableDnsOverUdpProxy + "&enableDnsOverTcpProxy=" + enableDnsOverTcpProxy + "&enableDnsOverHttp=" + enableDnsOverHttp + "&enableDnsOverTls=" + enableDnsOverTls + "&enableDnsOverHttps=" + enableDnsOverHttps + "&enableDnsOverHttp3=" + enableDnsOverHttp3 + "&enableDnsOverQuic=" + enableDnsOverQuic + "&useDnsCookies=" + useDnsCookies + "&dnsOverUdpProxyPort=" + dnsOverUdpProxyPort + "&dnsOverTcpProxyPort=" + dnsOverTcpProxyPort + "&dnsOverHttpPort=" + dnsOverHttpPort + "&dnsOverTlsPort=" + dnsOverTlsPort + "&dnsOverHttpsPort=" + dnsOverHttpsPort + "&dnsOverQuicPort=" + dnsOverQuicPort + "&reverseProxyNetworkACL=" + encodeURIComponent(reverseProxyNetworkACL) + "&dnsTlsCertificatePath=" + encodeURIComponent(dnsTlsCertificatePath) + "&dnsTlsCertificatePassword=" + encodeURIComponent(dnsTlsCertificatePassword) + "&dnsOverHttpRealIpHeader=" + encodeURIComponent(dnsOverHttpRealIpHeader); } //tsig