前言

上一篇完成單一任務 Capability URL 後,公開主頁已經能確認訪客是否具有該任務的唯讀能力。但主頁通過授權,不代表裡面的圖片、附件、HTML Runner 與內部連結自然安全。

這些資源通常由獨立 HTTP request 載入。若附件 route 只收到 file_id,或內容 API 只檢查 task_id,攻擊者可能替換網址參數,取得同專案或其他任務中沒有被分享的資源。

因此公開頁的權限不能只包住最外層 HTML,而必須成為所有子資源共用的 Scope。

最容易犯的錯誤:主頁驗證一次就相信後續請求

一個看似合理的流程可能是:

  1. 使用 Token 開啟公開任務頁。
  2. Server 驗證 Token 和 Task。
  3. HTML 輸出附件下載網址 /file/123
  4. 附件 Controller 只依 File ID 串流內容。

問題是第三步產生的新 request 不會自動繼承第一步的授權判斷。任何人只要猜到或取得其他 File ID,就可能直接呼叫附件 route。

正確驗證鏈需要同時確認:

Token
  → Public Task Scope
      → Project
          → Task
              → Task Document 中的 Block
                  → Native File ownership

其中任一關係不成立,都不能輸出檔名、MIME type、大小或檔案是否存在。

只有明確放入文件的附件才公開

Kanboard 任務可能有很多原生附件,但 Task Document 只引用其中幾個。公開頁不應該因為能看見任務,就自動列出所有 Task File。

外掛只為明確放入 imagefile 區塊的資源產生公開 URL。每次 preview 或 download 都重新驗證:

  • Scope Token 目前有效。
  • Project 仍然允許該 Scope。
  • Task 仍屬於該 Project 或單一分享紀錄。
  • File 確實屬於該 Task。
  • Task Document 仍有對應 block type 與 File ID。
  • 實體檔案可以安全開啟。

「Task 有這個附件」和「作者把附件放進公開文件」是兩件不同的事,不能混成同一個權限。

先確認檔案能開啟,再送出資源 Header

資料庫有附件記錄,不代表磁碟上的實體檔案仍然存在。如果 Controller 先送出檔名和 Content-Type,之後才發現檔案遺失,回應已經洩漏 metadata,也很難切換回通用錯誤頁。

因此本機檔案流程會先成功取得唯讀 handle,再輸出 no-store 與下載 header。資料列存在但檔案遺失時,仍然回到和錯誤 Token 相同的 generic not-found,不暴露儲存路徑或內部錯誤。

內部連結需要依公開模式決定行為

Task Document 的 Internal Link 可能指向同專案或跨專案任務,但目前訪客的 Scope 不一定包含目標。

在「公開專案」模式中,目標任務若屬於同一個公開專案,可以顯示標題並產生帶相同專案 Token 的公開網址。

跨專案、已刪除或不可存取目標則只顯示:

無法公開查看

不能輸出目標標題、Project 名稱、Task ID 衍生網址,甚至不應確認目標是否存在。

「單一任務分享」更窄:目前 Capability 只授權來源 Task,不能因為文件中有一條 Internal Link 就自動打開第二筆任務。第一版只顯示不具導覽能力的資訊卡;目標即使有自己的分享 URL,也不自動把兩組 Capability 串在一起。

同樣是 Internal Link,在不同 Public Task Scope 中必須採不同策略,不能只看「是否同專案」就決定。

External Link 也不能照單全收

外部連結不會讀取 Kanboard 資料,但仍可能使用危險 scheme 或控制 opener。

公開 renderer 只接受具有 host 的 absolute HTTP/HTTPS URL,並固定使用:

<a target="_blank" rel="noopener noreferrer">...</a>

這會排除 javascript:、相對內部路徑與缺少 host 的輸入,也避免新分頁透過 window.opener 控制原頁。

HTML Runner 也屬於公開資源

互動式 HTML 不只載入 iframe,還會經過 static preview、Block JSON、Runner handshake 與外部資源。每一個外掛自己的 endpoint 都必須知道目前 Scope。

作者 HTML 本身不會取得公開 Token;父頁只把需要執行的 HTML 與獨立 channel 傳進 runner。單一任務 Capability 或專案 Token 留在外層受信任的 request 流程,不進入不可信任的 postMessage payload。

這樣即使作者程式讀取自己的 DOM,也拿不到能再次存取其他公開資源的 bearer credential。

撤銷後,所有子資源都要一起失效

關閉專案公開功能、撤銷單一任務分享或讓 Token 過期後,不能只有主頁失效,先前取得的附件與圖片 URL 也必須在下一次 request 失效。

因此公開回應使用 no-store,每次重新解析目前公開狀態,不將「這個 Token 剛剛有效」保存成長時間 Session 或 CDN cache。

撤銷驗收會重新請求:

  • 來源任務頁。
  • Task Document block data。
  • 圖片 preview。
  • 附件 download。
  • HTML static preview 與 runner。
  • 公開專案模式下的同專案連結目標。

只有全部進入相同的未授權結果,撤銷才算真正完成。

不要只檢查 HTTP Status

有些 legacy framework 的通用 not-found 頁可能仍回傳非預期 status。安全測試不能只寫「不是 200 就通過」,也不能看到 200 就直接判定資料洩漏。

真正需要檢查的是 response body 與 headers:

  • 不含 Task title。
  • 不含 File name。
  • 不含 Project name。
  • 不含可推導的 Internal Link URL。
  • 不含儲存路徑或 stack trace。
  • 使用 no-store

HTTP 語意問題仍應另外修正,但授權測試必須確認受保護內容沒有出現在 response 中。

測試矩陣

公開資源測試涵蓋:

  • 已放置和未放置附件。
  • 同任務、跨任務與不存在的 File ID。
  • 同公開專案與跨專案 Internal Link。
  • 已刪除或無權限目標。
  • HTTP/HTTPS 與不安全 External Link。
  • 有效、錯誤、過期與撤銷 Token。
  • 實體檔案遺失。
  • 登入模式的原生附件流程沒有退化。

瀏覽器驗收還會實際解碼圖片、完成附件下載、開啟同專案連結,並搜尋頁面中是否出現不應公開的私有標題與識別資訊。

這次開發得到的結論是:公開頁的授權單位不是 Page,而是一整棵會被瀏覽器繼續請求的資源圖。只有所有 route 都接受相同 Scope,主頁上的 Token 驗證才真正有意義。

外部連結的 opener 行為可參考 WHATWG HTML Standard 的 hyperlink processing

系列最後一篇會進入發布流程:Kanboard 外掛發布實錄:Artifact 驗證、Migration 與回滾演練