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
1 change: 1 addition & 0 deletions App/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ public App()
services.AddTransient<FileSyncListWindow>();

services.AddSingleton<ISettingsManager<CoderConnectSettings>, SettingsManager<CoderConnectSettings>>();
services.AddSingleton<ISettingsManager<AppLinkSettings>, SettingsManager<AppLinkSettings>>();
services.AddSingleton<IStartupManager, StartupManager>();
// SettingsWindow views and view models
services.AddTransient<SettingsViewModel>();
Expand Down
4 changes: 4 additions & 0 deletions App/Models/CredentialModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ public class CredentialModel : ICoderApiClientCredentialProvider
public CredentialState State { get; init; } = CredentialState.Unknown;

public Uri? CoderUrl { get; init; }

/// <summary>The deployment's access URL from build info, which can differ from <see cref="CoderUrl" />.</summary>
public Uri? DashboardUrl { get; init; }
public string? ApiToken { get; init; }

public string? Username { get; init; }
Expand All @@ -30,6 +33,7 @@ public CredentialModel Clone()
{
State = State,
CoderUrl = CoderUrl,
DashboardUrl = DashboardUrl,
ApiToken = ApiToken,
Username = Username,
};
Expand Down
16 changes: 16 additions & 0 deletions App/Models/Settings.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Collections.Generic;

namespace Coder.Desktop.App.Models;

public interface ISettings<T> : ICloneable<T>
Expand Down Expand Up @@ -60,3 +62,17 @@ public CoderConnectSettings Clone()
return new CoderConnectSettings(Version, ConnectOnLaunch);
}
}

public class AppLinkSettings : ISettings<AppLinkSettings>
{
public static string SettingsFileName { get; } = "app-link-settings.json";
public int Version { get; set; } = 1;

/// <summary>Lowercase URI schemes the user chose to open without confirmation.</summary>
public HashSet<string> TrustedSchemes { get; set; } = [];

public AppLinkSettings Clone()
{
return new AppLinkSettings { Version = Version, TrustedSchemes = [.. TrustedSchemes] };
}
}
3 changes: 3 additions & 0 deletions App/Services/CredentialManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,9 @@ private async Task<CredentialModel> PopulateModel(RawCredentials? credentials, C
{
State = CredentialState.Valid,
CoderUrl = uri,
DashboardUrl = Uri.TryCreate(buildInfo.DashboardUrl, UriKind.Absolute, out var dashboardUrl)
? dashboardUrl
: null,
ApiToken = credentials.ApiToken,
Username = me.Username,
};
Expand Down
22 changes: 22 additions & 0 deletions App/Services/SettingsManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ namespace Coder.Desktop.App.Services;
/// <param name="ct"></param>
/// <returns></returns>
Task Write(T settings, CancellationToken ct = default);
/// <summary>
/// Reads, changes and writes the settings. Updates run one at a time, so
/// concurrent updates don't overwrite each other.
/// </summary>
Task Update(Action<T> update, CancellationToken ct = default);
}

public static class SettingsManagerUtils
Expand Down Expand Up @@ -69,6 +74,8 @@ public static string AppSettingsDirectory(string? settingsFilePath = null)
private T? _cachedSettings;

private readonly SemaphoreSlim _gate = new(1, 1);
// Read and Write each take _gate, so updates need a lock of their own.
private readonly SemaphoreSlim _updateGate = new(1, 1);
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(3);

/// <param name="settingsFilePath">
Expand Down Expand Up @@ -156,4 +163,19 @@ await File.WriteAllTextAsync(_settingsFilePath, json, ct)
_gate.Release();
}
}

public async Task Update(Action<T> update, CancellationToken ct = default)
{
await _updateGate.WaitAsync(ct).ConfigureAwait(false);
try
{
var settings = await Read(ct).ConfigureAwait(false);
update(settings);
await Write(settings, ct).ConfigureAwait(false);
}
finally
{
_updateGate.Release();
}
}
}
113 changes: 113 additions & 0 deletions App/Utils/SessionTokenLink.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using System;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;

namespace Coder.Desktop.App.Utils;

