Skip to content

Commit

Permalink
Add Meziantou.Framework.Http.Hsts
Browse files Browse the repository at this point in the history
  • Loading branch information
meziantou committed Dec 26, 2024
1 parent 6300065 commit 3af05a5
Show file tree
Hide file tree
Showing 16 changed files with 783 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions Meziantou.Framework.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
<Project Path="src/Meziantou.Framework.Html.Tool/Meziantou.Framework.Html.Tool.csproj" />
<Project Path="src/Meziantou.Framework.Html/Meziantou.Framework.Html.csproj" />
<Project Path="src/Meziantou.Framework.HtmlSanitizer/Meziantou.Framework.HtmlSanitizer.csproj" />
<Project Path="src/Meziantou.Framework.Http.Hsts/Meziantou.Framework.Http.Hsts.csproj" Id="6f7af63d-c479-436c-9591-5ecaea583f3f" />
<Project Path="src/Meziantou.Framework.Http/Meziantou.Framework.Http.csproj" />
<Project Path="src/Meziantou.Framework.HttpClientMock/Meziantou.Framework.HttpClientMock.csproj" />
<Project Path="src/Meziantou.Framework.HumanReadableSerializer/Meziantou.Framework.HumanReadableSerializer.csproj" />
Expand Down Expand Up @@ -123,6 +124,7 @@
<Project Path="tests/Meziantou.Framework.Html.Tests/Meziantou.Framework.Html.Tests.csproj" />
<Project Path="tests/Meziantou.Framework.Html.Tool.Tests/Meziantou.Framework.Html.Tool.Tests.csproj" />
<Project Path="tests/Meziantou.Framework.HtmlSanitizer.Tests/Meziantou.Framework.HtmlSanitizer.Tests.csproj" />
<Project Path="tests/Meziantou.Framework.Http.Hsts.Tests/Meziantou.Framework.Http.Hsts.Tests.csproj" Id="d39369d9-5b50-46e9-82c6-c3fb9c9be707" />
<Project Path="tests/Meziantou.Framework.Http.Tests/Meziantou.Framework.Http.Tests.csproj" />
<Project Path="tests/Meziantou.Framework.HttpClientMock.Tests/Meziantou.Framework.HttpClientMock.Tests.csproj" />
<Project Path="tests/Meziantou.Framework.HumanReadableSerializer.FSharp.Tests/Meziantou.Framework.HumanReadableSerializer.FSharp.Tests.fsproj" />
Expand Down Expand Up @@ -166,4 +168,7 @@
<Project Path="tests/Meziantou.Framework.ResxSourceGenerator.GeneratorTests/Meziantou.Framework.ResxSourceGenerator.GeneratorTests.csproj" />
<Project Path="tests/Meziantou.Framework.StronglyTypedId.GeneratorTests/Meziantou.Framework.StronglyTypedId.GeneratorTests.csproj" />
</Folder>
<Folder Name="/tools/" Id="02ea681e-c7d8-13c7-8484-4ac65e1b71e8">
<Project Path="tools/Meziantou.Framework.Http.Hsts.Generator/Meziantou.Framework.Http.Hsts.Generator.csproj" Id="5ee93510-9d10-40cc-99fc-d84b09b77034" />
</Folder>
</Solution>
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
2 changes: 2 additions & 0 deletions Samples/Trimmable/Trimmable.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<ProjectReference Include="..\..\src\Meziantou.Framework.Csv\Meziantou.Framework.Csv.csproj" />
<ProjectReference Include="..\..\src\Meziantou.Framework.Globbing\Meziantou.Framework.Globbing.csproj" />
<ProjectReference Include="..\..\src\Meziantou.Framework.Http\Meziantou.Framework.Http.csproj" />
<ProjectReference Include="..\..\src\Meziantou.Framework.Http.Hsts\Meziantou.Framework.Http.Hsts.csproj" />
<ProjectReference Include="..\..\src\Meziantou.Framework.RelativeDate\Meziantou.Framework.RelativeDate.csproj" />
<ProjectReference Include="..\..\src\Meziantou.Framework.Scheduling\Meziantou.Framework.Scheduling.csproj" />
<ProjectReference Include="..\..\src\Meziantou.Framework.SingleInstance\Meziantou.Framework.SingleInstance.csproj" />
Expand All @@ -43,6 +44,7 @@
<TrimmerRootAssembly Include="Meziantou.Framework.Csv" />
<TrimmerRootAssembly Include="Meziantou.Framework.Globbing" />
<TrimmerRootAssembly Include="Meziantou.Framework.Http" />
<TrimmerRootAssembly Include="Meziantou.Framework.Http.Hsts" />
<TrimmerRootAssembly Include="Meziantou.Framework.RelativeDate" />
<TrimmerRootAssembly Include="Meziantou.Framework.Scheduling" />
<TrimmerRootAssembly Include="Meziantou.Framework.SingleInstance" />
Expand Down
69 changes: 69 additions & 0 deletions src/Meziantou.Framework.Http.Hsts/HstsClientHandler.cs
Original file line number Diff line number Diff line change
@@ -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<HttpResponseMessage> 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;
}
}
25 changes: 25 additions & 0 deletions src/Meziantou.Framework.Http.Hsts/HstsDomainPolicy.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
131 changes: 131 additions & 0 deletions src/Meziantou.Framework.Http.Hsts/HstsDomainPolicyCollection.cs
Original file line number Diff line number Diff line change
@@ -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<HstsDomainPolicy>
{
private readonly List<ConcurrentDictionary<string, HstsDomainPolicy>> _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<string, HstsDomainPolicy> dictionary;
lock (_policies)
{
for (var i = _policies.Count; i < partCount; i++)
{
_policies.Add(new ConcurrentDictionary<string, HstsDomainPolicy>(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<ReadOnlySpan<char>>();
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<char> 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<HstsDomainPolicy> 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();
}
73 changes: 73 additions & 0 deletions src/Meziantou.Framework.Http.Hsts/HstsDomainPolicyCollection.g.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// <auto-generated />
// 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<string, HstsDomainPolicy>(concurrencyLevel: -1, capacity: 61, comparer: StringComparer.OrdinalIgnoreCase);
_policies.Add(dict1);
var dict2 = new ConcurrentDictionary<string, HstsDomainPolicy>(concurrencyLevel: -1, capacity: 149553, comparer: StringComparer.OrdinalIgnoreCase);
_policies.Add(dict2);
var dict3 = new ConcurrentDictionary<string, HstsDomainPolicy>(concurrencyLevel: -1, capacity: 11247, comparer: StringComparer.OrdinalIgnoreCase);
_policies.Add(dict3);
var dict4 = new ConcurrentDictionary<string, HstsDomainPolicy>(concurrencyLevel: -1, capacity: 197, comparer: StringComparer.OrdinalIgnoreCase);
_policies.Add(dict4);
var dict5 = new ConcurrentDictionary<string, HstsDomainPolicy>(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));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>$(LatestTargetFrameworks)</TargetFrameworks>
<RootNamespace>Meziantou.Framework.Http</RootNamespace>
<Version>1.0.0</Version>
<IsTrimmable>true</IsTrimmable>
<Description>Provide an HttpClientHandler to upgrade request from http to https if a policy is set</Description>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="Meziantou.Framework.Http.Hsts.Tests" />
</ItemGroup>
</Project>
Loading

0 comments on commit 3af05a5

Please sign in to comment.