diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 32f98070b..50db5778b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -49,6 +49,11 @@ jobs:
- uses: actions/checkout@v4
- name: Setup .NET Core (global.json)
uses: actions/setup-dotnet@v4
+
+ - run: dotnet run --project tools/Meziantou.Framework.Http.Hsts.Generator
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
- run: dotnet build eng/packages.proj --configuration Release /p:IsOfficialBuild=true /bl:build.binlog -warnaserror
- run: dotnet publish src/Meziantou.Framework.InlineSnapshotTesting.Prompt.TaskDialog --configuration Release --os win /p:IsOfficialBuild=true "--output:${{runner.temp}}/prompt" /bl:publish-taskdialog.binlog
- run: dotnet publish src/Meziantou.Framework.InlineSnapshotTesting.Prompt.NotificationTray --configuration Release --os win /p:IsOfficialBuild=true "--output:${{runner.temp}}/prompt" /bl:publish-notificationtray.binlog
diff --git a/Meziantou.Framework.slnx b/Meziantou.Framework.slnx
index 0e2a9e20f..ccfc5e9db 100644
--- a/Meziantou.Framework.slnx
+++ b/Meziantou.Framework.slnx
@@ -55,6 +55,7 @@
+
@@ -123,6 +124,7 @@
+
@@ -166,4 +168,7 @@
+
+
+
diff --git a/README.md b/README.md
index 4a3c63256..edbcbe69a 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,7 @@
| Meziantou.Framework.Html.Tool | [![NuGet](https://img.shields.io/nuget/v/Meziantou.Framework.Html.Tool.svg)](https://www.nuget.org/packages/Meziantou.Framework.Html.Tool/) | [readme](src/Meziantou.Framework.Html.Tool/readme.md) |
| Meziantou.Framework.HtmlSanitizer | [![NuGet](https://img.shields.io/nuget/v/Meziantou.Framework.HtmlSanitizer.svg)](https://www.nuget.org/packages/Meziantou.Framework.HtmlSanitizer/) | |
| Meziantou.Framework.Http | [![NuGet](https://img.shields.io/nuget/v/Meziantou.Framework.Http.svg)](https://www.nuget.org/packages/Meziantou.Framework.Http/) | [readme](src/Meziantou.Framework.Http/readme.md) |
+| Meziantou.Framework.Http.Hsts | [![NuGet](https://img.shields.io/nuget/v/Meziantou.Framework.Http.Hsts.svg)](https://www.nuget.org/packages/Meziantou.Framework.Http.Hsts/) | [readme](src/Meziantou.Framework.Http.Hsts/readme.md) |
| Meziantou.Framework.HttpClientMock | [![NuGet](https://img.shields.io/nuget/v/Meziantou.Framework.HttpClientMock.svg)](https://www.nuget.org/packages/Meziantou.Framework.HttpClientMock/) | [readme](src/Meziantou.Framework.HttpClientMock/readme.md) |
| Meziantou.Framework.HumanReadableSerializer | [![NuGet](https://img.shields.io/nuget/v/Meziantou.Framework.HumanReadableSerializer.svg)](https://www.nuget.org/packages/Meziantou.Framework.HumanReadableSerializer/) | [readme](src/Meziantou.Framework.HumanReadableSerializer/readme.md) |
| Meziantou.Framework.InlineSnapshotTesting | [![NuGet](https://img.shields.io/nuget/v/Meziantou.Framework.InlineSnapshotTesting.svg)](https://www.nuget.org/packages/Meziantou.Framework.InlineSnapshotTesting/) | [readme](src/Meziantou.Framework.InlineSnapshotTesting/readme.md) |
diff --git a/Samples/Trimmable/Trimmable.csproj b/Samples/Trimmable/Trimmable.csproj
index 992b58a4d..ef7d04073 100644
--- a/Samples/Trimmable/Trimmable.csproj
+++ b/Samples/Trimmable/Trimmable.csproj
@@ -20,6 +20,7 @@
+
@@ -43,6 +44,7 @@
+
diff --git a/src/Meziantou.Framework.Http.Hsts/HstsClientHandler.cs b/src/Meziantou.Framework.Http.Hsts/HstsClientHandler.cs
new file mode 100644
index 000000000..7242ff7e7
--- /dev/null
+++ b/src/Meziantou.Framework.Http.Hsts/HstsClientHandler.cs
@@ -0,0 +1,69 @@
+using System.Globalization;
+
+namespace Meziantou.Framework.Http;
+
+public sealed class HstsClientHandler : DelegatingHandler
+{
+ private readonly HstsDomainPolicyCollection _configuration;
+
+ public HstsClientHandler(HttpMessageHandler innerHandler)
+ : this(innerHandler, HstsDomainPolicyCollection.Default)
+ {
+ }
+
+ public HstsClientHandler(HttpMessageHandler innerHandler, HstsDomainPolicyCollection configuration)
+ : base(innerHandler)
+ {
+ _configuration = configuration;
+ }
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ if (request.RequestUri?.Scheme == Uri.UriSchemeHttp && request.RequestUri.Port == 80)
+ {
+ if (_configuration.Match(request.RequestUri.Host))
+ {
+ var builder = new UriBuilder(request.RequestUri) { Scheme = Uri.UriSchemeHttps };
+ builder.Port = 443;
+ builder.Scheme = Uri.UriSchemeHttps;
+ request.RequestUri = builder.Uri;
+ }
+ }
+
+ var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
+
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security
+ // Note: The Strict-Transport-Security header is ignored by the browser when your site has only been accessed using HTTP.
+ // Once your site is accessed over HTTPS with no certificate errors, the browser knows your site is HTTPS-capable and
+ // will honor the Strict-Transport-Security header.
+ if (response.RequestMessage?.RequestUri?.Scheme == Uri.UriSchemeHttps && response.Headers.TryGetValues("Strict-Transport-Security", out var headers))
+ {
+ TimeSpan maxAge = default;
+ var includeSubdomains = false;
+ foreach (var header in headers)
+ {
+ var headerSpan = header.AsSpan();
+ foreach (var part in headerSpan.Split(';'))
+ {
+ var trimmed = headerSpan[part].Trim();
+ if (trimmed.StartsWith("max-age=", StringComparison.OrdinalIgnoreCase))
+ {
+ var maxAgeValue = int.Parse(trimmed[8..], NumberStyles.None, CultureInfo.InvariantCulture);
+ maxAge = TimeSpan.FromSeconds(maxAgeValue);
+ }
+ else if (trimmed.Equals("includeSubDomains", StringComparison.OrdinalIgnoreCase))
+ {
+ includeSubdomains = true;
+ }
+ }
+ }
+
+ if (maxAge > TimeSpan.Zero)
+ {
+ _configuration.Add(response.RequestMessage.RequestUri.Host, maxAge, includeSubdomains);
+ }
+ }
+
+ return response;
+ }
+}
diff --git a/src/Meziantou.Framework.Http.Hsts/HstsDomainPolicy.cs b/src/Meziantou.Framework.Http.Hsts/HstsDomainPolicy.cs
new file mode 100644
index 000000000..da9b1056f
--- /dev/null
+++ b/src/Meziantou.Framework.Http.Hsts/HstsDomainPolicy.cs
@@ -0,0 +1,25 @@
+namespace Meziantou.Framework.Http;
+
+public sealed class HstsDomainPolicy
+{
+ internal HstsDomainPolicy(string host, DateTimeOffset expiresAt, bool includeSubdomains)
+ {
+ Host = host;
+ ExpiresAt = expiresAt;
+ IncludeSubdomains = includeSubdomains;
+ }
+
+ public string Host { get; }
+ public DateTimeOffset ExpiresAt { get; private set; }
+ public bool IncludeSubdomains { get; private set; }
+
+ public override string ToString()
+ {
+ var result = Host + "; expires=" + ExpiresAt;
+ if (IncludeSubdomains)
+ {
+ result += "; includeSubdomains";
+ }
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/src/Meziantou.Framework.Http.Hsts/HstsDomainPolicyCollection.cs b/src/Meziantou.Framework.Http.Hsts/HstsDomainPolicyCollection.cs
new file mode 100644
index 000000000..8f0e217b7
--- /dev/null
+++ b/src/Meziantou.Framework.Http.Hsts/HstsDomainPolicyCollection.cs
@@ -0,0 +1,131 @@
+using System.Collections.Concurrent;
+using System.Collections;
+using System.ComponentModel.Design;
+
+namespace Meziantou.Framework.Http;
+
+public sealed partial class HstsDomainPolicyCollection : IEnumerable
+{
+ private readonly List> _policies = new(capacity: 8);
+ private readonly TimeProvider _timeProvider;
+
+ public static HstsDomainPolicyCollection Default { get; } = new HstsDomainPolicyCollection();
+
+ [SetsRequiredMembers]
+ public HstsDomainPolicyCollection(bool includePreloadDomains = true)
+ : this(timeProvider: null, includePreloadDomains)
+ {
+ }
+
+ [SetsRequiredMembers]
+ public HstsDomainPolicyCollection(TimeProvider? timeProvider, bool includePreloadDomains = true)
+ {
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ if (includePreloadDomains)
+ {
+ Initialize(_timeProvider);
+ }
+ }
+
+ public void Add(string host, TimeSpan maxAge, bool includeSubdomains)
+ {
+ Add(host, _timeProvider.GetUtcNow().Add(maxAge), includeSubdomains);
+ }
+
+ public void Add(string host, DateTimeOffset expiresAt, bool includeSubdomains)
+ {
+ var partCount = CountSegments(host);
+ ConcurrentDictionary dictionary;
+ lock (_policies)
+ {
+ for (var i = _policies.Count; i < partCount; i++)
+ {
+ _policies.Add(new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase));
+ }
+
+ dictionary = _policies[partCount - 1];
+ }
+
+ dictionary.AddOrUpdate(host,
+ (key, arg) => new HstsDomainPolicy(key, arg.expiresAt, arg.includeSubdomains),
+ (key, value, arg) => new HstsDomainPolicy(key, arg.expiresAt, arg.includeSubdomains),
+ factoryArgument: (expiresAt, includeSubdomains));
+ }
+
+ public bool Match(string host)
+ {
+ var segments = CountSegments(host);
+ for (var i = 0; i < _policies.Count && i < segments; i++)
+ {
+ var dictionary = _policies[i];
+ var lastSegments = i == segments - 1 ? host : GetLastSegments(host, i + 1);
+
+#if NET9_0_OR_GREATER
+ var lookup = dictionary.GetAlternateLookup>();
+ if (lookup.TryGetValue(lastSegments, out var hsts))
+#else
+ if (dictionary.TryGetValue(lastSegments.ToString(), out var hsts))
+#endif
+ {
+ if (hsts.ExpiresAt < _timeProvider.GetUtcNow())
+ {
+ return false;
+ }
+
+ if (!hsts.IncludeSubdomains && i != segments - 1)
+ {
+ return false;
+ }
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static ReadOnlySpan GetLastSegments(string host, int count)
+ {
+ var hostSpan = host.AsSpan();
+ for (var i = 0; i < count; i++)
+ {
+ var start = hostSpan.LastIndexOf('.');
+ hostSpan = hostSpan.Slice(0, start);
+ }
+
+ return host.AsSpan(hostSpan.Length + 1);
+ }
+
+ // internal for tests
+ internal static int CountSegments(string host)
+ {
+ // foo.bar.com -> 3
+ var count = 1;
+
+ var index = -1;
+ while (host.IndexOf('.', index + 1) is >= 0 and var newIndex)
+ {
+ index = newIndex;
+ count++;
+ }
+
+ return count;
+ }
+
+ public IEnumerator GetEnumerator()
+ {
+ for (var i = 0; i < _policies.Count; i++)
+ {
+ var dictionary = _policies[i];
+ if (dictionary is null)
+ continue;
+
+ foreach (var kvp in dictionary)
+ {
+ yield return kvp.Value;
+ }
+ }
+ }
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+}
diff --git a/src/Meziantou.Framework.Http.Hsts/HstsDomainPolicyCollection.g.cs b/src/Meziantou.Framework.Http.Hsts/HstsDomainPolicyCollection.g.cs
new file mode 100644
index 000000000..c6f797935
--- /dev/null
+++ b/src/Meziantou.Framework.Http.Hsts/HstsDomainPolicyCollection.g.cs
@@ -0,0 +1,73 @@
+//
+// Data source: https://raw.githubusercontent.com/chromium/chromium/ba720cd91299fe45b6345be1971ee628af9bc3f5/net/http/transport_security_state_static.json
+// Commit date: 2024-12-18T23:38:47.0000000Z
+// #nullable enable // Roslyn doesn't like it :(
+
+using System.Collections.Concurrent;
+
+namespace Meziantou.Framework.Http;
+
+partial class HstsDomainPolicyCollection
+{
+ private void Initialize(TimeProvider timeProvider)
+ {
+ var expires126Days = timeProvider.GetUtcNow().Add(TimeSpan.FromDays(126));
+ var expires365Days = timeProvider.GetUtcNow().Add(TimeSpan.FromDays(365));
+ var dict1 = new ConcurrentDictionary(concurrencyLevel: -1, capacity: 61, comparer: StringComparer.OrdinalIgnoreCase);
+ _policies.Add(dict1);
+ var dict2 = new ConcurrentDictionary(concurrencyLevel: -1, capacity: 149553, comparer: StringComparer.OrdinalIgnoreCase);
+ _policies.Add(dict2);
+ var dict3 = new ConcurrentDictionary(concurrencyLevel: -1, capacity: 11247, comparer: StringComparer.OrdinalIgnoreCase);
+ _policies.Add(dict3);
+ var dict4 = new ConcurrentDictionary(concurrencyLevel: -1, capacity: 197, comparer: StringComparer.OrdinalIgnoreCase);
+ _policies.Add(dict4);
+ var dict5 = new ConcurrentDictionary(concurrencyLevel: -1, capacity: 11, comparer: StringComparer.OrdinalIgnoreCase);
+ _policies.Add(dict5);
+ // Segment size: 1
+ _ = dict1.TryAdd("amazon", new HstsDomainPolicy("amazon", expires365Days, true));
+ _ = dict1.TryAdd("android", new HstsDomainPolicy("android", expires365Days, true));
+ _ = dict1.TryAdd("app", new HstsDomainPolicy("app", expires365Days, true));
+ _ = dict1.TryAdd("audible", new HstsDomainPolicy("audible", expires365Days, true));
+ _ = dict1.TryAdd("azure", new HstsDomainPolicy("azure", expires365Days, true));
+ _ = dict1.TryAdd("bank", new HstsDomainPolicy("bank", expires365Days, true));
+ _ = dict1.TryAdd("bing", new HstsDomainPolicy("bing", expires365Days, true));
+ _ = dict1.TryAdd("boo", new HstsDomainPolicy("boo", expires365Days, true));
+ _ = dict1.TryAdd("channel", new HstsDomainPolicy("channel", expires365Days, true));
+ _ = dict1.TryAdd("chrome", new HstsDomainPolicy("chrome", expires365Days, true));
+ // Segment size: 2
+ _ = dict2.TryAdd("0--1.de", new HstsDomainPolicy("0--1.de", expires365Days, true));
+ _ = dict2.TryAdd("0-0.io", new HstsDomainPolicy("0-0.io", expires365Days, true));
+ _ = dict2.TryAdd("0-0.lt", new HstsDomainPolicy("0-0.lt", expires365Days, true));
+ _ = dict2.TryAdd("0-24.com", new HstsDomainPolicy("0-24.com", expires365Days, true));
+ _ = dict2.TryAdd("0-24.net", new HstsDomainPolicy("0-24.net", expires365Days, true));
+ _ = dict2.TryAdd("0-9.com", new HstsDomainPolicy("0-9.com", expires365Days, true));
+ _ = dict2.TryAdd("0.sb", new HstsDomainPolicy("0.sb", expires365Days, true));
+ _ = dict2.TryAdd("00.eco", new HstsDomainPolicy("00.eco", expires365Days, true));
+ _ = dict2.TryAdd("00010110.nl", new HstsDomainPolicy("00010110.nl", expires365Days, true));
+ _ = dict2.TryAdd("0008.life", new HstsDomainPolicy("0008.life", expires365Days, true));
+ // Segment size: 3
+ _ = dict3.TryAdd("0.com.ms", new HstsDomainPolicy("0.com.ms", expires365Days, true));
+ _ = dict3.TryAdd("0ii0.eu.org", new HstsDomainPolicy("0ii0.eu.org", expires365Days, true));
+ _ = dict3.TryAdd("1-2-3bounce.co.uk", new HstsDomainPolicy("1-2-3bounce.co.uk", expires365Days, true));
+ _ = dict3.TryAdd("100plus.com.my", new HstsDomainPolicy("100plus.com.my", expires365Days, true));
+ _ = dict3.TryAdd("100plus.com.sg", new HstsDomainPolicy("100plus.com.sg", expires365Days, true));
+ _ = dict3.TryAdd("101warehousing.com.au", new HstsDomainPolicy("101warehousing.com.au", expires365Days, true));
+ _ = dict3.TryAdd("106.hi.cn", new HstsDomainPolicy("106.hi.cn", expires365Days, true));
+ _ = dict3.TryAdd("11tv.dp.ua", new HstsDomainPolicy("11tv.dp.ua", expires365Days, true));
+ _ = dict3.TryAdd("123host.com.au", new HstsDomainPolicy("123host.com.au", expires365Days, true));
+ _ = dict3.TryAdd("123noticias.com.br", new HstsDomainPolicy("123noticias.com.br", expires365Days, true));
+ // Segment size: 4
+ _ = dict4.TryAdd("1.0.0.1", new HstsDomainPolicy("1.0.0.1", expires365Days, false));
+ _ = dict4.TryAdd("1022996493.rsc.cdn77.org", new HstsDomainPolicy("1022996493.rsc.cdn77.org", expires126Days, true));
+ _ = dict4.TryAdd("1464424382.rsc.cdn77.org", new HstsDomainPolicy("1464424382.rsc.cdn77.org", expires126Days, true));
+ _ = dict4.TryAdd("1844329061.rsc.cdn77.org", new HstsDomainPolicy("1844329061.rsc.cdn77.org", expires126Days, true));
+ _ = dict4.TryAdd("1972969867.rsc.cdn77.org", new HstsDomainPolicy("1972969867.rsc.cdn77.org", expires126Days, true));
+ _ = dict4.TryAdd("agriculture.vic.gov.au", new HstsDomainPolicy("agriculture.vic.gov.au", expires365Days, true));
+ _ = dict4.TryAdd("alanburr.us.eu.org", new HstsDomainPolicy("alanburr.us.eu.org", expires365Days, true));
+ _ = dict4.TryAdd("allamakee.k12.ia.us", new HstsDomainPolicy("allamakee.k12.ia.us", expires365Days, true));
+ _ = dict4.TryAdd("api.mega.co.nz", new HstsDomainPolicy("api.mega.co.nz", expires365Days, true));
+ _ = dict4.TryAdd("armadale.wa.gov.au", new HstsDomainPolicy("armadale.wa.gov.au", expires365Days, true));
+ // Segment size: 5
+ _ = dict5.TryAdd("wnc-frontend-alb-1765173526.ap-northeast-2.elb.amazonaws.com", new HstsDomainPolicy("wnc-frontend-alb-1765173526.ap-northeast-2.elb.amazonaws.com", expires365Days, true));
+ }
+}
\ No newline at end of file
diff --git a/src/Meziantou.Framework.Http.Hsts/Meziantou.Framework.Http.Hsts.csproj b/src/Meziantou.Framework.Http.Hsts/Meziantou.Framework.Http.Hsts.csproj
new file mode 100644
index 000000000..d26b176c5
--- /dev/null
+++ b/src/Meziantou.Framework.Http.Hsts/Meziantou.Framework.Http.Hsts.csproj
@@ -0,0 +1,14 @@
+
+
+
+ $(LatestTargetFrameworks)
+ Meziantou.Framework.Http
+ 1.0.0
+ true
+ Provide an HttpClientHandler to upgrade request from http to https if a policy is set
+
+
+
+
+
+
diff --git a/src/Meziantou.Framework.Http.Hsts/Polyfill.cs b/src/Meziantou.Framework.Http.Hsts/Polyfill.cs
new file mode 100644
index 000000000..1cee58b2f
--- /dev/null
+++ b/src/Meziantou.Framework.Http.Hsts/Polyfill.cs
@@ -0,0 +1,132 @@
+#if !NET9_0_OR_GREATER
+#pragma warning disable MA0048 // File name must match type name
+
+using System.Buffers;
+using System.Runtime.CompilerServices;
+
+namespace Meziantou.Framework.Http;
+internal static class Polyfill
+{
+ public static SpanSplitEnumerator Split(this ReadOnlySpan source, T separator) where T : IEquatable
+ => new SpanSplitEnumerator(source, separator);
+
+ public static SpanSplitEnumerator Split(this ReadOnlySpan source, ReadOnlySpan separator) where T : IEquatable
+ => new SpanSplitEnumerator(source, separator);
+}
+
+internal enum SpanSplitEnumeratorMode
+{
+ None = 0,
+ SingleElement,
+ Any,
+ Sequence,
+ EmptySequence,
+ SearchValues,
+}
+
+internal ref struct SpanSplitEnumerator where T : IEquatable
+{
+ private readonly ReadOnlySpan _span;
+ private readonly T _separator = default!;
+ private readonly ReadOnlySpan _separatorBuffer;
+ private readonly SearchValues _searchValues = default!;
+ private SpanSplitEnumeratorMode _splitMode;
+ private int _startCurrent = 0;
+ private int _endCurrent = 0;
+ private int _startNext = 0;
+
+ public SpanSplitEnumerator GetEnumerator() => this;
+ public Range Current => new Range(_startCurrent, _endCurrent);
+
+ internal SpanSplitEnumerator(ReadOnlySpan span, SearchValues searchValues)
+ {
+ _span = span;
+ _splitMode = SpanSplitEnumeratorMode.SearchValues;
+ _searchValues = searchValues;
+ }
+
+ internal SpanSplitEnumerator(ReadOnlySpan span, ReadOnlySpan separators)
+ {
+ _span = span;
+
+ if (typeof(T) == typeof(char) && separators.Length == 0)
+ {
+ _searchValues = Unsafe.As>(WhiteSpaceChars);
+ _splitMode = SpanSplitEnumeratorMode.SearchValues;
+ return;
+ }
+
+ _separatorBuffer = separators;
+ _splitMode = SpanSplitEnumeratorMode.Any;
+ }
+
+ internal SpanSplitEnumerator(ReadOnlySpan span, T separator)
+ {
+ _span = span;
+ _separator = separator;
+ _splitMode = SpanSplitEnumeratorMode.SingleElement;
+ }
+
+ public bool MoveNext()
+ {
+ // Search for the next separator index.
+ int separatorIndex, separatorLength;
+ switch (_splitMode)
+ {
+ case SpanSplitEnumeratorMode.None:
+ return false;
+
+ case SpanSplitEnumeratorMode.SingleElement:
+ separatorLength = 1;
+ separatorIndex = _span.Slice(_startNext)
+ .IndexOf(_separator);
+ break;
+
+ case SpanSplitEnumeratorMode.Any:
+ separatorLength = 1;
+ separatorIndex = _span.Slice(_startNext)
+ .IndexOfAny(_separatorBuffer);
+ break;
+
+ case SpanSplitEnumeratorMode.Sequence:
+ separatorIndex = _span.Slice(_startNext)
+ .IndexOf(_separatorBuffer);
+ separatorLength = _separatorBuffer.Length;
+ break;
+
+ case SpanSplitEnumeratorMode.EmptySequence:
+ separatorIndex = -1;
+ separatorLength = 1;
+ break;
+
+ case SpanSplitEnumeratorMode.SearchValues:
+ separatorIndex = _span.Slice(_startNext).IndexOfAny(_searchValues);
+ separatorLength = 1;
+ break;
+
+ default:
+ throw new InvalidOperationException($"Invalid split mode: {_splitMode}");
+ }
+
+ _startCurrent = _startNext;
+ if (separatorIndex >= 0)
+ {
+ _endCurrent = _startCurrent + separatorIndex;
+ _startNext = _endCurrent + separatorLength;
+ }
+ else
+ {
+ _startNext = _endCurrent = _span.Length;
+
+ // Set _splitMode to None so that subsequent MoveNext calls will return false.
+ _splitMode = SpanSplitEnumeratorMode.None;
+ }
+
+ return true;
+ }
+
+ private const string Whitespaces = "\t\n\v\f\r\u0020\u0085\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000";
+
+ public static readonly SearchValues WhiteSpaceChars = SearchValues.Create(Whitespaces.AsSpan());
+}
+#endif
\ No newline at end of file
diff --git a/src/Meziantou.Framework.Http.Hsts/readme.md b/src/Meziantou.Framework.Http.Hsts/readme.md
new file mode 100644
index 000000000..3bc056ba5
--- /dev/null
+++ b/src/Meziantou.Framework.Http.Hsts/readme.md
@@ -0,0 +1,11 @@
+# Meziantou.Framework.Http.Hsts
+
+This package provides an `HttpClientHandler` that automatically upgrades HTTP requests to HTTPS when the server supports HSTS. It comes with a list of preloaded HSTS hosts.
+
+```c#
+var policies = new HstsDomainPolicyCollection(includePreloadDomains: true);
+using var client = new HttpClient(new HstsClientHandler(new SocketsHttpHandler(), policies), disposeHandler: true);
+
+// Automatically upgrade to HTTPS as google.com is in the HSTS preload list
+using var response = await client.GetAsync("http://google.com");
+```
diff --git a/tests/Meziantou.Framework.Http.Hsts.Tests/HstsClientHandlerTests.cs b/tests/Meziantou.Framework.Http.Hsts.Tests/HstsClientHandlerTests.cs
new file mode 100644
index 000000000..a03483b19
--- /dev/null
+++ b/tests/Meziantou.Framework.Http.Hsts.Tests/HstsClientHandlerTests.cs
@@ -0,0 +1,59 @@
+#pragma warning disable CA2000 // Dispose objects before losing scope
+using System.Net;
+using Xunit;
+
+namespace Meziantou.Framework.Http.Hsts.Tests;
+public sealed class HstsClientHandlerTests
+{
+ [Fact]
+ public async Task DoNotUpgradeRequest()
+ {
+ var hsts = new HstsDomainPolicyCollection(includePreloadDomains: false);
+ using var client = new HttpClient(new HstsClientHandler(new MockHttpMessageHandler(headerResponse: null), hsts), disposeHandler: true);
+
+ using var response = await client.GetAsync("http://google.com", XunitCancellationToken);
+ Assert.Equal(Uri.UriSchemeHttp, response.RequestMessage!.RequestUri!.Scheme);
+ }
+
+ [Fact]
+ public async Task UpgradeRequest()
+ {
+ var hsts = new HstsDomainPolicyCollection(includePreloadDomains: false);
+ hsts.Add("google.com", DateTimeOffset.UtcNow.AddYears(1), includeSubdomains: true);
+ using var client = new HttpClient(new HstsClientHandler(new MockHttpMessageHandler(headerResponse: null), hsts), disposeHandler: true);
+
+ using var response = await client.GetAsync("http://sample.google.com", XunitCancellationToken);
+ Assert.Equal(Uri.UriSchemeHttps, response.RequestMessage!.RequestUri!.Scheme);
+ }
+
+ [Fact]
+ public async Task UpgradeRequest_AfterReadingHeader()
+ {
+ var hsts = new HstsDomainPolicyCollection(includePreloadDomains: false);
+ using var client = new HttpClient(new HstsClientHandler(new MockHttpMessageHandler(headerResponse: "max-age=31536000; includeSubDomains; preload"), hsts), disposeHandler: true);
+
+ using var response1 = await client.GetAsync("https://sample.google.com", XunitCancellationToken);
+ Assert.Equal(Uri.UriSchemeHttps, response1.RequestMessage!.RequestUri!.Scheme);
+
+ using var response2 = await client.GetAsync("http://sample.google.com", XunitCancellationToken);
+ Assert.Equal(Uri.UriSchemeHttps, response2.RequestMessage!.RequestUri!.Scheme);
+ }
+
+ private sealed class MockHttpMessageHandler(string? headerResponse) : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ RequestMessage = request,
+ };
+
+ if (headerResponse != null)
+ {
+ response.Headers.Add("Strict-Transport-Security", headerResponse);
+ }
+
+ return Task.FromResult(response);
+ }
+ }
+}
diff --git a/tests/Meziantou.Framework.Http.Hsts.Tests/HstsDomainPolicyCollectionTests.cs b/tests/Meziantou.Framework.Http.Hsts.Tests/HstsDomainPolicyCollectionTests.cs
new file mode 100644
index 000000000..3a857890f
--- /dev/null
+++ b/tests/Meziantou.Framework.Http.Hsts.Tests/HstsDomainPolicyCollectionTests.cs
@@ -0,0 +1,91 @@
+using Xunit;
+using Xunit.Internal;
+
+namespace Meziantou.Framework.Http.Hsts.Tests;
+public sealed class HstsDomainPolicyCollectionTests
+{
+ [Theory]
+ [InlineData("google", 1)]
+ [InlineData("google.com", 2)]
+ [InlineData("foo.google.com", 3)]
+ public void CountSegments(string domain, int count)
+ {
+ Assert.Equal(count, HstsDomainPolicyCollection.CountSegments(domain));
+ }
+
+ [Fact]
+ public void HstsCollection_Match_IncludeSubdomain_True()
+ {
+ var hsts = new HstsDomainPolicyCollection(includePreloadDomains: false);
+ hsts.Add("google.com", DateTimeOffset.UtcNow.AddYears(1), includeSubdomains: true);
+
+ Assert.True(hsts.Match("google.com"));
+ Assert.True(hsts.Match("dummy.google.com"));
+
+ Assert.False(hsts.Match("example.com"));
+ Assert.False(hsts.Match("agoogle.com"));
+ Assert.False(hsts.Match("oogle.com"));
+ Assert.False(hsts.Match("google.net"));
+ }
+
+ [Fact]
+ public void HstsCollection_Match_IncludeSubdomain_False()
+ {
+ var hsts = new HstsDomainPolicyCollection(includePreloadDomains: false);
+ hsts.Add("google.com", DateTimeOffset.UtcNow.AddYears(1), includeSubdomains: false);
+
+ Assert.True(hsts.Match("google.com"));
+ Assert.False(hsts.Match("dummy.google.com"));
+ Assert.False(hsts.Match("example.com"));
+ }
+
+ [Fact]
+ public void HstsCollection_Match_UsePreloadDomains()
+ {
+ var hsts = new HstsDomainPolicyCollection(includePreloadDomains: true);
+
+ Assert.True(hsts.Match("whatever.amazon"));
+ Assert.True(hsts.Match("amazon"));
+ Assert.False(hsts.Match("zzz"));
+ }
+
+ [Fact]
+ [SuppressMessage("Security", "CA5394:Do not use insecure randomness")]
+ public void HstsCollection_Parallel()
+ {
+ var hsts = new HstsDomainPolicyCollection();
+
+ var domains = Enumerable.Range(0, 500_000).Select(GenerateDomainName).ToArray();
+
+ Parallel.ForEach(domains, domain =>
+ {
+ hsts.Add(domain, DateTimeOffset.UtcNow.AddYears(1), includeSubdomains: false);
+ });
+
+ Parallel.ForEach(domains, domain =>
+ {
+ Assert.True(hsts.Match(domain));
+ });
+
+ Assert.False(hsts.Match("dummy.google.com"));
+
+ static string GenerateDomainName(int i)
+ {
+ var partCount = Random.Shared.Next(1, 16);
+ return string.Join('.', Enumerable.Range(0, partCount).Select(_ => Guid.NewGuid().ToString("N").ToLowerInvariant()));
+ }
+ }
+
+ [Fact]
+ public void GetEnumerator()
+ {
+ var hsts = new HstsDomainPolicyCollection(includePreloadDomains: false);
+ hsts.Add("google.com", DateTimeOffset.UtcNow.AddYears(1), includeSubdomains: true);
+ hsts.Add("example.com", DateTimeOffset.UtcNow.AddYears(1), includeSubdomains: false);
+
+ var list = hsts.OrderBy(entry => entry.Host, StringComparer.Ordinal).ToList();
+ Assert.Collection(list,
+ entry => Assert.Equal("example.com", entry.Host),
+ entry => Assert.Equal("google.com", entry.Host));
+ }
+}
diff --git a/tests/Meziantou.Framework.Http.Hsts.Tests/Meziantou.Framework.Http.Hsts.Tests.csproj b/tests/Meziantou.Framework.Http.Hsts.Tests/Meziantou.Framework.Http.Hsts.Tests.csproj
new file mode 100644
index 000000000..1bfce0adb
--- /dev/null
+++ b/tests/Meziantou.Framework.Http.Hsts.Tests/Meziantou.Framework.Http.Hsts.Tests.csproj
@@ -0,0 +1,11 @@
+
+
+
+ $(LatestTargetFrameworks)
+
+
+
+
+
+
+
diff --git a/tools/Meziantou.Framework.Http.Hsts.Generator/Meziantou.Framework.Http.Hsts.Generator.csproj b/tools/Meziantou.Framework.Http.Hsts.Generator/Meziantou.Framework.Http.Hsts.Generator.csproj
new file mode 100644
index 000000000..66c921ae8
--- /dev/null
+++ b/tools/Meziantou.Framework.Http.Hsts.Generator/Meziantou.Framework.Http.Hsts.Generator.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ net9.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/Meziantou.Framework.Http.Hsts.Generator/Program.cs b/tools/Meziantou.Framework.Http.Hsts.Generator/Program.cs
new file mode 100644
index 000000000..6ac0a2ea2
--- /dev/null
+++ b/tools/Meziantou.Framework.Http.Hsts.Generator/Program.cs
@@ -0,0 +1,135 @@
+#pragma warning disable CA1812 // Avoid uninstantiated internal classes
+#pragma warning disable MA0004 // Use Task.ConfigureAwait
+#pragma warning disable MA0047 // Declare types in namespaces
+#pragma warning disable MA0048 // File name must match type name
+using System.Diagnostics;
+using System.Globalization;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Meziantou.Framework;
+
+// By default, generate a subset of the data. Otherwise the IDE is not responsive because of the large file
+var fullGeneration = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"));
+var token = Environment.GetEnvironmentVariable("GITHUB_TOKEN");
+if(token is null)
+{
+ // gh auth token
+ var process = Process.Start(new ProcessStartInfo
+ {
+ FileName = "gh",
+ Arguments = "auth token",
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ });
+ await process!.WaitForExitAsync();
+ token = (await process.StandardOutput.ReadToEndAsync()).Trim();
+}
+
+var jsonOptions = new JsonSerializerOptions
+{
+ AllowTrailingCommas = true,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+};
+
+// Get commit info and download the file
+using var getCommitsRequest = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/repos/chromium/chromium/commits?path=net/http/transport_security_state_static.json&per_page=1");
+getCommitsRequest.Headers.UserAgent.Add(new ProductInfoHeaderValue("Meziantou.Framework.Http.Hsts.Generator", "1.0"));
+getCommitsRequest.Headers.Add("Authorization", "Bearer " + token);
+using var commitsResponse = await SharedHttpClient.Instance.SendAsync(getCommitsRequest);
+commitsResponse.EnsureSuccessStatusCode();
+var commits = await commitsResponse.Content.ReadFromJsonAsync(jsonOptions);
+var lastCommit = commits!.RootElement.EnumerateArray().First();
+var sha = lastCommit.GetProperty("sha").GetString();
+var commitDate = lastCommit.GetProperty("commit").GetProperty("committer").GetProperty("date").GetDateTime();
+var fileUrl = $"https://raw.githubusercontent.com/chromium/chromium/{sha}/net/http/transport_security_state_static.json";
+using var content = await SharedHttpClient.Instance.GetFromJsonAsync(fileUrl, jsonOptions);
+if (content is null)
+ throw new InvalidOperationException("The document is invalid");
+
+var entries = content.RootElement.GetProperty("entries").Deserialize>(jsonOptions);
+if (entries is null)
+ throw new InvalidOperationException("The entries are invalid");
+
+// Remove entries that are not relevant
+entries.RemoveAll(entries => entries.Mode != "force-https" || entries.Policy == "test");
+
+// check if there are duplicated domains
+var duplicatedDomains = entries.GroupBy(e => e.Name, StringComparer.OrdinalIgnoreCase).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
+if (duplicatedDomains.Count > 0)
+{
+ throw new InvalidOperationException("Duplicated domains: " + string.Join(", ", duplicatedDomains));
+}
+
+// Start generating the code
+var maxSegments = entries.Max(e => e.SegmentCount);
+
+var sb = new StringBuilder();
+sb.Append(" var expires126Days = timeProvider.GetUtcNow().Add(TimeSpan.FromDays(126));\n");
+sb.Append(" var expires365Days = timeProvider.GetUtcNow().Add(TimeSpan.FromDays(365));\n");
+
+for (var i = 1; i <= maxSegments; i++)
+{
+ var capacity = entries.Count(e => e.SegmentCount == i) + 10; // leave some space to add new entries later
+ sb.Append($" var dict{i.ToString(CultureInfo.InvariantCulture)} = new ConcurrentDictionary(concurrencyLevel: -1, capacity: {capacity.ToString(CultureInfo.InvariantCulture)}, comparer: StringComparer.OrdinalIgnoreCase);\n");
+ sb.Append($" _policies.Add(dict{i.ToString(CultureInfo.InvariantCulture)});\n");
+}
+
+foreach (var entryGroup in entries.GroupBy(e => e.SegmentCount).OrderBy(group => group.Key))
+{
+ sb.Append(CultureInfo.InvariantCulture, $" // Segment size: {entryGroup.Key}\n");
+ foreach (var entry in entryGroup.OrderBy(entry => entry.Name, StringComparer.Ordinal).Take(fullGeneration ? int.MaxValue : 10))
+ {
+ var expiresIn = entry.Policy switch
+ {
+ "bulk-18-weeks" => "expires126Days",
+ "bulk-1-year" => "expires365Days",
+ _ => "expires365Days",
+ };
+
+ sb.Append($""" _ = dict{entry.SegmentCount.ToString(CultureInfo.InvariantCulture)}.TryAdd("{entry.Name}", new HstsDomainPolicy("{entry.Name}", {expiresIn}, {(entry.IncludeSubdomains ? "true" : "false")}));""" + "\n");
+ }
+}
+
+var result = $$"""
+ //
+ // Data source: {{fileUrl}}
+ // Commit date: {{commitDate.ToString("O", CultureInfo.InvariantCulture)}}
+ // #nullable enable // Roslyn doesn't like it :(
+
+ using System.Collections.Concurrent;
+
+ namespace Meziantou.Framework.Http;
+
+ partial class HstsDomainPolicyCollection
+ {
+ private void Initialize(TimeProvider timeProvider)
+ {
+ {{sb.ToString().TrimEnd('\n')}}
+ }
+ }
+ """;
+
+if (!FullPath.CurrentDirectory().TryFindFirstAncestorOrSelf(path => Directory.Exists(path / ".git"), out var root))
+ throw new InvalidOperationException("Cannot find git root from " + FullPath.CurrentDirectory());
+
+await File.WriteAllTextAsync(root / "src" / "Meziantou.Framework.Http.Hsts" / "HstsDomainPolicyCollection.g.cs", result);
+
+internal sealed class Data
+{
+ [JsonPropertyName("name")]
+ public required string Name { get; set; }
+
+ [JsonPropertyName("policy")]
+ public string? Policy { get; set; }
+
+ [JsonPropertyName("mode")]
+ public string? Mode { get; set; }
+
+ [JsonPropertyName("include_subdomains")]
+ public bool IncludeSubdomains { get; set; }
+
+ public int SegmentCount => Name.Count(c => c == '.') + 1;
+}
\ No newline at end of file