Skip to content
Closed
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
Expand Up @@ -6,6 +6,7 @@
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
@inject IPasswordManager PasswordManager
@inject IPublicUrlProvider PublicUrlProvider

<PageTitle>Forgot your password?</PageTitle>

Expand Down Expand Up @@ -55,7 +56,7 @@ else
return;
}

var callbackUrl = NavigationManager.ToAbsoluteUri("Account/ResetPassword").AbsoluteUri;
var callbackUrl = PublicUrlProvider.GetAbsoluteUri("Account/ResetPassword");
var result = await PasswordManager.ForgotPassword(new InternalDtos.ForgotPasswordRequestDto(Input.Email), callbackUrl);
if (!result.IsSuccess)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
@inject IOptionsMonitor<AppOptions> AppOptions
@inject IdentityRedirectManager RedirectManager
@inject NavigationManager NavigationManager
@inject IPublicUrlProvider PublicUrlProvider

<PageTitle>Manage email</PageTitle>

Expand Down Expand Up @@ -94,8 +95,8 @@
var userId = await UserManager.GetUserIdAsync(_user);
var code = await UserManager.GenerateChangeEmailTokenAsync(_user, Input.NewEmail);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
NavigationManager.ToAbsoluteUri("Account/ConfirmEmailChange").AbsoluteUri,
var callbackUrl = PublicUrlProvider.GetAbsoluteUri(
"Account/ConfirmEmailChange",
new Dictionary<string, object?> { ["userId"] = userId, ["email"] = Input.NewEmail, ["code"] = code });

await EmailSender.SendConfirmationLinkAsync(_user, Input.NewEmail, HtmlEncoder.Default.Encode(callbackUrl));
Expand All @@ -119,8 +120,8 @@
var userId = await UserManager.GetUserIdAsync(_user);
var code = await UserManager.GenerateEmailConfirmationTokenAsync(_user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
var callbackUrl = PublicUrlProvider.GetAbsoluteUri(
"Account/ConfirmEmail",
new Dictionary<string, object?> { ["userId"] = userId, ["code"] = code });

await EmailSender.SendConfirmationLinkAsync(_user, _email, HtmlEncoder.Default.Encode(callbackUrl));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
@inject IOptionsMonitor<AppOptions> AppOptions
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
@inject IPublicUrlProvider PublicUrlProvider

<PageTitle>Resend email confirmation</PageTitle>

Expand Down Expand Up @@ -68,8 +69,8 @@ else
var userId = await UserManager.GetUserIdAsync(user);
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
var callbackUrl = PublicUrlProvider.GetAbsoluteUri(
"Account/ConfirmEmail",
new Dictionary<string, object?> { ["userId"] = userId, ["code"] = code });
await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));

Expand Down
117 changes: 29 additions & 88 deletions ControlR.Web.Server/Hubs/AgentHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -236,103 +236,39 @@ await _viewerHub.Clients
}
}

