Skip to content

Commit d62fa46

Browse files
committed
Fix: input validation
1 parent b2afde8 commit d62fa46

6 files changed

Lines changed: 308 additions & 67 deletions

File tree

‎Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs‎

Lines changed: 25 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Linq;
33
using System.Net;
44
using System.Text.RegularExpressions;
5+
using NETworkManager.Utilities;
56

67
namespace NETworkManager.Models.RunCommand;
78

@@ -10,40 +11,8 @@ namespace NETworkManager.Models.RunCommand;
1011
/// (e.g. "10.0.0.1" → ping, traceroute, ...). A single word without a domain is
1112
/// <see cref="RunCommandTargetKind.Hostname" />, since it can also be a mistyped command.
1213
/// </summary>
13-
public static partial class RunCommandTarget
14+
public static class RunCommandTarget
1415
{
15-
[GeneratedRegex("^https?://", RegexOptions.IgnoreCase)]
16-
private static partial Regex UrlRegex();
17-
18-
[GeneratedRegex("^(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}$|^[0-9a-f]{12}$|^(?:[0-9a-f]{4}\\.){2}[0-9a-f]{4}$",
19-
RegexOptions.IgnoreCase)]
20-
private static partial Regex MACAddressRegex();
21-
22-
[GeneratedRegex("^(?:[0-9a-f]{2}[:-]){2}[0-9a-f]{2}$", RegexOptions.IgnoreCase)]
23-
private static partial Regex OUIRegex();
24-
25-
[GeneratedRegex("^\\d{1,5}(?:-\\d{1,5})?(?:/(?:tcp|udp|sctp))?(?:;\\d{1,5}(?:-\\d{1,5})?(?:/(?:tcp|udp|sctp))?)*$",
26-
RegexOptions.IgnoreCase)]
27-
private static partial Regex PortsRegex();
28-
29-
[GeneratedRegex("^as\\d{1,10}$", RegexOptions.IgnoreCase)]
30-
private static partial Regex ASNRegex();
31-
32-
[GeneratedRegex("^[0-9.\\-\\[\\],]+$")]
33-
private static partial Regex IPv4RangeRegex();
34-
35-
// The last label (top-level domain) has at least one letter, so a shortened or partly typed IPv4 address (e.g.
36-
// "192.168.1") is not a host name.
37-
[GeneratedRegex(
38-
"^(?=.{1,253}\\.?$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+" +
39-
"(?=[a-z0-9-]*[a-z])[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.?$",
40-
RegexOptions.IgnoreCase)]
41-
private static partial Regex HostnameWithDomainRegex();
42-
43-
// A label with at least one letter (only digits is not a host name)
44-
[GeneratedRegex("^(?=[a-z0-9-]*[a-z])[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$", RegexOptions.IgnoreCase)]
45-
private static partial Regex HostnameRegex();
46-
4716
/// <summary>
4817
/// Gets the kind of the target.
4918
/// </summary>
@@ -54,19 +23,20 @@ public static RunCommandTargetKind GetKind(string target)
5423
if (string.IsNullOrEmpty(target) || target.Any(char.IsWhiteSpace))
5524
return RunCommandTargetKind.None;
5625

57-
if (UrlRegex().IsMatch(target))
26+
if (RegexHelper.HttpOrHttpsSchemeRegex().IsMatch(target))
5827
return Uri.TryCreate(target, UriKind.Absolute, out _) ? RunCommandTargetKind.URL : RunCommandTargetKind.None;
5928

60-
if (MACAddressRegex().IsMatch(target))
29+
if (Regex.IsMatch(target, RegexHelper.MACAddressRegex))
6130
return RunCommandTargetKind.MACAddress;
6231

63-
if (OUIRegex().IsMatch(target))
32+
// Only with separators: 6 hex digits without separators (e.g. "facade") are more likely a word.
33+
if (RegexHelper.MACAddressFirst3BytesWithSeparatorRegex().IsMatch(target))
6434
return RunCommandTargetKind.OUI;
6535

66-
if (PortsRegex().IsMatch(target))
36+
if (RegexHelper.PortLookupEntriesRegex().IsMatch(target))
6737
return RunCommandTargetKind.Ports;
6838

69-
if (ASNRegex().IsMatch(target))
39+
if (RegexHelper.ASNumberWithPrefixRegex().IsMatch(target))
7040
return RunCommandTargetKind.ASN;
7141

7242
// Several hosts, IP ranges or subnets
@@ -80,26 +50,32 @@ public static RunCommandTargetKind GetKind(string target)
8050
: RunCommandTargetKind.None;
8151
}
8252

