-
-
Notifications
You must be signed in to change notification settings - Fork 100
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Meziantou.Framework.Http.Hsts (#711)
- Loading branch information
Showing
22 changed files
with
1,103 additions
and
169 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }} |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
12 changes: 12 additions & 0 deletions
12
Samples/Meziantou.Framework.Http.HstsSample/Meziantou.Framework.Http.HstsSample.csproj
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
186
src/Meziantou.Framework.Http.Hsts/HstsDomainPolicyCollection.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} | ||
} |
Oops, something went wrong.