[Obsolete("This method is deprecated. Please use UpdateDeviceSigned instead.")]
public async Task<HubResult<InternalDtos.DeviceResponseDto>> UpdateDevice(DeviceUpdateRequestDto agentDto)
{
try
{
var device = await _appDb.Devices.FindAsync(agentDto.Id);
if (device is not null && !string.IsNullOrEmpty(device.PublicKey))
{
return HubResult.Fail<InternalDtos.DeviceResponseDto>("Device requires signed updates.");
}

if (_serverOptions.Value.DecommissionServer)
{
return await HandleAgentUpdateForDecommission(agentDto, device);
}

// Self-bootstrap: only permitted when exactly one tenant exists.
// Multi-tenant deployments must use installer keys with an explicit tenant.
if (_appOptions.Value.AllowAgentsToSelfBootstrap && agentDto.TenantId == Guid.Empty)
{
var tenants = await _appDb.Tenants
.OrderByDescending(x => x.CreatedAt)
.Take(2)
.ToListAsync();

if (tenants.Count == 0)
{
return HubResult.Fail<InternalDtos.DeviceResponseDto>("No tenants found.");
}

if (tenants.Count > 1)
{
return HubResult.Fail<InternalDtos.DeviceResponseDto>(
"Self-bootstrap is only allowed on single-tenant servers. Use an installer key instead.");
}

// Update the DTO with the assigned TenantId
agentDto = agentDto with { TenantId = tenants[0].Id };
}

if (agentDto.TenantId == Guid.Empty)
{
return HubResult.Fail<InternalDtos.DeviceResponseDto>("Invalid tenant ID.");
}

if (!await _appDb.Tenants.AnyAsync(x => x.Id == agentDto.TenantId))
{
return HubResult.Fail<InternalDtos.DeviceResponseDto>("Invalid tenant ID.");
}

var remoteIp = Context.GetHttpContext()?.Connection.RemoteIpAddress;
var connectionContext = new DeviceConnectionContext(
ConnectionId: Context.ConnectionId,
RemoteIpAddress: remoteIp,
LastSeen: _timeProvider.GetLocalNow(),
IsOnline: true
);

var updateResult = await UpdateDeviceEntity(agentDto, connectionContext);

if (!updateResult.IsSuccess)
{
return HubResult.Fail<InternalDtos.DeviceResponseDto>(updateResult.Reason);
}

var deviceEntity = updateResult.Value;

var isOutdated = await GetIsAgentOutdated(deviceEntity);
Device = deviceEntity.ToInternalResponseDto(isOutdated);

await SendDeviceUpdate(deviceEntity, Device);

return HubResult.Ok(Device);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while updating device.");
return HubResult.Fail<InternalDtos.DeviceResponseDto>("An error occurred while updating the device.");
}
}

