9 Commits

Author SHA1 Message Date
5a72164b68 8.1.1 pin System.Security.Cryptography.Pkcs 10.0.13 (CVE-2026-84008, CVE-2026-70162) 2026-10-25 08:45:23 -07:00
809f46fde1 Version bumps 2026-10-25 08:06:54 -07:00
6d6c74fd32 case 4726 - login lockout per login name and per address
Same as AyaNova: failed logins counted in memory, 10 per login name or 50
per client address within 15 minutes, then 429 for the rest of the window.
Sockeye's login is public (only SSH to the droplet is limited).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:12:31 -07:00
cd3ebed68b case 4681 - rate limits on the anonymous trial and licence routes, stricter email check, cryptographic codes
Anonymous trial requests could be sent without limit, each one emailing a
verification link to any address (bounces from made-up domains), and the
verification and v7 fetch codes came from System.Random (AyaNova security
review F40).
- ASP.NET Core rate limiter per client address: trial requests (rvr,
  subscription-server-trial-request) 5 per hour; verify, rvf and the legacy v7
  fetch 30 per 10 minutes; 429 over the limit. Runs after routing and after
  forwarded headers.
- rvr: the email must be a valid address (MailAddress) before anything is
  stored or sent.
- GenFetchCode: RandomNumberGenerator, same 10-letter format.
rvf is unchanged (still one-shot): repeatable fetch needs an AyaNova-side change
for older servers and is a separate later case.

Checked on the local server with invalid addresses only (no email sent, no
request stored): 6th trial request 429, 31st fetch 429, auth unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:54:59 -07:00
f7c1d7c856 case 4710 - 2FA step requires the temp token from the password step
Same fix as AyaNova (raven 141bdeb8): the 2FA step matched the user by temp
token alone and the token was left empty after a 2FA login, so an empty or
missing token skipped the password step. Now an empty or missing temp token is
refused, the token is cleared to null after success, and after 5 wrong codes
the password must be entered again.

Checked on the local server with a temporary user (removed afterwards): no or
empty temp token 401; normal sign in 200; 5 wrong codes then the right code 401;
new password entry 200.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:36:45 -07:00
b49bcaecff case 4670 .net logging set to trace by default caught by claude also random todo stuff re puppeteersharp 2026-10-19 14:25:55 -07:00
8f87f385de Added support for Atlanta DataCenter on subscription server request form to match contact form project which I just added that to as well, not technically part of the .net10 migration but might as well while I'm in there 2026-10-09 14:43:36 -07:00
d00ec199eb case 4670 update version properly 2026-10-09 13:19:24 -07:00
daebf202e9 Merge net10-port: .NET 10 port 2026-10-09 12:47:35 -07:00
12 changed files with 173 additions and 28 deletions

View File

