Skip to content

Commit 3b7901d

Browse files
committed
Add DoH support, prevent private address leaking
1 parent e4f3a4a commit 3b7901d

File tree

8 files changed

+144
-24
lines changed

8 files changed

+144
-24
lines changed

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Tiny DNS
2+
3+
[![Build](https://github.com/SmartHomeOS/TinyDNS/actions/workflows/dotnet.yml/badge.svg)](https://github.com/SmartHomeOS/TinyDNS/actions/workflows/dotnet.yml)
4+
[![Version](https://img.shields.io/nuget/v/TinyDNS.svg)](https://www.nuget.org/packages/TinyDNS)
5+
6+
A small, fast, modern DNS / MDNS client
7+
8+
### Features:
9+
* Recursive resolution from root hints with no DNS servers configured
10+
* Resolution from OS or DHCP configured DNS servers
11+
* Resolution using common public recursive resolvers (Google, CloudFlare, etc.)
12+
* Support for DoH (DNS over HTTPS) with options for secure or insecure lookup
13+
* Leak protection to ensure sensitive queries are not shared with public DNS servers
14+
* Support for async, zerocopy, spans and all the modern .Net performance features

TinyDNS/DNSResolver.cs

Lines changed: 96 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
// along with this program. If not, see <http://www.gnu.org/licenses/>.
1212

1313
using System.Net;
14+
using System.Net.Http.Headers;
1415
using System.Net.NetworkInformation;
1516
using System.Net.Sockets;
1617
using TinyDNS.Cache;
@@ -24,14 +25,17 @@ public sealed class DNSResolver
2425
public const int PORT = 53;
2526
private readonly HashSet<IPAddress> globalNameservers = [];
2627
private ResolverCache cache = new ResolverCache();
27-
public DNSResolver()
28+
private ResolutionMode resolutionMode;
29+
public DNSResolver(ResolutionMode mode = ResolutionMode.InsecureOnly)
2830
{
31+
this.resolutionMode = mode;
2932
ReloadNameservers();
3033
NetworkChange.NetworkAddressChanged += (s, e) => ReloadNameservers();
3134
}
3235

33-
public DNSResolver(List<IPAddress> nameservers)
36+
public DNSResolver(List<IPAddress> nameservers, ResolutionMode mode = ResolutionMode.InsecureOnly)
3437
{
38+
this.resolutionMode = mode;
3539
foreach (IPAddress nameserver in nameservers)
3640
this.globalNameservers.Add(nameserver);
3741
}
@@ -106,6 +110,7 @@ public async Task<List<IPAddress>> ResolveHostV6(string hostname)
106110
{
107111
byte[] addressBytes = address.GetAddressBytes();
108112
List<string> host;
113+
bool privateQuery = IsPrivate(address, addressBytes);
109114
if (address.AddressFamily == AddressFamily.InterNetwork)
110115
{
111116
host = new List<string>(6);
@@ -121,13 +126,13 @@ public async Task<List<IPAddress>> ResolveHostV6(string hostname)
121126
for (int i = addressBytes.Length - 1; i >= 0; i--)
122127
{
123128
string hex = addressBytes[i].ToString("x2");
124-
host.Add(hex.Substring(1,1));
129+
host.Add(hex.Substring(1, 1));
125130
host.Add(hex.Substring(0, 1));
126131
}
127132
host.Add("IP6");
128133
host.Add("ARPA");
129134
}
130-
Message? response = await ResolveQuery(new QuestionRecord(host, DNSRecordType.PTR, false));
135+
Message? response = await ResolveQuery(new QuestionRecord(host, DNSRecordType.PTR, false), privateQuery);
131136
if (response == null || response.ResponseCode != DNSStatus.OK)
132137
return null;
133138

@@ -141,15 +146,23 @@ public async Task<List<IPAddress>> ResolveHostV6(string hostname)
141146

142147
public async Task<Message?> ResolveQuery(QuestionRecord question)
143148
{
144-
return await ResolveQueryInternal(question, globalNameservers);
149+
bool privateQuery = (question.Name.Last() == "local");
150+
return await ResolveQueryInternal(question, globalNameservers, privateQuery);
145151
}
146152

147-
private async Task<Message?> ResolveQueryInternal(QuestionRecord question, HashSet<IPAddress> nameservers, int recursionCount = 0)
153+
private async Task<Message?> ResolveQuery(QuestionRecord question, bool privateQuery)
148154
{
155+
return await ResolveQueryInternal(question, globalNameservers, privateQuery);
156+
}
157+
158+
private async Task<Message?> ResolveQueryInternal(QuestionRecord question, HashSet<IPAddress> nameservers, bool privateQuery, int recursionCount = 0)
159+
{
160+
//Check for excessive recursion
149161
recursionCount++;
150162
if (recursionCount > 10)
151163
return null;
152164

165+
//Check for cache hits
153166
ResourceRecord[]? cacheHits = cache.Search(question);
154167
if (cacheHits != null && cacheHits.Length > 0)
155168
{
@@ -160,6 +173,7 @@ public async Task<List<IPAddress>> ResolveHostV6(string hostname)
160173
return msg;
161174
}
162175

176+
//Otherwise query the nameserver(s)
163177
Socket? socket = null;
164178
try
165179
{
@@ -170,12 +184,34 @@ public async Task<List<IPAddress>> ResolveHostV6(string hostname)
170184

171185
foreach (IPAddress nsIP in nameservers)
172186
{
187+
//Prevent leaking local domains into the global DNS space
188+
if (privateQuery && IsPrivate(nsIP))
189+
return null;
190+
173191
int bytes;
174192
try
175193
{
176-
int len = query.ToBytes(buffer.Span);
177-
await socket.SendToAsync(buffer.Slice(0, len), SocketFlags.None, new IPEndPoint(nsIP, PORT));
178-
bytes = await socket.ReceiveAsync(buffer, SocketFlags.None, new CancellationTokenSource(3000).Token);
194+
if (resolutionMode == ResolutionMode.InsecureOnly)
195+
bytes = await ResolveUDP(query, buffer, socket, nsIP);
196+
else
197+
{
198+
try
199+
{
200+
bytes = await ResolveHTTPS(query, buffer, nsIP);
201+
}
202+
catch (HttpRequestException)
203+
{
204+
if (resolutionMode == ResolutionMode.SecureOnly)
205+
continue;
206+
bytes = await ResolveUDP(query, buffer, socket, nsIP);
207+
}
208+
catch (OperationCanceledException)
209+
{
210+
if (resolutionMode == ResolutionMode.SecureOnly)
211+
continue;
212+
bytes = await ResolveUDP(query, buffer, socket, nsIP);
213+
}
214+
}
179215
}
180216
catch (SocketException) { continue; }
181217
catch (OperationCanceledException) { continue; }
@@ -192,10 +228,12 @@ public async Task<List<IPAddress>> ResolveHostV6(string hostname)
192228
if (response.ResponseCode != DNSStatus.OK)
193229
continue;
194230

195-
//Check if we have a valid answer
231+
//Add new info to the cache
196232
cache.Store(response.Answers);
197233
cache.Store(response.Authorities);
198234
cache.Store(response.Additionals);
235+
236+
//Check if we have a valid answer
199237
foreach (ResourceRecord answer in response.Answers)
200238
{
201239
if (answer.Type == question.Type)
@@ -213,7 +251,7 @@ public async Task<List<IPAddress>> ResolveHostV6(string hostname)
213251
if (answer is CNameRecord cname)
214252
{
215253
question.Name = cname.CNameLabels;
216-
return await ResolveQueryInternal(question, nameservers, recursionCount);
254+
return await ResolveQueryInternal(question, nameservers, privateQuery, recursionCount);
217255
}
218256
}
219257

@@ -264,7 +302,7 @@ public async Task<List<IPAddress>> ResolveHostV6(string hostname)
264302
}
265303

266304
if (nextNSIPs.Any())
267-
return await ResolveQueryInternal(question, nextNSIPs, recursionCount);
305+
return await ResolveQueryInternal(question, nextNSIPs, privateQuery, recursionCount);
268306
}
269307
}
270308
}
@@ -277,5 +315,51 @@ public async Task<List<IPAddress>> ResolveHostV6(string hostname)
277315
}
278316
return null;
279317
}
318+
319+
private static bool IsPrivate(IPAddress ip, byte[]? addr = null)
320+
{
321+
if (ip.IsIPv6UniqueLocal || ip.IsIPv6SiteLocal || ip.IsIPv6LinkLocal || IPAddress.IsLoopback(ip))
322+
return true;
323+
if (ip.AddressFamily == AddressFamily.InterNetwork)
324+
{
325+
if (addr == null)
326+
addr = ip.GetAddressBytes();
327+
if ((addr[0] == 169 && addr[1] == 254) || (addr[0] == 192 && addr[1] == 168) ||
328+
(addr[0] == 10) || (addr[0] == 172 && (addr[1] & 0xF0) == 0x10))
329+
return true;
330+
}
331+
return false;
332+
}
333+
334+
private async Task<int> ResolveUDP(Message query, Memory<byte> buffer, Socket socket, IPAddress nameserverIP)
335+
{
336+
int len = query.ToBytes(buffer.Span);
337+
await socket.SendToAsync(buffer.Slice(0, len), SocketFlags.None, new IPEndPoint(nameserverIP, PORT));
338+
return await socket.ReceiveAsync(buffer, SocketFlags.None, new CancellationTokenSource(3000).Token);
339+
}
340+
341+
private async Task<int> ResolveHTTPS(Message query, Memory<byte> buffer, IPAddress nameserverIP)
342+
{
343+
query.TransactionID = 0;
344+
int len = query.ToBytes(buffer.Span);
345+
using (HttpClient httpClient = new HttpClient())
346+
{
347+
ByteArrayContent content = new ByteArrayContent(buffer.Slice(0, len).ToArray());
348+
content.Headers.ContentType = new MediaTypeHeaderValue("application/dns-message");
349+
string hostname = nameserverIP.ToString();
350+
if (nameserverIP.AddressFamily == AddressFamily.InterNetworkV6)
351+
hostname = String.Concat("[", hostname, "]");
352+
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, $"https://{hostname}/dns-query");
353+
request.Content = content;
354+
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/dns-message"));
355+
request.Version = new Version(2, 0);
356+
request.VersionPolicy = HttpVersionPolicy.RequestVersionOrHigher;
357+
var response = await httpClient.SendAsync(request, new CancellationTokenSource(3000).Token);
358+
response.EnsureSuccessStatusCode();
359+
var tempBuff = await response.Content.ReadAsByteArrayAsync();
360+
tempBuff.CopyTo(buffer);
361+
return tempBuff.Length;
362+
}
363+
}
280364
}
281365
}

