fix: require HTTPS for owner activation links

This commit is contained in:
2026-08-03 09:17:46 +08:00
parent 1d071f02fe
commit 134e96dc69
5 changed files with 107 additions and 9 deletions

View File

@@ -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);
}
}

View File

@@ -29,7 +29,7 @@
"EnableDevelopmentLocalhostBypass": true
},
"TenantProvisioning": {
"OwnerActivationUrlTemplate": "http://{host}:5180"
"DevelopmentLocalhostOwnerActivationUrlTemplate": "http://{host}:5180"
},
"Authentication": {
"Sms": {

View File

@@ -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(

View File

@@ -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();

View File

@@ -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"));
}
}