From 3af05a5d5506e8f8c9ad091ac5b23671c48844b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Thu, 26 Dec 2024 00:55:12 -0500 Subject: [PATCH] Add Meziantou.Framework.Http.Hsts --- .github/workflows/ci.yml | 5 + Meziantou.Framework.slnx | 5 + README.md | 1 + Samples/Trimmable/Trimmable.csproj | 2 + .../HstsClientHandler.cs | 69 +++++++++ .../HstsDomainPolicy.cs | 25 ++++ .../HstsDomainPolicyCollection.cs | 131 +++++++++++++++++ .../HstsDomainPolicyCollection.g.cs | 73 ++++++++++ .../Meziantou.Framework.Http.Hsts.csproj | 14 ++ src/Meziantou.Framework.Http.Hsts/Polyfill.cs | 132 +++++++++++++++++ src/Meziantou.Framework.Http.Hsts/readme.md | 11 ++ .../HstsClientHandlerTests.cs | 59 ++++++++ .../HstsDomainPolicyCollectionTests.cs | 91 ++++++++++++ ...Meziantou.Framework.Http.Hsts.Tests.csproj | 11 ++ ...antou.Framework.Http.Hsts.Generator.csproj | 19 +++ .../Program.cs | 135 ++++++++++++++++++ 16 files changed, 783 insertions(+) create mode 100644 src/Meziantou.Framework.Http.Hsts/HstsClientHandler.cs create mode 100644 src/Meziantou.Framework.Http.Hsts/HstsDomainPolicy.cs create mode 100644 src/Meziantou.Framework.Http.Hsts/HstsDomainPolicyCollection.cs create mode 100644 src/Meziantou.Framework.Http.Hsts/HstsDomainPolicyCollection.g.cs create mode 100644 src/Meziantou.Framework.Http.Hsts/Meziantou.Framework.Http.Hsts.csproj create mode 100644 src/Meziantou.Framework.Http.Hsts/Polyfill.cs create mode 100644 src/Meziantou.Framework.Http.Hsts/readme.md create mode 100644 tests/Meziantou.Framework.Http.Hsts.Tests/HstsClientHandlerTests.cs create mode 100644 tests/Meziantou.Framework.Http.Hsts.Tests/HstsDomainPolicyCollectionTests.cs create mode 100644 tests/Meziantou.Framework.Http.Hsts.Tests/Meziantou.Framework.Http.Hsts.Tests.csproj create mode 100644 tools/Meziantou.Framework.Http.Hsts.Generator/Meziantou.Framework.Http.Hsts.Generator.csproj create mode 100644 tools/Meziantou.Framework.Http.Hsts.Generator/Program.cs 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