TinyDNS/DomainParser.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public static List<string> Parse(string domain)
9191
{
9292
//Escaped char follows
9393
if (char.IsAsciiHexDigit(domain[++i]))
94-
label.Append((char)int.Parse(domain.AsSpan().Slice(i++, 2), NumberStyles.HexNumber)); //2 digit char code
94+
label.Append((char)int.Parse(domain.AsSpan().Slice(i++, 2), NumberStyles.HexNumber)); //2 digit hex code
9595
else
9696
label.Append(domain[i]); //single character
9797
}

TinyDNS/Enums/ResolutionMode.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// TinyDNS Copyright (C) 2024
2+
//
3+
// This program is free software: you can redistribute it and/or modify
4+
// it under the terms of the GNU Affero General Public License as published by
5+
// the Free Software Foundation, either version 3 of the License, or any later version.
6+
// This program is distributed in the hope that it will be useful,
7+
// but WITHOUT ANY WARRANTY, without even the implied warranty of
8+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
9+
// See the GNU Affero General Public License for more details.
10+
// You should have received a copy of the GNU Affero General Public License
11+
// along with this program. If not, see <http://www.gnu.org/licenses/>.
12+
13+
namespace TinyDNS.Enums
14+
{
15+
public enum ResolutionMode : byte
16+
{
17+
InsecureOnly,
18+
SecureOnly,
19+
SecureWithFallback,
20+
21+
}
22+
}

