From 78b4e2aa633a659a063ddd8c7077524c7a153af1 Mon Sep 17 00:00:00 2001 From: JustMe Date: Sat, 17 Oct 2026 21:31:15 +0800 Subject: [PATCH] fix(server): trim every workspace structure response to the caller's grants and restrict avatars to shared spaces - structure mutations returned the full structure to any member who could call them (moving an item after sending a message exposed channels the member has no access to) - moving items now requires write access to the destination channel; new folders require an existing channel - account avatars are only readable by the owner or accounts sharing a space --- server/src/WebDrop.Api/Endpoints.Accounts.cs | 6 ++-- server/src/WebDrop.Core/ChannelWorkspace.cs | 36 ++++++++++++++++--- .../WebDrop.Core.Tests/ChannelCryptoTests.cs | 27 ++++++++++++++ 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/server/src/WebDrop.Api/Endpoints.Accounts.cs b/server/src/WebDrop.Api/Endpoints.Accounts.cs index 299b23e..bae5618 100644 --- a/server/src/WebDrop.Api/Endpoints.Accounts.cs +++ b/server/src/WebDrop.Api/Endpoints.Accounts.cs @@ -14,9 +14,11 @@ public static partial class Endpoints return Json(accounts.Profile(username).WithoutAvatar(), ApiJson.Default.AccountProfile); }); // 头像单独取:默认给有损的缩略图(列表、聊天用),size=full 才是原图(预览时)。带 ETag,没变化时只回 304。需要登录账号 - api.MapGet("/accounts/users/{username}/avatar", (string username, HttpContext ctx) => + api.MapGet("/accounts/users/{username}/avatar", (string username, HttpContext ctx, ChannelService svc) => { - if (!TryAccount(ctx, accounts, out _)) throw new WebDropException(ErrorCode.AccountRequired); + if (!TryAccount(ctx, accounts, out var viewer)) throw new WebDropException(ErrorCode.AccountRequired); + // 只有和对方同在一个空间(或就是自己)才能看头像,不能靠猜用户名批量抓取 + if (!svc.SharesSpace(viewer, NormalizeUsername(username))) throw new WebDropException(ErrorCode.NotFound); var profile = accounts.Profile(NormalizeUsername(username)); var full = ctx.Request.Query["size"] == "full"; return AvatarResult(ctx, full ? profile.Avatar : profile.AvatarThumb ?? profile.Avatar, $"{profile.ModifiedAt}-{(full ? "f" : "t")}"); diff --git a/server/src/WebDrop.Core/ChannelWorkspace.cs b/server/src/WebDrop.Core/ChannelWorkspace.cs index 7129723..6787d6b 100644 --- a/server/src/WebDrop.Core/ChannelWorkspace.cs +++ b/server/src/WebDrop.Core/ChannelWorkspace.cs @@ -6,6 +6,10 @@ public sealed partial class ChannelService { foreach (var id in store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs))) events.SettingsChanged(id); } + /// 两个账号是否在同一个空间里(自己总是可以)。成员资料只对有交集的账号开放 + public bool SharesSpace(string username, string other) => username == other + || store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs).Any(id => tx.GetChannelMember(id, other) is not null)); + public IReadOnlyList AccountSpaceIds(string username) => store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs)); public IReadOnlyList AccountSpaces(string username) => store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs).Select(id => { var channel = tx.GetChannel(id)!; @@ -35,9 +39,18 @@ public sealed partial class ChannelService var (space, principal) = Authenticate(tx, id, creds, Membership.Required); var state = tx.GetWorkspace(id) ?? new WorkspaceState(); EnsureDefaultRole(state, clock.NowMs); + return ViewFor(tx, space, principal, state); + }); + /// + /// 空间结构按调用者的授权裁剪:没授权的频道、目录和条目位置一律不下发。 + /// 读取和所有修改接口的返回值都必须经过它,否则任何一个成员的一次修改(比如发消息后登记条目位置) + /// 都会把整份结构返回给他。 + /// + private WorkspaceState ViewFor(IResourceTx tx, Channel space, Principal principal, WorkspaceState state) + { if (principal.AccountName != space.CreatorAccount) { - var crypto = tx.GetWorkspaceCrypto(id); + var crypto = tx.GetWorkspaceCrypto(space.Id); var grants = crypto.Access.Where(a => a.Username == principal.AccountName).ToList(); var ids = grants.Where(a => a.ChatRead || a.FilesRead).Select(a => a.ChannelId).ToHashSet(); state.Channels.RemoveAll(c => !ids.Contains(c.Id)); @@ -49,9 +62,9 @@ public sealed partial class ChannelService state.Folders.RemoveAll(f => !ids.Contains(f.ThreadId)); state.Locations.RemoveAll(l => !Id16.TryParse(l.ItemId, out var itemId) || !CanReadObject(tx, space, principal, itemId)); } - state.Locations = state.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(id, itemId) is { } item && item.ExpiresAt > clock.NowMs).ToList(); + state.Locations = state.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(space.Id, itemId) is { } item && item.ExpiresAt > clock.NowMs).ToList(); return state; - }); + } private WorkspaceState ChangeWorkspace(Id16 id, Credentials creds, Action change, bool requireAccount = true, string? permission = null) { var state = store.Write(tx => @@ -68,7 +81,8 @@ public sealed partial class ChannelService ValidateWorkspace(current); current.Revision++; tx.PutWorkspace(id, current); - return current; + // 先落库完整的结构,再按调用者裁剪后返回 + return ViewFor(tx, channel, principal, current); }); events.SettingsChanged(id); return state; @@ -166,12 +180,24 @@ public sealed partial class ChannelService }); private static WorkspaceRole FindRole(WorkspaceState state, string id) => state.Roles.Find(r => r.Id == id) ?? throw new WebDropException(ErrorCode.NotFound); public WorkspaceState CreateWorkspaceFolder(Id16 id, Credentials creds, string threadId, string? parentId, string name) => ChangeWorkspace(id, creds, (_, state, _) => - state.Folders.Add(new WorkspaceFolder { Id = Guid.NewGuid().ToString(), ThreadId = threadId, ParentId = parentId, Name = WorkspaceName(name) })); + { + FindChannel(state, threadId); + state.Folders.Add(new WorkspaceFolder { Id = Guid.NewGuid().ToString(), ThreadId = threadId, ParentId = parentId, Name = WorkspaceName(name) }); + }); public WorkspaceState RenameWorkspaceFolder(Id16 id, Credentials creds, string folderId, string name) => ChangeWorkspace(id, creds, (_, state, _) => (state.Folders.Find(f => f.Id == folderId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name)); public WorkspaceState MoveWorkspaceItems(Id16 id, Credentials creds, string[] itemIds, string threadId, string? folderId) => ChangeWorkspace(id, creds, (tx, state, principal) => { if (itemIds.Length is < 1 or > 100 || itemIds.Distinct().Count() != itemIds.Length) throw new WebDropException(ErrorCode.BadRequest); + // 目的频道必须存在,而且成员只能往自己有写入授权的频道放条目 + if (!state.Channels.Exists(c => c.Id == threadId)) throw new WebDropException(ErrorCode.NotFound); + var space = tx.GetChannel(id)!; var crypto = tx.GetWorkspaceCrypto(id); + // 与 CheckObjectWrite 同一口径:加密空间或已有授权记录时,必须有目的频道的写入授权 + if (principal.Role != Role.Owner && principal.AccountName != space.CreatorAccount && (space.Mode == ChannelMode.Enc ? crypto.Envelopes.Count > 0 : crypto.Access.Count > 0 || crypto.Objects.Count > 0)) + { + var grant = crypto.Access.Find(a => a.ChannelId == threadId && a.Username == principal.AccountName); + if (grant is null || !(grant.ChatWrite || grant.FilesUpload)) throw new WebDropException(ErrorCode.Forbidden); + } foreach (var itemId in itemIds) { if (!Id16.TryParse(itemId, out var parsed)) throw new WebDropException(ErrorCode.BadRequest); diff --git a/server/tests/WebDrop.Core.Tests/ChannelCryptoTests.cs b/server/tests/WebDrop.Core.Tests/ChannelCryptoTests.cs index 6530b33..13e8455 100644 --- a/server/tests/WebDrop.Core.Tests/ChannelCryptoTests.cs +++ b/server/tests/WebDrop.Core.Tests/ChannelCryptoTests.cs @@ -114,6 +114,33 @@ public sealed class ChannelCryptoTests Assert.Null(env.Service.ReadCrypto(space.Id, owner).Objects.Find(o => o.ItemId == file.ToBase64Url())?.Event); } + [Fact] + public void MemberOnlySeesChannelsItWasGrantedAndNeverTheirKeysObjectsOrWrites() + { + using var env = new TestEnv(); var ep = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var space = env.Create(ep); var owner = space.Owner(ep); + var defaultId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id; + Bootstrap(env, space, owner, defaultId); + var secret = env.Service.CreateWorkspaceChannelResult(space.Id, owner, "secret", null).Channel.Id; + Bootstrap(env, space, owner, secret); + var hidden = TestEnv.NewId().ToBase64Url(); + env.Service.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = hidden, ChannelId = secret, Domain = CryptoDomain.Chat, Epoch = 1, KeyEnvelope = "k" }); + + var plan = env.Service.InvitationPlan(space.Id, owner, "member"); + var packages = plan.Batches.SelectMany(batch => batch.Recipients.Select(recipient => new DomainEnvelope + { ChannelId = plan.ChannelId, Domain = batch.Domain, Epoch = batch.Epoch, Username = recipient, Payload = $"wrapped-{batch.Domain}-{recipient}" })).ToList(); + env.Service.InviteUser(space.Id, owner, "member", "opaque-account-grant", packages); + var member = space.Member(memberEp) with { AccountName = "member" }; env.Service.Join(space.Id, member); + + // 结构里没有没授权的频道;密钥包、对象登记里也没有它的任何东西 + Assert.Equal(new[] { defaultId }, env.Service.GetWorkspace(space.Id, member).Channels.Select(c => c.Id)); + var crypto = env.Service.ReadCrypto(space.Id, member); + Assert.DoesNotContain(crypto.Envelopes, e => e.ChannelId == secret); + Assert.DoesNotContain(crypto.Objects, o => o.ChannelId == secret); + Assert.DoesNotContain(crypto.Access, a => a.ChannelId == secret); + // 也不能往没授权的频道登记对象 + Assert.Throws(() => env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = TestEnv.NewId().ToBase64Url(), ChannelId = secret, Domain = CryptoDomain.Chat, Epoch = 1, KeyEnvelope = "x" })); + } + private static void Bootstrap(TestEnv env, TestChannel space, Credentials owner, string channelId) { foreach (var domain in new[] { CryptoDomain.Chat, CryptoDomain.Files }) env.Service.PublishDomain(space.Id, owner, new DomainPublishRequest