From b16e6575dec8bc303bb3fcf8d7617f924b097674 Mon Sep 17 00:00:00 2001 From: daguang Date: Mon, 1 Jun 2026 11:17:06 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=94=92=20=E4=BF=AE=E5=A4=8D=20sync=20?= =?UTF-8?q?create=5Fbookmark=20=E5=8F=AF=E8=B7=A8=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E6=94=B9=E5=86=99=E4=B9=A6=E7=AD=BE=E5=85=B3=E7=B3=BB=20(IDOR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit executeCreateBookmark 按全局唯一的 sr_user_bookmark.uuid 查到既有行后, 直接按 uuid update,未校验 user_id 归属。攻击者提交 op=PUT、 id=<受害者 uuid> 即可改写他人书签的 bookmark_id/deleted_at/archive_status。 补齐归属校验:UUID 存在但不属于当前用户时抛 ShareActionNotAllowedError; update 的 where 同时锁定 user_id 作为双保险。与同文件 update/delete/comment 路径已有的归属校验保持一致。 --- src/infra/repository/dbSyncBatch.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/infra/repository/dbSyncBatch.ts b/src/infra/repository/dbSyncBatch.ts index 8a1a6ef..bb95c81 100644 --- a/src/infra/repository/dbSyncBatch.ts +++ b/src/infra/repository/dbSyncBatch.ts @@ -100,9 +100,14 @@ export class DBSyncBatchOperation { where: { uuid: operation.bookmarkUuid } }) + // sr_user_bookmark.uuid 全局唯一,若该 UUID 已存在但不属于当前用户,拒绝跨用户改写 (IDOR) + if (existingByUuid && existingByUuid.user_id !== operation.userId) { + throw ShareActionNotAllowedError() + } + if (existingByUuid) { await tx.sr_user_bookmark.update({ - where: { uuid: operation.bookmarkUuid }, + where: { uuid: operation.bookmarkUuid, user_id: operation.userId }, data: { bookmark_id: bookmark.id, deleted_at: null, From f0faad13dd71c4a24a265613a45193f34344ede7 Mon Sep 17 00:00:00 2001 From: daguang Date: Mon, 1 Jun 2026 11:18:26 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9A=A1=20=E4=BF=AE=E5=A4=8D=20partial=5F?= =?UTF-8?q?changes=20=E5=A2=9E=E9=87=8F=E5=90=8C=E6=AD=A5=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E8=BF=87=E5=A4=A7=E5=AF=BC=E8=87=B4=20RangeError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPartialBookmarkChanges 查 append-only 变更日志且无上限,单用户变更过多时 15 天窗内全量返回会使 JSON 序列化超出 V8 buffer 上限,抛 RangeError: Invalid array buffer length(聚合 4 样本/27 次,与重试堆叠吻合)。 客户端(浏览器扩展)单向推进同步游标(只拉 created_at > previous_sync), 且假定响应为倒序(内部 logs.reverse 后按时间正序应用),出错仅重试不降级。 据此改为【前向分页】,响应结构与客户端零改动: - 查询取游标之后最旧的一批 (ASC) 最多 5000 条 - previous_sync 取本批最新一条:被截断时为'中间点',客户端下次从此继续 往后拉,逐批追平不丢数据;未截断时即为最新,已追平 - 响应按倒序返回以兼容客户端 logs.reverse - createBookmarkChangeLog 写入端截断超长 target_url (>2048) all_changes 因 JOIN sr_user_bookmark 天然每书签一行,无需处理。 --- src/domain/bookmark.ts | 21 ++++++++++++++------- src/infra/repository/dbBookmark.ts | 11 +++++++---- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/domain/bookmark.ts b/src/domain/bookmark.ts index 767d4b9..985b9eb 100644 --- a/src/domain/bookmark.ts +++ b/src/domain/bookmark.ts @@ -826,15 +826,22 @@ export class BookmarkService { } public async getPartialBookmarkChangesLog(ctx: ContextManager, userId: number, time: number) { + // res 按 created_at 正序(最旧在前),最多一批 limit 条;不足一批说明已追平 const res = (await this.bookmarkRepo.getPartialBookmarkChanges(userId, time)) || [] - const logs = res.map(item => ({ - target_url: item.target_url, - bookmark_id: ctx.hashIds.encodeId(item.bookmark_id), - action: item.action - })) - - const previous_sync = res.length > 0 ? res[0].created_at.getTime() : null + // 游标取本批最新一条(正序最后一条):被 limit 截断时这是"中间点",客户端下次 + // 会从此继续往后拉,逐批追平不丢数据;未截断时即为最新,已追平。 + const previous_sync = res.length > 0 ? res[res.length - 1].created_at.getTime() : null + + // 客户端假定响应为倒序(内部 logs.reverse 后再按时间正序应用),故倒序返回 + const logs = res + .slice() + .reverse() + .map(item => ({ + target_url: item.target_url, + bookmark_id: ctx.hashIds.encodeId(item.bookmark_id), + action: item.action + })) return { logs, diff --git a/src/infra/repository/dbBookmark.ts b/src/infra/repository/dbBookmark.ts index 1fe64ad..199bd36 100644 --- a/src/infra/repository/dbBookmark.ts +++ b/src/infra/repository/dbBookmark.ts @@ -695,7 +695,8 @@ export class BookmarkRepo { return await this.prisma().slax_user_bookmark_change.create({ data: { user_id: userId, - target_url: url, + // 截断异常超长 URL,避免单条变更撑爆后续增量同步的响应序列化 + target_url: url.length > 2048 ? url.slice(0, 2048) : url, bookmark_id: bookmarkId, action, created_at: time @@ -707,7 +708,8 @@ export class BookmarkRepo { } } - public async getPartialBookmarkChanges(userId: number, time: number) { + public async getPartialBookmarkChanges(userId: number, time: number, limit = 5000) { + // 取游标之后最旧的一批 (ASC) 最多 limit 条,前向分页,避免单次响应过大 (RangeError) const res = await this.prisma().slax_user_bookmark_change.findMany({ where: { user_id: userId, @@ -716,8 +718,9 @@ export class BookmarkRepo { } }, orderBy: { - created_at: 'desc' - } + created_at: 'asc' + }, + take: limit }) return res as bookmarkActionChangePO[] From 380b6235d165194dfc489595ceadfe73f646be22 Mon Sep 17 00:00:00 2001 From: daguang Date: Tue, 2 Jun 2026 11:06:04 +0800 Subject: [PATCH 3/3] :bug: fix changes too large --- src/domain/bookmark.ts | 4 ---- src/infra/repository/dbBookmark.ts | 4 +--- src/infra/repository/dbSyncBatch.ts | 1 - 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/domain/bookmark.ts b/src/domain/bookmark.ts index 985b9eb..f7366ec 100644 --- a/src/domain/bookmark.ts +++ b/src/domain/bookmark.ts @@ -826,14 +826,10 @@ export class BookmarkService { } public async getPartialBookmarkChangesLog(ctx: ContextManager, userId: number, time: number) { - // res 按 created_at 正序(最旧在前),最多一批 limit 条;不足一批说明已追平 const res = (await this.bookmarkRepo.getPartialBookmarkChanges(userId, time)) || [] - // 游标取本批最新一条(正序最后一条):被 limit 截断时这是"中间点",客户端下次 - // 会从此继续往后拉,逐批追平不丢数据;未截断时即为最新,已追平。 const previous_sync = res.length > 0 ? res[res.length - 1].created_at.getTime() : null - // 客户端假定响应为倒序(内部 logs.reverse 后再按时间正序应用),故倒序返回 const logs = res .slice() .reverse() diff --git a/src/infra/repository/dbBookmark.ts b/src/infra/repository/dbBookmark.ts index 199bd36..ac75a96 100644 --- a/src/infra/repository/dbBookmark.ts +++ b/src/infra/repository/dbBookmark.ts @@ -695,8 +695,7 @@ export class BookmarkRepo { return await this.prisma().slax_user_bookmark_change.create({ data: { user_id: userId, - // 截断异常超长 URL,避免单条变更撑爆后续增量同步的响应序列化 - target_url: url.length > 2048 ? url.slice(0, 2048) : url, + target_url: url, bookmark_id: bookmarkId, action, created_at: time @@ -709,7 +708,6 @@ export class BookmarkRepo { } public async getPartialBookmarkChanges(userId: number, time: number, limit = 5000) { - // 取游标之后最旧的一批 (ASC) 最多 limit 条,前向分页,避免单次响应过大 (RangeError) const res = await this.prisma().slax_user_bookmark_change.findMany({ where: { user_id: userId, diff --git a/src/infra/repository/dbSyncBatch.ts b/src/infra/repository/dbSyncBatch.ts index bb95c81..cd61224 100644 --- a/src/infra/repository/dbSyncBatch.ts +++ b/src/infra/repository/dbSyncBatch.ts @@ -100,7 +100,6 @@ export class DBSyncBatchOperation { where: { uuid: operation.bookmarkUuid } }) - // sr_user_bookmark.uuid 全局唯一,若该 UUID 已存在但不属于当前用户,拒绝跨用户改写 (IDOR) if (existingByUuid && existingByUuid.user_id !== operation.userId) { throw ShareActionNotAllowedError() }