83-
// Subnet with CIDR or subnet mask (e.g. 10.0.0.0/24, 10.0.0.0/255.255.255.0, 2001:db8::/32)
84-
var slash = target.IndexOf('/');
85-
86-
if (slash > 0)
87-
return IPAddress.TryParse(target[..slash], out _) &&
88-
(int.TryParse(target[(slash + 1)..], out _) || IPAddress.TryParse(target[(slash + 1)..], out _))
53+
// Subnet with CIDR or subnet mask (e.g. 10.0.0.0/24, 10.0.0.0/255.255.255.0, 2001:db8::/32), like the input
54+
// validation of the tools
55+
if (target.Contains('/'))
56+
return RegexHelper.IPv4AddressCidrRegex().IsMatch(target) ||
57+
RegexHelper.IPv4AddressSubnetmaskRegex().IsMatch(target) ||
58+
Regex.IsMatch(target, RegexHelper.IPv6AddressCidrRegex)
8959
? RunCommandTargetKind.Subnet
9060
: RunCommandTargetKind.None;
9161

92-
// IPv4 range (e.g. 10.0.0.1-10.0.0.100, 10.0.0.1-100, 10.0.[0-1].1)
93-
if ((target.Contains('-') || target.Contains('[')) && target.Contains('.') && IPv4RangeRegex().IsMatch(target))
62+
// IPv4 range (e.g. 10.0.0.1-10.0.0.100, 10.0.0.1-100, 10.0.[0-1].1). The special range also matches an IPv4
63+
// address without a range, so only with "-" or "[".
64+
if ((target.Contains('-') || target.Contains('[')) &&
65+
(RegexHelper.IPv4AddressRangeRegex().IsMatch(target) ||
66+
RegexHelper.IPv4AddressShortRangeRegex().IsMatch(target) ||
67+
RegexHelper.IPv4AddressSpecialRangeRegex().IsMatch(target)))
9468
return RunCommandTargetKind.IPRange;
9569

9670
// IP address: IPv4 with all 4 parts (a shortened or partly typed form like "10.1" is not a target) or IPv6
9771
if (IPAddress.TryParse(target, out _) && (target.Count(x => x == '.') == 3 || target.Contains(':')))
9872
return RunCommandTargetKind.Host;
9973

100-
if (HostnameWithDomainRegex().IsMatch(target))
101-
return RunCommandTargetKind.Host;
74+
// Host name with or without a domain (a shortened or partly typed IPv4 address like "192.168.1" is not a host
75+
// name, since the last label must contain a letter)
76+
if (!RegexHelper.HostnameOrDomainRegex().IsMatch(target))
77+
return RunCommandTargetKind.None;
10278