public async Task<HubResult<InternalDtos.DeviceResponseDto>> UpdateDeviceSigned(SignedDto<DeviceUpdateRequestDto> signedDto)
{
try
{
var agentDto = signedDto.Dto;

// Only trust the agent-supplied key when self-bootstrap is enabled.
// A stored key always wins. An agent-supplied one is trusted only to bootstrap a device the
// server has never seen, and only when self-bootstrap is enabled.
var device = await _appDb.Devices.FindAsync(agentDto.Id);
var storedPublicKey = device?.PublicKey;

if (string.IsNullOrEmpty(storedPublicKey) && !_appOptions.Value.AllowAgentsToSelfBootstrap)
if (string.IsNullOrEmpty(storedPublicKey))
{
_logger.LogWarning(
"Rejecting update from unknown device {DeviceId}. Self-bootstrap is disabled.",
agentDto.Id);
return HubResult.Fail<InternalDtos.DeviceResponseDto>("Unknown device.");
// A caller-supplied key is only ever trusted to bootstrap a device the server has never
// seen. Honouring one for a device that already exists would let any anonymous caller who
// knows its id re-key it and take over its hub connection. Adopting a key for an existing
// device goes through the installer-key-authenticated registration API instead.
if (device is not null)
{
_logger.LogWarning(
"Rejecting update for device {DeviceId}, which has no registered public key. " +
"Re-register the device with an installer key to adopt one.",
agentDto.Id);
return HubResult.Fail<InternalDtos.DeviceResponseDto>("Device has no registered public key.");
}

if (!_appOptions.Value.AllowAgentsToSelfBootstrap)
{
_logger.LogWarning(
"Rejecting update from unknown device {DeviceId}. Self-bootstrap is disabled.",
agentDto.Id);
return HubResult.Fail<InternalDtos.DeviceResponseDto>("Unknown device.");
}
}

var publicKeyBase64 = !string.IsNullOrEmpty(storedPublicKey)
Expand Down Expand Up @@ -381,7 +317,12 @@ await _viewerHub.Clients
// Allow agents to self-bootstrap when enabled. Only permitted when exactly one
// tenant exists, so there's no ambiguity about where the agent lands. Multi-tenant
// deployments must use installer keys, which carry an explicit tenant.
if (_appOptions.Value.AllowAgentsToSelfBootstrap && agentDto.TenantId == Guid.Empty)
//
// The restriction applies whenever self-bootstrap is the authority for this write, not only
// when the caller left the tenant unset. Gating it on an empty TenantId let a caller skip the
// single-tenant check simply by naming an existing tenant, which is the opposite of what the
// restriction is for. The tenant is taken from the server, never from the caller.
if (_appOptions.Value.AllowAgentsToSelfBootstrap && device is null)
{
var tenants = await _appDb.Tenants
.OrderByDescending(x => x.CreatedAt)
Expand Down
18 changes: 18 additions & 0 deletions ControlR.Web.Server/Options/AppOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,24 @@ public class AppOptions
/// </summary>
public bool PersistPasskeyLogin { get; init; }

/// <summary>
/// The absolute base URL where this server is reachable by its users, e.g. "https://controlr.example.com".
/// </summary>
/// <remarks>
/// <para>
/// Links that are emailed to users (password reset, email confirmation, email change) are built
/// from this value. When it is not set, those links fall back to the host of the incoming request,
/// which an attacker controls via the <c>Host</c> header unless <c>AllowedHosts</c> is pinned or a
/// reverse proxy overwrites it. A forged host produces a genuine email from this server carrying a
/// valid token that points at the attacker's origin.
/// </para>
/// <para>
/// Set this on every internet-facing deployment. Include the scheme and, if non-standard, the port;
/// a trailing slash is optional.
/// </para>
/// </remarks>
public string? PublicBaseUrl { get; init; }

/// <summary>
/// Whether users must confirm their email address before being allowed to log in.
/// If true, you must also configure SMTP settings below.
Expand Down
63 changes: 63 additions & 0 deletions ControlR.Web.Server/Services/PublicUrlProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Components;

namespace ControlR.Web.Server.Services;

/// <summary>
/// Builds absolute URLs for links that leave the application, such as the callback links embedded in
/// account emails.
/// </summary>
/// <remarks>
/// Emailed links must never be derived from the incoming request. The <c>Host</c> header is
/// attacker-controlled unless <c>AllowedHosts</c> is pinned or a reverse proxy overwrites it, and a
/// forged host turns a password-reset mail into a genuine token delivered to the attacker's origin.
/// Callers get the configured <see cref="AppOptions.PublicBaseUrl"/> when one is set, and only fall
/// back to the request when it is not.
/// </remarks>
public interface IPublicUrlProvider
{
/// <summary>
/// Whether an explicit public base URL is configured. When false, generated URLs fall back to the
/// host of the incoming request.
/// </summary>
bool HasConfiguredBaseUrl { get; }

/// <summary>
/// Builds an absolute URL for a path relative to the application root.
/// </summary>
/// <param name="relativePath">The path relative to the application root, e.g. "Account/ResetPassword".</param>
string GetAbsoluteUri(string relativePath);

/// <summary>
/// Builds an absolute URL for a path relative to the application root, with the supplied query
/// parameters appended.
/// </summary>
/// <param name="relativePath">The path relative to the application root, e.g. "Account/ConfirmEmail".</param>
/// <param name="queryParameters">The query parameters to append.</param>
string GetAbsoluteUri(string relativePath, IReadOnlyDictionary<string, object?> queryParameters);
}

public class PublicUrlProvider(
NavigationManager navigationManager,
IOptionsMonitor<AppOptions> appOptions) : IPublicUrlProvider
{
private readonly IOptionsMonitor<AppOptions> _appOptions = appOptions;
private readonly NavigationManager _navigationManager = navigationManager;

public bool HasConfiguredBaseUrl => !string.IsNullOrWhiteSpace(_appOptions.CurrentValue.PublicBaseUrl);

public string GetAbsoluteUri(string relativePath)
{
var configuredBaseUrl = _appOptions.CurrentValue.PublicBaseUrl;
if (string.IsNullOrWhiteSpace(configuredBaseUrl))
{
return _navigationManager.ToAbsoluteUri(relativePath.TrimStart('/')).AbsoluteUri;
}

return $"{configuredBaseUrl.TrimEnd('/')}/{relativePath.TrimStart('/')}";
}

public string GetAbsoluteUri(string relativePath, IReadOnlyDictionary<string, object?> queryParameters)
{
return _navigationManager.GetUriWithQueryParameters(GetAbsoluteUri(relativePath), queryParameters);
}
}
10 changes: 8 additions & 2 deletions ControlR.Web.Server/Services/Users/UserCreator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -333,9 +333,15 @@ await _assignmentSeeder.SeedAssignments(
["returnUrl"] = returnUrl
};

var callbackUrl = confirmationBaseUrl is not null
// The configured public base URL wins over any caller-supplied base, because callers such as
// the registration endpoint filter derive theirs from the request's Host header.
var effectiveBaseUrl = string.IsNullOrWhiteSpace(_appOptions.CurrentValue.PublicBaseUrl)
? confirmationBaseUrl
: _appOptions.CurrentValue.PublicBaseUrl;

var callbackUrl = effectiveBaseUrl is not null
? QueryHelpers.AddQueryString(
$"{confirmationBaseUrl.TrimEnd('/')}/Account/ConfirmEmail",
$"{effectiveBaseUrl.TrimEnd('/')}/Account/ConfirmEmail",
queryParams)
: _navigationManager.GetUriWithQueryParameters(
_navigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
Expand Down
11 changes: 11 additions & 0 deletions ControlR.Web.Server/Startup/WebApplicationBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ public static async Task<IHostApplicationBuilder> AddControlrServer(
.GetSection(AppOptions.SectionKey)
.Get<AppOptions>() ?? new AppOptions();

if (builder.Environment.IsProduction() &&
string.IsNullOrWhiteSpace(appOptions.PublicBaseUrl) &&
builder.Configuration["AllowedHosts"] is null or "" or "*")
{
Console.WriteLine(
"Links emailed to users will be built from the incoming request's Host header, which is " +
"attacker-controlled. Set AppOptions:PublicBaseUrl to this server's public URL, or pin " +
"AllowedHosts to its hostnames.");
}

// Configure logging.
builder.Logging.AddConfiguration(builder.Configuration.GetSection("Logging"));
builder.Services.AddStarRedactor();
Expand Down Expand Up @@ -255,6 +265,7 @@ public static async Task<IHostApplicationBuilder> AddControlrServer(
builder.Services.AddScoped<IUserPreferencesProvider>(services => services.GetRequiredService<IUserPreferencesManager>());
builder.Services.AddScoped<IUserStorageManager, UserStorageManager>();
builder.Services.AddScoped<IPublicServerSettingsProvider, PublicServerSettingsProviderServer>();
builder.Services.AddScoped<IPublicUrlProvider, PublicUrlProvider>();
builder.Services.AddScoped<ITenantInvitesProvider, TenantInvitesProvider>();
builder.Services.AddScoped<IServiceAccountManager, ServiceAccountManager>();
builder.Services.AddScoped<IDeviceGroupManager, DeviceGroupManager>();
Expand Down
1 change: 1 addition & 0 deletions ControlR.Web.Server/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"MicrosoftClientId": "",
"MicrosoftClientSecret": "",
"PersistPasskeyLogin": false,
"PublicBaseUrl": "",
"RequireUserEmailConfirmation": false,
"RequireUserUniqueEmail": true,
"SmtpCheckCertificateRevocation": true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,5 @@ public interface IAgentHub
Task<HubResult> SendFileContentStream(Guid streamId, ChannelReader<byte[]> fileChunks);
Task SendSubdirectoriesStream(Guid streamId, ChannelReader<FileSystemEntryDto[]> subdirectoryChunks);
Task SendTerminalOutputToViewer(string viewerConnectionId, TerminalOutputDto outputDto);
Task<HubResult<DeviceResponseDto>> UpdateDevice(DeviceUpdateRequestDto agentDto);
Task<HubResult<DeviceResponseDto>> UpdateDeviceSigned(SignedDto<DeviceUpdateRequestDto> signedDto);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;

namespace ControlR.Libraries.WebSocketRelay.Common.Sessions;

Expand Down Expand Up @@ -156,7 +158,11 @@ public bool TryAssignRole(Guid peerId, RelayRole role)

public bool ValidateToken(string accessToken)
{
return accessToken == _accessToken;
// Compared in fixed time. The responder side of a relay session is unauthenticated, so for that
// half this token is the only thing between a caller and the peer on the other end.
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(accessToken),
Encoding.UTF8.GetBytes(_accessToken));
}

public async Task WaitForPartner(CancellationToken cancellationToken)
Expand Down
Loading
Loading