Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Meziantou.Framework.Http.Hsts #711

Merged
merged 1 commit into from
Dec 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading