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
41 changes: 2 additions & 39 deletions src/Controls/src/Core/WebView/WebView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.Maui.Controls.Internals;

using Microsoft.Maui.Devices;
using Microsoft.Maui.Handlers;

namespace Microsoft.Maui.Controls
{
Expand Down Expand Up @@ -125,7 +125,7 @@ public async Task<string> EvaluateJavaScriptAsync(string script)
// Make all the platforms mimic Android's implementation, which is by far the most complete.
if (DeviceInfo.Platform != DevicePlatform.Android)
{
script = EscapeJsString(script);
script = WebViewHelper.EscapeJsString(script);

if (DeviceInfo.Platform != DevicePlatform.WinUI)
{
Expand Down Expand Up @@ -290,43 +290,6 @@ public IPlatformElementConfiguration<T, WebView> On<T>() where T : IConfigPlatfo
return _platformConfigurationRegistry.Value.On<T>();
}

private static string EscapeJsString(string js)
{
if (js == null)
return null;

if (js.IndexOf("'", StringComparison.Ordinal) == -1)
return js;

//get every quote in the string along with all the backslashes preceding it
var singleQuotes = Regex.Matches(js, @"(\\*?)'");

var uniqueMatches = new List<string>();

for (var i = 0; i < singleQuotes.Count; i++)
{
var matchedString = singleQuotes[i].Value;
if (!uniqueMatches.Contains(matchedString))
{
uniqueMatches.Add(matchedString);
}
}

uniqueMatches.Sort((x, y) => y.Length.CompareTo(x.Length));

//escape all quotes from the script as well as add additional escaping to all quotes that were already escaped
for (var i = 0; i < uniqueMatches.Count; i++)
{
var match = uniqueMatches[i];
var numberOfBackslashes = match.Length - 1;
var slashesToAdd = (numberOfBackslashes * 2) + 1;
var replacementStr = "'".PadLeft(slashesToAdd + 1, '\\');
js = Regex.Replace(js, @"(?<=[^\\])" + Regex.Escape(match), replacementStr);
}

return js;
}

/// <inheritdoc/>
IWebViewSource IWebView.Source => Source;

Expand Down
107 changes: 107 additions & 0 deletions src/Controls/tests/Core.UnitTests/WebViewHelperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using Xunit;
using Microsoft.Maui.Handlers;

namespace Microsoft.Maui.Controls.Core.UnitTests;

public class WebViewHelperTests
{
[Fact]
public void EscapeJsString_NullInput_ReturnsNull()
{
const string input = null;
var result = WebViewHelper.EscapeJsString(input);
Assert.Null(result);
}

[Fact]
public void EscapeJsString_NoSingleQuote_ReturnsSameString()
{
const string input = """console.log("Hello, world!");""";
var result = WebViewHelper.EscapeJsString(input);
Assert.Equal(input, result);
}

[Fact]
public void EscapeJsString_UnescapedQuote_EscapesCorrectly()
{
// Each unescaped single quote should be preceded by one backslash.
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()
{
const string input = """var str = 'Don\'t do that';""";
const string expected = """var str = \'Don\\\'t do that\';""";
var result = WebViewHelper.EscapeJsString(input);
Assert.Equal(expected, result);
}

[Fact]
public void EscapeJsString_MultipleLinesAndMixedQuotes()
{
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!\';
}
""";
var result = WebViewHelper.EscapeJsString(input);
Assert.Equal(expected, result);
}

[Fact]
public void EscapeJsString_MultipleBackslashesBeforeQuote()
{
const string input = @"var tricky = 'Backslash: \\\' tricky!';";
const string expected = @"var tricky = \'Backslash: \\\\\\\' tricky!\';";
var result = WebViewHelper.EscapeJsString(input);
Assert.Equal(expected, result);
}

[Fact]
public void EscapeJsString_QuoteAtBeginning()
{
const string input = @"'Start with quote";
const string expected = @"\'Start with quote";
var result = WebViewHelper.EscapeJsString(input);
Assert.Equal(expected, result);
}

[Fact]
public void EscapeJsString_QuoteAtEnd()
{
const string input = @"Ends with a quote'";
const string expected = @"Ends with a quote\'";
var result = WebViewHelper.EscapeJsString(input);
Assert.Equal(expected, result);
}

[Fact]
public void EscapeJsString_OnlyQuote()
{
const string input = @"'";
const string expected = @"\'";
var result = WebViewHelper.EscapeJsString(input);
Assert.Equal(expected, result);
}

[Fact]
public void EscapeJsString_RepeatedEscapedQuotes()
{
const string input = @"'Quote' and again \'Quote\'";
const string expected = @"\'Quote\' and again \\\'Quote\\\'";
var result = WebViewHelper.EscapeJsString(input);
Assert.Equal(expected, result);
}
}
43 changes: 1 addition & 42 deletions src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ public static async void MapEvaluateJavaScriptAsync(IHybridWebViewHandler handle
// Make all the platforms mimic Android's implementation, which is by far the most complete.
if (!OperatingSystem.IsAndroid())
{
script = EscapeJsString(script);
script = WebViewHelper.EscapeJsString(script);

if (!OperatingSystem.IsWindows())
{
Expand Down Expand Up @@ -430,47 +430,6 @@ await handler.InvokeAsync(nameof(IHybridWebView.EvaluateJavaScriptAsync),
}
}


#if PLATFORM && !TIZEN
// Copied from WebView.cs
internal static string? EscapeJsString(string js)
{
if (js == null)
return null;

if (!js.Contains('\'', StringComparison.Ordinal))
return js;

//get every quote in the string along with all the backslashes preceding it
var singleQuotes = Regex.Matches(js, @"(\\*?)'");

var uniqueMatches = new List<string>();

for (var i = 0; i < singleQuotes.Count; i++)
{
var matchedString = singleQuotes[i].Value;
if (!uniqueMatches.Contains(matchedString))
{
uniqueMatches.Add(matchedString);
}
}

uniqueMatches.Sort((x, y) => y.Length.CompareTo(x.Length));

//escape all quotes from the script as well as add additional escaping to all quotes that were already escaped
for (var i = 0; i < uniqueMatches.Count; i++)
{
var match = uniqueMatches[i];
var numberOfBackslashes = match.Length - 1;
var slashesToAdd = (numberOfBackslashes * 2) + 1;
var replacementStr = "'".PadLeft(slashesToAdd + 1, '\\');
js = Regex.Replace(js, @"(?<=[^\\])" + Regex.Escape(match), replacementStr);
}

return js;
}
#endif

internal static async Task<string?> GetAssetContentAsync(string assetPath)
{
using var stream = await GetAssetStreamAsync(assetPath);
Expand Down
36 changes: 36 additions & 0 deletions src/Core/src/Handlers/WebView/WebViewHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace Microsoft.Maui.Handlers;

using System;
using System.Text.RegularExpressions;

internal static partial class WebViewHelper
{
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))
#else
if (js.IndexOf('\'') == -1)
#endif
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) + "'";
});
}

#if NET6_0_OR_GREATER
[GeneratedRegex(@"(\\*)'")]
private static partial Regex EscapeJsStringRegex();
#else
static Regex? EscapeJsStringRegexCached;
private static Regex EscapeJsStringRegex() =>
EscapeJsStringRegexCached ??= new Regex(@"(\\*)'", RegexOptions.Compiled | RegexOptions.CultureInvariant);
#endif
}