/// <summary>
/// Decides whether an external app link may receive the user's session
/// token, using the same rules as the Coder dashboard.
/// </summary>
public static class SessionTokenLink
{
public const string Placeholder = "$SESSION_TOKEN";

public const string UntrustedUrlMessage =
"This link asks for your Coder sign-in, but its URL isn't trusted. Ask your template admin to fix it.";

/// <param name="Base">Scheme and authority, matched case-insensitively.</param>
/// <param name="Path">Matched exactly, up to where the params start.</param>
/// <param name="FirstParam">Required leading param, matched exactly.</param>
private record Handler(string Base, string Path, string FirstParam = "");

private static readonly Handler[] Handlers =
[
.. new[]
{
"vscode", "vscode-insiders", "windsurf", "devin", "cursor", "kiro", "positron", "antigravity",
"antigravity-ide",
}
.SelectMany(scheme => new[] { "/open?", "/openDevContainer?" }
.Select(path => new Handler($"{scheme}://coder.coder-remote", path))),
new("jetbrains://gateway", "/coder?"),
new("jetbrains://gateway", "/com.coder.toolbox?"),
// Gateway also handles non-Coder providers, such as `type=ssh`.
new("jetbrains-gateway://connect", "#", "type=coder&"),
];

public static bool IsKnownHandler(string appUrl)
{
return GetHandler(appUrl) is not null;
}

/// <summary>
/// Returns null if the link has no placeholder, or its only <c>url</c> is
/// <paramref name="signedInUrl" /> or <paramref name="dashboardUrl" />.
/// </summary>
public static string? BlockReason(string appUrl, Uri signedInUrl, Uri? dashboardUrl)
{
if (!appUrl.Contains(Placeholder)) return null;
var handler = GetHandler(appUrl);
if (handler is null || appUrl.Split(Placeholder).Length != 2) return UntrustedUrlMessage;

// The placeholder is replaced raw, so it must be the raw `token` value.
// Gateway doesn't decode names, IDEs decode values twice, and VS Code
// ignores params after a `#`.
var parameters = appUrl[(handler.Base.Length + handler.Path.Length)..];
var entries = parameters.Split('&');
var decoded = Uri.UnescapeDataString(parameters);
var url = UrlParam(parameters);
if (!entries.Contains($"token={Placeholder}") ||
entries.Any(entry => entry.Split('=')[0].Contains('%')) ||
parameters.Contains('#') ||
decoded.Contains('%') ||
url is null ||
UrlParam(decoded) != url)
return UntrustedUrlMessage;

return IsSameOrigin(url, signedInUrl) || (dashboardUrl is not null && IsSameOrigin(url, dashboardUrl))
? null
: $"This app signs in to {url.TrimEnd('/')}, but Coder Desktop is signed in to " +
$"{signedInUrl.GetLeftPart(UriPartial.Authority)}.";
}

/// <summary>
/// Links with control characters never match, since parsers drop tab, LF
/// and CR. <c>Ascii.EqualsIgnoreCase</c> doesn't fold a Kelvin sign into <c>k</c>.
/// </summary>
private static Handler? GetHandler(string appUrl)
{
if (appUrl.Any(char.IsControl)) return null;
return Handlers.FirstOrDefault(handler =>
appUrl.Length >= handler.Base.Length &&
Ascii.EqualsIgnoreCase(appUrl.AsSpan(0, handler.Base.Length), handler.Base) &&
appUrl.AsSpan(handler.Base.Length).StartsWith(handler.Path + handler.FirstParam, StringComparison.Ordinal));
}

/// <summary>
/// Returns <c>url</c> if the names are unique and it's a bare http(s)
/// origin, matched raw since parsing normalizes <c>/.</c> and drops an
/// empty <c>?</c>.
/// </summary>
private static string? UrlParam(string query)
{
// Parsed like `URLSearchParams`.
var parameters = query.Split('&', StringSplitOptions.RemoveEmptyEntries)
.Select(entry => entry.Split('=', 2).Select(part => Uri.UnescapeDataString(part.Replace('+', ' '))).ToArray())
.ToList();
var url = parameters.FirstOrDefault(parts => parts[0] == "url" && parts.Length == 2)?[1];
return parameters.DistinctBy(parts => parts[0]).Count() == parameters.Count &&
url is not null &&
Regex.IsMatch(url, @"^https?://[^/\\?#@\s]+/?\z", RegexOptions.IgnoreCase)
? url
: null;
}

private static bool IsSameOrigin(string link, Uri url)
{
return Uri.TryCreate(link, UriKind.Absolute, out var origin) &&
Uri.Compare(origin, url, UriComponents.SchemeAndServer, UriFormat.UriEscaped,
StringComparison.OrdinalIgnoreCase) == 0;
}
}
Loading
Loading