TinyDNS/Message.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ namespace TinyDNS
1818
{
1919
public sealed class Message : IEquatable<Message>
2020
{
21-
public ushort transaction;
21+
public ushort TransactionID { get; set; }
2222
public bool Response { get; set; }
2323
public bool RecursionDesired { get; set; }
2424
public bool RecursionAvailable { get; set; }
@@ -31,7 +31,7 @@ public sealed class Message : IEquatable<Message>
3131
public Message()
3232
{
3333
RecursionDesired = true;
34-
transaction = (ushort)new Random().Next(ushort.MaxValue);
34+
TransactionID = (ushort)new Random().Next(ushort.MaxValue);
3535
}
3636
/// <summary>
3737
/// Create a DNS Message from a byte buffer
@@ -44,7 +44,7 @@ public Message(Span<byte> buffer)
4444
byte op = buffer[2];
4545
if ((op & 0x2) == 0x2)
4646
throw new InvalidDataException("Message Truncated");
47-
transaction = BinaryPrimitives.ReadUInt16BigEndian(buffer);
47+
TransactionID = BinaryPrimitives.ReadUInt16BigEndian(buffer);
4848
Response = (op & 0x80) == 0x80;
4949
Authoritative = (op & 0x4) == 0x4;
5050
RecursionDesired = (op & 0x1) == 0x1;
@@ -79,7 +79,7 @@ public Message(Span<byte> buffer)
7979
public ResourceRecord[] Additionals { get; set; } = [];
8080
public int ToBytes(Span<byte> buffer)
8181
{
82-
BinaryPrimitives.WriteUInt16BigEndian(buffer, transaction);
82+
BinaryPrimitives.WriteUInt16BigEndian(buffer, TransactionID);
8383
byte op = (byte)(((byte)Operation & 0xF) << 3);
8484
if (Response)
8585
op |= 0x80;

TinyDNS/TinyDNS.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFramework>net8.0</TargetFramework>
4+
<TargetFrameworks>net80</TargetFrameworks>
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>
7+
<Version>0.5</Version>
78
</PropertyGroup>
89

910
<ItemGroup>

TinyDNSDemo/Program.cs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,23 @@
1212

1313
using System.Net;
1414
using TinyDNS;
15+
using TinyDNS.Enums;
1516

1617
internal class Program
1718
{
1819
static async Task Main()
1920
{
20-
List<IPAddress> addresses = DNSSources.RootNameservers;
21-
DNSResolver resolver = new DNSResolver(addresses); //From root hints
21+
DNSResolver resolver = new DNSResolver(DNSSources.CloudflareDNSAddresses, ResolutionMode.SecureWithFallback); //From root hints
2222
string host = "google.com";
2323
List<IPAddress> ip = await resolver.ResolveHost(host);
2424
if (ip.Count > 0)
2525
Console.WriteLine($"Resolved {host} as {ip[0]}");
26-
List<IPAddress> ip2 = await resolver.ResolveHost(host);
26+
List<IPAddress> ip2 = await resolver.ResolveHostV6("mail." + host);
2727
if (ip2.Count == 0)
2828
Console.WriteLine("Unable to resolve IPs");
2929
else
3030
{
31-
Console.WriteLine($"Resolved {host} as {ip2[0]}");
32-
//Console.WriteLine($"Resolved {ip[0]} as " + await resolver.ResolveIP(ip[0]));
31+
Console.WriteLine($"Resolved mail.{host} as {ip2[0]}");
3332
}
3433
Console.ReadLine();
3534
}

TinyDNSDemo/TinyDNSDemo.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
<PropertyGroup>
44
<OutputType>Exe</OutputType>
5-
<TargetFramework>net8.0</TargetFramework>
5+
<TargetFrameworks>net80</TargetFrameworks>
66
<ImplicitUsings>enable</ImplicitUsings>
77
<Nullable>enable</Nullable>
88
</PropertyGroup>

0 commit comments

Comments
 (0)