Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 80 additions & 28 deletions src/Controls/tests/Core.UnitTests/WebViewHelperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down
68 changes: 51 additions & 17 deletions src/Core/src/Handlers/WebView/WebViewHelper.cs
Original file line number Diff line number Diff line change
@@ -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)
/// <summary>
/// Escapes backslashes, single quotes, and line terminators in a JavaScript string
/// for use in a single-quoted string literal passed to eval().
/// </summary>
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;
}
}
Loading