@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using Asp.Versioning;
@@ -35,6 +36,10 @@ namespace Sockeye.Api.Controllers
private readonly ApiServerState serverState;
private const int JWT_LIFETIME_DAYS = 5;
//case 4710: wrong 2fa codes allowed per password entry, counted per user (in memory, a restart only resets the count)
private const int MAX_TFA_FAILURES = 5;
private static readonly ConcurrentDictionary<long, int> TfaFailures = new ConcurrentDictionary<long, int>();
/// <summary>
/// ctor
/// </summary>
@@ -74,8 +79,14 @@ namespace Sockeye.Api.Controllers
//case 4726: too many failed logins for this login name or from this address: refused at once, the password is not checked
var address = HttpContext.Connection.RemoteIpAddress;
if (LoginThrottle.IsRefused(creds.Login, address))
return StatusCode(429, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED, null, "Too many failed login attempts, please try again later"));
if (string.IsNullOrWhiteSpace(creds.Login) || string.IsNullOrWhiteSpace(creds.Password))
{
RecordLoginFailure(creds.Login, address);
//Make a failed pw wait
await Task.Delay(Sockeye.Util.ServerBootConfig.FAILED_AUTH_DELAY);
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
@@ -91,7 +102,7 @@ namespace Sockeye.Api.Controllers
string hashed = Hasher.hash(u.Salt, creds.Password);
if (hashed == u.Password)
{
LoginThrottle.RecordSuccess(creds.Login);
//TWO FACTOR ENABLED??
//if 2fa enabled then need to validate it before sending token, so we're halfway there and need to send a 2fa prompt
@@ -100,6 +111,7 @@ namespace Sockeye.Api.Controllers
//Generate a temporary token to identify and verify this is the same user
u.TempToken = Hasher.GenerateSalt().Replace("=", "").Replace("+", "");
await ct.SaveChangesAsync();
TfaFailures.TryRemove(u.Id, out _);
var UOpt = await ct.UserOptions.AsNoTracking().FirstAsync(z => z.UserId == u.Id);
List<string> TranslationKeysToFetch = new List<string> { "AuthTwoFactor", "AuthEnterPin", "AuthVerifyCode", "Cancel", "AuthPinInvalid" };
@@ -125,11 +137,20 @@ namespace Sockeye.Api.Controllers
}
//No users matched, it's a failed login
RecordLoginFailure(creds.Login, address);
//Make a failed pw wait
await Task.Delay(Sockeye.Util.ServerBootConfig.FAILED_AUTH_DELAY);
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
}
//case 4726
private void RecordLoginFailure(string login, System.Net.IPAddress address)
{
var reached = LoginThrottle.RecordFailure(login, address);
if (reached != null)
log.LogWarning($"Too many failed login attempts for {reached}: further attempts refused for up to 15 minutes");
}
@@ -164,7 +185,14 @@ namespace Sockeye.Api.Controllers
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
}
//Match to temp token that would have been set by initial credentialed login for 2fa User
//case 4710: the temp token is the only proof the password step was passed, so it must be present
if (string.IsNullOrWhiteSpace(pin.TempToken))
{
await Task.Delay(Sockeye.Util.ServerBootConfig.FAILED_AUTH_DELAY);
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
}
//Match to temp token that would have been set by initial credentialed login for 2fa User
var user = await ct.User.Where(z => z.TempToken == pin.TempToken && z.Active == true && z.AllowLogin==true && z.TwoFactorEnabled == true).FirstOrDefaultAsync();
@@ -181,14 +209,22 @@ namespace Sockeye.Api.Controllers
var tfa = new TwoFactorAuth("Sockeye");
if (!tfa.VerifyCode(user.TotpSecret, pin.Pin.Replace(" ", "").Trim()))
{
//case 4710: after too many wrong codes the password must be entered again
if (TfaFailures.AddOrUpdate(user.Id, 1, (k, n) => n + 1) >= MAX_TFA_FAILURES)
{
TfaFailures.TryRemove(user.Id, out _);
user.TempToken = null;
await ct.SaveChangesAsync();
}
await Task.Delay(Sockeye.Util.ServerBootConfig.FAILED_AUTH_DELAY);
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
}
//User is valid and authenticated
//clear temp token
user.TempToken = string.Empty;
//clear temp token (case 4710: null, not empty, so no value is left to match)
user.TempToken = null;
await ct.SaveChangesAsync();
TfaFailures.TryRemove(user.Id, out _);
return await ReturnUserCredsOnSuccessfulAuthentication(user);
}

View File

