diff --git a/ControlR.Web.Server/Components/Account/Pages/ForgotPassword.razor b/ControlR.Web.Server/Components/Account/Pages/ForgotPassword.razor
index b3d86ed14..28f59ce9b 100644
--- a/ControlR.Web.Server/Components/Account/Pages/ForgotPassword.razor
+++ b/ControlR.Web.Server/Components/Account/Pages/ForgotPassword.razor
@@ -6,6 +6,7 @@
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
@inject IPasswordManager PasswordManager
+@inject IPublicUrlProvider PublicUrlProvider
Forgot your password?
@@ -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)
{
diff --git a/ControlR.Web.Server/Components/Account/Pages/Manage/Email.razor b/ControlR.Web.Server/Components/Account/Pages/Manage/Email.razor
index 1a5c156f8..5464317c3 100644
--- a/ControlR.Web.Server/Components/Account/Pages/Manage/Email.razor
+++ b/ControlR.Web.Server/Components/Account/Pages/Manage/Email.razor
@@ -5,6 +5,7 @@
@inject IOptionsMonitor AppOptions
@inject IdentityRedirectManager RedirectManager
@inject NavigationManager NavigationManager
+@inject IPublicUrlProvider PublicUrlProvider
Manage email
@@ -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 { ["userId"] = userId, ["email"] = Input.NewEmail, ["code"] = code });
await EmailSender.SendConfirmationLinkAsync(_user, Input.NewEmail, HtmlEncoder.Default.Encode(callbackUrl));
@@ -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 { ["userId"] = userId, ["code"] = code });
await EmailSender.SendConfirmationLinkAsync(_user, _email, HtmlEncoder.Default.Encode(callbackUrl));
diff --git a/ControlR.Web.Server/Components/Account/Pages/ResendEmailConfirmation.razor b/ControlR.Web.Server/Components/Account/Pages/ResendEmailConfirmation.razor
index 9097bd8ff..569e5af39 100644
--- a/ControlR.Web.Server/Components/Account/Pages/ResendEmailConfirmation.razor
+++ b/ControlR.Web.Server/Components/Account/Pages/ResendEmailConfirmation.razor
@@ -5,6 +5,7 @@
@inject IOptionsMonitor AppOptions
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
+@inject IPublicUrlProvider PublicUrlProvider
Resend email confirmation
@@ -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 { ["userId"] = userId, ["code"] = code });
await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));
diff --git a/ControlR.Web.Server/Hubs/AgentHub.cs b/ControlR.Web.Server/Hubs/AgentHub.cs
index 0689891a7..e316ed553 100644
--- a/ControlR.Web.Server/Hubs/AgentHub.cs
+++ b/ControlR.Web.Server/Hubs/AgentHub.cs
@@ -236,103 +236,39 @@ await _viewerHub.Clients
}
}
- [Obsolete("This method is deprecated. Please use UpdateDeviceSigned instead.")]
- public async Task> UpdateDevice(DeviceUpdateRequestDto agentDto)
- {
- try
- {
- var device = await _appDb.Devices.FindAsync(agentDto.Id);
- if (device is not null && !string.IsNullOrEmpty(device.PublicKey))
- {
- return HubResult.Fail("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("No tenants found.");
- }
-
- if (tenants.Count > 1)
- {
- return HubResult.Fail(
- "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("Invalid tenant ID.");
- }
-
- if (!await _appDb.Tenants.AnyAsync(x => x.Id == agentDto.TenantId))
- {
- return HubResult.Fail("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(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("An error occurred while updating the device.");
- }
- }
-
public async Task> UpdateDeviceSigned(SignedDto 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("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("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("Unknown device.");
+ }
}
var publicKeyBase64 = !string.IsNullOrEmpty(storedPublicKey)
@@ -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)
diff --git a/ControlR.Web.Server/Options/AppOptions.cs b/ControlR.Web.Server/Options/AppOptions.cs
index ecfed081c..4282e1977 100644
--- a/ControlR.Web.Server/Options/AppOptions.cs
+++ b/ControlR.Web.Server/Options/AppOptions.cs
@@ -238,6 +238,24 @@ public class AppOptions
///
public bool PersistPasskeyLogin { get; init; }
+ ///
+ /// The absolute base URL where this server is reachable by its users, e.g. "https://controlr.example.com".
+ ///
+ ///
+ ///
+ /// 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 Host header unless AllowedHosts 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.
+ ///
+ ///
+ /// Set this on every internet-facing deployment. Include the scheme and, if non-standard, the port;
+ /// a trailing slash is optional.
+ ///
+ ///
+ public string? PublicBaseUrl { get; init; }
+
///
/// Whether users must confirm their email address before being allowed to log in.
/// If true, you must also configure SMTP settings below.
diff --git a/ControlR.Web.Server/Services/PublicUrlProvider.cs b/ControlR.Web.Server/Services/PublicUrlProvider.cs
new file mode 100644
index 000000000..3f8654f38
--- /dev/null
+++ b/ControlR.Web.Server/Services/PublicUrlProvider.cs
@@ -0,0 +1,63 @@
+using Microsoft.AspNetCore.Components;
+
+namespace ControlR.Web.Server.Services;
+
+///
+/// Builds absolute URLs for links that leave the application, such as the callback links embedded in
+/// account emails.
+///
+///
+/// Emailed links must never be derived from the incoming request. The Host header is
+/// attacker-controlled unless AllowedHosts 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 when one is set, and only fall
+/// back to the request when it is not.
+///
+public interface IPublicUrlProvider
+{
+ ///
+ /// Whether an explicit public base URL is configured. When false, generated URLs fall back to the
+ /// host of the incoming request.
+ ///
+ bool HasConfiguredBaseUrl { get; }
+
+ ///
+ /// Builds an absolute URL for a path relative to the application root.
+ ///
+ /// The path relative to the application root, e.g. "Account/ResetPassword".
+ string GetAbsoluteUri(string relativePath);
+
+ ///
+ /// Builds an absolute URL for a path relative to the application root, with the supplied query
+ /// parameters appended.
+ ///
+ /// The path relative to the application root, e.g. "Account/ConfirmEmail".
+ /// The query parameters to append.
+ string GetAbsoluteUri(string relativePath, IReadOnlyDictionary queryParameters);
+}
+
+public class PublicUrlProvider(
+ NavigationManager navigationManager,
+ IOptionsMonitor appOptions) : IPublicUrlProvider
+{
+ private readonly IOptionsMonitor _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 queryParameters)
+ {
+ return _navigationManager.GetUriWithQueryParameters(GetAbsoluteUri(relativePath), queryParameters);
+ }
+}
diff --git a/ControlR.Web.Server/Services/Users/UserCreator.cs b/ControlR.Web.Server/Services/Users/UserCreator.cs
index 853b6ac94..4482e4aff 100644
--- a/ControlR.Web.Server/Services/Users/UserCreator.cs
+++ b/ControlR.Web.Server/Services/Users/UserCreator.cs
@@ -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,
diff --git a/ControlR.Web.Server/Startup/WebApplicationBuilderExtensions.cs b/ControlR.Web.Server/Startup/WebApplicationBuilderExtensions.cs
index 9252920e5..c5df6ad46 100644
--- a/ControlR.Web.Server/Startup/WebApplicationBuilderExtensions.cs
+++ b/ControlR.Web.Server/Startup/WebApplicationBuilderExtensions.cs
@@ -80,6 +80,16 @@ public static async Task AddControlrServer(
.GetSection(AppOptions.SectionKey)
.Get() ?? 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();
@@ -255,6 +265,7 @@ public static async Task AddControlrServer(
builder.Services.AddScoped(services => services.GetRequiredService());
builder.Services.AddScoped();
builder.Services.AddScoped();
+ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
diff --git a/ControlR.Web.Server/appsettings.json b/ControlR.Web.Server/appsettings.json
index 76a267148..017fdbeec 100644
--- a/ControlR.Web.Server/appsettings.json
+++ b/ControlR.Web.Server/appsettings.json
@@ -72,6 +72,7 @@
"MicrosoftClientId": "",
"MicrosoftClientSecret": "",
"PersistPasskeyLogin": false,
+ "PublicBaseUrl": "",
"RequireUserEmailConfirmation": false,
"RequireUserUniqueEmail": true,
"SmtpCheckCertificateRevocation": true,
diff --git a/Libraries/ControlR.Libraries.Api.Contracts/Hubs/IAgentHub.cs b/Libraries/ControlR.Libraries.Api.Contracts/Hubs/IAgentHub.cs
index e3b17dc5e..a92d58f89 100644
--- a/Libraries/ControlR.Libraries.Api.Contracts/Hubs/IAgentHub.cs
+++ b/Libraries/ControlR.Libraries.Api.Contracts/Hubs/IAgentHub.cs
@@ -13,6 +13,5 @@ public interface IAgentHub
Task SendFileContentStream(Guid streamId, ChannelReader fileChunks);
Task SendSubdirectoriesStream(Guid streamId, ChannelReader subdirectoryChunks);
Task SendTerminalOutputToViewer(string viewerConnectionId, TerminalOutputDto outputDto);
- Task> UpdateDevice(DeviceUpdateRequestDto agentDto);
Task> UpdateDeviceSigned(SignedDto signedDto);
}
diff --git a/Libraries/ControlR.Libraries.WebSocketRelay.Common/Sessions/SessionSignaler.cs b/Libraries/ControlR.Libraries.WebSocketRelay.Common/Sessions/SessionSignaler.cs
index 174402c58..aceb4d74e 100644
--- a/Libraries/ControlR.Libraries.WebSocketRelay.Common/Sessions/SessionSignaler.cs
+++ b/Libraries/ControlR.Libraries.WebSocketRelay.Common/Sessions/SessionSignaler.cs
@@ -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;
@@ -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)
diff --git a/Tests/ControlR.Web.Server.Tests/DecommissionServerTests.cs b/Tests/ControlR.Web.Server.Tests/DecommissionServerTests.cs
index ab8aad902..49e6faa31 100644
--- a/Tests/ControlR.Web.Server.Tests/DecommissionServerTests.cs
+++ b/Tests/ControlR.Web.Server.Tests/DecommissionServerTests.cs
@@ -16,6 +16,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
+using ControlR.Libraries.Shared.Primitives;
using ControlR.Libraries.Shared.Services.Encryption;
using ControlR.Web.Server.Api.Internal;
@@ -23,6 +24,8 @@ namespace ControlR.Web.Server.Tests;
public class DecommissionServerTests(ITestOutputHelper testOutput)
{
+ private static readonly string _testPublicKeyBase64 = Convert.ToBase64String(new byte[32]);
+
private readonly ITestOutputHelper _testOutput = testOutput;
[Fact]
@@ -87,7 +90,7 @@ public void ServerLifecycleOptions_BindsFromConfiguration()
}
[Fact]
- public async Task UpdateDevice_WhenServerDecommissioned_UninstallsAgentAndDeletesDevice()
+ public async Task UpdateDeviceSigned_WhenServerDecommissioned_UninstallsAgentAndDeletesDevice()
{
await using var testApp = await TestAppBuilder.CreateTestApp(_testOutput);
using var scope = testApp.Services.CreateScope();
@@ -95,7 +98,7 @@ public async Task UpdateDevice_WhenServerDecommissioned_UninstallsAgentAndDelete
var tenant = await services.CreateTestTenant();
var deviceId = Guid.NewGuid();
- _ = await services.CreateTestDevice(tenant.Id, deviceId);
+ _ = await services.CreateTestDevice(tenant.Id, deviceId, _testPublicKeyBase64);
await using var appDb = services.GetRequiredService();
var timeProvider = services.GetRequiredService();
@@ -115,6 +118,15 @@ public async Task UpdateDevice_WhenServerDecommissioned_UninstallsAgentAndDelete
var mockHubStreamStore = new Mock();
var mockAgentVersionProvider = new Mock();
var mockKeyProvider = new Mock();
+ mockKeyProvider
+ .Setup(x => x.ValidatePublicKeyBase64(It.IsAny()))
+ .Returns(Result.Ok(new byte[32]));
+ mockKeyProvider
+ .Setup(x => x.Verify(It.IsAny>(), It.IsAny()))
+ .Returns(true);
+ mockKeyProvider
+ .Setup(x => x.VerifyTimestamp(It.IsAny>(), It.IsAny()))
+ .Returns(true);
var mockLogger = new Mock>();
var serverOptions = Microsoft.Extensions.Options.Options.Create(
@@ -171,7 +183,13 @@ public async Task UpdateDevice_WhenServerDecommissioned_UninstallsAgentAndDelete
]);
// Act
- var result = await hub.UpdateDevice(deviceDto);
+ var signedDto = new SignedDto(
+ deviceDto,
+ DateTimeOffset.UtcNow,
+ new byte[64],
+ _testPublicKeyBase64);
+
+ var result = await hub.UpdateDeviceSigned(signedDto);
// Assert - HubResult indicates failure with the decommissioned message.
Assert.False(result.IsSuccess);
@@ -194,7 +212,7 @@ public async Task UpdateDevice_WhenServerDecommissioned_UninstallsAgentAndDelete
}
[Fact]
- public async Task UpdateDevice_WhenServerNotDecommissioned_DoesNotCallUninstallAgent()
+ public async Task UpdateDeviceSigned_WhenServerNotDecommissioned_DoesNotCallUninstallAgent()
{
await using var testApp = await TestAppBuilder.CreateTestApp(_testOutput);
using var scope = testApp.Services.CreateScope();
@@ -202,7 +220,7 @@ public async Task UpdateDevice_WhenServerNotDecommissioned_DoesNotCallUninstallA
var tenant = await services.CreateTestTenant();
var deviceId = Guid.NewGuid();
- _ = await services.CreateTestDevice(tenant.Id, deviceId);
+ _ = await services.CreateTestDevice(tenant.Id, deviceId, _testPublicKeyBase64);
await using var appDb = services.GetRequiredService();
var timeProvider = services.GetRequiredService();
@@ -215,6 +233,15 @@ public async Task UpdateDevice_WhenServerNotDecommissioned_DoesNotCallUninstallA
var mockHubStreamStore = new Mock();
var mockAgentVersionProvider = new Mock();
var mockKeyProvider = new Mock();
+ mockKeyProvider
+ .Setup(x => x.ValidatePublicKeyBase64(It.IsAny()))
+ .Returns(Result.Ok(new byte[32]));
+ mockKeyProvider
+ .Setup(x => x.Verify(It.IsAny>(), It.IsAny()))
+ .Returns(true);
+ mockKeyProvider
+ .Setup(x => x.VerifyTimestamp(It.IsAny>(), It.IsAny()))
+ .Returns(true);
var mockLogger = new Mock>();
// DecommissionServer is false (the default).
@@ -271,7 +298,13 @@ public async Task UpdateDevice_WhenServerNotDecommissioned_DoesNotCallUninstallA
]);
// Act
- var result = await hub.UpdateDevice(deviceDto);
+ var signedDto = new SignedDto(
+ deviceDto,
+ DateTimeOffset.UtcNow,
+ new byte[64],
+ _testPublicKeyBase64);
+
+ var result = await hub.UpdateDeviceSigned(signedDto);
// Assert - UninstallAgent was NOT called.
mockCaller.Verify(
diff --git a/Tests/ControlR.Web.Server.Tests/Helpers/ServiceExtensions.cs b/Tests/ControlR.Web.Server.Tests/Helpers/ServiceExtensions.cs
index 35c984a6e..1b116b454 100644
--- a/Tests/ControlR.Web.Server.Tests/Helpers/ServiceExtensions.cs
+++ b/Tests/ControlR.Web.Server.Tests/Helpers/ServiceExtensions.cs
@@ -128,7 +128,8 @@ public static async Task CreateServerPrincipal(this IServicePro
public static async Task CreateTestDevice(
this IServiceProvider services,
Guid tenantId,
- Guid? deviceId = null)
+ Guid? deviceId = null,
+ string? publicKeyBase64 = null)
{
using var scope = services.CreateScope();
var deviceManager = scope.ServiceProvider.GetRequiredService();
@@ -163,7 +164,7 @@ public static async Task CreateTestDevice(
IsOnline: true
);
- var device = await deviceManager.AddOrUpdate(deviceDto, connectionContext, tagIds: null);
+ var device = await deviceManager.AddOrUpdate(deviceDto, connectionContext, tagIds: null, publicKeyBase64: publicKeyBase64);
return device;
}
diff --git a/docker-compose/docker-compose-secrets.yml b/docker-compose/docker-compose-secrets.yml
index 8dacb376b..451b9cc03 100644
--- a/docker-compose/docker-compose-secrets.yml
+++ b/docker-compose/docker-compose-secrets.yml
@@ -87,6 +87,18 @@ services:
# recommended to balance between allowing legitimate clock skew and limiting replay attacks.
ControlR_AppOptions__AgentClockSkewTolerance: "00:01:00"
+ # The absolute base URL where this server is reachable by its users, e.g.
+ # "https://controlr.example.com". Links emailed to users (password reset, email
+ # confirmation, email change) are built from this value. Leave it unset and those links
+ # fall back to the Host header of the incoming request, which a caller controls: a forged
+ # host produces a genuine email from this server carrying a valid token that points at the
+ # attacker's origin. Set this on every internet-facing deployment.
+ ControlR_AppOptions__PublicBaseUrl: ""
+
+ # The hostnames this server will answer to, semicolon-separated. Pinning this rejects
+ # requests carrying a forged Host header before they reach the application. "*" allows any.
+ ControlR_AllowedHosts: "*"
+
# Allows devices to self-register without requiring an installer key.
ControlR_AppOptions__AllowAgentsToSelfBootstrap: false
diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml
index 90d02bcba..1c13cfc80 100644
--- a/docker-compose/docker-compose.yml
+++ b/docker-compose/docker-compose.yml
@@ -72,6 +72,18 @@ services:
# that auto-disables after the first user is created.
ControlR_AppOptions__EnablePublicRegistration: false
+ # The absolute base URL where this server is reachable by its users, e.g.
+ # "https://controlr.example.com". Links emailed to users (password reset, email
+ # confirmation, email change) are built from this value. Leave it unset and those links
+ # fall back to the Host header of the incoming request, which a caller controls: a forged
+ # host produces a genuine email from this server carrying a valid token that points at the
+ # attacker's origin. Set this on every internet-facing deployment.
+ ControlR_AppOptions__PublicBaseUrl: ""
+
+ # The hostnames this server will answer to, semicolon-separated. Pinning this rejects
+ # requests carrying a forged Host header before they reach the application. "*" allows any.
+ ControlR_AllowedHosts: "*"
+
# Allows devices to self-register without requiring an installer key.
ControlR_AppOptions__AllowAgentsToSelfBootstrap: false