本卡為 FR-079 母案。用 claude-security plugin 掃「公告」這條鏈的資安問題,切兩棒:B1 套件本體(jedi monorepo)、B2 宿主接線(BE repo)。只掃不修,修正卡另開、由 PM 統一安排。子卡清單在末段。
公告是系統裡「一則訊息要發給哪些人看」的功能:管理員發公告、指定要發給哪些部門,其他人在自己的畫面上看到。這系列要檢查的是——有沒有人可以看到不該看的公告、改別人的公告、或發公告給不屬於自己的部門。
這是繼 FR-075(登入身分)、FR-076(授權碼)、FR-077(遠端代理)、FR-078(通知寄送)之後的第五個掃描案,方法與紀律完全沿用前四案。
jedi-bulletin 在全套件掃描排名裡被歸在「讀取展示型、攻擊面小」那組(29 檔、847 行),看起來風險低。但那個評估只看了套件那半邊。 首腦這次讀完程式碼發現:套件本體確實只有沒有守門的 CRUD,而「誰能看到哪一則公告」的判定整個在主專案這邊,且判定邏輯出乎意料地繞(見下方「首腦讀完後的重點」)。這是一條被低估的鏈,值得完整掃一次。
另外,公告內容欄位是 fields.Raw()(不做任何型別或內容驗證)而前端要渲染它,這條路徑本身值得看。
@jwt_required()。 五條 route 裡,寫入的三條(POST/PUT/DELETE)都掛了 @require_capability("bulletin.create/update/delete"),但列表(POST /bulletins)與單筆讀取(GET /bulletin/<uid>)兩條完全沒有 capability 守門——任何登入者都打得到。這與 FR-075 S5/S6 修過的「讀取端點漏 .read 守門」(CM-1585/CM-1589)形狀完全相同,是本專案已知會重犯的病。get_bulletin(uid) 只有 uid 一個參數(app/bulletin/service/bulletin_service.py:116-122),拿到 uid 就回傳整則公告,不看部門、不看建立者、不看 enable、不看 release_time。列表那條費心做的部門過濾,在這條路上完全繞過。要看的是:uid 猜不猜得到(generate_uuid)、FE 有沒有把別人的 uid 洩漏出來、以及未發布/已過期/停用的公告是不是也照樣回傳。get_bulletins_and_pager()(同檔 73-99 行):auth=True 且使用者有部門 → 只看自己部門;否則只看自己建立的。但 auth 是呼叫端在 request body 的 filters 裡自己傳的布林值(api/bulletin/serializers/bulletin_query.py:6),不是伺服器判定的。要追:auth 傳 false 會走到哪、user.org_unit_id 為 None 的使用者(註解說「不過濾部門、顯示全部公告」)是誰、以及這兩條路合起來有沒有辦法看到全部公告。_gen_filters() 都在 for field, value in query_entity.__dict__.items() 上跑 hasattr(self.model, field) 判斷(infra/bulletin/repository/bulletin_repo_impl.py:60-76、套件端 bulletin_repo_impl.py:19-33),而 BulletinQueryEntity.__init__ 收 **kwargs(domain/bulletin/entities/bulletin_query_entity.py:17)。要追的是:呼叫端傳進來的鍵能不能塞進 __dict__ 進而變成查詢條件——serializer 是 apply=False 手動 load() 的,未宣告欄位的處理方式要實查。tenant_id、is_delete、org_unit_id 這些欄位若可被外部指定,語意就變了。update_bulletin(uid, user, **kwargs)(同檔 151-177 行)拿到 uid 直接更新,只驗 capability 不驗歸屬;delete_bulletin(uid) 同理,連 user 參數都沒有。要看的是:有 bulletin.update capability 的租戶管理員能不能改到別的租戶/別的部門的公告——這件事最終靠 RLS 擋,所以要一路追到 ExtendedBulletin 的 tenant scope 是否真的生效。update_bulletin 第 165 行無條件 delete_by_bulletin_id(),然後才依 kwargs.get("org_units") 重建。沒傳 org_units 的部分更新會把公告的部門對象全部清空,公告的可見範圍因此改變。這與 CM-1588(租戶/部門部分更新清空上層歸屬)是同一類 bug,該案已確認為真。Raw 欄位。 content = fields.Raw(required=True)(api/bulletin/serializers/bulletin.py:9),寫入不驗、讀出也是 fields.Raw() 原樣吐回。若 FE 用 v-html 之類渲染,這是儲存型 XSS 的天然位置。FE 的渲染方式要實查後才能下結論,不要憑猜。另外 content 是 Text 欄位、無長度上限。BulletinService:主專案那支(181 行,route 真正吃的)與套件那支(74 行,末行有個模組層級單例 bulletin_service = BulletinService(...))。套件那支的 add_bulletin 用 get_user_context().uid 當 created_user,主專案那支用 login_name——兩者語意不同。掃描時要分清哪一支真的在服務請求,不要對著沒人呼叫的那支下結論。di_containers/dashboard_apis/bulletin.py 把 bulletin.get_bulletins 註冊給 jedi-ai-dashboard 呼叫。這是一條不經過 route 層守門的取用路徑,user 參數怎麼來、optional_params 裡宣告的 org_unit_id 會不會變成前述 ④ 的動態查詢條件,都值得追。ExtendedBulletin 掛了 TenantScopedMixinModel,但 CM-1559 的盤點曾發現「有 policy 但 RLS 沒啟用」的表;另有 migration 註解(scripts/sql/2026-08-28-cm1418-...sql:54)明指 bulletins 的 INSERT policy 與其餘三支不對稱。跨租戶隔離是否真的成立,要看 DB 現況而不是看 model 有沒有掛 mixin。 唯讀查詢 DEV 即可(CLAUDE.md 讀寫分界:SELECT 屬唯讀、不需請示)。jedi-bulletin/jedi_bulletin/ + harness/。套件端的 CRUD、query entity、repo impl、plugin 契約、以及那個模組層級單例。