diff --git a/src/Controls/tests/Core.UnitTests/WebViewHelperTests.cs b/src/Controls/tests/Core.UnitTests/WebViewHelperTests.cs index a3b895536b69..18c943d4ef42 100644 --- a/src/Controls/tests/Core.UnitTests/WebViewHelperTests.cs +++ b/src/Controls/tests/Core.UnitTests/WebViewHelperTests.cs @@ -8,63 +8,69 @@ public class WebViewHelperTests [Fact] public void EscapeJsString_NullInput_ReturnsNull() { - const string input = null; - var result = WebViewHelper.EscapeJsString(input); + var result = WebViewHelper.EscapeJsString(null); Assert.Null(result); } [Fact] - public void EscapeJsString_NoSingleQuote_ReturnsSameString() + public void EscapeJsString_NoSpecialChars_ReturnsSameString() { + // No backslashes or single quotes - returns unchanged const string input = """console.log("Hello, world!");"""; var result = WebViewHelper.EscapeJsString(input); Assert.Equal(input, result); } [Fact] - public void EscapeJsString_UnescapedQuote_EscapesCorrectly() + public void EscapeJsString_SingleQuote_EscapesCorrectly() { - // Each unescaped single quote should be preceded by one backslash. + // Single quotes should be escaped const string input = """console.log('Hello, world!');"""; - // Expected: each occurrence of "'" becomes "\'" const string expected = """console.log(\'Hello, world!\');"""; var result = WebViewHelper.EscapeJsString(input); Assert.Equal(expected, result); } [Fact] - public void EscapeJsString_AlreadyEscapedQuote_EscapesFurther() + public void EscapeJsString_Backslash_EscapesCorrectly() + { + // Backslashes should be escaped to prevent double-evaluation in eval() + const string input = @"console.log(""Hello\\World"");"; + const string expected = @"console.log(""Hello\\\\World"");"; + var result = WebViewHelper.EscapeJsString(input); + Assert.Equal(expected, result); + } + + [Fact] + public void EscapeJsString_BackslashAndQuote_EscapesBothCorrectly() { - const string input = """var str = 'Don\'t do that';"""; - const string expected = """var str = \'Don\\\'t do that\';"""; + // Both backslashes and single quotes must be escaped + // Backslashes first, then quotes + const string input = @"var str = 'Don\'t do that';"; + // \ -> \\, then ' -> \' + // Input has: ' D o n \ ' t (quote, backslash, quote) + // After escaping \: ' D o n \\ ' t + // After escaping ': \' D o n \\ \' t + const string expected = @"var str = \'Don\\\'t do that\';"; var result = WebViewHelper.EscapeJsString(input); Assert.Equal(expected, result); } [Fact] - public void EscapeJsString_MultipleLinesAndMixedQuotes() + public void EscapeJsString_MultipleBackslashes() { - const string input = """ - function test() { - console.log('Test "string" with a single quote'); - var example = 'It\\'s tricky!'; - } - """; - const string expected = """ - function test() { - console.log(\'Test "string" with a single quote\'); - var example = \'It\\\\\'s tricky!\'; - } - """; + // Multiple backslashes should each be doubled + const string input = @"path\\to\\file"; + const string expected = @"path\\\\to\\\\file"; var result = WebViewHelper.EscapeJsString(input); Assert.Equal(expected, result); } [Fact] - public void EscapeJsString_MultipleBackslashesBeforeQuote() + public void EscapeJsString_XssAttackPrevention() { - const string input = @"var tricky = 'Backslash: \\\' tricky!';"; - const string expected = @"var tricky = \'Backslash: \\\\\\\' tricky!\';"; + const string input = """console.log("\\");alert('xss');(\\"");"""; + const string expected = """console.log("\\\\");alert(\'xss\');(\\\\"");"""; var result = WebViewHelper.EscapeJsString(input); Assert.Equal(expected, result); } @@ -97,10 +103,56 @@ public void EscapeJsString_OnlyQuote() } [Fact] - public void EscapeJsString_RepeatedEscapedQuotes() + public void EscapeJsString_OnlyBackslash() + { + const string input = @"\"; + const string expected = @"\\"; + var result = WebViewHelper.EscapeJsString(input); + Assert.Equal(expected, result); + } + + [Fact] + public void EscapeJsString_TrailingBackslashBeforeQuote() + { + // Edge case: backslash immediately before quote + const string input = @"test\'"; + // \ -> \\, then ' -> \' + const string expected = @"test\\\'"; + var result = WebViewHelper.EscapeJsString(input); + Assert.Equal(expected, result); + } + + [Fact] + public void EscapeJsString_EmptyString_ReturnsSameString() + { + const string input = ""; + var result = WebViewHelper.EscapeJsString(input); + Assert.Equal(input, result); + } + + [Fact] + public void EscapeJsString_Newlines_EscapesCorrectly() + { + const string input = "line1\nline2\rline3"; + const string expected = "line1\\nline2\\rline3"; + var result = WebViewHelper.EscapeJsString(input); + Assert.Equal(expected, result); + } + + [Fact] + public void EscapeJsString_UnicodeLineSeparators_EscapesCorrectly() + { + const string input = "line1\u2028line2\u2029line3"; + const string expected = "line1\\u2028line2\\u2029line3"; + var result = WebViewHelper.EscapeJsString(input); + Assert.Equal(expected, result); + } + + [Fact] + public void EscapeJsString_MultilineWithQuotesAndBackslashes() { - const string input = @"'Quote' and again \'Quote\'"; - const string expected = @"\'Quote\' and again \\\'Quote\\\'"; + const string input = "var x = 'test\\path';\nalert('done');"; + const string expected = "var x = \\'test\\\\path\\';\\nalert(\\'done\\');"; var result = WebViewHelper.EscapeJsString(input); Assert.Equal(expected, result); } diff --git a/src/Core/src/Handlers/WebView/WebViewHelper.cs b/src/Core/src/Handlers/WebView/WebViewHelper.cs index febd2f14a373..8f91142029a8 100644 --- a/src/Core/src/Handlers/WebView/WebViewHelper.cs +++ b/src/Core/src/Handlers/WebView/WebViewHelper.cs @@ -1,36 +1,70 @@ namespace Microsoft.Maui.Handlers; using System; -using System.Text.RegularExpressions; internal static partial class WebViewHelper { - internal static string? EscapeJsString(string js) + /// + /// Escapes backslashes, single quotes, and line terminators in a JavaScript string + /// for use in a single-quoted string literal passed to eval(). + /// + internal static string? EscapeJsString(string? js) { if (js == null) return null; #if NET6_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER - if (!js.Contains('\'', StringComparison.Ordinal)) + bool hasBackslash = js.Contains('\\', StringComparison.Ordinal); + bool hasSingleQuote = js.Contains('\'', StringComparison.Ordinal); + bool hasNewline = js.Contains('\n', StringComparison.Ordinal); + bool hasCarriageReturn = js.Contains('\r', StringComparison.Ordinal); + bool hasLineSeparator = js.Contains('\u2028', StringComparison.Ordinal); + bool hasParagraphSeparator = js.Contains('\u2029', StringComparison.Ordinal); #else - if (js.IndexOf('\'') == -1) + bool hasBackslash = js.IndexOf('\\') != -1; + bool hasSingleQuote = js.IndexOf('\'') != -1; + bool hasNewline = js.IndexOf('\n') != -1; + bool hasCarriageReturn = js.IndexOf('\r') != -1; + bool hasLineSeparator = js.IndexOf('\u2028') != -1; + bool hasParagraphSeparator = js.IndexOf('\u2029') != -1; #endif + + if (!hasBackslash && !hasSingleQuote && !hasNewline && !hasCarriageReturn && !hasLineSeparator && !hasParagraphSeparator) return js; - return EscapeJsStringRegex().Replace(js, m => - { - int count = m.Groups[1].Value.Length; - // Replace with doubled backslashes plus one extra backslash, then the quote. - return new string('\\', (count * 2) + 1) + "'"; - }); - } + var result = js; +#if NET6_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER + if (hasBackslash) + result = result.Replace("\\", "\\\\", StringComparison.Ordinal); + if (hasSingleQuote) + result = result.Replace("'", "\\'", StringComparison.Ordinal); -#if NET6_0_OR_GREATER - [GeneratedRegex(@"(\\*)'")] - private static partial Regex EscapeJsStringRegex(); + // Escape line terminators to prevent SyntaxError in eval('...') + if (hasNewline) + result = result.Replace("\n", "\\n", StringComparison.Ordinal); + if (hasCarriageReturn) + result = result.Replace("\r", "\\r", StringComparison.Ordinal); + if (hasLineSeparator) + result = result.Replace("\u2028", "\\u2028", StringComparison.Ordinal); + if (hasParagraphSeparator) + result = result.Replace("\u2029", "\\u2029", StringComparison.Ordinal); #else - static Regex? EscapeJsStringRegexCached; - private static Regex EscapeJsStringRegex() => - EscapeJsStringRegexCached ??= new Regex(@"(\\*)'", RegexOptions.Compiled | RegexOptions.CultureInvariant); + if (hasBackslash) + result = result.Replace("\\", "\\\\"); + if (hasSingleQuote) + result = result.Replace("'", "\\'"); + + // Escape line terminators to prevent SyntaxError in eval('...') + if (hasNewline) + result = result.Replace("\n", "\\n"); + if (hasCarriageReturn) + result = result.Replace("\r", "\\r"); + if (hasLineSeparator) + result = result.Replace("\u2028", "\\u2028"); + if (hasParagraphSeparator) + result = result.Replace("\u2029", "\\u2029"); #endif + + return result; + } }