From 6dc145a2c8dd570fb263fc41940fb5c65cdc6efb Mon Sep 17 00:00:00 2001 From: olcxja Date: Mon, 24 Aug 2026 19:22:38 +0200 Subject: [PATCH] massive security update --- LarpixServer/Account/Captcha.cs | 4 +- LarpixServer/Account/Requests.cs | 273 ++++++++++++++++---------- LarpixServer/Account/Utils.cs | 90 +++++++-- LarpixServer/Encryption/Encryption.cs | 118 ++++++++++- LarpixServer/Filesystem/Fs.cs | 24 ++- LarpixServer/Program.cs | 27 ++- LarpixServer/Room/Groups.cs | 97 ++++++++- LarpixServer/Room/Requests.cs | 148 ++++++++------ LarpixServer/Room/Shared.cs | 60 +++++- LarpixServer/Utils/Jsons.cs | 1 + LarpixServer/Utils/Utils.cs | 122 ++++++++++-- 11 files changed, 739 insertions(+), 225 deletions(-) diff --git a/LarpixServer/Account/Captcha.cs b/LarpixServer/Account/Captcha.cs index 43c467a..5f18da2 100644 --- a/LarpixServer/Account/Captcha.cs +++ b/LarpixServer/Account/Captcha.cs @@ -1,6 +1,7 @@ using SkiaSharp; using System; using System.Runtime.InteropServices; +using System.Security.Cryptography; namespace LarpixServer.Account; @@ -20,7 +21,8 @@ public class Captcha for (int i = 0; i < charCount; i++) { - captchaSpan[i] = Chars[Random.Shared.Next(Chars.Length)]; + //tekst captchy MUSI pochodzić z CSPRNG - Random.Shared (MT19937) jest przewidywalny + captchaSpan[i] = Chars[RandomNumberGenerator.GetInt32(Chars.Length)]; charColors[i] = new SKColor( (byte)Random.Shared.Next(100, 256), (byte)Random.Shared.Next(100, 256), diff --git a/LarpixServer/Account/Requests.cs b/LarpixServer/Account/Requests.cs index 5ad2fe4..b6028fe 100644 --- a/LarpixServer/Account/Requests.cs +++ b/LarpixServer/Account/Requests.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Numerics; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using LarpixServer.Filesystem; @@ -36,6 +37,18 @@ public class Requests return false; } + //wywoływane przez sweeper w Program.cs - usuwa wygasłe okna + public static void PruneActionRateLimits(long nowSec) + { + foreach (var entry in actionRateLimits) + { + if (entry.Value.resetTime <= DateTimeOffset.FromUnixTimeSeconds(nowSec)) + { + actionRateLimits.TryRemove(entry.Key, out _); + } + } + } + public static SemaphoreSlim createLock = new SemaphoreSlim(1, 1); @@ -90,6 +103,14 @@ public class Requests { case "init": { + //KEM keygen jest kosztowny - limit per IP + string initIp = context.Connection.RemoteIpAddress?.ToString() ?? ""; + if (RateLimited(AccountInitRateLimits, initIp, ACCOUNT_INIT_RATE_LIMIT)) + { + await context.Response.WriteAsync("error:rate.limit"); + return; + } + foreach (var kvp in createHolder) // czyszczenie nieaktywnych od 2 minut requestow { if (kvp.Value.date < DateTimeOffset.UtcNow.AddMinutes(-2)) @@ -106,15 +127,17 @@ public class Requests payload.pubMlKem = Convert.ToBase64String(serverInfo.pubMlKem); payload.idKey = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + "\n"; - while (createHolder.ContainsKey(payload.idKey)) - { - payload.idKey += Random.Shared.Next(0, 10).ToString(); - } - CreateHolder dataHolder = new(); dataHolder.date = DateTimeOffset.UtcNow; dataHolder.privX25519Server = serverInfo.privX25519; dataHolder.privMlKemServer = serverInfo.privMlKem; + 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); @@ -271,6 +294,11 @@ public class Requests Encoding.UTF8.GetBytes(entry.name)); await Fs.WriteFile($"{ACCOUNTS_DATA_DIR}/{id.ToString()}/secret", Encoding.UTF8.GetBytes(entry.pass)); + if (!string.IsNullOrEmpty(entry.KdfSalt)) + { + await Fs.WriteFile($"{ACCOUNTS_DATA_DIR}/{id.ToString()}/kdfsalt", + Encoding.UTF8.GetBytes(entry.KdfSalt)); + } } catch (Exception) { @@ -301,7 +329,7 @@ public class Requests -public static async Task Auth(HttpContext context, Func next, IQueryCollection query, StreamReader bodyReader) + public static async Task Auth(HttpContext context, Func next, IQueryCollection query, StreamReader bodyReader) { if (!query.TryGetValue("id", out var idQuery)) { @@ -314,6 +342,15 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec } 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); @@ -347,18 +384,13 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec return; } - if (!Utils.IsUserLocal(username, out string domain)) //federation + if (!Utils.IsUserLocal(username, out string domain)) //federation wylaczona (brak uwierzytelnienia) { - username = Utils.GetIdFromUsernameWD(username.ToString()); - string remoteId = await Federation.Sender.GetUserId(username, domain); - if (remoteId != "0" && !string.IsNullOrEmpty(remoteId)) - { - await context.Response.WriteAsync(remoteId + $":{domain}"); - } + await context.Response.WriteAsync("error:federation.disabled"); return; } username = Utils.GetIdFromUsernameWD(username.ToString()); - + await context.Response.WriteAsync(await Utils.IdFromName(username) + $":{DOMAIN}"); return; } @@ -381,6 +413,34 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec } + // Zwraca sól KDF konta: "P:" = PBKDF2, "S:" = brak soli (konto nielogowalne nowym klientem). + public static async Task GetKdfSalt(HttpContext context, IQueryCollection query) + { + if (!query.TryGetValue("id", out var idQuery)) + { + return; + } + + //rate limit per IP + string clientIp = context.Connection.RemoteIpAddress?.ToString() ?? ""; + if (RateLimited(KdfSaltRateLimits, clientIp, KDFSALT_RATE_LIMIT)) + { + context.Response.StatusCode = 429; + return; + } + + string id = Utils.GetValidIdOrZero(idQuery.ToString()); + string path = $"{ACCOUNTS_DATA_DIR}/{id}/kdfsalt"; + if (!Fs.Exists(path)) + { + await context.Response.WriteAsync("S:"); + return; + } + + string salt = Encoding.UTF8.GetString(await Fs.ReadFile(path)); + await context.Response.WriteAsync("P:" + salt); + } + public static async Task NextNonce(HttpContext context, Func next, IQueryCollection query) { if (!query.TryGetValue("id", out var idQuery)) @@ -389,6 +449,15 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec } string id = Utils.GetValidIdOrZero(idQuery.ToString()); + + //rate limit pobran nonce (konto) + if (RateLimited(NonceRateLimits, id, NONCE_RATE_LIMIT)) + { + string pass = await Utils.GetPassword(id); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("error:rate.limit", pass)); + return; + } + string plainPass = await Utils.GetPassword(id); foreach (var kvp in nonceHolder) //clearowanie nieuzytych nonce { @@ -398,7 +467,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec } else if (kvp.Key == id) { - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(kvp.Value.Item1, + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(kvp.Value.Item1, plainPass)); return; } @@ -406,7 +475,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec string nonce = Encryption.Encryption.GetRandomString(64); nonceHolder.TryAdd(id, (nonce, DateTimeOffset.UtcNow)); - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(nonce, + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(nonce, plainPass)); return; } @@ -524,16 +593,8 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec return; } - SemaphoreSlim userLock = Utils.GetUserLock(id.ToString()); - await userLock.WaitAsync(); - try - { - await context.Response.WriteAsync(await Utils.SetUsername(id, body)); - } - finally - { - userLock.Release(); - } + //SetUsername sam zarządza lockami (createLock -> userLock) - patrz Account.Utils.SetUsername + await context.Response.WriteAsync(await Utils.SetUsername(id, body)); return; } @@ -657,28 +718,15 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec if (auth != Utils.LOGIN_SUCCESS) { - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(auth, secret)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(auth, secret)); return; } - string[] actionRequests = { - "user/dm/invite", "user/dm/invite/revoke", "user/dm/invite/decline", "user/dm/create", - "dm/message/send", "dm/message/delete", "dm/message/react", "dm/key/update", "dm/message/pin/toggle", - "dm/message/react/add", "dm/message/react/remove", - "user/storage/public/update", "user/storage/private/update", "dm/read", - "group/create", "user/group/list", "group/info", "group/settings/update", - "user/invite/accept", "user/invite/decline", "user/invite/revoke", "user/dm/list", "user/group/list", - "user/storage/public/update", "user/storage/private/update", "group/storage/public/update", "group/storage/private/update", "group/storage/private/getentry", "dm/read", "group/read", - "dm/message/send", "dm/message/delete", "dm/message/react/add", "dm/message/react/remove", "dm/message/pin/toggle", - "group/messages/get", "group/messages/pinned" - }; - if (System.Array.IndexOf(actionRequests, serializedBody.string1) >= 0) + //limit wszystkich żądań encryptedrequest per konto + if (IsActionRateLimited(id)) { - if (IsActionRateLimited(id)) - { - await context.Response.WriteAsync(Encryption.Encryption.EncryptString("error:rate.limit", password)); - return; - } + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("error:rate.limit", password)); + return; } SemaphoreSlim userLock = Utils.GetUserLock(id); @@ -689,25 +737,25 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec { case "user/invites/sent": await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Utils.GetUserSentInvites(id) , password)); break; case "user/invites/received": await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Utils.GetUserReceivedInvites(id) , password)); break; case "user/key/update": await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await UpdateUserKeys(id, serializedBody.string2) , password)); break; case "user/key/get": await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Utils.GetUserKeys(id) , password)); break; @@ -718,7 +766,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec AppJsonSerializerContext.Default.Universal2String ); await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Utils.UpdateUserPublicStorageEntry(id, storageUpdate.string1, storageUpdate.string2) , password)); break; @@ -730,7 +778,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec AppJsonSerializerContext.Default.Universal2String ); await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Utils.UpdateUserPrivateStorageEntry(id, storageUpdate.string1, storageUpdate.string2) , password)); break; @@ -742,7 +790,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec AppJsonSerializerContext.Default.Universal3String ); await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Groups.UpdateGroupPublicStorageEntry(id, storageUpdate.string1, storageUpdate.string2, storageUpdate.string3) , password)); break; @@ -754,7 +802,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec AppJsonSerializerContext.Default.Universal3String ); await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Groups.UpdateGroupPrivateStorageEntry(id, storageUpdate.string1, storageUpdate.string2, storageUpdate.string3) , password)); break; @@ -766,43 +814,43 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec AppJsonSerializerContext.Default.Universal2String ); await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Groups.GetGroupPrivateStorageEntry(id, storageUpdate.string1, storageUpdate.string2) , password)); break; } case "user/dm/list": await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Utils.GetUserDms(id) , password)); break; case "user/group/list": await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Groups.GetUserGroups(id) , password)); break; case "user/dm/invite": await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Requests.DmInvite(id, serializedBody.string2) , password)); break; case "user/dm/invite/revoke": await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Requests.DmInviteRevoke(id, serializedBody.string2) , password)); break; case "user/dm/invite/decline": await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Requests.DmInviteDecline(id, serializedBody.string2) , password)); break; case "user/dm/create": - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Requests.DmCreate(id, serializedBody.string2), password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Requests.DmCreate(id, serializedBody.string2), password)); break; case "group/create": { @@ -810,11 +858,11 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec serializedBody.string2, AppJsonSerializerContext.Default.Universal3String ); - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupCreate(id, groupCreate.string1, groupCreate.string2, groupCreate.string3), password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupCreate(id, groupCreate.string1, groupCreate.string2, groupCreate.string3), password)); break; } case "group/info": - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupInfo(serializedBody.string2), password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupInfo(serializedBody.string2), password)); break; case "group/settings/update": { @@ -822,7 +870,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec serializedBody.string2, AppJsonSerializerContext.Default.Universal3String ); - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupSettingsUpdate(id, req.string1, req.string2, req.string3), password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupSettingsUpdate(id, req.string1, req.string2, req.string3), password)); break; } case "group/invite": @@ -831,7 +879,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec serializedBody.string2, AppJsonSerializerContext.Default.Universal3String ); - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupInvite(id, payload.string1, payload.string2, payload.string3), password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupInvite(id, payload.string1, payload.string2, payload.string3), password)); break; } case "group/invite/revoke": @@ -840,7 +888,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec serializedBody.string2, AppJsonSerializerContext.Default.Universal2String ); - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupInviteRevoke(id, payload.string1, payload.string2), password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupInviteRevoke(id, payload.string1, payload.string2), password)); break; } case "group/invite/decline": @@ -849,7 +897,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec serializedBody.string2, AppJsonSerializerContext.Default.Universal2String ); - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupInviteDecline(id, payload.string1, payload.string2), password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupInviteDecline(id, payload.string1, payload.string2), password)); break; } case "group/join": @@ -858,7 +906,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec serializedBody.string2, AppJsonSerializerContext.Default.Universal2String ); - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupJoin(id, payload.string1, payload.string2), password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupJoin(id, payload.string1, payload.string2), password)); break; } case "group/leave": @@ -867,7 +915,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec serializedBody.string2, AppJsonSerializerContext.Default.Universal2String ); - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupLeave(id, payload.string1), password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupLeave(id, payload.string1), password)); break; } case "group/kick": @@ -876,7 +924,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec serializedBody.string2, AppJsonSerializerContext.Default.Universal2String ); - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupKickMember(id, payload.string1, payload.string2), password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupKickMember(id, payload.string1, payload.string2), password)); break; } case "group/transferowner": @@ -885,7 +933,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec serializedBody.string2, AppJsonSerializerContext.Default.Universal2String ); - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupTransferOwner(id, payload.string1, payload.string2), password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupTransferOwner(id, payload.string1, payload.string2), password)); break; } case "room/role": @@ -894,14 +942,14 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec serializedBody.string2, AppJsonSerializerContext.Default.Universal3String ); - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Shared.GetUserRole(id, payload.string1, payload.string2, payload.string3), password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Shared.GetUserRole(id, payload.string1, payload.string2, payload.string3), password)); break; } case "group/members": - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupMembers(id, serializedBody.string2), password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupMembers(id, serializedBody.string2), password)); break; case "group/key/rotate": - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupKeyRotate(id, serializedBody.string2), password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupKeyRotate(id, serializedBody.string2), password)); break; case "group/key/get": { @@ -909,7 +957,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec serializedBody.string2, AppJsonSerializerContext.Default.Universal2String ); - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupKeyGet(id, payload.string1, payload.string2), password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupKeyGet(id, payload.string1, payload.string2), password)); break; } case "dm/message/send": @@ -919,7 +967,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec AppJsonSerializerContext.Default.Universal4String ); await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Shared.MessageSend(id, "dms", msgSend.string1, msgSend.string2, msgSend.string3, msgSend.string4) , password)); break; @@ -931,7 +979,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec AppJsonSerializerContext.Default.Universal2String ); await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Shared.MessageDelete(id, "dms", msgDel.string1, msgDel.string2) , password)); break; @@ -943,7 +991,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec AppJsonSerializerContext.Default.Universal2String ); await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Shared.MessagePinToggle(id, "dms", msgPin.string1, msgPin.string2) , password)); break; @@ -955,7 +1003,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec AppJsonSerializerContext.Default.Universal3String ); await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Shared.MessageReactAdd(id, "dms", msgReact.string1, msgReact.string2, msgReact.string3) , password)); break; @@ -967,7 +1015,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec AppJsonSerializerContext.Default.Universal3String ); await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Shared.MessageReactRemove(id, "dms", msgReactRm.string1, msgReactRm.string2, msgReactRm.string3) , password)); break; @@ -978,12 +1026,12 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec serializedBody.string2, AppJsonSerializerContext.Default.Universal3String ); - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Shared.GetMessages(id, "dms", msgGet.string1, msgGet.string3, msgGet.string2), password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Shared.GetMessages(id, "dms", msgGet.string1, msgGet.string3, msgGet.string2), password)); break; } case "dm/messages/pinned": await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Shared.MessageGetPinned(id, "dms", serializedBody.string2) , password)); break; @@ -993,12 +1041,12 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec serializedBody.string2, AppJsonSerializerContext.Default.Universal3String ); - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Shared.GetMessages(id, "groups", msgGet.string1, msgGet.string3, msgGet.string2), password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Shared.GetMessages(id, "groups", msgGet.string1, msgGet.string3, msgGet.string2), password)); break; } case "group/messages/pinned": await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Shared.MessageGetPinned(id, "groups", serializedBody.string2) , password)); break; @@ -1009,7 +1057,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec AppJsonSerializerContext.Default.Universal5String ); await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Shared.MessageSend(id, "groups", msgSend.string1, msgSend.string2, msgSend.string3, msgSend.string4, msgSend.string5) , password)); break; @@ -1021,7 +1069,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec AppJsonSerializerContext.Default.Universal2String ); await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Shared.MessageDelete(id, "groups", msgDel.string1, msgDel.string2) , password)); break; @@ -1033,26 +1081,26 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec AppJsonSerializerContext.Default.Universal3String ); await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Shared.MessageReactAdd(id, "groups", msgReact.string1, msgReact.string2, msgReact.string3) , password)); break; } case "room/voice/join": await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Shared.VoiceJoin(id, serializedBody.string2) , password)); break; case "room/voice/leave": await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Shared.VoiceLeave(id, serializedBody.string2) , password)); break; case "room/signal": await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Shared.RoomSignal(id, serializedBody.string2) , password)); break; @@ -1063,7 +1111,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec AppJsonSerializerContext.Default.Universal3String ); await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Shared.MessageReactRemove(id, "groups", msgReact.string1, msgReact.string2, msgReact.string3) , password)); break; @@ -1075,7 +1123,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec AppJsonSerializerContext.Default.Universal2String ); await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Shared.MessagePinToggle(id, "groups", msgPin.string1, msgPin.string2) , password)); break; @@ -1087,7 +1135,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec AppJsonSerializerContext.Default.Universal2String ); await Account.Utils.UpdateUserDm(id, readGet.string1, "true", ""); - await context.Response.WriteAsync(Encryption.Encryption.EncryptString("success:dm.read", password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("success:dm.read", password)); break; } case "group/read": @@ -1097,12 +1145,12 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec AppJsonSerializerContext.Default.Universal2String ); await Room.Groups.UpdateUserGroup(id, readGet.string1, "true", ""); - await context.Response.WriteAsync(Encryption.Encryption.EncryptString("success:group.read", password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("success:group.read", password)); break; } case "dm/key/get": await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Requests.GetDmKey(id, serializedBody.string2) , password)); break; @@ -1113,13 +1161,13 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec AppJsonSerializerContext.Default.Universal2String ); await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Room.Requests.UpdateDmKey(id, dmKeyUpdate.string1, dmKeyUpdate.string2) , password)); break; } default: - await context.Response.WriteAsync(Encryption.Encryption.EncryptString( + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient( $"error:unknown.request:{serializedBody.string1}" , password)); break; @@ -1152,13 +1200,13 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec if (auth != Utils.LOGIN_SUCCESS) { - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(auth, secretStr)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(auth, secretStr)); return; } if (IsActionRateLimited(id)) { - await context.Response.WriteAsync(Encryption.Encryption.EncryptString("error:rate.limit", password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("error:rate.limit", password)); return; } @@ -1171,7 +1219,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec { if (ex.Message.StartsWith("error:")) { - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(ex.Message, password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(ex.Message, password)); return; } throw; @@ -1180,7 +1228,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec if (fileBytes == null) { - await context.Response.WriteAsync(Encryption.Encryption.EncryptString("error:invalid.nonce", password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("error:invalid.nonce", password)); return; } @@ -1189,7 +1237,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec try { await context.Response.WriteAsync( - Encryption.Encryption.EncryptString( + Encryption.Encryption.EncryptForClient( await Utils.UpdateUserPublicStorageEntryRaw(id, target, fileBytes) , password)); } @@ -1210,13 +1258,13 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec if (auth != Utils.LOGIN_SUCCESS) { - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(auth, secretStr)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(auth, secretStr)); return; } if (IsActionRateLimited(id)) { - await context.Response.WriteAsync(Encryption.Encryption.EncryptString("error:rate.limit", password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("error:rate.limit", password)); return; } @@ -1229,14 +1277,14 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec { if (ex.Message.StartsWith("error:")) { - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(ex.Message, password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(ex.Message, password)); return; } throw; } if (fileBytes == null || fileBytes.Length == 0) { - await context.Response.WriteAsync(Encryption.Encryption.EncryptString("error:empty.body", password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("error:empty.body", password)); return; } @@ -1256,7 +1304,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec } }); - await context.Response.WriteAsync(Encryption.Encryption.EncryptString($"success:{stagingId}", password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient($"success:{stagingId}", password)); } public static async Task GetDmAttachment(HttpContext context, Func next, IQueryCollection query) @@ -1281,6 +1329,13 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec // Check if user is in room if (!await Room.Shared.IsMemberOfRoom(id, "dms", dmId)) return; + //walidacja komponentow sciezki - blokuje traversal do dowolnych plikow + if (!Utils.IsSafePathComponent(msgId) || !Utils.IsSafePathComponent(attId)) + { + context.Response.StatusCode = 404; + return; + } + string filePath = $"{Room.Shared.GetRoomPath("dms", dmId)}/attachments/{msgId}/{attId}"; if (!Fs.Exists(filePath)) { @@ -1314,6 +1369,13 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec if (!await Room.Shared.IsMemberOfRoom(id, "groups", dmId)) return; + //walidacja komponentow sciezki - blokuje traversal do dowolnych plikow + if (!Utils.IsSafePathComponent(msgId) || !Utils.IsSafePathComponent(attId)) + { + context.Response.StatusCode = 404; + return; + } + string filePath = $"{Room.Shared.GetRoomPath("groups", dmId)}/attachments/{msgId}/{attId}"; if (!Fs.Exists(filePath)) { @@ -1333,6 +1395,7 @@ public class CreateHolder public string name; public string pass; public string captcha; + public string KdfSalt; public X25519PrivateKeyParameters privX25519Server; public MLKemPrivateKeyParameters privMlKemServer; } \ No newline at end of file diff --git a/LarpixServer/Account/Utils.cs b/LarpixServer/Account/Utils.cs index f5a1db7..b88bb93 100644 --- a/LarpixServer/Account/Utils.cs +++ b/LarpixServer/Account/Utils.cs @@ -44,6 +44,21 @@ public class Utils string idPart = GetIdFromUsernameWD(input); return ulong.TryParse(idPart, out _) ? idPart : "0"; } + + //Pojedynczy komponent sciezki (nazwa pliku/katalogu, id pokoju, klucz itp.) + //Blokuje traversal ("..", "/", "\\") oraz puste/nadmiernie dlugie wartosci. + public static bool IsSafePathComponent(string value, int maxLength = 128) + { + if (string.IsNullOrEmpty(value) || value.Length > maxLength) + { + return false; + } + if (value.Contains("..") || value.Contains('/') || value.Contains('\\')) + { + return false; + } + return true; + } public static string GetDmId(string id1, string domain1, string id2, string domain2) { @@ -191,7 +206,7 @@ public class Utils } } - return Encryption.Encryption.DecryptByte(body, nonce.Item1 + password); + return Encryption.Encryption.DecryptBytesAuto(body, nonce.Item1 + password); } public static async Task UpdateLastLogin(string id) @@ -498,6 +513,24 @@ public class Utils await Fs.WriteFile(dmPath, Encoding.UTF8.GetBytes(JsonSerializer.Serialize(fileDm, AppJsonSerializerContext.Default.Universal3String))); } + //Usuwa nieaktualny wpis DM z listy użytkownika - wywoływane gdy serwer wykryje + //brak permisji przy próbie dostępu (użytkownik nie jest uczestnikiem rozmowy). + public static void RemoveStaleUserDmEntry(string userId, string dmId) + { + try + { + if (string.IsNullOrEmpty(dmId) || dmId.Contains("..") || dmId.Contains('/') || dmId.Contains('\\')) return; + string entryPath = $"{ACCOUNTS_DATA_DIR}/{userId}/dms/{dmId}"; + if (Fs.Exists(entryPath)) + { + Fs.DeleteFile(entryPath); + } + } + catch + { + } + } + public static async Task GetUserDms(string id) { string path = $"{ACCOUNTS_DATA_DIR}/{id}/dms"; @@ -553,30 +586,49 @@ public class Utils string oldName = await NameFromId(id); string lowerNewName = newName.ToLowerInvariant(); string lowerOldName = oldName.ToLowerInvariant(); - - if (lowerNewName != lowerOldName) - { - if (Fs.Exists($"{ACCOUNTS_NAME_DIR}/{lowerNewName}")) - { - return "error:username.taken"; - } - - Fs.MoveFile($"{ACCOUNTS_NAME_DIR}/{lowerOldName}", $"{ACCOUNTS_NAME_DIR}/{lowerNewName}"); - } - + + //createLock -> userLock (ta sama kolejność co Create/finish i BackgroundDelete). + //Zamyka TOCTOU z rejestracją: nazwa rezerwowana pod wspólnym lockiem. + await Requests.createLock.WaitAsync(); try - { - await Fs.WriteFile($"{ACCOUNTS_DATA_DIR}/{id}/username", Encoding.UTF8.GetBytes(newName)); - } - catch (Exception) { if (lowerNewName != lowerOldName) { - Fs.MoveFile($"{ACCOUNTS_NAME_DIR}/{lowerNewName}", $"{ACCOUNTS_NAME_DIR}/{lowerOldName}"); + if (Fs.Exists($"{ACCOUNTS_NAME_DIR}/{lowerNewName}")) + { + return "error:username.taken"; + } + + Fs.MoveFile($"{ACCOUNTS_NAME_DIR}/{lowerOldName}", $"{ACCOUNTS_NAME_DIR}/{lowerNewName}"); + } + + SemaphoreSlim userLock = GetUserLock(id); + await userLock.WaitAsync(); + try + { + try + { + await Fs.WriteFile($"{ACCOUNTS_DATA_DIR}/{id}/username", Encoding.UTF8.GetBytes(newName)); + } + catch (Exception) + { + if (lowerNewName != lowerOldName) + { + Fs.MoveFile($"{ACCOUNTS_NAME_DIR}/{lowerNewName}", $"{ACCOUNTS_NAME_DIR}/{lowerOldName}"); + } + throw; + } + } + finally + { + userLock.Release(); } - throw; } - + finally + { + Requests.createLock.Release(); + } + return "success:username.changed"; } } \ No newline at end of file diff --git a/LarpixServer/Encryption/Encryption.cs b/LarpixServer/Encryption/Encryption.cs index ca97792..cf57673 100644 --- a/LarpixServer/Encryption/Encryption.cs +++ b/LarpixServer/Encryption/Encryption.cs @@ -11,6 +11,117 @@ namespace LarpixServer.Encryption; public class Encryption { + public const string GcmPrefix = "G1:"; + + // Odpowiedź do klienta - zawsze AES-GCM (format "G1:" + b64(iv||ct||tag)). + public static string EncryptForClient(string plainText, string passphrase) + { + return EncryptStringGcm(plainText, passphrase); + } + + private static byte[] PassphraseToKey(string passphrase) + { + return SHA256.HashData(Encoding.UTF8.GetBytes(passphrase)); + } + + public static bool IsGcmPacket(string text) + { + return text != null && text.StartsWith(GcmPrefix, StringComparison.Ordinal); + } + + public static string EncryptStringGcm(string plainText, string passphrase) + { + byte[] key = PassphraseToKey(passphrase); + byte[] iv = RandomNumberGenerator.GetBytes(12); + byte[] plainBytes = Encoding.UTF8.GetBytes(plainText); + byte[] cipherBytes = new byte[plainBytes.Length]; + byte[] tag = new byte[16]; + + using (AesGcm gcm = new AesGcm(key, 16)) + { + gcm.Encrypt(iv, plainBytes, cipherBytes, tag); + } + + byte[] combined = new byte[iv.Length + cipherBytes.Length + tag.Length]; + Buffer.BlockCopy(iv, 0, combined, 0, iv.Length); + Buffer.BlockCopy(cipherBytes, 0, combined, iv.Length, cipherBytes.Length); + Buffer.BlockCopy(tag, 0, combined, iv.Length + cipherBytes.Length, tag.Length); + + return GcmPrefix + Convert.ToBase64String(combined); + } + + public static string DecryptStringGcm(string gcmCipherText, string passphrase) + { + try + { + byte[] combined = Convert.FromBase64String(gcmCipherText.Substring(GcmPrefix.Length)); + byte[] key = PassphraseToKey(passphrase); + + byte[] iv = new byte[12]; + byte[] tag = new byte[16]; + byte[] cipherBytes = new byte[combined.Length - iv.Length - tag.Length]; + Buffer.BlockCopy(combined, 0, iv, 0, iv.Length); + Buffer.BlockCopy(combined, iv.Length, cipherBytes, 0, cipherBytes.Length); + Buffer.BlockCopy(combined, iv.Length + cipherBytes.Length, tag, 0, tag.Length); + + byte[] plainBytes = new byte[cipherBytes.Length]; + using (AesGcm gcm = new AesGcm(key, 16)) + { + gcm.Decrypt(iv, cipherBytes, tag, plainBytes); + } + + return Encoding.UTF8.GetString(plainBytes); + } + catch (Exception) + { + return ""; + } + } + + // Binaria przychodzące: wyłącznie "G1:" + iv||ct||tag. Bez prefiksu = odrzucamy. + public static byte[] DecryptBytesAuto(byte[] combinedBytes, string passphrase) + { + try + { + if (combinedBytes == null || combinedBytes.Length < 3) + { + return new byte[] { }; + } + + if (combinedBytes[0] != 'G' || combinedBytes[1] != '1' || combinedBytes[2] != ':') + { + return new byte[] { }; + } + + byte[] key = PassphraseToKey(passphrase); + int bodyOffset = Encoding.ASCII.GetByteCount(GcmPrefix); + byte[] iv = new byte[12]; + byte[] tag = new byte[16]; + byte[] cipherBytes = new byte[combinedBytes.Length - bodyOffset - iv.Length - tag.Length]; + + if (cipherBytes.Length < 0) + { + return new byte[] { }; + } + + Buffer.BlockCopy(combinedBytes, bodyOffset, iv, 0, iv.Length); + Buffer.BlockCopy(combinedBytes, bodyOffset + iv.Length, cipherBytes, 0, cipherBytes.Length); + Buffer.BlockCopy(combinedBytes, bodyOffset + iv.Length + cipherBytes.Length, tag, 0, tag.Length); + + byte[] plainBytes = new byte[cipherBytes.Length]; + using (AesGcm gcm = new AesGcm(key, 16)) + { + gcm.Decrypt(iv, cipherBytes, tag, plainBytes); + } + + return plainBytes; + } + catch (Exception) + { + return new byte[] { }; + } + } + public static (byte[] pubX25519, X25519PrivateKeyParameters privX25519, byte[] pubMlKem, MLKemPrivateKeyParameters privMlKem) InitHybridKEM() { var random = new SecureRandom(); @@ -188,14 +299,15 @@ public class Encryption public static string PacketDecPass(string encResult, string key, string nonce) { - return DecryptString(encResult, nonce + key); + //tylko GCM - pakiety bez prefiksu "G1:" odrzucane + return IsGcmPacket(encResult) ? DecryptStringGcm(encResult, nonce + key) : ""; } - + public static string GetRandomString(int length) { const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz!@_"; return new string(Enumerable.Repeat(chars, length) - .Select(s => s[Random.Shared.Next(s.Length)]).ToArray()); + .Select(s => s[RandomNumberGenerator.GetInt32(s.Length)]).ToArray()); } public static string ComputeSHA3_512(string input) diff --git a/LarpixServer/Filesystem/Fs.cs b/LarpixServer/Filesystem/Fs.cs index ebacd4e..44de485 100644 --- a/LarpixServer/Filesystem/Fs.cs +++ b/LarpixServer/Filesystem/Fs.cs @@ -143,18 +143,26 @@ public class Fs } var fileInfo = new FileInfo(path); - if (fileInfo.Length < CACHE_ENTRY_SIZE) + if (!fileInfo.Exists) { - byte[] content = await File.ReadAllBytesAsync(path); - fileCache[path] = new FileData(content, false, fileInfo.Length); - existCache[path] = true; - return content; + //samonaprawa cache: wpis existCache może być nieaktualny + existCache[path] = false; + return Array.Empty(); } - - byte[] bytes = await File.ReadAllBytesAsync(path); + byte[] content = await File.ReadAllBytesAsync(path); + if (content.Length < CACHE_ENTRY_SIZE) + { + fileCache[path] = new FileData(content, false, content.Length); + } existCache[path] = true; - return bytes; + return content; + } + catch (FileNotFoundException) + { + existCache[path] = false; + fileCache.TryRemove(path, out _); + return Array.Empty(); } finally { diff --git a/LarpixServer/Program.cs b/LarpixServer/Program.cs index 3ac713a..dd0258a 100644 --- a/LarpixServer/Program.cs +++ b/LarpixServer/Program.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Text; using System.Text.Json; using LarpixServer.Account; @@ -33,6 +34,12 @@ public class Program { if (entry.Value.resetAt <= nowSec) VoiceAuthLimits.TryRemove(entry.Key, out _); } + //prune pozostałych rate limitów (okna 60s) + PruneRateLimits(AuthRateLimits, nowSec); + PruneRateLimits(NonceRateLimits, nowSec); + PruneRateLimits(KdfSaltRateLimits, nowSec); + PruneRateLimits(AccountInitRateLimits, nowSec); + LarpixServer.Account.Requests.PruneActionRateLimits(nowSec); } }); @@ -44,7 +51,7 @@ public class Program { serverOptions.AddServerHeader = false; serverOptions.ListenAnyIP(PORT); - serverOptions.Limits.MaxRequestBodySize = null; + serverOptions.Limits.MaxRequestBodySize = MAX_BODY_SIZE; }); var app = builder.Build(); @@ -98,6 +105,7 @@ public class Program context.Response.Headers["Access-Control-Allow-Origin"] = "*"; context.Response.Headers["Access-Control-Allow-Headers"] = "*"; context.Response.Headers["Access-Control-Allow-Methods"] = "*"; + if (context.Request.Method.ToUpper() == "OPTIONS") { context.Response.StatusCode = 200; @@ -252,9 +260,9 @@ public class Program case "/_larpix/group/attachment/get": await Account.Requests.GetGroupAttachment(context, next, query); return; - case "/_larpix/federation": - await Federation.Receiver.FederationRequest(context, next, query, reader); - return; + //case "/_larpix/federation": //federation wylaczona (brak uwierzytelnienia miedzyserwerowego) + // await Federation.Receiver.FederationRequest(context, next, query, reader); + // return; case "/_larpix/user/key/getpublic": await Account.Requests.GetUserPublicKey(context, next, query, reader); return; @@ -264,6 +272,9 @@ public class Program case "/_larpix/group/storage/public/getentry": await Account.Requests.GetGroupPublicStorageEntry(context, next, query, reader); return; + case "/_larpix/kdfsalt": + await Account.Requests.GetKdfSalt(context, query); + return; } } catch (Exception ex) @@ -280,4 +291,12 @@ public class Program Console.WriteLine("Starting server at port: " + PORT); app.Run(); } + + private static void PruneRateLimits(ConcurrentDictionary store, long nowSec) + { + foreach (var entry in store) + { + if (entry.Value.resetAt <= nowSec) store.TryRemove(entry.Key, out _); + } + } } \ No newline at end of file diff --git a/LarpixServer/Room/Groups.cs b/LarpixServer/Room/Groups.cs index 8f21706..28932d3 100644 --- a/LarpixServer/Room/Groups.cs +++ b/LarpixServer/Room/Groups.cs @@ -9,6 +9,19 @@ namespace LarpixServer.Room; public class Groups { + //groupId składa się z części ścieżek (id;domain) - blokuj traversal + public static bool IsSafeGroupId(string groupId) + { + if (string.IsNullOrEmpty(groupId) || groupId.Length > 128) return false; + string[] parts = groupId.Split(';'); + foreach (string part in parts) + { + if (string.IsNullOrEmpty(part)) return false; + if (part.Contains("..") || part.Contains('/') || part.Contains('\\')) return false; + } + return true; + } + public static async Task UpdateUserGroup(string id, string groupId, string isRead = "false", string timestamp = "") { if (await Account.Utils.NameFromId(id) == "") //if user account just got deleted @@ -53,10 +66,34 @@ public class Groups await Fs.WriteFile(groupPath, Encoding.UTF8.GetBytes(JsonSerializer.Serialize(fileGroup, AppJsonSerializerContext.Default.Universal3String))); } + //Usuwa nieaktualny wpis grupy z listy użytkownika - wywoływane gdy serwer wykryje + //brak permisji przy próbie dostępu (np. po kicku, gdy wpis został na liście). + public static void RemoveStaleUserGroupEntry(string userId, string groupId) + { + try + { + if (!IsSafeGroupId(groupId)) return; + if (!groupId.Contains(';')) + { + groupId = $"{groupId};{Utils.Utils.DOMAIN}"; + } + string entryPath = $"{Utils.Utils.ACCOUNTS_DATA_DIR}/{userId}/groups/{groupId}"; + if (Fs.Exists(entryPath)) + { + Fs.DeleteFile(entryPath); + } + } + catch + { + } + } + + //Globalny lock alokacji groupId - zapobiega duplikacji ID przy równoległych tworzeniach. + private static readonly SemaphoreSlim GroupIdLock = new SemaphoreSlim(1, 1); + public static async Task GroupCreate(string id, string name, string body, string historyVisible = "false") { - SemaphoreSlim roomLock = Room.Requests.GetRoomLock(id); - await roomLock.WaitAsync(); + await GroupIdLock.WaitAsync(); try { string groupsDir = $"{ROOMS_DIR}/groups"; @@ -114,7 +151,7 @@ public class Groups } finally { - roomLock.Release(); + GroupIdLock.Release(); } } @@ -154,6 +191,7 @@ public class Groups public static async Task GroupSettingsUpdate(string userId, string groupId, string name, string historyVisible) { + if (!IsSafeGroupId(groupId)) return "error:group.not.found"; string groupPath = GetGroupPath(groupId); if (!Fs.Exists(groupPath)) return "error:group.not.found"; @@ -219,6 +257,7 @@ public class Groups public static async Task GroupInfo(string groupId) { + if (!IsSafeGroupId(groupId)) return "error:group.not.found"; string groupPath = GetGroupPath(groupId); if (!Fs.Exists(groupPath)) { @@ -277,6 +316,7 @@ public class Groups public static async Task GroupInvite(string id, string groupId, string targetId, string keysPayload = "") { + if (!IsSafeGroupId(groupId)) return "error:group.not.found"; bool isLocal = Account.Utils.IsUserLocal(targetId, out string domain); string tId = Account.Utils.GetValidIdOrZero(targetId); @@ -312,6 +352,15 @@ public class Groups if (keysDict == null) return "error:missing.keys"; + //indeksy kluczy trafiają do ścieżek plików przy GroupJoin - tylko cyfry + foreach (var key in keysDict.Keys) + { + if (string.IsNullOrEmpty(key) || !System.Text.RegularExpressions.Regex.IsMatch(key, "^[0-9]{1,10}$")) + { + return "error:invalid.keys"; + } + } + string lastKeyFile = $"{groupPath}/keys/last"; if (Fs.Exists(lastKeyFile)) { @@ -379,6 +428,7 @@ public class Groups public static async Task GroupInviteRevoke(string id, string groupId, string targetId) { + if (!IsSafeGroupId(groupId)) return "error:group.not.found"; string tId = Account.Utils.GetValidIdOrZero(targetId); if (tId == "0") return "error:invalid.user"; @@ -406,6 +456,7 @@ public class Groups public static async Task GroupInviteDecline(string id, string groupId, string inviterId) { + if (!IsSafeGroupId(groupId)) return "error:group.not.found"; string iId = Account.Utils.GetValidIdOrZero(inviterId); if (iId == "0") return "error:invalid.user"; @@ -433,6 +484,7 @@ public class Groups public static async Task GroupJoin(string id, string groupId, string inviterId) { + if (!IsSafeGroupId(groupId)) return "error:group.not.found"; string iId = Account.Utils.GetValidIdOrZero(inviterId); if (iId == "0") return "error:invalid.user"; @@ -491,10 +543,19 @@ public class Groups if (keysDict != null) { + //obrona w głębi: GroupInvite już waliduje, ale tu dane trafiają do ścieżek + foreach (var key in keysDict.Keys) + { + if (string.IsNullOrEmpty(key) || !System.Text.RegularExpressions.Regex.IsMatch(key, "^[0-9]{1,10}$")) + { + return "error:invalid.keys"; + } + } + foreach (var kvp in keysDict) { string keyIndex = kvp.Key; - string keyData = kvp.Value; + string keyData = kvp.Value ?? ""; string newKeyDir = $"{groupPath}/keys/{keyIndex}"; Fs.CreateDirectory(newKeyDir); await Fs.WriteFile($"{newKeyDir}/{id};{DOMAIN}", Encoding.UTF8.GetBytes(keyData)); @@ -555,6 +616,7 @@ public class Groups public static async Task GroupMembers(string id, string groupId) { + if (!IsSafeGroupId(groupId)) return "error:group.not.found"; string groupPath = GetGroupPath(groupId); if (!Fs.Exists(groupPath)) return "error:group.not.found"; @@ -580,6 +642,16 @@ public class Groups return "error:invalid.payload"; string groupId = payload.groupId; + if (!IsSafeGroupId(groupId)) return "error:group.not.found"; + //klucze rotacji trafiają do ścieżek - członkowie (nazwy plików) i wartości muszą być bezpieczne + foreach (var kvp in payload.keys) + { + if (!Account.Utils.IsSafePathComponent(kvp.Key) || kvp.Value == null || + kvp.Value.Length > LarpixServer.Room.Requests.MAX_DM_KEY_SIZE) + { + return "error:invalid.payload"; + } + } string groupPath = GetGroupPath(groupId); if (!Fs.Exists(groupPath)) return "error:group.not.found"; @@ -648,6 +720,10 @@ public class Groups public static async Task GroupKeyGet(string id, string groupId, string keyIndex) { + if (!IsSafeGroupId(groupId)) return "error:group.not.found"; + //keyIndex trafia do sciezki - tylko cyfry + if (string.IsNullOrEmpty(keyIndex) || !System.Text.RegularExpressions.Regex.IsMatch(keyIndex, "^[0-9]{1,10}$")) return ""; + string groupPath = GetGroupPath(groupId); if (!Fs.Exists(groupPath)) return "error:group.not.found"; @@ -663,6 +739,7 @@ public class Groups public static async Task GetGroupPublicStorageEntry(string groupId, string entry) { + if (!IsSafeGroupId(groupId)) return new byte[] {}; if (string.IsNullOrEmpty(entry) || entry.Contains("..") || entry.Contains("/") || entry.Contains("\\")) return new byte[] {}; string[] parts = groupId.Split(';'); string id = parts[0]; @@ -673,6 +750,7 @@ public class Groups } public static async Task UpdateGroupPublicStorageEntry(string userId, string groupId, string entry, string contentBase64) { + if (!IsSafeGroupId(groupId)) return "error:group.not.found"; if (string.IsNullOrEmpty(entry) || entry.Contains("..") || entry.Contains("/") || entry.Contains("\\")) return "error:invalid.entry.name"; string[] parts = groupId.Split(';'); string id = parts[0]; @@ -707,6 +785,7 @@ public class Groups } public static async Task GetGroupPrivateStorageEntry(string userId, string groupId, string entry) { + if (!IsSafeGroupId(groupId)) return ""; if (string.IsNullOrEmpty(entry) || entry.Contains("..") || entry.Contains("/") || entry.Contains("\\")) return ""; string[] parts = groupId.Split(';'); string id = parts[0]; @@ -719,6 +798,7 @@ public class Groups } public static async Task UpdateGroupPrivateStorageEntry(string userId, string groupId, string entry, string contentBase64) { + if (!IsSafeGroupId(groupId)) return "error:group.not.found"; if (string.IsNullOrEmpty(entry) || entry.Contains("..") || entry.Contains("/") || entry.Contains("\\")) return "error:invalid.entry.name"; string[] parts = groupId.Split(';'); string id = parts[0]; @@ -749,6 +829,7 @@ public class Groups public static async Task GroupDelete(string groupId) { + if (!IsSafeGroupId(groupId)) return "error:group.not.found"; string[] parts = groupId.Split(';'); string id = parts[0]; string domain = parts.Length > 1 ? parts[1] : Utils.Utils.DOMAIN; @@ -786,6 +867,7 @@ public class Groups public static async Task GroupLeave(string userId, string groupId) { + if (!IsSafeGroupId(groupId)) return "error:group.not.found"; string[] parts = groupId.Split(';'); string id = parts[0]; string domain = parts.Length > 1 ? parts[1] : Utils.Utils.DOMAIN; @@ -892,6 +974,7 @@ public class Groups public static async Task GroupKickMember(string id, string groupId, string targetId) { + if (!IsSafeGroupId(groupId)) return "error:group.not.found"; string groupPath = GetGroupPath(groupId); if (!Fs.Exists(groupPath)) return "error:group.not.found"; @@ -916,7 +999,10 @@ public class Groups Fs.DeleteFile(memberPath); - string userGroupPath = $"{Utils.Utils.ACCOUNTS_DATA_DIR}/{tId}/groups/{groupId}"; + //UpdateUserGroup normalizuje wpis do "{id};{DOMAIN}" - tu musi być ta sama forma, + //inaczej przy kicku zostanie osierocony wpis na liście grup użytkownika + string normalizedGroupId = groupId.Contains(';') ? groupId : $"{groupId};{Utils.Utils.DOMAIN}"; + string userGroupPath = $"{Utils.Utils.ACCOUNTS_DATA_DIR}/{tId}/groups/{normalizedGroupId}"; if (Fs.Exists(userGroupPath)) { Fs.DeleteFile(userGroupPath); @@ -978,6 +1064,7 @@ public class Groups public static async Task GroupTransferOwner(string id, string groupId, string targetId) { + if (!IsSafeGroupId(groupId)) return "error:group.not.found"; string groupPath = GetGroupPath(groupId); if (!Fs.Exists(groupPath)) return "error:group.not.found"; diff --git a/LarpixServer/Room/Requests.cs b/LarpixServer/Room/Requests.cs index 57d8d57..274f546 100644 --- a/LarpixServer/Room/Requests.cs +++ b/LarpixServer/Room/Requests.cs @@ -58,7 +58,12 @@ public class Requests } public static async Task GetDmMessages(string id, string dmId, string startOffset, string countStr) { - if (!await IsMemberOfDm(id, dmId)) return "error:forbidden"; + if (!await IsMemberOfDm(id, dmId)) + { + //brak permisji - usuń nieaktualny wpis DM z listy użytkownika (jeśli istnieje) + Account.Utils.RemoveStaleUserDmEntry(id, dmId); + return "error:forbidden"; + } string msgDir = $"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/messages"; if (!Fs.Exists(msgDir)) return "{}"; @@ -175,19 +180,18 @@ public class Requests { bool isLocal = Account.Utils.IsUserLocal(targetId, out string domain); string tId = Account.Utils.GetValidIdOrZero(targetId); - + + if (!isLocal) //federation wylaczona (brak uwierzytelnienia miedzyserwerowego) + { + return "error:federation.disabled"; + } + string checkDmId = Account.Utils.GetDmId(id, DOMAIN, tId, domain); if (Fs.Exists($"{ROOMS_DIR}/dms/{DOMAIN}/{checkDmId}")) { return "error:dm.already.exists"; } - if (!isLocal) //federation - { - await Fs.WriteFile(ACCOUNTS_DATA_DIR + $"/{id}/dminvites/sent/{tId};{domain}", Encoding.UTF8.GetBytes(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString())); - return await Federation.Sender.DmInvite(id, domain, tId); - } - string id2 = tId; if (id2 == "0" || string.IsNullOrEmpty(id2)) { @@ -229,12 +233,11 @@ public class Requests bool isLocal = Account.Utils.IsUserLocal(targetId, out string domain); string tId = Account.Utils.GetValidIdOrZero(targetId); - if (!isLocal) //federation + if (!isLocal) //federation wylaczona { - Fs.DeleteFile(ACCOUNTS_DATA_DIR + $"/{id}/dminvites/sent/{tId};{domain}"); - return await Federation.Sender.DmInviteRevoke(id, domain, tId); + return "error:federation.disabled"; } - + string id2 = tId; if (id2 == "0" || string.IsNullOrEmpty(id2)) return "error:user.not.found"; @@ -262,12 +265,11 @@ public class Requests bool isLocal = Account.Utils.IsUserLocal(targetId, out string domain); string tId = Account.Utils.GetValidIdOrZero(targetId); - if (!isLocal) //federation + if (!isLocal) //federation wylaczona { - Fs.DeleteFile(ACCOUNTS_DATA_DIR + $"/{id}/dminvites/recv/{tId};{domain}"); - return await Federation.Sender.DmInviteDecline(id, domain, tId); + return "error:federation.disabled"; } - + string id2 = tId; if (id2 == "0" || string.IsNullOrEmpty(id2)) return "error:user.not.found"; @@ -292,24 +294,33 @@ public class Requests public static async Task DmCreate(string id, string body) { - await createLock.WaitAsync(); - try + Universal3String serializedBody = JsonSerializer.Deserialize( //1 is targetId, 2 is creators dm key, 3 is key for ID2 + body, + AppJsonSerializerContext.Default.Universal3String + ); + + string targetId = serializedBody.string1; + bool isUserLocal = Account.Utils.IsUserLocal(targetId, out string domain); + string id2 = Account.Utils.GetValidIdOrZero(targetId); + + if (!isUserLocal) //federation wylaczona { - Universal3String serializedBody = JsonSerializer.Deserialize( //1 is targetId, 2 is creators dm key, 3 is key for ID2 - body, - AppJsonSerializerContext.Default.Universal3String - ); - - string targetId = serializedBody.string1; - bool isUserLocal = Account.Utils.IsUserLocal(targetId, out string domain); - string id2 = Account.Utils.GetValidIdOrZero(targetId); - - if (id2 == "0" || string.IsNullOrEmpty(id2)) - { - return "error:user.not.found"; - } - - string checkDmId = Account.Utils.GetDmId(id, DOMAIN, id2, domain); + return "error:federation.disabled"; + } + + if (id2 == "0" || string.IsNullOrEmpty(id2)) + { + return "error:user.not.found"; + } + + //klucze DM trafiaja na dysk - twardy limit rozmiaru + if (serializedBody.string2 == null || serializedBody.string2.Length > MAX_DM_KEY_SIZE || + serializedBody.string3 == null || serializedBody.string3.Length > MAX_DM_KEY_SIZE) + { + return "error:dm.key.too.large"; + } + + string checkDmId = Account.Utils.GetDmId(id, DOMAIN, id2, domain); if (Fs.Exists($"{ROOMS_DIR}/dms/{DOMAIN}/{checkDmId}")) { return "error:dm.already.exists"; @@ -330,20 +341,31 @@ public class Requests } Fs.DeleteFile(inviteSentFile); } - else //check sent on federated server - { - if (!await Federation.Sender.HasSentDmInvite(id2, domain, id)) - { - return "error:cant.create.dm.without.invitation"; - } - } + //else //check sent on federated server (federation wylaczona) + //{ + // if (!await Federation.Sender.HasSentDmInvite(id2, domain, id)) + // { + // return "error:cant.create.dm.without.invitation"; + // } + //} string inviteFile = $"{ACCOUNTS_DATA_DIR}/{id}/dminvites/recv/{id2}" + (isUserLocal ? "" : $";{domain}"); if (Fs.Exists(inviteFile)) { - - try + //lock na docelowy dmId - serializuje tworzenie tego samego pokoju + //bez globalnego blokowania wszystkich DM-ów + SemaphoreSlim roomLock = GetRoomLock(checkDmId); + await roomLock.WaitAsync(); + try { + //re-check pod lockiem - drugi równoległy DmCreate może już utworzyć pokój + if (Fs.Exists($"{ROOMS_DIR}/dms/{DOMAIN}/{checkDmId}")) + { + return "error:dm.already.exists"; + } + + try + { Fs.DeleteFile(inviteFile); //remove invite bc now its accepted (error = no invite & no dm) @@ -400,10 +422,10 @@ public class Requests } }); } - else //federacja - { - await Federation.Sender.AddToDm(id, domain, id2); - } + //else //federacja (wylaczona) + //{ + // await Federation.Sender.AddToDm(id, domain, id2); + //} } catch (Exception e) @@ -413,14 +435,12 @@ public class Requests } return "success:dm.accepted"; - - + } + finally + { + roomLock.Release(); + } } - } - finally - { - createLock.Release(); - } return "error:cant.create.dm.without.invitation"; } @@ -443,11 +463,19 @@ public class Requests return $"error:message.too.long:{sizeStr}"; } + //pola załączników trafiają do ścieżek plików - odrzuć niebezpieczne wartości + if (!string.IsNullOrEmpty(attachment) && !Shared.ValidateAttachmentsJson(attachment)) + { + return "error:invalid.attachment"; + } + string msgDir = $"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/messages"; long nextId = 0; Message msg = new Message(); - await createLock.WaitAsync(); + //lock per pokój (spójnie z Shared.MessageSend) zamiast globalnego createLock + SemaphoreSlim roomSendLock = GetRoomLock(dmId); + await roomSendLock.WaitAsync(); try { long last = 0; @@ -507,7 +535,7 @@ public class Requests } finally { - createLock.Release(); + roomSendLock.Release(); } string[] members = Fs.ReadDirectory($"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/members"); @@ -632,7 +660,8 @@ public class Requests string reactDir = $"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/reactions/{msgId}"; if (!Fs.Exists(reactDir)) Fs.CreateDirectory(reactDir); - await createLock.WaitAsync(); + SemaphoreSlim reactRoomLock = GetRoomLock(dmId); + await reactRoomLock.WaitAsync(); try { long last = 0; @@ -647,7 +676,7 @@ public class Requests } finally { - createLock.Release(); + reactRoomLock.Release(); } string[] members = Fs.ReadDirectory($"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/members"); @@ -727,7 +756,8 @@ public class Requests string pinnedFilePath = $"{pinnedDir}/{msgId}"; bool isNowPinned = false; - await createLock.WaitAsync(); + SemaphoreSlim pinRoomLock = GetRoomLock(dmId); + await pinRoomLock.WaitAsync(); try { if (Fs.Exists(pinnedFilePath)) @@ -767,7 +797,7 @@ public class Requests } finally { - createLock.Release(); + pinRoomLock.Release(); } string[] members = Fs.ReadDirectory($"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/members"); diff --git a/LarpixServer/Room/Shared.cs b/LarpixServer/Room/Shared.cs index e2714e2..33f9467 100644 --- a/LarpixServer/Room/Shared.cs +++ b/LarpixServer/Room/Shared.cs @@ -32,6 +32,41 @@ public class Shared return true; } + //Pola JSON-a załącznika (staging_id / id) trafiają do ścieżek plików - muszą być bezpieczne. + public static bool IsSafeAttachmentField(string value) + { + return Account.Utils.IsSafePathComponent(value, 128); + } + + //Waliduje tablicę załączników: true = OK, false = porzuć wiadomość. + public static bool ValidateAttachmentsJson(string attachmentJson) + { + if (string.IsNullOrEmpty(attachmentJson) || attachmentJson == "[]") return true; + try + { + var node = System.Text.Json.Nodes.JsonNode.Parse(attachmentJson); + if (node is not System.Text.Json.Nodes.JsonArray arr) return true; + + foreach (var item in arr) + { + if (item is not System.Text.Json.Nodes.JsonObject obj) continue; + foreach (var fieldName in new[] { "staging_id", "id" }) + { + if (obj.ContainsKey(fieldName) && + !IsSafeAttachmentField(obj[fieldName]?.ToString())) + { + return false; + } + } + } + return true; + } + catch + { + return true; //parsowanie nie powiedzie się później i tak przy zapisie treści + } + } + public static async Task IsMemberOfRoom(string id, string roomType, string roomId) { if (!IsSafeId(roomId)) return false; @@ -83,7 +118,13 @@ public class Shared public static async Task GetMessages(string id, string roomType, string roomId, string startOffset, string countStr) { - if (!await IsMemberOfRoom(id, roomType, roomId)) return "error:forbidden"; + if (!await IsMemberOfRoom(id, roomType, roomId)) + { + //brak permisji - jeśli użytkownik ma ten pokój na liście (stan po kicku/usunięciu), + //usuń nieaktualny wpis, żeby lista sama się oczyściła + if (roomType == "groups") Groups.RemoveStaleUserGroupEntry(id, roomId); + return "error:forbidden"; + } string msgDir = $"{GetRoomPath(roomType, roomId)}/messages"; if (!Fs.Exists(msgDir)) return "{}"; @@ -204,6 +245,12 @@ public class Shared return $"error:message.too.long:{sizeStr}"; } + //pola załączników trafiają do ścieżek plików - odrzuć niebezpieczne wartości + if (!string.IsNullOrEmpty(attachment) && !ValidateAttachmentsJson(attachment)) + { + return "error:invalid.attachment"; + } + long last = 0; if (Fs.Exists($"{msgDir}/last")) { @@ -601,6 +648,13 @@ public class Shared return "room"; } + //Współdzielony klient HTTP do odpytywania serwerów głosowych o load + //(new HttpClient() per żądanie wyczerpałoby gniazda) + private static readonly System.Net.Http.HttpClient VoiceLoadHttp = new System.Net.Http.HttpClient() + { + Timeout = TimeSpan.FromSeconds(2) + }; + public static async Task VoiceJoin(string id, string body) { Universal2String serializedBody = JsonSerializer.Deserialize( @@ -631,10 +685,8 @@ public class Shared { try { - using var client = new System.Net.Http.HttpClient(); - client.Timeout = TimeSpan.FromSeconds(2); string url = ip.StartsWith("http") ? ip : $"http://{ip}"; - string loadStr = await client.GetStringAsync($"{url}/load"); + string loadStr = await VoiceLoadHttp.GetStringAsync($"{url}/load"); if (long.TryParse(loadStr, out long load)) { if (load < bestLoad) diff --git a/LarpixServer/Utils/Jsons.cs b/LarpixServer/Utils/Jsons.cs index ab2656c..93f6cbe 100644 --- a/LarpixServer/Utils/Jsons.cs +++ b/LarpixServer/Utils/Jsons.cs @@ -60,6 +60,7 @@ public class KeyExchangePayload public string pubX25519 { get; set; } public string pubMlKem { get; set; } public string idKey { get; set; } + public string kdfSalt { get; set; } } public class KeyExchangePayloadClient diff --git a/LarpixServer/Utils/Utils.cs b/LarpixServer/Utils/Utils.cs index d4fc7f5..fa972c0 100644 --- a/LarpixServer/Utils/Utils.cs +++ b/LarpixServer/Utils/Utils.cs @@ -29,6 +29,28 @@ public class Utils // Per-IP rate limit state for the internal voice auth endpoint. // Fixed 60s window: (request count, window reset unix time). public static ConcurrentDictionary VoiceAuthLimits = new(); + + // Rate limity per-minute (okno 60s). Wszystkie konfigurowalne przez .env. + public static ConcurrentDictionary AuthRateLimits = new(); + public static ConcurrentDictionary NonceRateLimits = new(); + public static ConcurrentDictionary KdfSaltRateLimits = new(); + public static ConcurrentDictionary 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 + + // Zwraca true jeśli przekroczono limit. Okno stałe 60s. + public static bool RateLimited(ConcurrentDictionary store, string key, int limit) + { + if (limit <= 0) return false; // <=0 wyłącza limit + long nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var window = store.AddOrUpdate(key, + (1, nowSec + 60), + (_, old) => old.resetAt <= nowSec ? (1, nowSec + 60) : (old.count + 1, old.resetAt)); + return window.count > limit; + } public static string DATA_DIR = "./data"; public static string ROOMS_DIR = "./data/rooms"; @@ -126,14 +148,28 @@ public class Utils public static async Task WsHandler(HttpContext context, System.Net.WebSockets.WebSocket webSocket, IQueryCollection query) { var buffer = new byte[8192]; - var result = await webSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); - if (result.MessageType == System.Net.WebSockets.WebSocketMessageType.Close) + + //odbierz PIERWSZĄ pełną ramkę (moze byc pofragmentowana) + int totalRead = 0; + System.Net.WebSockets.WebSocketReceiveResult result; + while (true) { - await webSocket.CloseAsync(System.Net.WebSockets.WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None); - return; + if (totalRead >= buffer.Length) + { + await webSocket.CloseAsync(System.Net.WebSockets.WebSocketCloseStatus.MessageTooBig, "Auth too large", CancellationToken.None); + return; + } + result = await webSocket.ReceiveAsync(new ArraySegment(buffer, totalRead, buffer.Length - totalRead), CancellationToken.None); + totalRead += result.Count; + if (result.MessageType == System.Net.WebSockets.WebSocketMessageType.Close) + { + await webSocket.CloseAsync(System.Net.WebSockets.WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None); + return; + } + if (result.EndOfMessage) break; } - string authMessage = Encoding.UTF8.GetString(buffer, 0, result.Count); + string authMessage = Encoding.UTF8.GetString(buffer, 0, totalRead); Universal2String authParams; try { authParams = JsonSerializer.Deserialize(authMessage, AppJsonSerializerContext.Default.Universal2String); @@ -143,6 +179,15 @@ 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); @@ -181,6 +226,7 @@ public class Utils list.Remove(webSocket); } } + WsSendLocks.TryRemove(webSocket, out _); if (webSocket.State == System.Net.WebSockets.WebSocketState.Open) { await webSocket.CloseAsync(System.Net.WebSockets.WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None); @@ -221,30 +267,67 @@ public class Utils } } + //Jeden outstanding SendAsync na WebSocket - semafory per-socket serializują wysyłkę + //(równoległe SendAsync na tym samym sockecie rzucają InvalidOperationException). + public static readonly ConcurrentDictionary WsSendLocks = new(); + + private static SemaphoreSlim GetWsSendLock(System.Net.WebSockets.WebSocket ws) + { + return WsSendLocks.GetOrAdd(ws, _ => new SemaphoreSlim(1, 1)); + } + public static async Task SendWsMessage(string id, string message) { - if (ActiveWebSockets.TryGetValue(id, out var list)) + if (!ActiveWebSockets.TryGetValue(id, out var list)) { - List toRemove = null; - var bytes = Encoding.UTF8.GetBytes(message); - lock(list) { - foreach(var ws in list) + return; + } + + byte[] bytes; + System.Net.WebSockets.WebSocket[] snapshot; + lock (list) + { + if (list.Count == 0) return; + bytes = Encoding.UTF8.GetBytes(message); + snapshot = list.ToArray(); + } + + foreach (var ws in snapshot) + { + bool remove = false; + var sendLock = GetWsSendLock(ws); + await sendLock.WaitAsync(); + try + { + if (ws.State == System.Net.WebSockets.WebSocketState.Open) { - if (ws.State == System.Net.WebSockets.WebSocketState.Open) + try { - _ = ws.SendAsync(new ArraySegment(bytes), System.Net.WebSockets.WebSocketMessageType.Text, true, CancellationToken.None); + await ws.SendAsync(new ArraySegment(bytes), System.Net.WebSockets.WebSocketMessageType.Text, true, CancellationToken.None); } - else + catch { - if (toRemove == null) toRemove = new(); - toRemove.Add(ws); + remove = true; } } - if (toRemove != null) + else { - foreach(var ws in toRemove) list.Remove(ws); + remove = true; } } + finally + { + sendLock.Release(); + } + + if (remove) + { + lock (list) + { + list.Remove(ws); + } + WsSendLocks.TryRemove(ws, out _); + } } } @@ -345,6 +428,11 @@ public class Utils { VOICE_AUTH_RATE_LIMIT = parsedVoiceAuthLimit; } + + AUTH_RATE_LIMIT = int.TryParse(Environment.GetEnvironmentVariable("AUTH_RATE_LIMIT"), out int parsedAuthLimit) ? parsedAuthLimit : AUTH_RATE_LIMIT; + NONCE_RATE_LIMIT = int.TryParse(Environment.GetEnvironmentVariable("NONCE_RATE_LIMIT"), out int parsedNonceLimit) ? parsedNonceLimit : NONCE_RATE_LIMIT; + KDFSALT_RATE_LIMIT = int.TryParse(Environment.GetEnvironmentVariable("KDFSALT_RATE_LIMIT"), out int parsedKdfSaltLimit) ? parsedKdfSaltLimit : KDFSALT_RATE_LIMIT; + ACCOUNT_INIT_RATE_LIMIT = int.TryParse(Environment.GetEnvironmentVariable("ACCOUNT_INIT_RATE_LIMIT"), out int parsedAccountInitLimit) ? parsedAccountInitLimit : ACCOUNT_INIT_RATE_LIMIT; VOICE_IPS.Clear();