@@ -41,6 +41,7 @@ namespace Sockeye.Api.Controllers
//////////////////////////////////////////////////////////////////////////
// GET: fetch/somecode/bob@bob.com
[HttpGet("{code}/{email}")]
[Microsoft.AspNetCore.RateLimiting.EnableRateLimiting(Startup.RATE_LIMIT_LICENSE_FETCH)]//case 4681
public async Task<IActionResult> Get([FromRoute] string code, [FromRoute] string email)
{

View File

@@ -46,6 +46,7 @@ namespace Sockeye.Api.Controllers
//### RAVEN FETCH LICENSE REQUEST HERE ####
//This route is used by Raven to check if there is an unfetched license key and return it
[HttpPost]
[Microsoft.AspNetCore.RateLimiting.EnableRateLimiting(Startup.RATE_LIMIT_LICENSE_FETCH)]//case 4681
public async Task<IActionResult> PostLicenseFetchRequest([FromBody] dtoFetchRequest fetchRequest)
{
if (!serverState.IsOpen)

View File

@@ -60,6 +60,7 @@ namespace Sockeye.Api.Controllers
//REQUEST TRIAL ROUTE CALLED FROM RAVEN
//#########################################################
[HttpPost]
[Microsoft.AspNetCore.RateLimiting.EnableRateLimiting(Startup.RATE_LIMIT_TRIAL_REQUESTS)]//case 4681
public async Task<IActionResult> Post([FromBody] dtoRequest r)
{
if (!serverState.IsOpen)
@@ -70,6 +71,12 @@ namespace Sockeye.Api.Controllers
return BadRequest(ModelState);
}
//case 4681: a verification email is sent to this address, so it must at least be a valid address
if (string.IsNullOrWhiteSpace(r.Email) || !System.Net.Mail.MailAddress.TryCreate(r.Email.Trim(), out var parsedEmail) || parsedEmail.Address != r.Email.Trim())
{
return BadRequest("E1000 - Email address invalid");
}
// if (r.DbId == Guid.Empty)
if (string.IsNullOrWhiteSpace(r.DbId))
{
@@ -114,6 +121,7 @@ namespace Sockeye.Api.Controllers
//### CUSTOMER ROUTE CALLED FROM CUSTOMER BROWSER / EMAIL ####
[HttpGet("verify/{code}")]
[Microsoft.AspNetCore.RateLimiting.EnableRateLimiting(Startup.RATE_LIMIT_LICENSE_FETCH)]//case 4681
public async Task<IActionResult> GetVerify([FromRoute] string code)
{
if (!serverState.IsOpen)

View File

@@ -57,6 +57,7 @@ namespace Sockeye.Api.Controllers
//REQUEST ROUTE CALLED FROM CONTACT FORMS APP
//#########################################################
[HttpPost]
[Microsoft.AspNetCore.RateLimiting.EnableRateLimiting(Startup.RATE_LIMIT_TRIAL_REQUESTS)]//case 4681
public async Task<IActionResult> Post([FromBody] dtoSubscriptionServerTrialRequest r)
{
if (!serverState.IsOpen)
@@ -109,6 +110,9 @@ namespace Sockeye.Api.Controllers
case "NY":
newObject.TimeZone = "America/New_York";
break;
case "ATL":
newObject.TimeZone = "America/New_York";//this is the correct time zone for Atlanta officially
break;
case "TOR":
newObject.TimeZone = "America/Toronto";
break;

View File

@@ -28,6 +28,10 @@ namespace Sockeye
{
public class Startup
{
//case 4681: rate limit policy names used on the anonymous trial and licence routes
internal const string RATE_LIMIT_TRIAL_REQUESTS = "trial-requests";
internal const string RATE_LIMIT_LICENSE_FETCH = "license-fetch";
/////////////////////////////////////////////////////////////
//
public Startup(Microsoft.AspNetCore.Hosting.IWebHostEnvironment hostingEnvironment)
@@ -174,6 +178,19 @@ namespace Sockeye
);
});
//case 4681: per client address limits on the anonymous trial request and licence fetch routes
_newLog.LogDebug("Rate limiting");
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy(RATE_LIMIT_TRIAL_REQUESTS, context => System.Threading.RateLimiting.RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new System.Threading.RateLimiting.FixedWindowRateLimiterOptions { PermitLimit = 5, Window = TimeSpan.FromHours(1), QueueLimit = 0 }));
options.AddPolicy(RATE_LIMIT_LICENSE_FETCH, context => System.Threading.RateLimiting.RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new System.Threading.RateLimiting.FixedWindowRateLimiterOptions { PermitLimit = 30, Window = TimeSpan.FromMinutes(10), QueueLimit = 0 }));
});
@@ -375,7 +392,10 @@ namespace Sockeye
#endregion
_newLog.LogDebug("Routing pipeline");
app.UseRouting();//this wasn't here for 2.2 but added for 3.0, needs to come before the stuff after
app.UseRouting();//this wasn't here for 2.2 but added for 3.0, needs to come before the stuff after
//case 4681: after routing so the per-route policies apply (and after forwarded headers so the client address is the real one)
app.UseRateLimiter();
_newLog.LogDebug("CORS pipeline");
app.UseCors("CorsPolicy");

View File

