fix: require HTTPS for owner activation links
This commit is contained in:
@@ -60,8 +60,9 @@ internal static class NetworkConfigurationExtensions
|
||||
options.OwnerActivationUrlTemplate.Replace("{host}", "tenant.example.com", StringComparison.Ordinal),
|
||||
UriKind.Absolute,
|
||||
out var activationOrigin) &&
|
||||
(activationOrigin.Scheme == Uri.UriSchemeHttp || activationOrigin.Scheme == Uri.UriSchemeHttps),
|
||||
"Tenant provisioning requires a default offering code, valid trial/activation durations, and an absolute HTTP(S) owner activation URL template containing {host}.")
|
||||
activationOrigin.Scheme == Uri.UriSchemeHttps &&
|
||||
ValidateDevelopmentActivationTemplate(options, environment.IsDevelopment()),
|
||||
"Tenant provisioning requires a default offering code, valid trial/activation durations, an HTTPS owner activation URL template, and permits an HTTP template only for Development .localhost sites.")
|
||||
.ValidateOnStart();
|
||||
if (environment.IsProduction())
|
||||
{
|
||||
@@ -107,4 +108,24 @@ internal static class NetworkConfigurationExtensions
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static bool ValidateDevelopmentActivationTemplate(
|
||||
TenantProvisioningOptions options,
|
||||
bool isDevelopment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.DevelopmentLocalhostOwnerActivationUrlTemplate))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return isDevelopment &&
|
||||
options.DevelopmentLocalhostOwnerActivationUrlTemplate.Contains("{host}", StringComparison.Ordinal) &&
|
||||
Uri.TryCreate(
|
||||
options.DevelopmentLocalhostOwnerActivationUrlTemplate.Replace(
|
||||
"{host}", "tenant.localhost", StringComparison.Ordinal),
|
||||
UriKind.Absolute,
|
||||
out var developmentOrigin) &&
|
||||
(developmentOrigin.Scheme == Uri.UriSchemeHttp ||
|
||||
developmentOrigin.Scheme == Uri.UriSchemeHttps);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"EnableDevelopmentLocalhostBypass": true
|
||||
},
|
||||
"TenantProvisioning": {
|
||||
"OwnerActivationUrlTemplate": "http://{host}:5180"
|
||||
"DevelopmentLocalhostOwnerActivationUrlTemplate": "http://{host}:5180"
|
||||
},
|
||||
"Authentication": {
|
||||
"Sms": {
|
||||
|
||||
@@ -139,6 +139,40 @@ public sealed class TenantProvisioningOptions
|
||||
public int DefaultTrialDays { get; set; } = 14;
|
||||
public int OwnerActivationMinutes { get; set; } = 30;
|
||||
public string OwnerActivationUrlTemplate { get; set; } = "https://{host}";
|
||||
public string? DevelopmentLocalhostOwnerActivationUrlTemplate { get; set; }
|
||||
}
|
||||
|
||||
public static class TenantOwnerActivationUrlPolicy
|
||||
{
|
||||
public static string Build(
|
||||
TenantProvisioningOptions options,
|
||||
string host,
|
||||
Guid activationId,
|
||||
string token)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(host);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(token);
|
||||
|
||||
var normalizedHost = host.Trim().TrimEnd('.').ToLowerInvariant();
|
||||
var useDevelopmentLocalhost = normalizedHost.EndsWith(".localhost", StringComparison.Ordinal) &&
|
||||
!string.IsNullOrWhiteSpace(options.DevelopmentLocalhostOwnerActivationUrlTemplate);
|
||||
var template = useDevelopmentLocalhost
|
||||
? options.DevelopmentLocalhostOwnerActivationUrlTemplate!
|
||||
: options.OwnerActivationUrlTemplate;
|
||||
var origin = template.Replace("{host}", normalizedHost, StringComparison.Ordinal).TrimEnd('/');
|
||||
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri) ||
|
||||
(!useDevelopmentLocalhost && uri.Scheme != Uri.UriSchemeHttps) ||
|
||||
(useDevelopmentLocalhost &&
|
||||
uri.Scheme != Uri.UriSchemeHttp &&
|
||||
uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Owner activation URLs must use HTTPS except for an explicitly configured Development .localhost origin.");
|
||||
}
|
||||
|
||||
return $"{origin}/activate/{activationId}#token={token}";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record TenantBillingPolicyItem(
|
||||
|
||||
@@ -1436,12 +1436,7 @@ internal sealed class PlatformAdminService(
|
||||
Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
private string BuildOwnerActivationUrl(string host, Guid activationId, string token)
|
||||
{
|
||||
var siteOrigin = provisioning.OwnerActivationUrlTemplate
|
||||
.Replace("{host}", host, StringComparison.Ordinal)
|
||||
.TrimEnd('/');
|
||||
return $"{siteOrigin}/activate/{activationId}#token={token}";
|
||||
}
|
||||
=> TenantOwnerActivationUrlPolicy.Build(provisioning, host, activationId, token);
|
||||
|
||||
private static JsonElement JsonObjectOrDefault(JsonElement value) =>
|
||||
value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
|
||||
namespace Tiku.UnitTests.Tenancy;
|
||||
|
||||
public sealed class TenantOwnerActivationUrlPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Custom_domain_always_uses_secure_activation_origin()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var options = new TenantProvisioningOptions
|
||||
{
|
||||
OwnerActivationUrlTemplate = "https://{host}",
|
||||
DevelopmentLocalhostOwnerActivationUrlTemplate = "http://{host}:5180"
|
||||
};
|
||||
|
||||
var url = TenantOwnerActivationUrlPolicy.Build(options, "School.Example.Test", id, "secret");
|
||||
|
||||
Assert.Equal($"https://school.example.test/activate/{id}#token=secret", url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Development_localhost_can_use_explicit_http_origin()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var options = new TenantProvisioningOptions
|
||||
{
|
||||
OwnerActivationUrlTemplate = "https://{host}",
|
||||
DevelopmentLocalhostOwnerActivationUrlTemplate = "http://{host}:5180"
|
||||
};
|
||||
|
||||
var url = TenantOwnerActivationUrlPolicy.Build(options, "school.localhost", id, "secret");
|
||||
|
||||
Assert.Equal($"http://school.localhost:5180/activate/{id}#token=secret", url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Insecure_custom_domain_template_is_rejected()
|
||||
{
|
||||
var options = new TenantProvisioningOptions
|
||||
{
|
||||
OwnerActivationUrlTemplate = "http://{host}"
|
||||
};
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
TenantOwnerActivationUrlPolicy.Build(options, "school.example.test", Guid.NewGuid(), "secret"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user