diff --git a/SafeExamBrowser.Browser.UnitTests/Filters/LegacyFilter.cs b/SafeExamBrowser.Browser.UnitTests/Filters/LegacyFilter.cs new file mode 100644 index 00000000..6b834c8e --- /dev/null +++ b/SafeExamBrowser.Browser.UnitTests/Filters/LegacyFilter.cs @@ -0,0 +1,553 @@ +/* + * Copyright (c) 2019 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla internal + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +using System; +using System.Text; +using System.Text.RegularExpressions; + +namespace SafeExamBrowser.Browser.UnitTests.Filters +{ + internal class LegacyFilter + { + internal Regex scheme; + internal Regex user; + internal Regex password; + internal Regex host; + internal int? port; + internal Regex path; + internal Regex query; + internal Regex fragment; + + internal LegacyFilter(string filterExpressionString) + { + SEBURLFilterExpression URLFromString = new SEBURLFilterExpression(filterExpressionString); + try + { + this.scheme = RegexForFilterString(URLFromString.scheme); + this.user = RegexForFilterString(URLFromString.user); + this.password = RegexForFilterString(URLFromString.password); + this.host = RegexForHostFilterString(URLFromString.host); + this.port = URLFromString.port; + this.path = RegexForPathFilterString(URLFromString.path); + this.query = RegexForQueryFilterString(URLFromString.query); + this.fragment = RegexForFilterString(URLFromString.fragment); + } + catch (Exception) + { + throw; + } + } + + // Method comparing all components of a passed URL with the filter expression + // and returning YES (= allow or block) if it matches + internal bool IsMatch(Uri URLToFilter) + { + Regex filterComponent; + + // If a scheme is indicated in the filter expression, it has to match + filterComponent = scheme; + UriBuilder urlToFilterParts = new UriBuilder(URLToFilter); + + if (filterComponent != null && + !Regex.IsMatch(URLToFilter.Scheme, filterComponent.ToString(), RegexOptions.IgnoreCase)) + { + // Scheme of the URL to filter doesn't match the one from the filter expression: Exit with matching = NO + return false; + } + + string userInfo = URLToFilter.UserInfo; + filterComponent = user; + if (filterComponent != null && + !Regex.IsMatch(urlToFilterParts.UserName, filterComponent.ToString(), RegexOptions.IgnoreCase)) + { + return false; + } + + filterComponent = password; + if (filterComponent != null && + !Regex.IsMatch(urlToFilterParts.Password, filterComponent.ToString(), RegexOptions.IgnoreCase)) + { + return false; + } + + filterComponent = host; + if (filterComponent != null && + !Regex.IsMatch(URLToFilter.Host, filterComponent.ToString(), RegexOptions.IgnoreCase)) + { + return false; + } + + if (port != null && URLToFilter.Port != port) + { + return false; + } + + filterComponent = path; + if (filterComponent != null && + !Regex.IsMatch(URLToFilter.AbsolutePath.Trim(new char[] { '/' }), filterComponent.ToString(), RegexOptions.IgnoreCase)) + { + return false; + } + + string urlQuery = URLToFilter.GetComponents(UriComponents.Query, UriFormat.Unescaped); + filterComponent = query; + if (filterComponent != null) + { + // If there's a query filter component, then we need to even filter empty URL query strings + // as the filter might either allow some specific queries or no query at all ("?." query filter) + if (urlQuery == null) + { + urlQuery = ""; + } + if (!Regex.IsMatch(urlQuery, filterComponent.ToString(), RegexOptions.IgnoreCase)) + { + return false; + } + } + + string urlFragment = URLToFilter.GetComponents(UriComponents.Fragment, UriFormat.Unescaped); + filterComponent = fragment; + if (filterComponent != null && + !Regex.IsMatch(urlFragment, filterComponent.ToString(), RegexOptions.IgnoreCase)) + { + return false; + } + + // URL matches the filter expression + return true; + } + + internal static Regex RegexForFilterString(string filterString) + { + if (string.IsNullOrEmpty(filterString)) + { + return null; + } + else + { + string regexString = Regex.Escape(filterString); + regexString = regexString.Replace("\\*", ".*?"); + // Add regex command characters for matching at start and end of a line (part) + regexString = string.Format("^{0}$", regexString); + + try + { + Regex regex = new Regex(regexString, RegexOptions.IgnoreCase); + return regex; + } + catch (Exception) + { + throw; + } + } + } + + internal static Regex RegexForHostFilterString(string filterString) + { + if (string.IsNullOrEmpty(filterString)) + { + return null; + } + else + { + try + { + // Check if host string has a dot "." prefix to disable subdomain matching + if (filterString.Length > 1 && filterString.StartsWith(".")) + { + // Get host string without the "." prefix + filterString = filterString.Substring(1); + // Get regex for host <*://example.com> (without possible subdomains) + return RegexForFilterString(filterString); + } + // Allow subdomain matching: Create combined regex for and <*.example.com> + string regexString = Regex.Escape(filterString); + regexString = regexString.Replace("\\*", ".*?"); + // Add regex command characters for matching at start and end of a line (part) + regexString = string.Format("^(({0})|(.*?\\.{0}))$", regexString); + Regex regex = new Regex(regexString, RegexOptions.IgnoreCase); + return regex; + } + catch (Exception) + { + throw; + } + } + } + + internal static Regex RegexForPathFilterString(string filterString) + { + // Trim a possible trailing slash "/", we will instead add a rule to also match paths to directories without trailing slash + filterString = filterString.TrimEnd(new char[] { '/' }); + ; + + if (string.IsNullOrEmpty(filterString)) + { + return null; + } + else + { + try + { + // Check if path string ends with a "/*" for matching contents of a directory + if (filterString.EndsWith("/*")) + { + // As the path filter string matches for a directory, we need to add a string to match directories without trailing slash + + // Get path string without the "/*" suffix + string filterStringDirectory = filterString.Substring(0, filterString.Length - 2); + + string regexString = Regex.Escape(filterString); + regexString = regexString.Replace("\\*", ".*?"); + + string regexStringDir = Regex.Escape(filterString); + regexStringDir = regexStringDir.Replace("\\*", ".*?"); + + // Add regex command characters for matching at start and end of a line (part) + regexString = string.Format("^(({0})|({1}))$", regexString, regexStringDir); + + Regex regex = new Regex(regexString, RegexOptions.IgnoreCase); + return regex; + } + else + { + return RegexForFilterString(filterString); + } + } + catch (Exception) + { + throw; + } + } + } + + internal static Regex RegexForQueryFilterString(string filterString) + { + if (string.IsNullOrEmpty(filterString)) + { + return null; + } + else + { + if (filterString.Equals(".")) + { + // Add regex command characters for matching at start and end of a line (part) + // and regex for no string allowed + string regexString = @"^$"; + try + { + Regex regex = new Regex(regexString, RegexOptions.IgnoreCase); + return regex; + } + catch (Exception) + { + throw; + } + } + else + { + return RegexForFilterString(filterString); + } + } + } + + public override string ToString() + { + StringBuilder expressionString = new StringBuilder(); + string part; + expressionString.Append("^"); + + /// Scheme + if (this.scheme != null) + { + // If there is a regex filter for scheme + // get stripped regex pattern + part = StringForRegexFilter(this.scheme); + } + else + { + // otherwise use the regex wildcard pattern for scheme + part = @".*?"; + } + + expressionString.AppendFormat("{0}:\\/\\/", part); + + /// User/Password + if (this.user != null) + { + part = StringForRegexFilter(this.user); + + expressionString.Append(part); + + if (this.password != null) + { + expressionString.AppendFormat(":{0}@", StringForRegexFilter(this.password)); + } + else + { + expressionString.Append("@"); + } + } + + /// Host + string hostPort = ""; + if (this.host != null) + { + hostPort = StringForRegexFilter(this.host); + } + else + { + hostPort = ".*?"; + } + + /// Port + if (this.port != null && this.port > 0 && this.port <= 65535) + { + hostPort = string.Format("{0}:{1}", hostPort, this.port); + } + + // When there is a host, but no path + if (this.host != null && this.path == null) + { + hostPort = string.Format("(({0})|({0}\\/.*?))", hostPort); + } + + expressionString.Append(hostPort); + + /// Path + if (this.path != null) + { + string path = StringForRegexFilter(this.path); + if (path.StartsWith("\\/")) + { + expressionString.Append(path); + } + else + { + expressionString.AppendFormat("\\/{0}", path); + } + } + + /// Query + if (this.query != null) + { + // Check for special case Query = "?." which means no query string is allowed + if (StringForRegexFilter(this.query).Equals(".")) + { + expressionString.AppendFormat("[^\\?]"); + } + else + { + expressionString.AppendFormat("\\?{0}", StringForRegexFilter(this.query)); + } + } + else + { + expressionString.AppendFormat("(()|(\\?.*?))"); + } + + /// Fragment + if (this.fragment != null) + { + expressionString.AppendFormat("#{0}", StringForRegexFilter(this.fragment)); + } + + expressionString.Append("$"); + + return expressionString.ToString(); + } + + internal string StringForRegexFilter(Regex regexFilter) + { + // Get pattern string from regular expression + string regexPattern = regexFilter.ToString(); + if (regexPattern.Length <= 2) + { + return ""; + } + // Remove the regex command characters for matching at start and end of a line + regexPattern = regexPattern.Substring(1, regexPattern.Length - 2); + return regexPattern; + } + + private class SEBURLFilterExpression + { + internal string scheme; + internal string user; + internal string password; + internal string host; + internal int? port; + internal string path; + internal string query; + internal string fragment; + + internal SEBURLFilterExpression(string filterExpressionString) + { + if (!string.IsNullOrEmpty(filterExpressionString)) + { + /// Convert Uri to a SEBURLFilterExpression + string splitURLRegexPattern = @"(?:([^\:]*)\:\/\/)?(?:([^\:\@]*)(?:\:([^\@]*))?\@)?(?:([^\/‌​\:]*))?(?:\:([0-9\*]*))?([^\?#]*)?(?:\?([^#]*))?(?:#(.*))?"; + Regex splitURLRegex = new Regex(splitURLRegexPattern); + Match regexMatch = splitURLRegex.Match(filterExpressionString); + if (regexMatch.Success == false) + { + return; + } + + this.scheme = regexMatch.Groups[1].Value; + this.user = regexMatch.Groups[2].Value; + this.password = regexMatch.Groups[3].Value; + this.host = regexMatch.Groups[4].Value; + + // Treat a special case when a query is interpreted as part of the host address + if (this.host.Contains("?")) + { + string splitURLRegexPattern2 = @"([^\?#]*)?(?:\?([^#]*))?(?:#(.*))?"; + Regex splitURLRegex2 = new Regex(splitURLRegexPattern2); + Match regexMatch2 = splitURLRegex2.Match(this.host); + if (regexMatch.Success == false) + { + return; + } + this.host = regexMatch2.Groups[1].Value; + this.port = null; + this.path = ""; + this.query = regexMatch2.Groups[2].Value; + this.fragment = regexMatch2.Groups[3].Value; + } + else + { + string portNumber = regexMatch.Groups[5].Value; + + // We only want a port if the filter expression string explicitely defines one! + if (portNumber.Length == 0 || portNumber == "*") + { + this.port = null; + } + else + { + this.port = UInt16.Parse(portNumber); + } + + this.path = regexMatch.Groups[6].Value.Trim(new char[] { '/' }); + this.query = regexMatch.Groups[7].Value; + this.fragment = regexMatch.Groups[8].Value; + } + } + } + + internal static string User(string userInfo) + { + string user = ""; + if (!string.IsNullOrEmpty(userInfo)) + { + int userPasswordSeparator = userInfo.IndexOf(":"); + if (userPasswordSeparator == -1) + { + user = userInfo; + } + else + { + if (userPasswordSeparator != 0) + { + user = userInfo.Substring(0, userPasswordSeparator); + } + } + } + return user; + } + + internal static string Password(string userInfo) + { + string password = ""; + if (!string.IsNullOrEmpty(userInfo)) + { + int userPasswordSeparator = userInfo.IndexOf(":"); + if (userPasswordSeparator != -1) + { + if (userPasswordSeparator < userInfo.Length - 1) + { + password = userInfo.Substring(userPasswordSeparator + 1, userInfo.Length - 1 - userPasswordSeparator); + } + } + } + return password; + } + + internal SEBURLFilterExpression(string scheme, string user, string password, string host, int port, string path, string query, string fragment) + { + this.scheme = scheme; + this.user = user; + this.password = password; + this.host = host; + this.port = port; + this.path = path; + this.query = query; + this.fragment = fragment; + } + + public override string ToString() + { + StringBuilder expressionString = new StringBuilder(); + if (!string.IsNullOrEmpty(this.scheme)) + { + if (!string.IsNullOrEmpty(this.host)) + { + expressionString.AppendFormat("{0}://", this.scheme); + } + else + { + expressionString.AppendFormat("{0}:", this.scheme); + } + } + if (!string.IsNullOrEmpty(this.user)) + { + expressionString.Append(this.user); + + if (!string.IsNullOrEmpty(this.password)) + { + expressionString.AppendFormat(":{0}@", this.password); + } + else + { + expressionString.Append("@"); + } + } + if (!string.IsNullOrEmpty(this.host)) + { + expressionString.Append(this.host); + } + if (this.port != null && this.port > 0 && this.port <= 65535) + { + expressionString.AppendFormat(":{0}", this.port); + } + if (!string.IsNullOrEmpty(this.path)) + { + if (this.path.StartsWith("/")) + { + expressionString.Append(this.path); + } + else + { + expressionString.AppendFormat("/{0}", this.path); + } + } + if (!string.IsNullOrEmpty(this.query)) + { + expressionString.AppendFormat("?{0}", this.query); + } + if (!string.IsNullOrEmpty(this.fragment)) + { + expressionString.AppendFormat("#{0}", this.fragment); + } + + return expressionString.ToString(); + } + } + } +} diff --git a/SafeExamBrowser.Browser.UnitTests/Filters/Rules/RegexRuleTests.cs b/SafeExamBrowser.Browser.UnitTests/Filters/Rules/RegexRuleTests.cs index 7af4d3c5..d16d84df 100644 --- a/SafeExamBrowser.Browser.UnitTests/Filters/Rules/RegexRuleTests.cs +++ b/SafeExamBrowser.Browser.UnitTests/Filters/Rules/RegexRuleTests.cs @@ -6,8 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +using System; +using System.Text.RegularExpressions; using Microsoft.VisualStudio.TestTools.UnitTesting; +using SafeExamBrowser.Browser.Contracts.Filters; using SafeExamBrowser.Browser.Filters.Rules; +using SafeExamBrowser.Settings.Browser; namespace SafeExamBrowser.Browser.UnitTests.Filters.Rules { @@ -21,5 +25,43 @@ namespace SafeExamBrowser.Browser.UnitTests.Filters.Rules { sut = new RegexRule(); } + + [TestMethod] + public void MustIgnoreCase() + { + sut.Initialize(new FilterRuleSettings { Expression = Regex.Escape("http://www.test.org/path/file.txt?param=123") }); + + Assert.IsTrue(sut.IsMatch(new Request { Url = "hTtP://wWw.TeSt.OrG/pAtH/fIlE.tXt?PaRaM=123" })); + Assert.IsTrue(sut.IsMatch(new Request { Url = "HtTp://WwW.tEst.oRg/PaTh/FiLe.TxT?pArAm=123" })); + + sut.Initialize(new FilterRuleSettings { Expression = Regex.Escape("HTTP://WWW.TEST.ORG/PATH/FILE.TXT?PARAM=123") }); + + Assert.IsTrue(sut.IsMatch(new Request { Url = "hTtP://wWw.TeSt.OrG/pAtH/fIlE.tXt?PaRaM=123" })); + Assert.IsTrue(sut.IsMatch(new Request { Url = "HtTp://WwW.tEst.oRg/PaTh/FiLe.TxT?pArAm=123" })); + } + + [TestMethod] + public void MustInitializeResult() + { + foreach (var result in Enum.GetValues(typeof(FilterResult))) + { + sut.Initialize(new FilterRuleSettings { Expression = "", Result = (FilterResult) result }); + Assert.AreEqual(result, sut.Result); + } + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void MustNotAllowUndefinedExpression() + { + sut.Initialize(new FilterRuleSettings()); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void MustValidateExpression() + { + sut.Initialize(new FilterRuleSettings { Expression = "ç+\"}%&*/(+)=?{=*+¦]@#°§]`?´^¨'°[¬|¢" }); + } } } diff --git a/SafeExamBrowser.Browser.UnitTests/Filters/Rules/SimplifiedRuleTests.cs b/SafeExamBrowser.Browser.UnitTests/Filters/Rules/SimplifiedRuleTests.cs index f445b077..d514076b 100644 --- a/SafeExamBrowser.Browser.UnitTests/Filters/Rules/SimplifiedRuleTests.cs +++ b/SafeExamBrowser.Browser.UnitTests/Filters/Rules/SimplifiedRuleTests.cs @@ -6,8 +6,11 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +using System; using Microsoft.VisualStudio.TestTools.UnitTesting; +using SafeExamBrowser.Browser.Contracts.Filters; using SafeExamBrowser.Browser.Filters.Rules; +using SafeExamBrowser.Settings.Browser; namespace SafeExamBrowser.Browser.UnitTests.Filters.Rules { @@ -21,5 +24,269 @@ namespace SafeExamBrowser.Browser.UnitTests.Filters.Rules { sut = new SimplifiedRule(); } + + [TestMethod] + public void TestAlphanumericExpressionAsHost() + { + var expression = "hostname-123"; + var positive = new[] + { + $"scheme://{expression}.org", + $"scheme://www.{expression}.org", + $"scheme://subdomain.{expression}.com", + $"scheme://subdomain-1.subdomain-2.{expression}.org", + $"scheme://user:password@www.{expression}.org/path/file.txt?param=123#fragment" + }; + var negative = new[] + { + $"scheme://hostname.org", + $"scheme://hostname-12.org", + $"scheme://{expression}4.org", + $"scheme://{expression}.realhost.org", + $"scheme://subdomain.{expression}.realhost.org", + $"scheme://www.realhost.{expression}", + $"{expression}://www.host.org", + $"scheme://www.host.org/{expression}/path", + $"scheme://www.host.org/path?param={expression}", + $"scheme://{expression}:password@www.host.org", + $"scheme://user:{expression}@www.host.org", + $"scheme://user:password@www.host.org/path?param=123#{expression}" + }; + + Execute(expression, positive, negative, false); + } + + [TestMethod] + public void TestHostExpressionWithDomain() + { + var expression = "123-hostname.org"; + var positive = new[] + { + $"scheme://{expression}", + $"scheme://www.{expression}", + $"scheme://subdomain.{expression}", + $"scheme://subdomain-1.subdomain-2.{expression}", + $"scheme://user:password@www.{expression}/path/file.txt?param=123#fragment" + }; + var negative = new[] + { + $"scheme://123.org", + $"scheme://123-host.org", + $"scheme://{expression}.com", + $"scheme://{expression}s.org", + $"scheme://{expression}.realhost.org", + $"scheme://subdomain.{expression}.realhost.org", + $"scheme{expression}://www.host.org", + $"scheme://www.host.org/{expression}/path", + $"scheme://www.host.org/path?param={expression}", + $"scheme://{expression}:password@www.host.org", + $"scheme://user:{expression}@www.host.org", + $"scheme://user:password@www.host.org/path?param=123#{expression}" + }; + + Execute(expression, positive, negative); + } + + [TestMethod] + public void TestHostExpressionWithWildcard() + { + var expression = "test.*.org"; + var positive = new[] + { + "scheme://test.host.org", + "scheme://test.host.domain.org", + "scheme://subdomain.test.host.org", + "scheme://user:password@test.domain.org/path/file.txt?param=123#fragment" + }; + var negative = new[] + { + "scheme://test.org", + "scheme://host.com/test.host.org", + "scheme://www.host.org/test.host.org/path", + "scheme://www.host.org/path?param=test.host.org", + "scheme://test.host.org:password@www.host.org", + "scheme://user:test.host.org@www.host.org", + "scheme://user:password@www.host.org/path?param=123#test.host.org" + }; + + Execute(expression, positive, negative); + } + + [TestMethod] + public void TestHostExpressionWithWildcardAsSuffix() + { + var expression = "test.host.*"; + var positive = new[] + { + "scheme://test.host.org", + "scheme://test.host.domain.org", + "scheme://subdomain.test.host.org", + "scheme://user:password@test.host.org/path/file.txt?param=123#fragment" + }; + var negative = new[] + { + "scheme://host.com", + "scheme://test.host", + "scheme://host.com/test.host.txt" + }; + + Execute(expression, positive, negative); + } + + [TestMethod] + public void TestHostExpressionWithWildcardAsPrefix() + { + var expression = "*.org"; + var positive = new[] + { + "scheme://domain.org", + "scheme://test.host.org", + "scheme://test.host.domain.org", + "scheme://user:password@www.host.org/path/file.txt?param=123#fragment" + }; + var negative = new[] + { + "scheme://org", + "scheme://host.com", + "scheme://test.net", + "scheme://test.ch", + "scheme://host.com/test.org" + }; + + Execute(expression, positive, negative); + } + + [TestMethod] + public void TestHostExpressionWithExactSubdomain() + { + var expression = ".www.host.org"; + var positive = new[] + { + "scheme://www.host.org", + "scheme://user:password@www.host.org/path/file.txt?param=123#fragment" + }; + var negative = new[] + { + "scheme://host.org", + "scheme://test.www.host.org", + "scheme://www.host.org.com" + }; + + Execute(expression, positive, negative); + } + + [TestMethod] + public void TestExpressionWithPortNumber() + { + var expression = "host.org:2020"; + var positive = new[] + { + "scheme://host.org:2020", + "scheme://www.host.org:2020", + "scheme://user:password@www.host.org:2020/path/file.txt?param=123#fragment" + }; + var negative = new[] + { + "scheme://host.org", + "scheme://www.host.org", + "scheme://www.host.org:2", + "scheme://www.host.org:20", + "scheme://www.host.org:202", + "scheme://www.host.org:20202" + }; + + Execute(expression, positive, negative); + } + + [TestMethod] + public void MustIgnoreCase() + { + sut.Initialize(new FilterRuleSettings { Expression = "http://www.test.org/path/file.txt?param=123" }); + + Assert.IsTrue(sut.IsMatch(new Request { Url = "hTtP://wWw.TeSt.OrG/pAtH/fIlE.tXt?PaRaM=123" })); + Assert.IsTrue(sut.IsMatch(new Request { Url = "HtTp://WwW.tEst.oRg/PaTh/FiLe.TxT?pArAm=123" })); + + sut.Initialize(new FilterRuleSettings { Expression = "HTTP://WWW.TEST.ORG/PATH/FILE.TXT?PARAM=123" }); + + Assert.IsTrue(sut.IsMatch(new Request { Url = "hTtP://wWw.TeSt.OrG/pAtH/fIlE.tXt?PaRaM=123" })); + Assert.IsTrue(sut.IsMatch(new Request { Url = "HtTp://WwW.tEst.oRg/PaTh/FiLe.TxT?pArAm=123" })); + } + + // TODO + //[TestMethod] + //public void MustIgnoreTrailingSlash() + //{ + // Assert.Fail(); + //} + + //[TestMethod] + //public void MustAllowWildcard() + //{ + // Assert.Fail(); + //} + + [TestMethod] + public void MustInitializeResult() + { + foreach (var result in Enum.GetValues(typeof(FilterResult))) + { + sut.Initialize(new FilterRuleSettings { Expression = "*", Result = (FilterResult) result }); + Assert.AreEqual(result, sut.Result); + } + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void MustNotAllowUndefinedExpression() + { + sut.Initialize(new FilterRuleSettings()); + } + + [TestMethod] + public void MustValidateExpression() + { + var invalid = new[] + { + ".", "+", "\"", "ç", "%", "&", "/", "(", ")", "=", "?", "^", "!", "[", "]", "{", "}", "¦", "@", "#", "°", "§", "¬", "|", "¢", "´", "'", "`", "~", "<", ">", "\\" + }; + + sut.Initialize(new FilterRuleSettings { Expression = "*" }); + sut.Initialize(new FilterRuleSettings { Expression = "a" }); + sut.Initialize(new FilterRuleSettings { Expression = "A" }); + sut.Initialize(new FilterRuleSettings { Expression = "0" }); + sut.Initialize(new FilterRuleSettings { Expression = "abcdeFGHIJK-12345" }); + + foreach (var expression in invalid) + { + Assert.ThrowsException(() => sut.Initialize(new FilterRuleSettings { Expression = expression })); + } + } + + private void Execute(string expression, string[] positive, string[] negative, bool testLegacy = true) + { + var legacy = new LegacyFilter(expression); + + sut.Initialize(new FilterRuleSettings { Expression = expression }); + + foreach (var url in positive) + { + Assert.IsTrue(sut.IsMatch(new Request { Url = url }), url); + + if (testLegacy) + { + Assert.IsTrue(legacy.IsMatch(new Uri(url)), url); + } + } + + foreach (var url in negative) + { + Assert.IsFalse(sut.IsMatch(new Request { Url = url }), url); + + if (testLegacy) + { + Assert.IsFalse(legacy.IsMatch(new Uri(url)), url); + } + } + } } } diff --git a/SafeExamBrowser.Browser.UnitTests/SafeExamBrowser.Browser.UnitTests.csproj b/SafeExamBrowser.Browser.UnitTests/SafeExamBrowser.Browser.UnitTests.csproj index 43bb803b..d8c78f62 100644 --- a/SafeExamBrowser.Browser.UnitTests/SafeExamBrowser.Browser.UnitTests.csproj +++ b/SafeExamBrowser.Browser.UnitTests/SafeExamBrowser.Browser.UnitTests.csproj @@ -80,6 +80,7 @@ + diff --git a/SafeExamBrowser.Browser/Filters/Rules/RegexRule.cs b/SafeExamBrowser.Browser/Filters/Rules/RegexRule.cs index 8d4712a2..a9a55af5 100644 --- a/SafeExamBrowser.Browser/Filters/Rules/RegexRule.cs +++ b/SafeExamBrowser.Browser/Filters/Rules/RegexRule.cs @@ -6,6 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +using System; using System.Text.RegularExpressions; using SafeExamBrowser.Browser.Contracts.Filters; using SafeExamBrowser.Settings.Browser; @@ -20,6 +21,8 @@ namespace SafeExamBrowser.Browser.Filters.Rules public void Initialize(FilterRuleSettings settings) { + ValidateExpression(settings.Expression); + expression = settings.Expression; Result = settings.Result; } @@ -28,5 +31,22 @@ namespace SafeExamBrowser.Browser.Filters.Rules { return Regex.IsMatch(request.Url, expression, RegexOptions.IgnoreCase); } + + private void ValidateExpression(string expression) + { + if (expression == default(string)) + { + throw new ArgumentNullException(nameof(expression)); + } + + try + { + Regex.Match("", expression); + } + catch (Exception e) + { + throw new ArgumentException($"Invalid regular expression!", nameof(expression), e); + } + } } } diff --git a/SafeExamBrowser.Browser/Filters/Rules/SimplifiedRule.cs b/SafeExamBrowser.Browser/Filters/Rules/SimplifiedRule.cs index 90acdf8e..82c64ad3 100644 --- a/SafeExamBrowser.Browser/Filters/Rules/SimplifiedRule.cs +++ b/SafeExamBrowser.Browser/Filters/Rules/SimplifiedRule.cs @@ -6,6 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +using System; using System.Text.RegularExpressions; using SafeExamBrowser.Browser.Contracts.Filters; using SafeExamBrowser.Settings.Browser; @@ -14,19 +15,123 @@ namespace SafeExamBrowser.Browser.Filters.Rules { internal class SimplifiedRule : IRule { - private string expression; + private const string URL_DELIMITER_PATTERN = @"(?:([^\:]*)\:\/\/)?(?:([^\:\@]*)(?:\:([^\@]*))?\@)?(?:([^\/‌​\:]*))?(?:\:([0-9\*]*))?([^\?#]*)?(?:\?([^#]*))?(?:#(.*))?"; + + private Regex fragment; + private Regex host; + private Regex password; + private Regex path; + private int? port; + private Regex query; + private Regex scheme; + private Regex user; public FilterResult Result { get; private set; } public void Initialize(FilterRuleSettings settings) { - expression = settings.Expression.Replace("*", @".*"); + ValidateExpression(settings.Expression); + ParseExpression(settings.Expression); + Result = settings.Result; } public bool IsMatch(Request request) { - return Regex.IsMatch(request.Url, expression, RegexOptions.IgnoreCase); + var url = new Uri(request.Url, UriKind.Absolute); + var isMatch = true; + + //isMatch &= scheme == default(Regex) || ...; + //isMatch &= user == default(Regex) || ...; + //isMatch &= password == default(Regex) || ...; + isMatch &= host.IsMatch(url.Host); + isMatch &= !port.HasValue || port == url.Port; + //isMatch &= path == default(Regex) || ...; + //isMatch &= query == default(Regex) || ...; + //isMatch &= fragment == default(Regex) || ...; + + return isMatch; + } + + private void ParseExpression(string expression) + { + var match = Regex.Match(expression, URL_DELIMITER_PATTERN); + + //ParseScheme(match.Groups[1].Value); + //ParseUser(match.Groups[2].Value); + //ParsePassword(match.Groups[3].Value); + ParseHost(match.Groups[4].Value); + ParsePort(match.Groups[5].Value); + //ParsePath(match.Groups[6].Value); + //ParseQuery(match.Groups[7].Value); + //ParseFragment(match.Groups[8].Value); + } + + private void ParseHost(string expression) + { + var hasToplevelDomain = Regex.IsMatch(expression, @"\.+"); + var hasSubdomain = Regex.IsMatch(expression, @"\.{2,}"); + var allowOnlyExactSubdomain = expression.StartsWith("."); + + if (allowOnlyExactSubdomain) + { + expression = expression.Substring(1); + } + + expression = Regex.Escape(expression); + expression = ReplaceWildcard(expression); + + if (!hasToplevelDomain) + { + expression = $@"{expression}(\.[a-z]+)"; + } + + if (!hasSubdomain && !allowOnlyExactSubdomain) + { + expression = $@"(.+?\.)*{expression}"; + } + + host = Build(expression); + } + + private void ParsePort(string expression) + { + if (int.TryParse(expression, out var port)) + { + this.port = port; + } + } + + private Regex Build(string expression) + { + return new Regex($"^{expression}$", RegexOptions.IgnoreCase); + } + + private string ReplaceWildcard(string expression) + { + return expression.Replace(@"\*", ".*?"); + } + + private void ValidateExpression(string expression) + { + if (expression == default(string)) + { + throw new ArgumentNullException(nameof(expression)); + } + + if (!Regex.IsMatch(expression, @"[a-zA-Z0-9\*]+")) + { + throw new ArgumentException("Expression must consist of at least one alphanumeric character or asterisk!", nameof(expression)); + } + + try + { + Regex.Match(expression, URL_DELIMITER_PATTERN); + } + catch (Exception e) + { + throw new ArgumentException("Expression is not a valid simplified filter expression!", nameof(expression), e); + } } } } diff --git a/SafeExamBrowser.Configuration/ConfigurationData/DataMapper.cs b/SafeExamBrowser.Configuration/ConfigurationData/DataMapper.cs index 6c23f198..005a315e 100644 --- a/SafeExamBrowser.Configuration/ConfigurationData/DataMapper.cs +++ b/SafeExamBrowser.Configuration/ConfigurationData/DataMapper.cs @@ -29,6 +29,10 @@ namespace SafeExamBrowser.Configuration.ConfigurationData MapApplicationLogAccess(rawData, settings); MapKioskMode(rawData, settings); MapUserAgentMode(rawData, settings); + + // TODO: Automatically create filter rule for start URL! + // -> Only if filter active + // -> Create mechanism for post-processing of settings? } private void MapAudioSettings(string key, object value, ApplicationSettings settings)