本卡屬 FR-079(母卡 CM-1609),第 2 棒:宿主接線(BE repo,不是 jedi monorepo)。只掃不修。建議先跑本棒——授權判定全在這半邊。
用 claude-security plugin 掃主專案裡「公告」這個功能的接線程式碼(33 檔),找出誰能看到不該看的公告、改別人的公告、發公告給不屬於自己的部門這類問題,產出報告。只找問題、不修問題。
jedi-bulletin 套件本身只有沒有守門的 CRUD——「誰能看到哪一則公告」的判定整個在主專案這半邊(route 的 capability 守門、app service 的部門過濾、infra 的動態查詢組裝、model 的 tenant scope)。母卡列的十個重點有八個落在這裡。與 FR-077 R3 的情形相同:套件把安全決策外包給宿主,真正的答案在宿主這幾十個檔裡。
另外,本專案已知會重犯「讀取端點漏守門」這個病(CM-1585/CM-1589 都是),而這裡的兩條讀取 route 正好只有 @jwt_required()。
這些是首腦讀過程式碼後認為最容易出事的地方,是思考起點不是檢查清單;全部未經任何驗證,可能有錯,以你實際讀到的為準。工具不會照這份清單走,HIGH 級的東西常常要靠人照這裡追出來(FR-076 L2 的 HIGH 就是這樣來的)。
api/bulletin/routes/bulletin_route.py:BulletinsRoute.post(列表,第 21-38 行)與 BulletinRoute.get(單筆,第 42-49 行)只有 @jwt_required();同檔的 put/delete/post 三條寫入都掛了 @require_capability("bulletin.update/delete/create")。要判斷的是:公告讀取是否本來就設計成全體登入者可見(那就不是洞),還是漏了 bulletin.read。判準不要只看程式碼註解——查 DB 的 capability 目錄裡有沒有 bulletin.read 這個項、FE 的選單/按鈕權限怎麼配,比註解可靠。app/bulletin/service/bulletin_service.py:116-122 的 get_bulletin(uid) 只吃一個 uid,回傳整則公告——不看部門、不看建立者、不看 enable、不看 release_time/expire_time。列表那條費心做的部門過濾在這條路上完全繞過。要追:(a) 未發布/已過期/enable=0 的公告是不是照樣讀得到;(b) uid 是 generate_uuid 產的,要確認實作是不是真的隨機(不要假設「UUID 就是不可猜」,FR-077 已經吃過「把 UUID 當秘密」的虧);(c) FE 或其他 API 有沒有把不該給你的 uid 洩漏出來。if _filter.auth and user.org_unit_id: → 只看自己部門;else: → 若有部門則只看自己建立的、沒有部門則不過濾(看全部)。而 auth 是呼叫端在 request body 的 filters 裡自己傳的布林值(api/bulletin/serializers/bulletin_query.py:6,default=False),不是伺服器判定的。要追三條路:(a) auth 傳 false 走到 else 分支會看到什麼;(b) user.org_unit_id 為 None 的使用者是誰(哪種帳號會沒有部門)、註解說的「顯示全部公告」範圍多大;(c) 這兩者組合起來能不能讓一般使用者看到全部公告。注意 default= 在 marshmallow 是序列化預設值、不是反序列化預設值,auth 完全不傳時 BulletinQueryEntity 收到什麼要實查。infra/bulletin/repository/bulletin_repo_impl.py:60-76 的 _gen_filters() 在 query_entity.__dict__ 上跑迴圈、用 hasattr(self.model, field) 決定要不要變成 SQL 條件;而 BulletinQueryEntity.__init__(domain/bulletin/entities/bulletin_query_entity.py:1-36)收 **kwargs。關鍵問題:呼叫端傳進來的鍵能不能落進 __dict__? serializer 是 use_kwargs(..., apply=False) 手動 load() 的(route 第 30 行),未宣告欄位的處置(RAISE/EXCLUDE/INCLUDE)要實查 marshmallow 的預設與 RequestMetaSchema 的設定。若可以,tenant_id/is_delete/org_unit_id 被外部指定就會改變查詢語意。同時看 _get_pager_list_query()(78-95 行)——它有一段刻意把 owner_created_user 從 base 的字串 OR 群組剔除的處理,那段註解說明了「範圍限制混進 OR 會讓條件恆真」,要確認同類問題在別的欄位上不存在。update_bulletin(uid, user, **kwargs)(app service 151-177 行)拿到 uid 直接改;delete_bulletin(uid)(179-181 行)連 user 參數都沒有。有 bulletin.update 的租戶管理員能不能改到別租戶/別部門的公告,最終只靠 RLS 擋——要一路追到 infra/bulletin/models/bulletin.py 的 ExtendedBulletin(BaseBulletin, TenantScopedMixinModel) 那個 tenant scope 到底有沒有生效,不要看到掛了 mixin 就當作有隔離。delete_by_bulletin_id(bulletin.id),之後才依 kwargs.get("org_units") 重建。沒帶 org_units 的部分更新會把公告的部門對象清空,公告可見範圍因此改變。這與 CM-1588(租戶/部門部分更新清空上層歸屬)同類,該案已確認為真 bug。要判斷的是:這條路徑實際上打不打得到(route 用 apply=False 手動取 payload,org_units 在 serializer 是 required=True 但沒有 apply)。Raw 欄位。 api/bulletin/serializers/bulletin.py:9 寫入 content = fields.Raw(required=True)、第 20 行讀出也是 fields.Raw() 原樣吐回,DB 是無長度上限的 Text。若前端用 v-html 之類渲染,這是儲存型 XSS 的位置。FE 怎麼渲染要實查再下結論(FE repo 在 ~/Projects/Billows/Audit-Manager/compliance-manager-fe/,src/views/bulletins/),不要憑猜報。另外注意公告是「發給很多人看」的內容,XSS 在這裡的受害面比一般欄位大。di_containers/dashboard_apis/bulletin.py 把 bulletin.get_bulletins 註冊給 jedi-ai-dashboard 呼叫,這條路不經過 route 層的 @jwt_required 與守門。要追:user 參數從哪來(誰決定它是誰)、宣告的 optional_params: [title, status, org_unit_id] 會不會經由 get_bulletins()(app service 101-114 行)流進 ④ 的動態查詢條件。infra/bulletin/models/bulletin.py:59-100:created_user_name/updated_user_name 用 select(User.nickname)...scalar_subquery() 實作,且 relationship 的 primaryjoin 用 User.status > -2 過濾。這條 subquery 跨到 users 表——要確認它會不會繞過 users 表自己的 RLS/狀態限制而洩漏使用者資訊(例如已停用或別租戶使用者的 nickname)。同時 created_user_name 在 BulletinQueryRequest 裡是可被呼叫端傳入的查詢欄位(serializer 第 16 行),配合 ④ 要一起看。bulletins 與 bulletin_org_units 兩張表的 RLS 是否 enabled(不只是有沒有 policy)、四個動作的 policy 是否對稱。CM-1559 的盤點曾發現「有 policy 但 RLS 沒啟用」的表;scripts/sql/2026-08-28-cm1418-system-configs-insert-rls-super-admin.sql:54 的註解也明指 bulletins 的 INSERT policy 與其餘三支不對稱。只做 SELECT/\d(唯讀,CLAUDE.md 讀寫分界允許、不需請示),對 DEV 庫(188:25432 / guidant_ai_dev),絕對不要改任何東西。