@@ -1,17 +1,8 @@
{
"Logging": {
"IncludeScopes": false,
"Debug": {
"LogLevel": {
"Default": "Trace"
}
},
"Console": {
"LogLevel": {
"Default": "Trace"
}
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
}

View File

@@ -4,8 +4,8 @@
</PropertyGroup>
<PropertyGroup>
<GenerateFullPaths>true</GenerateFullPaths>
<Version>8.1.0</Version>
<FileVersion>8.1.0.0</FileVersion>
<Version>8.1.1</Version>
<FileVersion>8.1.1.0</FileVersion>
<ApplicationIcon>sockeye.ico</ApplicationIcon>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
<noWarn>1591</noWarn>
@@ -24,6 +24,7 @@
<PackageReference Include="Enums.NET" Version="5.0.0" />
<PackageReference Include="jose-jwt" Version="5.3.0" />
<PackageReference Include="MailKit" Version="4.18.0" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.13" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.12" />
<PackageReference Include="Asp.Versioning.Mvc" Version="10.2.1" />

View File

@@ -0,0 +1,78 @@
using System;
using System.Collections.Concurrent;
using System.Net;
namespace Sockeye.Util
{
/// <summary>
/// case 4726: failed logins counted per login name and per client address (in memory, a restart resets the counts).
/// Too many failures within the window and further attempts are refused until the window ends.
/// Login names that don't exist are counted the same way, so the refusal says nothing about which logins exist.
/// Requests from this machine (loopback) are not counted per address: behind nginx the real client address is seen,
/// so loopback is only the machine itself
/// </summary>
internal static class LoginThrottle
{
internal const int MAX_LOGIN_FAILURES = 10;
internal const int MAX_ADDRESS_FAILURES = 50;
private static readonly TimeSpan WINDOW = TimeSpan.FromMinutes(15);
private const int PRUNE_ABOVE = 10000;
private record Window(DateTime Start, int Count);
private static readonly ConcurrentDictionary<string, Window> Failures = new ConcurrentDictionary<string, Window>();
private static string LoginKey(string login) => "L:" + (login ?? string.Empty).Trim().ToLowerInvariant();
private static string AddressKey(IPAddress address) => address == null || IPAddress.IsLoopback(address) ? null : "A:" + address.ToString();
/// <summary>
/// True if this login or this address has reached its limit in the current window
/// </summary>
internal static bool IsRefused(string login, IPAddress address)
{
return Reached(LoginKey(login), MAX_LOGIN_FAILURES) || Reached(AddressKey(address), MAX_ADDRESS_FAILURES);
}
/// <summary>
/// Counts a failed login; returns a description when this failure reached a limit (for the log), otherwise null
/// </summary>
internal static string RecordFailure(string login, IPAddress address)
{
if (Failures.Count > PRUNE_ABOVE)
Prune();
string reached = null;
if (Count(LoginKey(login)) == MAX_LOGIN_FAILURES)
reached = $"login \"{login}\"";
var addressKey = AddressKey(address);
if (addressKey != null && Count(addressKey) == MAX_ADDRESS_FAILURES)
reached = (reached == null ? "" : reached + " and ") + $"address {address}";
return reached;
}
/// <summary>
/// A correct password clears the count for that login
/// </summary>
internal static void RecordSuccess(string login)
{
Failures.TryRemove(LoginKey(login), out _);
}
private static bool Reached(string key, int max)
{
return key != null && Failures.TryGetValue(key, out var w) && DateTime.UtcNow - w.Start < WINDOW && w.Count >= max;
}
private static int Count(string key)
{
var now = DateTime.UtcNow;
return Failures.AddOrUpdate(key, new Window(now, 1), (_, w) => now - w.Start < WINDOW ? w with { Count = w.Count + 1 } : new Window(now, 1)).Count;
}
private static void Prune()
{
var now = DateTime.UtcNow;
foreach (var kv in Failures)
if (now - kv.Value.Start >= WINDOW)
Failures.TryRemove(kv.Key, out _);
}
}
}

View File

@@ -5,7 +5,7 @@ namespace Sockeye.Util
/// </summary>
internal static class SockeyeVersion
{
public const string VersionString = "8.0.17";
public const string VersionString = "8.1.1";
public const string FullNameAndVersion = "Sockeye server " + VersionString;
public const string CurrentApiVersion="v8";
}//eoc

View File

@@ -178,11 +178,11 @@ namespace Sockeye.Util
var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
var stringChars = new char[10];
var random = new Random();
//case 4681: cryptographic random, same format as before
for (int i = 0; i < stringChars.Length; i++)
{
stringChars[i] = chars[random.Next(chars.Length)];
stringChars[i] = chars[System.Security.Cryptography.RandomNumberGenerator.GetInt32(chars.Length)];
}
var finalString = new String(stringChars);

View File

@@ -1,11 +1,16 @@
TODO:
Chromium / chrome change is in the changelog of ayanova, no big mystery just some kind of default:
AyaNova 8.2.0
Released 2024-06-03
Server: switched the report rendering system from Chromium to Chrome browser to align with the default browser now supported by the Puppeteer headless browser controller library that we use for report generation. There will be a longer than normal delay the first time a report is rendered after upgrading while AyaNova automatically downloads the new report rendering browser.
- Subscriptions
todo: Two reports, one revenue report for list as filterd and one showing who subscribes to what and count of subscription items for each type etc
- Need to make it reportable so the subscription biz object needs to be able to support reporting fully with renewal and date and price viz etc