Skip to content

Commit

Permalink
Add Meziantou.Framework.Http.Hsts (#711)
Browse files Browse the repository at this point in the history
  • Loading branch information
meziantou authored Dec 27, 2024
1 parent cc84580 commit 6ef7ae7
Show file tree
Hide file tree
Showing 22 changed files with 1,103 additions and 169 deletions.
36 changes: 36 additions & 0 deletions .github/workflows/update-hsts-preload.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: publish
on:
workflow_dispatch:
schedule:
- cron: '0 0 * * 0' # once a week

env:
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
DOTNET_NOLOGO: true

defaults:
run:
shell: pwsh

jobs:
update_file:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- 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
if ($LASTEXITCODE -ne 0) {
git config --global user.email "[email protected]"
git config --global user.name "meziantou"
git checkout -b update-hsts-preload
git add .
git commit -m "Update HSTS preload list"
git push origin update-hsts-preload
gh pr create --title "Update HSTS preload list" --body "Update HSTS preload list" --base main
}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
344 changes: 175 additions & 169 deletions Meziantou.Framework.slnx

Large diffs are not rendered by default.

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>$(LatestTargetFramework)</TargetFramework>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Meziantou.Framework.Http.Hsts\Meziantou.Framework.Http.Hsts.csproj" />
</ItemGroup>

</Project>
13 changes: 13 additions & 0 deletions Samples/Meziantou.Framework.Http.HstsSample/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#pragma warning disable CA2000 // Dispose objects before losing scope
#pragma warning disable CA2234 // Pass system uri objects instead of strings
using System.Diagnostics;
using Meziantou.Framework.Http;

var loadingTime = Stopwatch.StartNew();
var policyCollection = new HstsDomainPolicyCollection();
Console.WriteLine("Data Loaded in " + loadingTime.ElapsedMilliseconds + "ms");

using var client = new HttpClient(new HstsClientHandler(new SocketsHttpHandler(), policyCollection), disposeHandler: true);
using var response = await client.GetAsync("http://apis.google.com").ConfigureAwait(false);

Console.WriteLine(response.RequestMessage.RequestUri);
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.MustUpgradeRequest(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;
}
}
186 changes: 186 additions & 0 deletions src/Meziantou.Framework.Http.Hsts/HstsDomainPolicyCollection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
using System.Collections.Concurrent;
using System.Collections;
using System.Runtime.InteropServices;
using System.IO.Compression;
using System.Diagnostics;

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;

// Avoid recomputing the value during initialization
private readonly DateTimeOffset _expires18weeks;
private readonly DateTimeOffset _expires1year;

public static HstsDomainPolicyCollection Default { get; } = new();

public HstsDomainPolicyCollection(bool includePreloadDomains = true)
: this(timeProvider: null, includePreloadDomains)
{
}

public HstsDomainPolicyCollection(TimeProvider? timeProvider, bool includePreloadDomains = true)
{
_timeProvider = timeProvider ?? TimeProvider.System;
if (includePreloadDomains)
{
_expires18weeks = _timeProvider.GetUtcNow().Add(TimeSpan.FromDays(18 * 7));
_expires1year = _timeProvider.GetUtcNow().Add(TimeSpan.FromDays(365));
LoadPreloadDomains();
}
}

private void Load(ConcurrentDictionary<string, HstsDomainPolicy> dictionary, int entryCount, string resourceName)
{
using var stream = typeof(HstsDomainPolicyCollection).Assembly.GetManifestResourceStream(resourceName);
Debug.Assert(stream is not null);
using var gz = new GZipStream(stream, CompressionMode.Decompress);
using var reader = new BinaryReader(gz);
for (var i = 0; i < entryCount; i++)
{
var name = reader.ReadString();
var includeSubdomains = reader.ReadBoolean();
var expiresIn = reader.ReadInt32();
var expiresAt = expiresIn switch
{
18 * 7 * 24 * 60 * 60 => _expires18weeks,
365 * 24 * 60 * 60 => _expires1year,
_ => _timeProvider.GetUtcNow().AddSeconds(expiresIn),
};
dictionary.TryAdd(name, new(name, expiresAt, includeSubdomains));
}
}

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)
{
ArgumentNullException.ThrowIfNull(host);

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 MustUpgradeRequest(string host)
{
ArgumentNullException.ThrowIfNull(host);
return MustUpgradeRequest(host.AsSpan());
}

public bool MustUpgradeRequest(ReadOnlySpan<char> host)
{
var enumerator = new DomainSplitReverseEnumerator(host);
for (var i = 0; i < _policies.Count && enumerator.MoveNext(); i++)
{
var dictionary = _policies[i];
var lastSegments = host[enumerator.Current..];

#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 && enumerator.Current != 0)
{
return false;
}

return true;
}
}

return false;
}

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

[StructLayout(LayoutKind.Auto)]
private ref struct DomainSplitReverseEnumerator
{
private ReadOnlySpan<char> _span;
private int _index;
public DomainSplitReverseEnumerator(ReadOnlySpan<char> span)
{
_span = span;
_index = span.Length;
}

public int Current => _index == 0 ? 0 : (_index + 1);

public bool MoveNext()
{
var index = _span.LastIndexOf('.');
if (index == -1)
{
if (_span.IsEmpty)
return false;

_index = 0;
_span = ReadOnlySpan<char>.Empty;
return true;
}

_index = index;
_span = _span.Slice(0, index);
return true;
}
}
}
Loading

0 comments on commit 6ef7ae7

Please sign in to comment.