Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace DCL.ChangeRealmPrompt
using UnityEngine;

namespace DCL.ChangeRealmPrompt
{
public partial class ChangeRealmPromptController
{
Expand All @@ -7,10 +9,14 @@ public struct Params
public string Message { get; }
public string Realm { get; }

public Params(string message, string realm)
/// <summary>Optional target parcel to land on after the realm switch.</summary>
public Vector2Int? Position { get; }

public Params(string message, string realm, Vector2Int? position = null)
{
Message = message;
Realm = realm;
Position = position;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,39 @@
using MVC;
using System;
using System.Threading;
using UnityEngine;

namespace DCL.ChangeRealmPrompt
{
public partial class ChangeRealmPromptController : ControllerBase<ChangeRealmPromptView, ChangeRealmPromptController.Params>
{
public override CanvasOrdering.SortingLayer Layer => CanvasOrdering.SortingLayer.POPUP;

private const string DEFAULT_CONFIRMATION_MESSAGE = "Are you sure you want to enter this World?";

private readonly ICursor cursor;
private readonly Action<string> changeRealmCallback;
private Action<ChangeRealmPromptResultType> resultCallback;
private readonly Action<string, Vector2Int?> changeRealmCallback;
private Action<ChangeRealmPromptResultType>? resultCallback;

public ChangeRealmPromptController(
ViewFactoryMethod viewFactory,
ICursor cursor,
Action<string> changeRealmCallback) : base(viewFactory)
Action<string, Vector2Int?> changeRealmCallback) : base(viewFactory)
{
this.cursor = cursor;
this.changeRealmCallback = changeRealmCallback;
}

protected override void OnViewInstantiated()
{
viewInstance.CloseButton.onClick.AddListener(Dismiss);
viewInstance!.CloseButton.onClick.AddListener(Dismiss);
viewInstance.CancelButton.onClick.AddListener(Dismiss);
viewInstance.ContinueButton.onClick.AddListener(Approve);

// Message and realm are attacker-controllable (scene changeRealm / deep link). Disable rich-text
// parsing so neither can inject TMP markup into this consent prompt (SEC-003; same class as SEC-034/050).
viewInstance.MessageText.richText = false;
viewInstance.RealmText.richText = false;
}

protected override void OnViewShow()
Expand All @@ -38,32 +46,50 @@ protected override void OnViewShow()
if (result != ChangeRealmPromptResultType.Approved)
return;

changeRealmCallback?.Invoke(inputData.Realm);
changeRealmCallback.Invoke(inputData.Realm, inputData.Position);
});
}

protected override UniTask WaitForCloseIntentAsync(CancellationToken ct)
{
if (string.IsNullOrEmpty(inputData.Message))
return UniTask.CompletedTask;

return UniTask.WhenAny(
viewInstance.CloseButton.OnClickAsync(ct),
protected override UniTask WaitForCloseIntentAsync(CancellationToken ct) =>
UniTask.WhenAny(
viewInstance!.CloseButton.OnClickAsync(ct),
viewInstance.CancelButton.OnClickAsync(ct),
viewInstance.ContinueButton.OnClickAsync(ct));
}

private void RequestChangeRealm(string message, string realm, Action<ChangeRealmPromptResultType> result)
{
resultCallback = result;
viewInstance!.MessageText.text = string.IsNullOrEmpty(message) ? DEFAULT_CONFIRMATION_MESSAGE : message;
viewInstance.RealmText.text = DestinationHostFor(realm);
}

if (string.IsNullOrEmpty(message))
resultCallback?.Invoke(ChangeRealmPromptResultType.Approved);
else
/// <summary>
/// The destination shown to the user: for a URL realm the authority (host[:port]) with any misleading
/// userinfo (<c>https://trusted@evil.com</c>) and path/query stripped, so the true host is shown — not a
/// spoof; a world name or realm alias is shown unchanged. This — with rich-text disabled in
/// <see cref="OnViewInstantiated"/> — is what the user actually consents to.
/// </summary>
internal static string DestinationHostFor(string realm)
{
int schemeIdx = realm.IndexOf("://", StringComparison.Ordinal);

if (schemeIdx < 0)
return realm;

int start = schemeIdx + 3;
int end = start;

while (end < realm.Length && realm[end] != '/' && realm[end] != '?' && realm[end] != '#')
{
viewInstance.MessageText.text = message;
viewInstance.RealmText.text = realm;
// Skip past any userinfo (e.g. https://decentraland.org@evil.com) so the real host after the
// last '@' is displayed, not the trusted-looking prefix (consent-prompt spoofing, SEC-004).
if (realm[end] == '@')
start = end + 1;

end++;
}

return end > start ? realm.Substring(start, end - start) : realm;
}

private void Dismiss() =>
Expand Down
72 changes: 57 additions & 15 deletions Explorer/Assets/DCL/Chat/Commands/ChatEnvironmentValidator.cs
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
using DCL.Multiplayer.Connections.DecentralandUrls;
using DCL.Utility.Types;
using UnityEngine.Networking;
using System;

namespace DCL.Chat.Commands
{
public class ChatEnvironmentValidator
{
private readonly DecentralandEnvironment dclEnvironment;
private const string ORG_HOST_SUFFIX = "decentraland.org";
private const string ZONE_HOST_SUFFIX = "decentraland.zone";

private readonly string zoneDescription;
private readonly string orgDescription;
private readonly DecentralandEnvironment dclEnvironment;

public ChatEnvironmentValidator(DecentralandEnvironment dclEnvironment)
{
this.dclEnvironment = dclEnvironment;
zoneDescription = DecentralandEnvironment.Zone.ToString().ToLower();
orgDescription = DecentralandEnvironment.Org.ToString().ToLower();
}

public Result ValidateTeleport(string realmToTeleportTo)
Expand All @@ -26,19 +24,63 @@ public Result ValidateTeleport(string realmToTeleportTo)
return Result.ErrorResult(
"🔴 Error. You cannot change realms in the Today environment. Please restart DCL with the desired environment");
case DecentralandEnvironment.Zone:
if (realmToTeleportTo.Contains(zoneDescription))
return Result.SuccessResult();
return Result.ErrorResult(
"🔴 Error. You cannot teleport to other realms that are not Zone in Zone environment. Please restart DCL with the desired environment");
return HostHasSuffix(realmToTeleportTo, ZONE_HOST_SUFFIX)
? Result.SuccessResult()
: Result.ErrorResult(
"🔴 Error. You cannot teleport to other realms that are not Zone in Zone environment. Please restart DCL with the desired environment");
case DecentralandEnvironment.Org:
if (realmToTeleportTo.Contains(orgDescription))
return Result.SuccessResult();

return Result.ErrorResult(
"🔴 Error. You cannot teleport to other realms that are not Org or World in Org environment. Please restart DCL with the desired environment");
return HostHasSuffix(realmToTeleportTo, ORG_HOST_SUFFIX)
? Result.SuccessResult()
: Result.ErrorResult(
"🔴 Error. You cannot teleport to other realms that are not Org or World in Org environment. Please restart DCL with the desired environment");
}

return Result.SuccessResult();
}

/// <summary>
/// True if the URL's host equals <paramref name="suffix"/> or ends with "." + suffix at a domain
/// boundary. Reads the host out of the string via a span (no <see cref="Uri"/> allocation); a URL
/// carrying userinfo ('@') is rejected so it cannot spoof the host — the '@' is checked before any ':'
/// so a colon-before-'@' form (https://decentraland.org:1234@evil.com) cannot slip past the guard.
/// </summary>
private static bool HostHasSuffix(string url, string suffix)
{
int schemeIdx = url.IndexOf("://", StringComparison.Ordinal);

if (schemeIdx < 0)
return false;

int hostStart = schemeIdx + 3;
int authorityEnd = hostStart;

while (authorityEnd < url.Length)
{
char c = url[authorityEnd];

if (c == '/' || c == '?' || c == '#')
break;

// userinfo is not expected in a realm URL; reject rather than risk host-confusion. Checked BEFORE
// any ':' so https://decentraland.org:1234@evil.com (port before userinfo) can't slip past.
if (c == '@')
return false;

authorityEnd++;
}

// Strip the port (if any) from the authority to get the bare host.
ReadOnlySpan<char> authority = url.AsSpan(hostStart, authorityEnd - hostStart);
int portSep = authority.IndexOf(':');
ReadOnlySpan<char> host = portSep >= 0 ? authority.Slice(0, portSep) : authority;

if (host.Equals(suffix, StringComparison.OrdinalIgnoreCase))
return true;

// subdomain boundary match: host ends with ".{suffix}"
return host.Length > suffix.Length
&& host[host.Length - suffix.Length - 1] == '.'
&& host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
using System;
using DCL.Browser;
using System;

namespace DCL.ExternalUrlPrompt
{
public partial class ExternalUrlPromptController
{
public struct Params
{
public Uri Uri { get; }
/// <summary>Null when <c>url</c> is not an absolute http/https URL — callers must null-check.</summary>
public Uri? Uri { get; }

public Params(string url)
{
Uri = Uri.TryCreate(url, UriKind.Absolute, out Uri uri) ? uri : null;
Uri = ExternalUrlPolicy.IsWebScheme(url) && Uri.TryCreate(url, UriKind.Absolute, out Uri uri)
? uri
: null;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ public partial class ExternalUrlPromptController : ControllerBase<ExternalUrlPro

private readonly UnityAppWebBrowser webBrowser;
private readonly ICursor cursor;
private readonly List<string> trustedDomains = new ();
private Action<ExternalUrlPromptResultType> resultCallback;
private readonly HashSet<string> trustedKeys = new ();
private Action<ExternalUrlPromptResultType>? resultCallback;

public ExternalUrlPromptController(
ViewFactoryMethod viewFactory,
Expand All @@ -28,7 +28,7 @@ public ExternalUrlPromptController(

protected override void OnViewInstantiated()
{
viewInstance.CloseButton.onClick.AddListener(Dismiss);
viewInstance!.CloseButton.onClick.AddListener(Dismiss);
viewInstance.CancelButton.onClick.AddListener(Dismiss);
viewInstance.ContinueButton.onClick.AddListener(Approve);
}
Expand All @@ -38,33 +38,38 @@ protected override void OnViewShow()
if (inputData.Uri == null)
return;

if (trustedDomains.Contains(inputData.Uri.Host))
Uri uri = inputData.Uri;

if (ExternalUrlPolicy.TryGetTrustKey(uri, out string trustKey) && trustedKeys.Contains(trustKey))
{
webBrowser.OpenUrlMainThreadOnly(inputData.Uri.OriginalString);
viewInstance.CloseButton.OnClickAsync(CancellationToken.None).Forget();
webBrowser.OpenUrlMainThreadOnly(uri.OriginalString);
viewInstance!.CloseButton.OnClickAsync(CancellationToken.None).Forget();
return;
}

cursor.Unlock();
RequestOpenUrl(inputData.Uri, result =>
RequestOpenUrl(uri, result =>
{
switch (result)
{
case ExternalUrlPromptResultType.ApprovedTrusted:
if (!trustedDomains.Contains(inputData.Uri.Host))
trustedDomains.Add(inputData.Uri.Host);
webBrowser.OpenUrlMainThreadOnly(inputData.Uri.OriginalString);
// Only cache when a real (scheme, host) key exists — empty-host URIs are never trusted (SEC-008).
if (ExternalUrlPolicy.TryGetTrustKey(uri, out string key))
trustedKeys.Add(key);
webBrowser.OpenUrlMainThreadOnly(uri.OriginalString);
break;
case ExternalUrlPromptResultType.Approved:
webBrowser.OpenUrlMainThreadOnly(inputData.Uri.OriginalString);
webBrowser.OpenUrlMainThreadOnly(uri.OriginalString);
break;
}
});
}

protected override UniTask WaitForCloseIntentAsync(CancellationToken ct)
{
if (inputData.Uri != null && trustedDomains.Contains(inputData.Uri.Host))
if (inputData.Uri != null
&& ExternalUrlPolicy.TryGetTrustKey(inputData.Uri, out string trustKey)
&& trustedKeys.Contains(trustKey))
return UniTask.CompletedTask;

return UniTask.WhenAny(
Expand All @@ -75,13 +80,13 @@ protected override UniTask WaitForCloseIntentAsync(CancellationToken ct)

public override void Dispose()
{
trustedDomains.Clear();
trustedKeys.Clear();
}

private void RequestOpenUrl(Uri uri, Action<ExternalUrlPromptResultType> result)
{
resultCallback = result;
viewInstance.DomainText.text = uri.Host;
viewInstance!.DomainText.text = uri.Host;
viewInstance.UrlText.text = uri.OriginalString;
viewInstance.TrustToggle.isOn = false;
}
Expand All @@ -90,6 +95,6 @@ private void Dismiss() =>
resultCallback?.Invoke(ExternalUrlPromptResultType.Canceled);

private void Approve() =>
resultCallback?.Invoke(viewInstance.TrustToggle.isOn ? ExternalUrlPromptResultType.ApprovedTrusted : ExternalUrlPromptResultType.Approved);
resultCallback?.Invoke(viewInstance!.TrustToggle.isOn ? ExternalUrlPromptResultType.ApprovedTrusted : ExternalUrlPromptResultType.Approved);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ public bool TryGet(in string key, out GltfContainerAsset? asset)
/// </summary>
public void Dereference(in string key, GltfContainerAsset asset, bool putInBridge = false, bool handleAssetLoad = true)
{
// A stale promise result can still reference an asset whose Root was already destroyed
// (e.g. drained by Unload). Comparing against Unity's overloaded null catches that destroyed
// GameObject; re-pooling it would crash DereferenceFinalOperation and hand a dead instance back out.
if (asset.Root == null)
return;

if (handleAssetLoad && assetLoadCache != null && assetLoadCache.ContainsGltf(key))
{
assetLoadCache.ReleaseGltfInstance(key, asset);
Expand Down
Loading
Loading