refactor(release-cleanup): 將清理邏輯由 bash 改寫為 Node.js #5
@@ -4,6 +4,18 @@
|
|||||||
// 單一 HTTP 請求的逾時(毫秒)。避免 API 緩慢或掛起時容器永久卡死。
|
// 單一 HTTP 請求的逾時(毫秒)。避免 API 緩慢或掛起時容器永久卡死。
|
||||||
const REQUEST_TIMEOUT_MS = 30000
|
const REQUEST_TIMEOUT_MS = 30000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將回應內容整理成可安全寫入錯誤訊息的片段:移除控制字元(避免換行等造成的 log 注入)並限制長度。
|
||||||
|
* @param {string} text 原始回應文字
|
||||||
|
* @returns {string} 清理後、最長 200 字元的片段
|
||||||
|
Ghost marked this conversation as resolved
|
|||||||
|
*/
|
||||||
|
function sanitizeBody(text) {
|
||||||
|
return (text || '')
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Maya
**問題**:sanitizeBody 函式負責 API 回應的字串清理與格式化,但目前缺乏直接的單元測試,無法確保正規表示式能正確處理所有控制字元以及長度限制。
**建議**:請在 app/test/gitea-client.test.js 中新增 sanitizeBody 的單元測試,務必包含正常字串、包含控制字元的字串、空字串/null 值、以及超過 200 字元的極端案例。
|
|||||||
|
.replace(/[\u0000-\u001F\u007F]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 200)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 與 Gitea REST API 溝通的輕量 HTTP 客戶端,負責帶上認證標頭、分頁讀取清單與發出刪除請求。
|
* 與 Gitea REST API 溝通的輕量 HTTP 客戶端,負責帶上認證標頭、分頁讀取清單與發出刪除請求。
|
||||||
* 取代原 bash 版本以 `curl`/`jq` 進行的 API 操作。
|
* 取代原 bash 版本以 `curl`/`jq` 進行的 API 操作。
|
||||||
@@ -22,14 +34,15 @@ export class GiteaClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 逐頁讀取分頁式清單 API(每頁以 `?page=N` 由 1 遞增),直到某頁回傳空陣列(或非陣列)為止,
|
* 逐頁讀取分頁式清單 API(每頁以 `?page=N` 由 1 遞增),直到某頁回傳空陣列為止,
|
||||||
* 將所有頁面項目合併成單一陣列回傳。
|
* 將所有頁面項目合併成單一陣列回傳。
|
||||||
*
|
*
|
||||||
* 注意:終止條件依賴「空陣列代表最後一頁」的假設;若 API 不以空陣列結尾則迴圈不會自然停止。
|
* 在 `res.ok` 為真的前提下嚴格要求回應為 JSON 陣列:非 JSON、JSON 解析失敗或非陣列內容
|
||||||
|
* 都會拋出帶 URL 上下文的錯誤,而非被誤判為最後一頁而靜默結束。
|
||||||
*
|
*
|
||||||
* @param {string} baseUrl 不含 query string 的 API 位址(本方法會自行附加 `?page=N`)
|
* @param {string} baseUrl 不含 query string 的 API 位址(本方法會自行附加 `?page=N`)
|
||||||
* @returns {Promise<any[]>} 所有頁面合併後的項目陣列;無資料時為空陣列
|
* @returns {Promise<any[]>} 所有頁面合併後的項目陣列;無資料時為空陣列
|
||||||
* @throws {Error} 任一頁回應 HTTP 非 2xx(`!res.ok`)時拋出,訊息含 URL 與狀態碼
|
* @throws {Error} HTTP 非 2xx、回應非 JSON、JSON 無法解析或非陣列、或請求逾時(AbortError)時拋出
|
||||||
*/
|
*/
|
||||||
async fetchAllPages(baseUrl) {
|
async fetchAllPages(baseUrl) {
|
||||||
const all = []
|
const all = []
|
||||||
|
Ghost marked this conversation as resolved
Outdated
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Assassin
**問題**:將 HTTP 錯誤回應內容不加淨化地直接寫入錯誤訊息。攻擊者可能構造特殊的錯誤回應,透過 log 注入或後續錯誤處理機制進行惡意利用。
**建議**:錯誤訊息應限制內容長度(已做到),建議進一步剝離 HTML 標籤,或僅記錄必要的狀態碼與錯誤類型,避免直接紀錄原始回應內容。
|
|||||||
@@ -43,27 +56,41 @@ export class GiteaClient {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.text().catch(() => '')
|
const body = sanitizeBody(await res.text().catch(() => ''))
|
||||||
|
Ghost marked this conversation as resolved
Outdated
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Bard
**問題**:在 `fetchAllPages` 中使用 `all.push(...items)` 展開處理分頁資料,若 API 回傳的項目數量龐大(例如數千筆),極可能觸發「超過呼叫堆疊最大長度」(Maximum call stack size exceeded)導致程式崩潰,這對長期運行的工具而言是一大隱憂。
**建議**:建議改用簡單的迴圈 `for (const item of items) { all.push(item); }` 來逐一加入項目,這能完美規避展開運算子在處理大型陣列時的堆疊風險,讓程式運行得更加穩健優雅。
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Bard
**問題**:fetchAllPages 採展開運算子處理分頁資料,面對龐大項目數量可能導致 Maximum call stack size exceeded 錯誤,並伴隨無窮迴圈風險 (缺少 MAX_PAGES 限制)。
**建議**:改用簡單的 for...of 迴圈逐一 push 項目以規避堆疊風險,並務必加入 MAX_PAGES 常數作為安全斷點,防止 API 異常導致無限迴圈與資源耗盡。
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Bard
**問題**:fetchAllPages 採展開運算子處理分頁資料,面對龐大項目數量可能導致 Maximum call stack size exceeded 錯誤,並伴隨無窮迴圈風險 (缺少 MAX_PAGES 限制)。
**建議**:改用簡單的 for...of 迴圈逐一 push 項目以規避堆疊風險,並務必加入 MAX_PAGES 常數作為安全斷點,防止 API 異常導致無限迴圈與資源耗盡。
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
`GET ${url} failed: HTTP ${res.status}${body ? ` - ${body.slice(0, 200)}` : ''}`,
|
`GET ${url} failed: HTTP ${res.status}${body ? ` - ${body}` : ''}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Mage
**問題**:在 fetchAllPages 的 while 迴圈中使用了 AbortSignal.timeout。若運行環境的 Node.js 版本低於 16,此程式碼會直接崩潰。
**建議**:確認運行環境的 Node.js 版本,若未來需向下相容,建議使用 AbortController 搭配 setTimeout 手動實作逾時控制。
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Assassin
**問題**:攻擊者可控制 baseUrl 參數,若未對 baseUrl 的來源進行嚴格限制,可能導致 Server-Side Request Forgery (SSRF) 攻擊。此處直接將變數拼接到 URL,如果 baseUrl 來自惡意環境變數,攻擊者能發送請求到內網資源或意圖控制的位址。
**建議**:應在 config.js 中對 GITEA_SERVER_URL 和 GITEA_REPOSITORY 進行嚴格的 URL 結構驗證與白名單過濾,確保拼接出的 URL 處於預期範圍內。目前雖有 requireUrl 和 requireRepository,應進一步強化確保 baseUrl 只能以預期的 GITEA_SERVER_URL 開頭。
|
|||||||
|
|
||||||
// 確認回應確實是 JSON,避免 API 回傳 HTML 錯誤頁時 res.json() 拋出難以理解的 SyntaxError。
|
// 確認回應確實是 JSON,避免 API 回傳 HTML 錯誤頁時 res.json() 拋出難以理解的 SyntaxError。
|
||||||
const contentType = res.headers.get('content-type') || ''
|
const contentType = res.headers.get('content-type') || ''
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Assassin
**問題**:攻擊者可透過惡意伺服器回傳極大的錯誤回應內容,利用此處未限制大小的 res.text() 直接讀取整個回應主體,導致記憶體耗盡 (DoS)。
**建議**:應限制讀取的回應大小,例如在請求前檢查 Content-Length 或使用串流處理並設定讀取限制。
|
|||||||
if (!contentType.includes('application/json')) {
|
if (!contentType.includes('application/json')) {
|
||||||
|
Ghost marked this conversation as resolved
Outdated
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Assassin
**問題**:將 Content-Type 不符的錯誤內容直接寫入錯誤訊息。同樣面臨 log 注入或惡意回應內容的問題。
**建議**:同上,建議精簡錯誤資訊,移除可能包含惡意 payload 的回應 body 部分。
|
|||||||
const body = await res.text().catch(() => '')
|
const body = sanitizeBody(await res.text().catch(() => ''))
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`GET ${url} returned non-JSON content-type "${contentType}": ${body.slice(0, 200)}`,
|
`GET ${url} returned non-JSON content-type "${contentType}"${body ? `: ${body}` : ''}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = await res.json()
|
// 即使 content-type 正確,內容仍可能格式錯誤;明確攔截 SyntaxError 並補上 URL 上下文。
|
||||||
if (!Array.isArray(items) || items.length === 0) {
|
let items
|
||||||
|
try {
|
||||||
|
items = await res.json()
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Assassin
**問題**:攻擊者可透過惡意伺服器回傳極大的 JSON 資料,利用此處未限制大小的 res.json() 直接解析整個回應,導致記憶體耗盡 (DoS)。
**建議**:應對 API 回應設定明確的大小上限,超過時拒絕解析並拋出異常。
|
|||||||
|
} catch (error) {
|
||||||
|
throw new Error(`GET ${url} returned invalid JSON: ${error.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// res.ok 為真時嚴格要求陣列:非陣列代表非預期回應(如錯誤物件),應報錯而非視為最後一頁。
|
||||||
|
if (!Array.isArray(items)) {
|
||||||
|
throw new Error(`GET ${url} returned a non-array JSON payload`)
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
all.push(...items)
|
// 逐一加入(不使用展開運算子),避免大量項目時觸發呼叫堆疊上限。
|
||||||
|
for (const item of items) {
|
||||||
|
all.push(item)
|
||||||
|
}
|
||||||
page += 1
|
page += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
嚴重等級:🔵 建議
審查員:Leo
問題:目前
MAX_PAGES是寫死在程式碼中的常數,未來如果 API 規格變更或是特殊儲存庫的 Release 數量激增,維護者需要進程式碼修改,且這在不同的環境下可能需要不同的上限。建議:建議將
MAX_PAGES改為透過環境變數傳入,並設定一個合理的預設值,增加部署時的彈性。