From 30afb42a18ce5353d5b0c415a92672b521dafb1d Mon Sep 17 00:00:00 2001 From: Joel Byford Date: Fri, 27 Feb 2026 10:44:50 -0800 Subject: [PATCH 1/7] Implement security enhancements: add rate limiting, enforce HTTPS, and update examples to use environment variables --- .github/prompts/review.security.md | 9 + BasicAuth.cs | 328 +++++++++++++++--- BasicAuth.csproj | 8 +- CHANGELOG.md | 10 + README.md | 52 ++- .../BasicAuthHarness/BasicAuthHarness.csproj | 15 + harness/BasicAuthHarness/Program.cs | 21 ++ .../testing/manualtesting.http | 30 ++ .../BasicAuthHarness/testing/test-auth.ps1 | 20 ++ 9 files changed, 445 insertions(+), 48 deletions(-) create mode 100644 .github/prompts/review.security.md create mode 100644 CHANGELOG.md create mode 100644 harness/BasicAuthHarness/BasicAuthHarness.csproj create mode 100644 harness/BasicAuthHarness/Program.cs create mode 100644 harness/BasicAuthHarness/testing/manualtesting.http create mode 100644 harness/BasicAuthHarness/testing/test-auth.ps1 diff --git a/.github/prompts/review.security.md b/.github/prompts/review.security.md new file mode 100644 index 0000000..e66ac20 --- /dev/null +++ b/.github/prompts/review.security.md @@ -0,0 +1,9 @@ +# Overall Review (ask mode) +``` +Reviewing this project, are there any major or minor security concerns with how its written? Anything I should consider doing differently? +``` + +# Plan for change (plan mode) +``` +Can you create a plan for mitigating the major issues and can you create it as an execution-ready checklist but be sure it is added to the .gitignore so it is not pushed to the main repo. +``` \ No newline at end of file diff --git a/BasicAuth.cs b/BasicAuth.cs index 4f97238..b8a8428 100644 --- a/BasicAuth.cs +++ b/BasicAuth.cs @@ -4,6 +4,8 @@ using System.Text; using System.Threading.Tasks; using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; namespace joelbyford { @@ -50,13 +52,23 @@ public class BasicAuth /// /// The credentials that are allowed to access the site. /// - private Dictionary activeUsers; + private readonly Dictionary activeUsers; /// /// Required for asp.net core middleware to function right /// private readonly RequestDelegate next; private readonly string realm; + private readonly object throttleLock = new object(); + private readonly Dictionary> failedAttemptsByIp = new Dictionary>(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> failedAttemptsByUser = new Dictionary>(StringComparer.Ordinal); + private readonly Dictionary lockedOutIps = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary lockedOutUsers = new Dictionary(StringComparer.Ordinal); + + private const int MaxFailedAttemptsPerIp = 10; + private const int MaxFailedAttemptsPerUser = 5; + private static readonly TimeSpan FailedAttemptWindow = TimeSpan.FromMinutes(5); + private static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(10); /// @@ -89,61 +101,299 @@ public BasicAuth(RequestDelegate next, string realm, Dictionary /// public async Task Invoke(HttpContext context) { - string authHeader = context.Request.Headers[HttpAuthorizationHeader]; - - if (authHeader != null && authHeader.StartsWith(HttpBasicSchemeName)) - { - // Get the encoded username and password - var encodedUsernamePassword = authHeader.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries)[1]?.Trim(); - - // Decode from Base64 to string - var decodedUsernamePassword = Encoding.UTF8.GetString(Convert.FromBase64String(encodedUsernamePassword)); - - // Split username and password - var username = decodedUsernamePassword.Split(':', 2)[0]; - var password = decodedUsernamePassword.Split(':', 2)[1]; - - // Check if login is correct - if (IsAuthorized(username, password)) - { - //if it's good, pass them on to the next middleware step in the pipeline - await next.Invoke(context); - } - else - { - // Return unauthorized - context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; - } + if (!IsSecureRequest(context)) + { + WriteUnauthorizedResponse(context); + return; + } + + string clientKey = GetClientKey(context); + DateTimeOffset now = DateTimeOffset.UtcNow; + + if (IsLockedOut(clientKey, null, now)) + { + WriteUnauthorizedResponse(context); return; } - else + + if (!TryGetCredentials(context, out string username, out string password, out bool hadAuthorizationHeader)) { - // Return authentication type (causes browser to show login dialog) - context.Response.Headers[HttpWwwAuthenticateHeader] = HttpBasicSchemeName; - // Add realm if it is not null - if (!string.IsNullOrWhiteSpace(realm)) + if (hadAuthorizationHeader) { - context.Response.Headers[HttpWwwAuthenticateHeader] += $" realm=\"{realm}\""; + RegisterFailedAttempt(clientKey, null, now); } - // Return unauthorized - context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + + WriteUnauthorizedResponse(context); + return; + } + + if (IsLockedOut(clientKey, username, now)) + { + WriteUnauthorizedResponse(context); + return; + } + + if (IsAuthorized(username, password)) + { + ClearFailedAttempts(clientKey, username); + await next.Invoke(context); return; } - + RegisterFailedAttempt(clientKey, username, now); + WriteUnauthorizedResponse(context); } public bool IsAuthorized(string username, string password) { - // Check that username and password are correct - string lowerCaseUserName = username.ToLower(); + if (!activeUsers.TryGetValue(username, out string expectedPassword)) + { + return false; + } + + return SecureEquals(expectedPassword, password); + } + + private bool TryGetCredentials(HttpContext context, out string username, out string password, out bool hadAuthorizationHeader) + { + username = string.Empty; + password = string.Empty; + hadAuthorizationHeader = false; + + if (!context.Request.Headers.TryGetValue(HttpAuthorizationHeader, out var authorizationValues)) + { + return false; + } + + hadAuthorizationHeader = true; + string authorizationHeader = authorizationValues.ToString(); + if (string.IsNullOrWhiteSpace(authorizationHeader)) + { + return false; + } + + if (!authorizationHeader.StartsWith(HttpBasicSchemeName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string[] schemeAndToken = authorizationHeader.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + if (schemeAndToken.Length != 2) + { + return false; + } + + string encodedCredentials = schemeAndToken[1].Trim(); + if (string.IsNullOrWhiteSpace(encodedCredentials)) + { + return false; + } + + byte[] decodedBytes; + try + { + decodedBytes = Convert.FromBase64String(encodedCredentials); + } + catch (FormatException) + { + return false; + } + + string decodedCredentials = Encoding.UTF8.GetString(decodedBytes); + int separatorIndex = decodedCredentials.IndexOf(HttpCredentialSeparator); + if (separatorIndex <= 0 || separatorIndex == decodedCredentials.Length - 1) + { + return false; + } + + username = decodedCredentials.Substring(0, separatorIndex); + password = decodedCredentials.Substring(separatorIndex + 1); + + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + { + return false; + } + + return true; + } + + private void WriteUnauthorizedResponse(HttpContext context) + { + context.Response.Headers[HttpWwwAuthenticateHeader] = BuildAuthenticateHeaderValue(); + context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + } + + private string BuildAuthenticateHeaderValue() + { + if (string.IsNullOrWhiteSpace(realm)) + { + return HttpBasicSchemeName; + } - if (activeUsers.ContainsKey(username) && activeUsers[username] == password) + return $"{HttpBasicSchemeName} realm=\"{realm}\""; + } + + private bool IsSecureRequest(HttpContext context) + { + if (context.Request.IsHttps) { return true; } - else + + if (context.Request.Headers.TryGetValue("X-Forwarded-Proto", out var forwardedProtoValues)) + { + string firstForwardedProto = forwardedProtoValues.ToString() + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(value => value.Trim()) + .FirstOrDefault(); + + if (string.Equals(firstForwardedProto, "https", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private string GetClientKey(HttpContext context) + { + if (context.Request.Headers.TryGetValue("X-Forwarded-For", out var forwardedForValues)) + { + string forwardedFor = forwardedForValues.ToString() + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(value => value.Trim()) + .FirstOrDefault(); + + if (!string.IsNullOrWhiteSpace(forwardedFor)) + { + return forwardedFor; + } + } + + return context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + } + + private bool IsLockedOut(string clientKey, string username, DateTimeOffset now) + { + lock (throttleLock) + { + if (IsActiveLockout(lockedOutIps, clientKey, now)) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(username) && IsActiveLockout(lockedOutUsers, username, now)) + { + return true; + } + + return false; + } + } + + private static bool IsActiveLockout(Dictionary lockouts, string key, DateTimeOffset now) + { + if (string.IsNullOrWhiteSpace(key)) + { + return false; + } + + if (!lockouts.TryGetValue(key, out DateTimeOffset lockoutUntil)) + { + return false; + } + + if (lockoutUntil <= now) + { + lockouts.Remove(key); return false; + } + + return true; + } + + private void RegisterFailedAttempt(string clientKey, string username, DateTimeOffset now) + { + lock (throttleLock) + { + RegisterFailedAttemptForKey(failedAttemptsByIp, lockedOutIps, clientKey, MaxFailedAttemptsPerIp, now); + + if (!string.IsNullOrWhiteSpace(username)) + { + RegisterFailedAttemptForKey(failedAttemptsByUser, lockedOutUsers, username, MaxFailedAttemptsPerUser, now); + } + } + } + + private void RegisterFailedAttemptForKey( + Dictionary> attemptsByKey, + Dictionary lockoutsByKey, + string key, + int maxAttempts, + DateTimeOffset now) + { + if (string.IsNullOrWhiteSpace(key)) + { + return; + } + + Queue attempts = GetOrCreateAttemptQueue(attemptsByKey, key); + PruneAttempts(attempts, now); + attempts.Enqueue(now); + + if (attempts.Count >= maxAttempts) + { + lockoutsByKey[key] = now.Add(LockoutDuration); + attempts.Clear(); + } + } + + private static Queue GetOrCreateAttemptQueue(Dictionary> attemptsByKey, string key) + { + if (!attemptsByKey.TryGetValue(key, out Queue attempts)) + { + attempts = new Queue(); + attemptsByKey[key] = attempts; + } + + return attempts; + } + + private static void PruneAttempts(Queue attempts, DateTimeOffset now) + { + while (attempts.Count > 0 && now - attempts.Peek() > FailedAttemptWindow) + { + attempts.Dequeue(); + } + } + + private void ClearFailedAttempts(string clientKey, string username) + { + lock (throttleLock) + { + failedAttemptsByIp.Remove(clientKey); + lockedOutIps.Remove(clientKey); + + if (!string.IsNullOrWhiteSpace(username)) + { + failedAttemptsByUser.Remove(username); + lockedOutUsers.Remove(username); + } + } + } + + private static bool SecureEquals(string left, string right) + { + byte[] leftHash = SHA256.HashData(Encoding.UTF8.GetBytes(left ?? string.Empty)); + byte[] rightHash = SHA256.HashData(Encoding.UTF8.GetBytes(right ?? string.Empty)); + + bool result = CryptographicOperations.FixedTimeEquals(leftHash, rightHash); + + CryptographicOperations.ZeroMemory(leftHash); + CryptographicOperations.ZeroMemory(rightHash); + + return result; } } } \ No newline at end of file diff --git a/BasicAuth.csproj b/BasicAuth.csproj index 64a9d38..03e4e97 100644 --- a/BasicAuth.csproj +++ b/BasicAuth.csproj @@ -5,7 +5,7 @@ BasicAuthLibirary joelbyford.BasicAuth True - 1.2.1 + 2.0.0 Joel Byford Mathco Software joelbyford.BasicAuthLibirary @@ -13,11 +13,15 @@ MIT https://github.com/joelbyford/BasicAuth git - A library which includes a dotnet 5+ Basic Authentication middleware component which can be added to dotnet web and API apps on Azure to enable classic/old RFC 2617 Basic Authentication. See GitHub repo for usage instructions. + A library which includes a dotnet Basic Authentication middleware component which can be added to dotnet web and API apps on Azure to enable classic/old RFC 2617 Basic Authentication. See GitHub repo for usage instructions. + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4811ad7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable activity for this repository is documented in this file. + +## v2.0.0 - 2026-02-27 - Security Updates +- Enforce TLS/HTTPS +- Enforce a max number of failed attempts by IP and User +- Update examples to use environment variables instead of plain text in code +- Added test harness + diff --git a/README.md b/README.md index 6264db4..35f3af0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,16 @@ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/8943/badge)](https://www.bestpractices.dev/projects/8943) # BasicAuth -A library which includes a dotnet 6 Basic Authentication middleware component which can be added to dotnet web and API apps on Azure to enable classic/old RFC 2617 Basic Authentication. Please note, Basic Auth is one of the oldest forms of web authentication and is [not known for being the most secure](https://datatracker.ietf.org/doc/html/rfc2617). Use and implement at your own risk and of course only use in conjunction with secure communications protocols (e.g. SSL) to prevent sending user names and passwords unencrypted over the Internet. +A library which includes a dotnet Basic Authentication middleware component which can be added to dotnet web and API apps on Azure to enable classic/old RFC 2617 Basic Authentication. Please note, Basic Auth is one of the oldest forms of web authentication and is [not known for being the most secure](https://datatracker.ietf.org/doc/html/rfc2617). Use and implement at your own risk and only over HTTPS/TLS to prevent sending user names and passwords unencrypted over the Internet. + +## Changelog +- See [CHANGELOG.md](CHANGELOG.md) for review history and recorded security findings. + +## Security Requirements +- Use HTTPS/TLS only. This middleware rejects non-HTTPS requests. +- Do not store credentials directly in source code. +- Add host-level rate limiting and monitoring for internet-facing workloads. +- Prefer modern auth (OIDC/OAuth/JWT) for public-facing production apps where possible. ## Install - Leveraging [NuGet Package](https://www.nuget.org/packages/joelbyford.BasicAuth/) Assuming you would like to add the library to your project via a NuGet package, the following are the steps required: @@ -17,38 +26,44 @@ If you would rather use the raw source code, just copy the BasicAuth.cs file int Once installed, to use the library, simply modify the `Configure` method in your `startup.cs` to call the library in any *one* of *two* ways: ### Authorize a Single User -For simple use cases, this may satisfy your need. ***PLEASE take steps to avoid having credentials in code*** +For simple use cases, this may satisfy your need. Source credentials from environment variables or a secret store. ``` using joelbyford; +using System; public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { ... string basicAuthRealm = "mywebsite.com"; - string basicAuthUser = "testUser"; //hardcoded values here for example only - string basicAuthPass = "testPass"; //hardcoded values here for example only + string basicAuthUser = Environment.GetEnvironmentVariable("BASIC_AUTH_USER"); + string basicAuthPass = Environment.GetEnvironmentVariable("BASIC_AUTH_PASS"); app.UseMiddleware(basicAuthRealm, basicAuthUser, basicAuthPass); } ``` ### Authorize a Dictionary of Users -If you would like to control how and where you get the users and passwords from, this method is best (e.g. you are obtaining from a database). ***PLEASE take steps to avoid having credentials in code*** +If you would like to control how and where you get the users and passwords from, this method is best (e.g. you are obtaining from a database or secure configuration source). ``` using joelbyford; using System.IO; +using System.Collections.Generic; using System.Text.Json; public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { ... Dictionary myUsers = new Dictionary(); - var packageJson = File.ReadAllText("authorizedUsers.json"); + var packageJson = File.ReadAllText("authorizedUsers.secure.json"); myUsers = JsonSerializer.Deserialize>(packageJson); string basicAuthRealm = "mywebsite.com"; app.UseMiddleware(basicAuthRealm, myUsers); } ``` -In this example, a json file is loaded from the web app's root directory with the following format: +In this example, credentials are loaded from a controlled configuration source. Avoid committing credential files to source control and rotate passwords regularly. + +If you must use a local file for development, do not store production credentials in that file and exclude it from git. + +Example format: ``` { "testUser" : "testPassword", @@ -58,3 +73,26 @@ In this example, a json file is loaded from the web app's root directory with th This can of course be loaded in from a database call instead as long as users and passwords are loaded into a `Dictionary` To see an example of this in use, please see `startup.cs` in the https://github.com/joelbyford/CSVtoJSONcore repo. + +## Local Authentication Test Harness +To quickly verify middleware behavior (including a bogus endpoint auth check), a runnable sample is included at `harness/BasicAuthHarness`. + +Run the harness: +``` +dotnet run --project harness/BasicAuthHarness/BasicAuthHarness.csproj +``` + +In a second terminal, run the included curl test script: +``` +powershell -ExecutionPolicy Bypass -File .\harness\BasicAuthHarness\testing\test-auth.ps1 +``` + +Alternatively you may call the tests via the [REST Client VSCode plugin](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) in the testing.http file. + +Expected results: +- Missing Authorization header -> `401 Unauthorized` +- Invalid credentials -> `401 Unauthorized` +- Valid credentials to `POST /bogus` -> `404 Not Found` (auth passed, route missing) +- Valid credentials to `GET /health` -> `200 OK` + +Note: The harness runs on HTTP for convenience and uses `X-Forwarded-Proto: https` in curl commands to simulate TLS termination at a reverse proxy. diff --git a/harness/BasicAuthHarness/BasicAuthHarness.csproj b/harness/BasicAuthHarness/BasicAuthHarness.csproj new file mode 100644 index 0000000..cda90b1 --- /dev/null +++ b/harness/BasicAuthHarness/BasicAuthHarness.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + Exe + enable + enable + + + + + + + + diff --git a/harness/BasicAuthHarness/Program.cs b/harness/BasicAuthHarness/Program.cs new file mode 100644 index 0000000..f8ad892 --- /dev/null +++ b/harness/BasicAuthHarness/Program.cs @@ -0,0 +1,21 @@ +using joelbyford; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; + +var builder = WebApplication.CreateBuilder(args); +builder.WebHost.UseUrls("http://localhost:5057"); + +var app = builder.Build(); + +var users = new Dictionary +{ + ["demoUser"] = "demoPass!123" +}; + +app.UseMiddleware("basic-auth-harness", users); + +app.MapGet("/health", () => Results.Ok(new { status = "ok" })); +app.MapPost("/echo", () => Results.Ok(new { status = "authenticated" })); + +app.Run(); diff --git a/harness/BasicAuthHarness/testing/manualtesting.http b/harness/BasicAuthHarness/testing/manualtesting.http new file mode 100644 index 0000000..f844b6e --- /dev/null +++ b/harness/BasicAuthHarness/testing/manualtesting.http @@ -0,0 +1,30 @@ +#### +# Missing Auth +# Expect 401 +#### +POST http://localhost:5057/bogus +X-Forwarded-Proto: https + +#### +# Bad Credentials +# Expect 401 +#### +POST http://localhost:5057/bogus +X-Forwarded-Proto: https +Authorization: Basic badUser:badPassword + +#### +# Good Credentials but Bad Path +# Expect 404 +#### +POST http://localhost:5057/bogus +X-Forwarded-Proto: https +Authorization: Basic demoUser:demoPass!123 + +#### +# Good Credentials +# Expect 200 +#### +GET http://localhost:5057/health +X-Forwarded-Proto: https +Authorization: Basic demoUser:demoPass!123 diff --git a/harness/BasicAuthHarness/testing/test-auth.ps1 b/harness/BasicAuthHarness/testing/test-auth.ps1 new file mode 100644 index 0000000..bb3d329 --- /dev/null +++ b/harness/BasicAuthHarness/testing/test-auth.ps1 @@ -0,0 +1,20 @@ +param( + [string]$BaseUrl = "http://localhost:5057", + [string]$User = "demoUser", + [string]$Pass = "demoPass!123" +) + +$authValue = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("$User`:$Pass")) +$badAuthValue = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("$User`:wrongPassword")) + +Write-Host "\n1) Missing auth header -> expect 401" +curl.exe -i -X POST "$BaseUrl/bogus" -H "X-Forwarded-Proto: https" + +Write-Host "\n2) Bad credentials -> expect 401" +curl.exe -i -X POST "$BaseUrl/bogus" -H "X-Forwarded-Proto: https" -H "Authorization: Basic $badAuthValue" + +Write-Host "\n3) Valid credentials + bogus endpoint -> expect 404" +curl.exe -i -X POST "$BaseUrl/bogus" -H "X-Forwarded-Proto: https" -H "Authorization: Basic $authValue" + +Write-Host "\n4) Valid credentials + valid endpoint -> expect 200" +curl.exe -i "$BaseUrl/health" -H "X-Forwarded-Proto: https" -H "Authorization: Basic $authValue" From 20e7c05d59fefecb205ebf78f84fe5a3b3febe25 Mon Sep 17 00:00:00 2001 From: Joel Byford Date: Thu, 5 Mar 2026 07:50:18 -0800 Subject: [PATCH 2/7] Update .github/prompts/review.security.md typo Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/prompts/review.security.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/prompts/review.security.md b/.github/prompts/review.security.md index e66ac20..139a50d 100644 --- a/.github/prompts/review.security.md +++ b/.github/prompts/review.security.md @@ -1,6 +1,6 @@ # Overall Review (ask mode) ``` -Reviewing this project, are there any major or minor security concerns with how its written? Anything I should consider doing differently? +Reviewing this project, are there any major or minor security concerns with how it's written? Anything I should consider doing differently? ``` # Plan for change (plan mode) From 97f824ff8b86132578a06363a06cf85f27ffffb2 Mon Sep 17 00:00:00 2001 From: Joel Byford Date: Thu, 5 Mar 2026 07:51:52 -0800 Subject: [PATCH 3/7] Update harness/BasicAuthHarness/testing/test-auth.ps1 Making script more portable. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- harness/BasicAuthHarness/testing/test-auth.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/harness/BasicAuthHarness/testing/test-auth.ps1 b/harness/BasicAuthHarness/testing/test-auth.ps1 index bb3d329..fd7084a 100644 --- a/harness/BasicAuthHarness/testing/test-auth.ps1 +++ b/harness/BasicAuthHarness/testing/test-auth.ps1 @@ -8,13 +8,13 @@ $authValue = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("$User`:$P $badAuthValue = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("$User`:wrongPassword")) Write-Host "\n1) Missing auth header -> expect 401" -curl.exe -i -X POST "$BaseUrl/bogus" -H "X-Forwarded-Proto: https" +curl -i -X POST "$BaseUrl/bogus" -H "X-Forwarded-Proto: https" Write-Host "\n2) Bad credentials -> expect 401" -curl.exe -i -X POST "$BaseUrl/bogus" -H "X-Forwarded-Proto: https" -H "Authorization: Basic $badAuthValue" +curl -i -X POST "$BaseUrl/bogus" -H "X-Forwarded-Proto: https" -H "Authorization: Basic $badAuthValue" Write-Host "\n3) Valid credentials + bogus endpoint -> expect 404" -curl.exe -i -X POST "$BaseUrl/bogus" -H "X-Forwarded-Proto: https" -H "Authorization: Basic $authValue" +curl -i -X POST "$BaseUrl/bogus" -H "X-Forwarded-Proto: https" -H "Authorization: Basic $authValue" Write-Host "\n4) Valid credentials + valid endpoint -> expect 200" -curl.exe -i "$BaseUrl/health" -H "X-Forwarded-Proto: https" -H "Authorization: Basic $authValue" +curl -i "$BaseUrl/health" -H "X-Forwarded-Proto: https" -H "Authorization: Basic $authValue" From 0495fa87690710e55184176061f8b684e3a26291 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:55:33 +0000 Subject: [PATCH 4/7] Initial plan From d6d793cb123209a704db15c3c7040fbedc057f77 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:00:06 +0000 Subject: [PATCH 5/7] Fix: only register failed attempts for valid Basic credential submissions Co-authored-by: joelbyford <57726719+joelbyford@users.noreply.github.com> --- BasicAuth.cs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/BasicAuth.cs b/BasicAuth.cs index b8a8428..86d19ab 100644 --- a/BasicAuth.cs +++ b/BasicAuth.cs @@ -116,13 +116,8 @@ public async Task Invoke(HttpContext context) return; } - if (!TryGetCredentials(context, out string username, out string password, out bool hadAuthorizationHeader)) + if (!TryGetCredentials(context, out string username, out string password)) { - if (hadAuthorizationHeader) - { - RegisterFailedAttempt(clientKey, null, now); - } - WriteUnauthorizedResponse(context); return; } @@ -154,18 +149,16 @@ public bool IsAuthorized(string username, string password) return SecureEquals(expectedPassword, password); } - private bool TryGetCredentials(HttpContext context, out string username, out string password, out bool hadAuthorizationHeader) + private bool TryGetCredentials(HttpContext context, out string username, out string password) { username = string.Empty; password = string.Empty; - hadAuthorizationHeader = false; if (!context.Request.Headers.TryGetValue(HttpAuthorizationHeader, out var authorizationValues)) { return false; } - hadAuthorizationHeader = true; string authorizationHeader = authorizationValues.ToString(); if (string.IsNullOrWhiteSpace(authorizationHeader)) { From 131cda9eb68e41bd15defd9cbe9a6e1623cb4413 Mon Sep 17 00:00:00 2001 From: Joel Byford Date: Thu, 5 Mar 2026 08:29:22 -0800 Subject: [PATCH 6/7] Add PR harness bash test workflow and update auth test scripts for improved validation --- .github/workflows/pr-harness-bash-test.yml | 64 +++++++++++++++++++ README.md | 38 +++++++++++ .../BasicAuthHarness/testing/test-auth.ps1 | 56 +++++++++++++--- harness/BasicAuthHarness/testing/test-auth.sh | 63 ++++++++++++++++++ 4 files changed, 213 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/pr-harness-bash-test.yml create mode 100644 harness/BasicAuthHarness/testing/test-auth.sh diff --git a/.github/workflows/pr-harness-bash-test.yml b/.github/workflows/pr-harness-bash-test.yml new file mode 100644 index 0000000..8ccfdaf --- /dev/null +++ b/.github/workflows/pr-harness-bash-test.yml @@ -0,0 +1,64 @@ +name: PR Harness Bash Test + +on: + pull_request: + branches: + - main + +jobs: + harness-bash-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET 10 SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Restore harness dependencies + run: dotnet restore harness/BasicAuthHarness/BasicAuthHarness.csproj + + - name: Build harness project + run: dotnet build harness/BasicAuthHarness/BasicAuthHarness.csproj --configuration Release --no-restore + + - name: Start harness + shell: bash + run: | + dotnet run --project harness/BasicAuthHarness/BasicAuthHarness.csproj --configuration Release --no-build --urls "http://localhost:5057" > harness.log 2>&1 & + echo "HARNESS_PID=$!" >> "$GITHUB_ENV" + + - name: Wait for harness readiness + shell: bash + run: | + for i in {1..30}; do + status="$(curl -s -o /dev/null -w "%{http_code}" -X POST "http://localhost:5057/bogus" -H "X-Forwarded-Proto: https" || true)" + if [ "$status" = "401" ]; then + echo "Harness is ready" + exit 0 + fi + sleep 1 + done + + echo "Harness did not become ready in time" + cat harness.log || true + exit 1 + + - name: Run bash auth assertions + shell: bash + run: bash harness/BasicAuthHarness/testing/test-auth.sh + + - name: Print harness log on failure + if: failure() + shell: bash + run: cat harness.log || true + + - name: Stop harness + if: always() + shell: bash + run: | + if [ -n "${HARNESS_PID:-}" ]; then + kill "$HARNESS_PID" || true + fi diff --git a/README.md b/README.md index 35f3af0..af023d4 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,34 @@ In a second terminal, run the included curl test script: powershell -ExecutionPolicy Bypass -File .\harness\BasicAuthHarness\testing\test-auth.ps1 ``` +### Auth Test Scripts (PowerShell + Bash) +Both scripts validate the same 4 assertions: +- Missing Authorization header -> `401` +- Invalid credentials -> `401` +- Valid credentials to `POST /bogus` -> `404` +- Valid credentials to `GET /health` -> `200` + +Each script prints `PASS`/`FAIL` for every assertion, then prints: +- `true` if all assertions pass (process exit code `0`) +- `false` if any assertion fails (process exit code `1`) + +PowerShell usage: +``` +powershell -ExecutionPolicy Bypass -File .\harness\BasicAuthHarness\testing\test-auth.ps1 +powershell -ExecutionPolicy Bypass -File .\harness\BasicAuthHarness\testing\test-auth.ps1 "http://localhost:5057" "demoUser" "demoPass!123" +``` + +Bash usage: +``` +bash harness/BasicAuthHarness/testing/test-auth.sh +bash harness/BasicAuthHarness/testing/test-auth.sh http://localhost:5057 demoUser demoPass!123 +``` + +Parameter order for both scripts: +1. `BaseUrl` +2. `User` +3. `Pass` + Alternatively you may call the tests via the [REST Client VSCode plugin](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) in the testing.http file. Expected results: @@ -96,3 +124,13 @@ Expected results: - Valid credentials to `GET /health` -> `200 OK` Note: The harness runs on HTTP for convenience and uses `X-Forwarded-Proto: https` in curl commands to simulate TLS termination at a reverse proxy. + +### Recommended Branch Protection Check +If you use GitHub branch protection for `main`, require the status check from this workflow: +- Workflow: `PR Harness Bash Test` +- Job/check name: `harness-bash-test` + +In GitHub, go to **Settings -> Branches -> Branch protection rules** for `main` and add this check under **Require status checks to pass before merging**. + +### Test Usage of X-Forwarded-Proto: https +*PLEASE NOTE* this library allows the developer to use `X-Forwarded-Proto: https` in the API calls to ease development and testing in environments (like local workstations) that do not have SSL certificates installed. Do NOT use this in production as it will expose passwords in clear text without a secure SSL socket. \ No newline at end of file diff --git a/harness/BasicAuthHarness/testing/test-auth.ps1 b/harness/BasicAuthHarness/testing/test-auth.ps1 index fd7084a..9d32038 100644 --- a/harness/BasicAuthHarness/testing/test-auth.ps1 +++ b/harness/BasicAuthHarness/testing/test-auth.ps1 @@ -7,14 +7,54 @@ param( $authValue = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("$User`:$Pass")) $badAuthValue = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("$User`:wrongPassword")) -Write-Host "\n1) Missing auth header -> expect 401" -curl -i -X POST "$BaseUrl/bogus" -H "X-Forwarded-Proto: https" +$allPassed = $true -Write-Host "\n2) Bad credentials -> expect 401" -curl -i -X POST "$BaseUrl/bogus" -H "X-Forwarded-Proto: https" -H "Authorization: Basic $badAuthValue" +function Assert-Status { + param( + [string]$Description, + [string]$ExpectedStatus, + [string[]]$CurlArgs + ) -Write-Host "\n3) Valid credentials + bogus endpoint -> expect 404" -curl -i -X POST "$BaseUrl/bogus" -H "X-Forwarded-Proto: https" -H "Authorization: Basic $authValue" + $actualStatus = & curl.exe -s -o NUL -w "%{http_code}" @CurlArgs -Write-Host "\n4) Valid credentials + valid endpoint -> expect 200" -curl -i "$BaseUrl/health" -H "X-Forwarded-Proto: https" -H "Authorization: Basic $authValue" + if ($actualStatus -eq $ExpectedStatus) { + Write-Host "PASS: $Description (expected $ExpectedStatus, got $actualStatus)" + } + else { + Write-Host "FAIL: $Description (expected $ExpectedStatus, got $actualStatus)" + $script:allPassed = $false + } +} + +Assert-Status -Description "Missing auth header" -ExpectedStatus "401" -CurlArgs @( + "-X", "POST", "$BaseUrl/bogus", + "-H", "X-Forwarded-Proto: https" +) + +Assert-Status -Description "Bad credentials" -ExpectedStatus "401" -CurlArgs @( + "-X", "POST", "$BaseUrl/bogus", + "-H", "X-Forwarded-Proto: https", + "-H", "Authorization: Basic $badAuthValue" +) + +Assert-Status -Description "Valid credentials + bogus endpoint" -ExpectedStatus "404" -CurlArgs @( + "-X", "POST", "$BaseUrl/bogus", + "-H", "X-Forwarded-Proto: https", + "-H", "Authorization: Basic $authValue" +) + +Assert-Status -Description "Valid credentials + valid endpoint" -ExpectedStatus "200" -CurlArgs @( + "$BaseUrl/health", + "-H", "X-Forwarded-Proto: https", + "-H", "Authorization: Basic $authValue" +) + +if ($allPassed) { + Write-Output "true" + exit 0 +} +else { + Write-Output "false" + exit 1 +} diff --git a/harness/BasicAuthHarness/testing/test-auth.sh b/harness/BasicAuthHarness/testing/test-auth.sh new file mode 100644 index 0000000..f8df4fe --- /dev/null +++ b/harness/BasicAuthHarness/testing/test-auth.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash + +set -u + +BASE_URL="${1:-http://localhost:5057}" +USER_NAME="${2:-demoUser}" +PASSWORD="${3:-demoPass!123}" + +AUTH_VALUE="$(printf '%s:%s' "$USER_NAME" "$PASSWORD" | base64 | tr -d '\n')" +BAD_AUTH_VALUE="$(printf '%s:%s' "$USER_NAME" "wrongPassword" | base64 | tr -d '\n')" + +all_passed=true + +assert_status() { + local description="$1" + local expected_status="$2" + shift 2 + + local actual_status + actual_status="$(curl -s -o /dev/null -w "%{http_code}" "$@")" + + if [[ "$actual_status" == "$expected_status" ]]; then + echo "PASS: ${description} (expected ${expected_status}, got ${actual_status})" + else + echo "FAIL: ${description} (expected ${expected_status}, got ${actual_status})" + all_passed=false + fi +} + +assert_status \ + "Missing auth header" \ + "401" \ + -X POST "${BASE_URL}/bogus" \ + -H "X-Forwarded-Proto: https" + +assert_status \ + "Bad credentials" \ + "401" \ + -X POST "${BASE_URL}/bogus" \ + -H "X-Forwarded-Proto: https" \ + -H "Authorization: Basic ${BAD_AUTH_VALUE}" + +assert_status \ + "Valid credentials + bogus endpoint" \ + "404" \ + -X POST "${BASE_URL}/bogus" \ + -H "X-Forwarded-Proto: https" \ + -H "Authorization: Basic ${AUTH_VALUE}" + +assert_status \ + "Valid credentials + valid endpoint" \ + "200" \ + "${BASE_URL}/health" \ + -H "X-Forwarded-Proto: https" \ + -H "Authorization: Basic ${AUTH_VALUE}" + +if [[ "$all_passed" == true ]]; then + echo "true" + exit 0 +else + echo "false" + exit 1 +fi From 2307e1de3fa6c4c5652c0c929be70962fa928443 Mon Sep 17 00:00:00 2001 From: Joel Byford Date: Thu, 5 Mar 2026 09:24:50 -0800 Subject: [PATCH 7/7] Add push trigger to PR harness bash test workflow and create NuGet publish workflow --- .github/workflows/PublishNugetArtifact.yml | 54 ++++++++++++++++++++++ .github/workflows/pr-harness-bash-test.yml | 3 ++ README.md | 2 +- 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/PublishNugetArtifact.yml diff --git a/.github/workflows/PublishNugetArtifact.yml b/.github/workflows/PublishNugetArtifact.yml new file mode 100644 index 0000000..904580e --- /dev/null +++ b/.github/workflows/PublishNugetArtifact.yml @@ -0,0 +1,54 @@ +# This is a basic workflow to help you get started with Actions + +name: Manually Publish NuGet.org + +# Controls when the workflow will run +on: + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +permissions: + contents: read + id-token: write # REQUIRED for OIDC + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v4 + + # Get the latest version tag to duplicate it in the nuget package + - uses: oprypin/find-latest-tag@v1 + with: + repository: joelbyford/BasicAuth # The repository to scan. + releases-only: true # We know that all relevant tags have a GitHub release for them. + id: latesttag # The step ID to refer to later. + + # Install DotNet SDK + - name: Setup dotnet 10.x + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.x + + # Restore Dependancies (can be omitted if you remove --no-restore on build step) + - name: Restore package dependencies + run: dotnet restore + + # Build the project + - name: dotnet build + run: dotnet build --no-restore --configuration Release + + # Pack the project for Nuget + - name: dotnet pack + if: success() # this should be implied, but adding just to be sure this runs only when the previous steps are successfull + run: dotnet pack -v normal -c Release --no-restore --include-source -p:PackageVersion=${{ steps.latesttag.outputs.tag }} -o ${{env.DOTNET_ROOT}}/myapp + + # Upload the artifact to NuGet.org + - name: dotnet nuget push + run: dotnet nuget push ${{env.DOTNET_ROOT}}/myapp/*.nupkg --source https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/.github/workflows/pr-harness-bash-test.yml b/.github/workflows/pr-harness-bash-test.yml index 8ccfdaf..ea3e12b 100644 --- a/.github/workflows/pr-harness-bash-test.yml +++ b/.github/workflows/pr-harness-bash-test.yml @@ -4,6 +4,9 @@ on: pull_request: branches: - main + push: + branches: + - main jobs: harness-bash-test: diff --git a/README.md b/README.md index af023d4..16733ae 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/8943/badge)](https://www.bestpractices.dev/projects/8943) +[![PR Harness Bash Test](https://github.com/joelbyford/BasicAuth/actions/workflows/pr-harness-bash-test.yml/badge.svg)](https://github.com/joelbyford/BasicAuth/actions/workflows/pr-harness-bash-test.yml) # BasicAuth A library which includes a dotnet Basic Authentication middleware component which can be added to dotnet web and API apps on Azure to enable classic/old RFC 2617 Basic Authentication. Please note, Basic Auth is one of the oldest forms of web authentication and is [not known for being the most secure](https://datatracker.ietf.org/doc/html/rfc2617). Use and implement at your own risk and only over HTTPS/TLS to prevent sending user names and passwords unencrypted over the Internet.