security patches & rate limits
This commit is contained in:
parent
dc893baf94
commit
4e08d1a2df
4 changed files with 106 additions and 42 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue