security patches & rate limits

This commit is contained in:
olcxja 2026-10-02 22:21:12 +02:00
commit 4e08d1a2df
4 changed files with 106 additions and 42 deletions

View file

@ -68,7 +68,7 @@ public class Requests
string id = Utils.GetValidIdOrZero(idQuery.ToString());
string password = await Utils.GetPassword(id);
secret = await Utils.NonceDecryptBody(id, password, secret);
string auth = await Utils.Auth(id, password, secret);
string auth = await Utils.Auth(id, password, secret, context.Connection.RemoteIpAddress?.ToString() ?? "");
if (auth != Utils.LOGIN_SUCCESS)
{
await context.Response.WriteAsync(auth);
@ -103,9 +103,10 @@ public class Requests
{
case "init":
{
//KEM keygen jest kosztowny - limit per IP
//check tylko: kwota jest zapisywana w finish po faktycznym utworzeniu konta,
//więc nieudane podejścia jej nie zjadają
string initIp = context.Connection.RemoteIpAddress?.ToString() ?? "";
if (RateLimited(AccountInitRateLimits, initIp, ACCOUNT_INIT_RATE_LIMIT))
if (IsRateLimitReached(AccountInitRateLimits, initIp, ACCOUNT_INIT_RATE_LIMIT))
{
await context.Response.WriteAsync("error:rate.limit");
return;
@ -125,7 +126,12 @@ public class Requests
KeyExchangePayload payload = new KeyExchangePayload();
payload.pubX25519 = Convert.ToBase64String(serverInfo.pubX25519);
payload.pubMlKem = Convert.ToBase64String(serverInfo.pubMlKem);
payload.idKey = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + "\n";
//losowy klucz: bez przejścia przez init nie da się odgadnąć idKey,
//więc register bez ważnego init robi tylko tani lookup
do
{
payload.idKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
} while (createHolder.ContainsKey(payload.idKey));
CreateHolder dataHolder = new();
dataHolder.date = DateTimeOffset.UtcNow;
@ -134,11 +140,6 @@ public class Requests
dataHolder.KdfSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
payload.kdfSalt = dataHolder.KdfSalt;
while (createHolder.ContainsKey(payload.idKey))
{
payload.idKey += Random.Shared.Next(0, 10).ToString();
}
createHolder.TryAdd(payload.idKey, dataHolder);
var serializedPayload = JsonSerializer.Serialize(
@ -163,6 +164,14 @@ public class Requests
return;
}
//register jest jednorazowy w tej sesji: powtórka (albo równoległe żądanie)
//z tym samym idKey nie może drugi raz liczyć KEM
if (Interlocked.CompareExchange(ref entry.registerDone, 1, 0) != 0)
{
await context.Response.WriteAsync("error:account.creation.request.expired");
return;
}
entry.date = DateTimeOffset.UtcNow;
byte[] pubX25519Client = Convert.FromBase64String(serializedBody.pubX25519);
@ -315,6 +324,9 @@ public class Requests
userLock.Release();
}
//kwota liczona dopiero po faktycznym utworzeniu konta
RecordRateLimitHit(AccountInitRateLimits,
context.Connection.RemoteIpAddress?.ToString() ?? "", 3600);
await context.Response.WriteAsync("success:account.created");
return;
}
@ -343,18 +355,11 @@ public class Requests
}
string id = Utils.GetValidIdOrZero(idQuery.ToString());
//rate limit prob logowania (konto+IP)
string clientIp = context.Connection.RemoteIpAddress?.ToString() ?? "";
if (RateLimited(AuthRateLimits, $"{id}:{clientIp}", AUTH_RATE_LIMIT))
{
await context.Response.WriteAsync("error:rate.limit");
return;
}
string password = await Utils.GetPassword(id);
secret = await Utils.NonceDecryptBody(id, password, secret);
string auth = await Utils.Auth(id, password, secret);
string auth = await Utils.Auth(id, password, secret, clientIp);
await context.Response.WriteAsync(auth);
return;
@ -511,7 +516,7 @@ public class Requests
string bodyDecrypted = await Utils.NonceDecryptBody(id, password, body, false);
secret = await Utils.NonceDecryptBody(id, password, secret);
string auth = await Utils.Auth(id, password, secret);
string auth = await Utils.Auth(id, password, secret, context.Connection.RemoteIpAddress?.ToString() ?? "");
if (auth != Utils.LOGIN_SUCCESS)
{
@ -598,7 +603,7 @@ public class Requests
body = await Utils.NonceDecryptBody(id, password, body, false);
secret = await Utils.NonceDecryptBody(id, password, secret);
string auth = await Utils.Auth(id, secret, password);
string auth = await Utils.Auth(id, secret, password, context.Connection.RemoteIpAddress?.ToString() ?? "");
if (auth != Utils.LOGIN_SUCCESS)
{
await context.Response.WriteAsync(auth);
@ -726,7 +731,7 @@ public class Requests
AppJsonSerializerContext.Default.Universal2String
);
string auth = await Utils.Auth(id, secret, password);
string auth = await Utils.Auth(id, secret, password, context.Connection.RemoteIpAddress?.ToString() ?? "");
if (auth != Utils.LOGIN_SUCCESS)
{
@ -1216,7 +1221,7 @@ public class Requests
string target = await Utils.NonceDecryptBody(id, password, targetHeader, false);
string secretStr = await Utils.NonceDecryptBody(id, password, secret, false);
string auth = await Utils.Auth(id, secretStr, password);
string auth = await Utils.Auth(id, secretStr, password, context.Connection.RemoteIpAddress?.ToString() ?? "");
if (auth != Utils.LOGIN_SUCCESS)
{
@ -1276,7 +1281,7 @@ public class Requests
//S9: jednorazowa akcja zapisu - konsumuj nonce (delEntry:false pozwalał
//na nieograniczony replay przechwyconej pary secret+body)
string secretStr = await Utils.NonceDecryptBody(id, password, secret, true);
string auth = await Utils.Auth(id, secretStr, password);
string auth = await Utils.Auth(id, secretStr, password, context.Connection.RemoteIpAddress?.ToString() ?? "");
if (auth != Utils.LOGIN_SUCCESS)
{
@ -1340,7 +1345,7 @@ public class Requests
string id = Utils.GetValidIdOrZero(idQuery.ToString());
string password = await Utils.GetPassword(id);
string secretStr = await Utils.NonceDecryptBody(id, password, secret, false);
string auth = await Utils.Auth(id, secretStr, password);
string auth = await Utils.Auth(id, secretStr, password, context.Connection.RemoteIpAddress?.ToString() ?? "");
if (auth != Utils.LOGIN_SUCCESS) return; // Don't leak info
@ -1381,7 +1386,7 @@ public class Requests
string id = Utils.GetValidIdOrZero(idQuery.ToString());
string password = await Utils.GetPassword(id);
string secretStr = await Utils.NonceDecryptBody(id, password, secret, false);
string auth = await Utils.Auth(id, secretStr, password);
string auth = await Utils.Auth(id, secretStr, password, context.Connection.RemoteIpAddress?.ToString() ?? "");
if (auth != Utils.LOGIN_SUCCESS) return;
@ -1414,6 +1419,7 @@ public class Requests
public class CreateHolder
{
public DateTimeOffset date;
public int registerDone;
public string name;
public string pass;
public string captcha;

View file

@ -151,17 +151,23 @@ public class Utils
return Encoding.UTF8.GetString(await Fs.ReadFile(path));
}
public static async Task<string> Auth(string id, string password, string password2)
public static async Task<string> Auth(string id, string password, string password2, string clientIp = "")
{
if (IsAuthRateLimited(id, clientIp))
{
return "error:invalid.password";
}
if (!Fs.Exists($"{ACCOUNTS_DATA_DIR}/{id}"))
{
return "error:invalid.username.or.password";
}
if (password != password2)
{
RecordAuthFailure(id, clientIp);
return "error:invalid.password";
}
RecordAuthSuccess(id, clientIp);
await UpdateLastLogin(id);
return LOGIN_SUCCESS;
}

View file

@ -41,7 +41,7 @@ public class Program
{
if (entry.Value.resetAt <= nowSec) VoiceAuthLimits.TryRemove(entry.Key, out _);
}
//prune pozostałych rate limitów (okna 60s)
//prune pozostałych rate limitów (okna 60s / 1h)
PruneRateLimits(AuthRateLimits, nowSec);
PruneRateLimits(NonceRateLimits, nowSec);
PruneRateLimits(KdfSaltRateLimits, nowSec);

View file

@ -30,26 +30,85 @@ public class Utils
// Fixed 60s window: (request count, window reset unix time).
public static ConcurrentDictionary<string, (int count, long resetAt)> VoiceAuthLimits = new();
// Rate limity per-minute (okno 60s). Wszystkie konfigurowalne przez .env.
// Rate limity per IP/konto (okno stałe, domyślnie 60s). Wszystkie konfigurowalne przez .env.
public static ConcurrentDictionary<string, (int count, long resetAt)> AuthRateLimits = new();
public static ConcurrentDictionary<string, (int count, long resetAt)> NonceRateLimits = new();
public static ConcurrentDictionary<string, (int count, long resetAt)> KdfSaltRateLimits = new();
public static ConcurrentDictionary<string, (int count, long resetAt)> AccountInitRateLimits = new();
public static int AUTH_RATE_LIMIT = 10; // próby logowania /auth + /ws na konto+IP
public static int NONCE_RATE_LIMIT = 240; // pobrania nonce per konto
public static int KDFSALT_RATE_LIMIT = 60; // pobrania soli per IP
public static int ACCOUNT_INIT_RATE_LIMIT = 10; // inicjalizacje rejestracji (KEM keygen) per IP
public static int AUTH_RATE_LIMIT = 10; // failed password verifications per account+IP per minute
public static int NONCE_RATE_LIMIT = 240; // pobrania nonce per konto per minute
public static int KDFSALT_RATE_LIMIT = 60; // pobrania soli per IP per minute
public static int ACCOUNT_INIT_RATE_LIMIT = 4; // utworzone konta per IP per hour (check w init, zapis w finish)
// Zwraca true jeśli przekroczono limit. Okno stałe 60s.
public static bool RateLimited(ConcurrentDictionary<string, (int count, long resetAt)> store, string key, int limit)
// Zwraca true jeśli przekroczono limit. Okno stałe, domyślnie 60s.
public static bool RateLimited(ConcurrentDictionary<string, (int count, long resetAt)> store, string key, int limit, int windowSeconds = 60)
{
if (limit <= 0) return false; // <=0 wyłącza limit
long nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var window = store.AddOrUpdate(key,
(1, nowSec + windowSeconds),
(_, old) => old.resetAt <= nowSec ? (1, nowSec + windowSeconds) : (old.count + 1, old.resetAt));
return window.count > limit;
}
//Sprawdzenie bez inkrementacji: true jeśli klucz osiągnął już limit w bieżącym oknie
public static bool IsRateLimitReached(ConcurrentDictionary<string, (int count, long resetAt)> store, string key, int limit)
{
if (limit <= 0) return false;
if (!store.TryGetValue(key, out var entry)) return false;
long nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (entry.resetAt <= nowSec)
{
store.TryRemove(key, out _);
return false;
}
return entry.count >= limit;
}
//Zapis jednego zdarzenia; okno startuje przy pierwszym zapisie
public static void RecordRateLimitHit(ConcurrentDictionary<string, (int count, long resetAt)> store, string key, int windowSeconds = 60)
{
long nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
store.AddOrUpdate(key,
(1, nowSec + windowSeconds),
(_, old) => old.resetAt <= nowSec ? (1, nowSec + windowSeconds) : (old.count + 1, old.resetAt));
}
// Failed password verifications only, keyed "{id}:{ip}". A success clears the
// counter unless the limit is already reached; then the 60s window must expire.
public static bool IsAuthRateLimited(string id, string clientIp)
{
if (AUTH_RATE_LIMIT <= 0) return false;
string key = $"{id}:{clientIp}";
if (!AuthRateLimits.TryGetValue(key, out var entry)) return false;
long nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (entry.resetAt <= nowSec)
{
AuthRateLimits.TryRemove(key, out _);
return false;
}
return entry.count >= AUTH_RATE_LIMIT;
}
public static void RecordAuthFailure(string id, string clientIp)
{
if (AUTH_RATE_LIMIT <= 0) return;
long nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
AuthRateLimits.AddOrUpdate($"{id}:{clientIp}",
(1, nowSec + 60),
(_, old) => old.resetAt <= nowSec ? (1, nowSec + 60) : (old.count + 1, old.resetAt));
return window.count > limit;
}
public static void RecordAuthSuccess(string id, string clientIp)
{
string key = $"{id}:{clientIp}";
if (AuthRateLimits.TryGetValue(key, out var entry) && entry.count < AUTH_RATE_LIMIT)
{
AuthRateLimits.TryRemove(key, out _);
}
}
public static string DATA_DIR = "./data";
@ -188,19 +247,12 @@ public class Utils
}
string id = Account.Utils.GetValidIdOrZero(authParams.string1);
//rate limit prób autoryzacji WS (konto+IP) - wspólny zasobnik z /auth
string clientIp = context.Connection.RemoteIpAddress?.ToString() ?? "";
if (RateLimited(AuthRateLimits, $"{id}:{clientIp}", AUTH_RATE_LIMIT))
{
await webSocket.CloseAsync(System.Net.WebSockets.WebSocketCloseStatus.PolicyViolation, "Rate limited", CancellationToken.None);
return;
}
string password = await Account.Utils.GetPassword(id);
string secret = await Account.Utils.NonceDecryptBody(id, password, authParams.string2);
string auth = await Account.Utils.Auth(id, password, secret);
string auth = await Account.Utils.Auth(id, password, secret, clientIp);
if (auth != Account.Utils.LOGIN_SUCCESS)
{