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
This commit is contained in:
justme committed 2026-10-17 21:31:15 +08:00
1 parent d66c93fe98
commit 78b4e2aa63
3 files changed
+62 -7

No files matched your search

+4 -2
View File
@@ -14,9 +14,11 @@ public static partial class Endpoints
return Json(accounts.Profile(username).WithoutAvatar(), ApiJson.Default.AccountProfile); return Json(accounts.Profile(username).WithoutAvatar(), ApiJson.Default.AccountProfile);
}); });
// 头像单独取:默认给有损的缩略图(列表、聊天用),size=full 才是原图(预览时)。带 ETag,没变化时只回 304。需要登录账号 // 头像单独取:默认给有损的缩略图(列表、聊天用),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 profile = accounts.Profile(NormalizeUsername(username));
var full = ctx.Request.Query["size"] == "full"; var full = ctx.Request.Query["size"] == "full";
return AvatarResult(ctx, full ? profile.Avatar : profile.AvatarThumb ?? profile.Avatar, $"{profile.ModifiedAt}-{(full ? "f" : "t")}"); return AvatarResult(ctx, full ? profile.Avatar : profile.AvatarThumb ?? profile.Avatar, $"{profile.ModifiedAt}-{(full ? "f" : "t")}");
+31 -5
View File
@@ -6,6 +6,10 @@ public sealed partial class ChannelService
{ {
foreach (var id in store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs))) events.SettingsChanged(id); foreach (var id in store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs))) events.SettingsChanged(id);
} }
/// <summary>两个账号是否在同一个空间里(自己总是可以)。成员资料只对有交集的账号开放</summary>
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<Id16> AccountSpaceIds(string username) => store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs));
public IReadOnlyList<ChannelInfo> AccountSpaces(string username) => store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs).Select(id => public IReadOnlyList<ChannelInfo> AccountSpaces(string username) => store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs).Select(id =>
{ {
var channel = tx.GetChannel(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 (space, principal) = Authenticate(tx, id, creds, Membership.Required);
var state = tx.GetWorkspace(id) ?? new WorkspaceState(); var state = tx.GetWorkspace(id) ?? new WorkspaceState();
EnsureDefaultRole(state, clock.NowMs); EnsureDefaultRole(state, clock.NowMs);
return ViewFor(tx, space, principal, state);
});
/// <summary>
/// 空间结构按调用者的授权裁剪:没授权的频道、目录和条目位置一律不下发。
/// 读取和所有修改接口的返回值都必须经过它,否则任何一个成员的一次修改(比如发消息后登记条目位置)
/// 都会把整份结构返回给他。
/// </summary>
private WorkspaceState ViewFor(IResourceTx tx, Channel space, Principal principal, WorkspaceState state)
{
if (principal.AccountName != space.CreatorAccount) 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 grants = crypto.Access.Where(a => a.Username == principal.AccountName).ToList();
var ids = grants.Where(a => a.ChatRead || a.FilesRead).Select(a => a.ChannelId).ToHashSet(); var ids = grants.Where(a => a.ChatRead || a.FilesRead).Select(a => a.ChannelId).ToHashSet();
state.Channels.RemoveAll(c => !ids.Contains(c.Id)); 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.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.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; return state;
}); }
private WorkspaceState ChangeWorkspace(Id16 id, Credentials creds, Action<IResourceTx, WorkspaceState, Principal> change, bool requireAccount = true, string? permission = null) private WorkspaceState ChangeWorkspace(Id16 id, Credentials creds, Action<IResourceTx, WorkspaceState, Principal> change, bool requireAccount = true, string? permission = null)
{ {
var state = store.Write(tx => var state = store.Write(tx =>
@@ -68,7 +81,8 @@ public sealed partial class ChannelService
ValidateWorkspace(current); ValidateWorkspace(current);
current.Revision++; current.Revision++;
tx.PutWorkspace(id, current); tx.PutWorkspace(id, current);
return current; // 先落库完整的结构,再按调用者裁剪后返回
return ViewFor(tx, channel, principal, current);
}); });
events.SettingsChanged(id); events.SettingsChanged(id);
return state; 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); 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, _) => 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, _) => 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)); (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) => 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 (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) foreach (var itemId in itemIds)
{ {
if (!Id16.TryParse(itemId, out var parsed)) throw new WebDropException(ErrorCode.BadRequest); if (!Id16.TryParse(itemId, out var parsed)) throw new WebDropException(ErrorCode.BadRequest);
@@ -114,6 +114,33 @@ public sealed class ChannelCryptoTests
Assert.Null(env.Service.ReadCrypto(space.Id, owner).Objects.Find(o => o.ItemId == file.ToBase64Url())?.Event); 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<WebDropException>(() => 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) 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 foreach (var domain in new[] { CryptoDomain.Chat, CryptoDomain.Files }) env.Service.PublishDomain(space.Id, owner, new DomainPublishRequest