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:
1 parent
d66c93fe98
commit
78b4e2aa63
3 files changed
+62
-7
No files matched your search
@@ -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")}");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user