103-
return HostnameRegex().IsMatch(target) ? RunCommandTargetKind.Hostname : RunCommandTargetKind.None;
79+
return target.TrimEnd('.').Contains('.') ? RunCommandTargetKind.Host : RunCommandTargetKind.Hostname;
10480
}
10581
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
using System.Diagnostics;
2+
using System.Linq;
3+
using System.Text.RegularExpressions;
4+
using NETworkManager.Utilities;
5+
using Xunit;
6+
7+
namespace NETworkManager.Tests.Utilities;
8+
9+
public class RegexHelperTests
10+
{
11+
[Theory]
12+
[InlineData("00:11:22:33:44:55", true)]
13+
[InlineData("00-11-22-33-44-55", true)]
14+
[InlineData("001122334455", true)]
15+
[InlineData("0011.2233.4455", true)]
16+
[InlineData("0011 2233 4455", false)]
17+
[InlineData("0011x2233x4455", false)]
18+
[InlineData("00:11:22:33:44", false)]
19+
public void MACAddress(string input, bool isMatch)
20+
{
21+
Assert.Equal(isMatch, Regex.IsMatch(input, RegexHelper.MACAddressRegex));
22+
}
23+
24+
[Theory]
25+
[InlineData("00:11:22", true)]
26+
[InlineData("001122", true)]
27+
[InlineData("0011.22", true)]
28+
[InlineData("0011 22", false)]
29+
[InlineData("0011x22", false)]
30+
public void MACAddressFirst3Bytes(string input, bool isMatch)
31+
{
32+
Assert.Equal(isMatch, Regex.IsMatch(input, RegexHelper.MACAddressFirst3BytesRegex));
33+
}
34+
35+
[Theory]
36+
[InlineData("2001:db8::/32", true)]
37+
[InlineData("::ffff:192.168.0.1/96", true)]
38+
[InlineData("sss2001:db8::/32", false)]
39+
[InlineData("2001:db8::/129", false)]
40+
public void IPv6AddressCidr(string input, bool isMatch)
41+
{
42+
Assert.Equal(isMatch, Regex.IsMatch(input, RegexHelper.IPv6AddressCidrRegex));
43+
}
44+
45+
[Theory]
46+
[InlineData("1", true)]
47+
[InlineData("3389", true)]
48+
[InlineData("65535", true)]
49+
[InlineData("", false)]
50+
[InlineData("0", false)]
51+
[InlineData("65536", false)]
52+
[InlineData("05555", false)]
53+
public void Port(string input, bool isMatch)
54+
{
55+
Assert.Equal(isMatch, Regex.IsMatch(input, RegexHelper.PortRegex));
56+
}
57+
58+
[Theory]
59+
[InlineData("server-01", true)]
60+
[InlineData("server-01.example.com", true)]
61+
[InlineData("example.com.", true)]
62+
[InlineData("123abc", true)]
63+
[InlineData("-server", false)]
64+
[InlineData("server-", false)]
65+
[InlineData("server.-bad-.com", false)]
66+
[InlineData("a..b", false)]
67+
// IPv4 addresses, shortened or partly typed ones are not hostnames (checked separately)
68+
[InlineData("10.0.0.1", false)]
69+
[InlineData("10.0.0.", false)]
70+
[InlineData("192.168.1", false)]
71+
[InlineData("1234", false)]
72+
public void HostnameOrDomain(string input, bool isMatch)
73+
{
74+
Assert.Equal(isMatch, RegexHelper.HostnameOrDomainRegex().IsMatch(input));
75+
}
76+
77+
[Theory]
78+
[InlineData("borntoberoot.net", true)]
79+
[InlineData("local.example.com", true)]
80+
[InlineData("example", false)]
81+
public void Domain(string input, bool isMatch)
82+
{
83+
Assert.Equal(isMatch, Regex.IsMatch(input, RegexHelper.DomainRegex));
84+
}
85+
86+
[Theory]
87+
[InlineData("22", true)]
88+
[InlineData("22/tcp", true)]
89+
[InlineData("22/TCP", true)]
90+
[InlineData("80-90", true)]
91+
[InlineData("80-90/udp", true)]
92+
[InlineData("22;80-90/tcp;443", true)]
93+
[InlineData("22;", false)]
94+
[InlineData("ssh", false)]
95+
[InlineData("22/http", false)]
96+
[InlineData("123456", false)]
97+
public void PortLookupEntries(string input, bool isMatch)
98+
{
99+
Assert.Equal(isMatch, RegexHelper.PortLookupEntriesRegex().IsMatch(input));
100+
}
101+
102+
[Theory]
103+
[InlineData("22", true, false, false, false)]
104+
[InlineData("22/tcp", false, true, false, false)]
105+
[InlineData("80-90", false, false, true, false)]
106+
[InlineData("80-90/sctp", false, false, false, true)]
107+
[InlineData("ssh", false, false, false, false)]
108+
public void PortLookupEntry(string input, bool port, bool portAndProtocol, bool portRange,
109+
bool portRangeAndProtocol)
110+
{
111+
Assert.Equal(port, RegexHelper.PortLookupPortRegex().IsMatch(input));
112+
Assert.Equal(portAndProtocol, RegexHelper.PortLookupPortAndProtocolRegex().IsMatch(input));
113+
Assert.Equal(portRange, RegexHelper.PortLookupPortRangeRegex().IsMatch(input));
114+
Assert.Equal(portRangeAndProtocol, RegexHelper.PortLookupPortRangeAndProtocolRegex().IsMatch(input));
115+
}
116+
117+
[Theory]
118+
[InlineData("00:11:22", true)]
119+
[InlineData("00-11-22", true)]
120+
[InlineData("001122", false)]
121+
[InlineData("facade", false)]
122+
public void MACAddressFirst3BytesWithSeparator(string input, bool isMatch)
123+
{
124+
Assert.Equal(isMatch, RegexHelper.MACAddressFirst3BytesWithSeparatorRegex().IsMatch(input));
125+
}
126+
127+
[Theory]
128+
[InlineData("AS13335", true)]
129+
[InlineData("as13335", true)]
130+
[InlineData("13335", false)]
131+
[InlineData("AS", false)]
132+
public void ASNumberWithPrefix(string input, bool isMatch)
133+
{
134+
Assert.Equal(isMatch, RegexHelper.ASNumberWithPrefixRegex().IsMatch(input));
135+
}
136+
137+
[Theory]
138+
[InlineData("https://borntoberoot.net", true)]
139+
[InlineData("HTTP://borntoberoot.net", true)]
140+
[InlineData("ftp://borntoberoot.net", false)]
141+
[InlineData("borntoberoot.net", false)]
142+
public void HttpOrHttpsScheme(string input, bool isMatch)
143+
{
144+
Assert.Equal(isMatch, RegexHelper.HttpOrHttpsSchemeRegex().IsMatch(input));
145+
}
146+
147+
// An invalid input with many labels took exponentially long (catastrophic backtracking).
148+
[Fact]
149+
public void DomainAndHostnameNoBacktracking()
150+
{
151+
var input = "a" + string.Concat(Enumerable.Repeat(".a", 100)) + "!";
152+
153+
var stopwatch = Stopwatch.StartNew();
154+
155+
Assert.DoesNotMatch(RegexHelper.DomainRegex, input);
156+
Assert.DoesNotMatch(RegexHelper.HostnameOrDomainRegex(), input);
157+
158+
Assert.True(stopwatch.ElapsedMilliseconds < 1000);
159+
}
160+
}

0 commit comments

Comments
 (0)