fix(release-cleanup): 強化輸入驗證、請求逾時與錯誤處理

解決 AI review 的安全性與健壯性問題:
- 驗證 GITEA_SERVER_URL 為合法 http/https URL(降低 SSRF 風險)
- 驗證 GITEA_REPOSITORY 為 owner/repo 形式並拒絕路徑穿越
- 為所有 fetch 請求加上 30 秒逾時(AbortSignal.timeout),避免卡死
- fetchAllPages 解析前檢查 content-type,非 JSON 時拋出明確錯誤
- selectReleasesToDelete 對無效 created_at 防呆,避免 NaN 排序
- index.js 匯出 main 並加上直接執行守衛,錯誤輸出含類型與堆疊

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-06-26 10:55:45 +08:00
co-authored by Claude Opus 4.8
parent 1f27a46f7b
commit 154ab032bf
5 changed files with 96 additions and 12 deletions
+9 -1
View File
@@ -1,7 +1,13 @@
// 讀取並驗證環境變數,組出後續流程所需的設定物件。 // 讀取並驗證環境變數,組出後續流程所需的設定物件。
// 對應原本 entrypoint.sh 的「參數檢查」區段。 // 對應原本 entrypoint.sh 的「參數檢查」區段。
import { isEmptyOrNull, requireValue, requireInteger } from './validate.js' import {
isEmptyOrNull,
requireValue,
requireInteger,
requireUrl,
requireRepository,
} from './validate.js'
import { section, info, warn } from './logger.js' import { section, info, warn } from './logger.js'
/** /**
@@ -27,9 +33,11 @@ export function loadConfig(env = process.env) {
info(`GITEA_SERVER_URL=${serverUrl}`) info(`GITEA_SERVER_URL=${serverUrl}`)
requireValue('GITEA_SERVER_URL', serverUrl) requireValue('GITEA_SERVER_URL', serverUrl)
requireUrl('GITEA_SERVER_URL', serverUrl)
info(`GITEA_REPOSITORY=${repository}`) info(`GITEA_REPOSITORY=${repository}`)
requireValue('GITEA_REPOSITORY', repository) requireValue('GITEA_REPOSITORY', repository)
requireRepository('GITEA_REPOSITORY', repository)
info(`KEEP_COUNT=${keepCountRaw}`) info(`KEEP_COUNT=${keepCountRaw}`)
requireValue('KEEP_COUNT', keepCountRaw) requireValue('KEEP_COUNT', keepCountRaw)
+25 -3
View File
@@ -1,6 +1,9 @@
// 與 Gitea API 溝通的 HTTP 客戶端,封裝認證標頭、分頁讀取與刪除請求。 // 與 Gitea API 溝通的 HTTP 客戶端,封裝認證標頭、分頁讀取與刪除請求。
// 對應原本 entrypoint.sh 的 fetch_all_pages 與 curl DELETE 呼叫。 // 對應原本 entrypoint.sh 的 fetch_all_pages 與 curl DELETE 呼叫。
// 單一 HTTP 請求的逾時(毫秒)。避免 API 緩慢或掛起時容器永久卡死。
const REQUEST_TIMEOUT_MS = 30000
/** /**
* 與 Gitea REST API 溝通的輕量 HTTP 客戶端,負責帶上認證標頭、分頁讀取清單與發出刪除請求。 * 與 Gitea REST API 溝通的輕量 HTTP 客戶端,負責帶上認證標頭、分頁讀取清單與發出刪除請求。
* 取代原 bash 版本以 `curl`/`jq` 進行的 API 操作。 * 取代原 bash 版本以 `curl`/`jq` 進行的 API 操作。
@@ -34,10 +37,25 @@ export class GiteaClient {
while (true) { while (true) {
const url = `${baseUrl}?page=${page}` const url = `${baseUrl}?page=${page}`
const res = await fetch(url, { headers: this.headers }) const res = await fetch(url, {
headers: this.headers,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
if (!res.ok) { if (!res.ok) {
throw new Error(`GET ${url} failed: HTTP ${res.status}`) const body = await res.text().catch(() => '')
throw new Error(
`GET ${url} failed: HTTP ${res.status}${body ? ` - ${body.slice(0, 200)}` : ''}`,
)
}
// 確認回應確實是 JSON,避免 API 回傳 HTML 錯誤頁時 res.json() 拋出難以理解的 SyntaxError。
const contentType = res.headers.get('content-type') || ''
if (!contentType.includes('application/json')) {
const body = await res.text().catch(() => '')
throw new Error(
`GET ${url} returned non-JSON content-type "${contentType}": ${body.slice(0, 200)}`,
)
} }
const items = await res.json() const items = await res.json()
@@ -58,7 +76,11 @@ export class GiteaClient {
* @returns {Promise<number>} 回應的 HTTP 狀態碼(成功刪除通常為 204) * @returns {Promise<number>} 回應的 HTTP 狀態碼(成功刪除通常為 204)
*/ */
async deleteResource(url) { async deleteResource(url) {
const res = await fetch(url, { method: 'DELETE', headers: this.headers }) const res = await fetch(url, {
method: 'DELETE',
headers: this.headers,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
return res.status return res.status
} }
} }
+24 -5
View File
@@ -14,7 +14,7 @@ import { separator, fail } from './logger.js'
* *
* @returns {Promise<void>} 流程完成時 resolve;設定驗證或讀取 API 失敗時 reject。 * @returns {Promise<void>} 流程完成時 resolve;設定驗證或讀取 API 失敗時 reject。
*/ */
async function main() { export async function main() {
const config = loadConfig() const config = loadConfig()
const client = new GiteaClient({ token: config.token }) const client = new GiteaClient({ token: config.token })
@@ -24,7 +24,26 @@ async function main() {
separator() separator()
} }
main().catch((error) => { /**
fail(error.message) * 將錯誤輸出至 stderr,並盡量保留可供除錯的上下文(錯誤類型與堆疊),
process.exit(1) * 以便區分設定驗證錯誤與網路/API 請求錯誤。
}) * @param {unknown} error 捕捉到的錯誤
*/
function reportFatal(error) {
if (error instanceof Error) {
fail(`${error.name}: ${error.message}`)
if (error.stack) {
process.stderr.write(`${error.stack}\n`)
}
} else {
fail(String(error))
}
}
// 僅在直接以 `node index.js` 執行時啟動主流程;被測試 import 時不自動執行,方便撰寫整合測試。
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((error) => {
reportFatal(error)
process.exit(1)
})
}
+6 -3
View File
@@ -12,9 +12,12 @@ import { isEmptyOrNull } from './validate.js'
* @returns {any[]} 需要刪除的成品陣列(較舊者),依新到舊排序 * @returns {any[]} 需要刪除的成品陣列(較舊者),依新到舊排序
*/ */
export function selectReleasesToDelete(releases, keepCount) { export function selectReleasesToDelete(releases, keepCount) {
const sorted = [...releases].sort( // 解析 created_at;格式無效或缺漏時視為最舊(0),避免 NaN 造成排序結果不可預期。
(a, b) => new Date(b.created_at) - new Date(a.created_at), const createdTime = (release) => {
) const time = Date.parse(release?.created_at)
return Number.isNaN(time) ? 0 : time
}
const sorted = [...releases].sort((a, b) => createdTime(b) - createdTime(a))
return sorted.slice(keepCount) return sorted.slice(keepCount)
} }
+32
View File
@@ -34,3 +34,35 @@ export function requireInteger(name, value) {
throw new Error(`${name} must be a non-negative integer`) throw new Error(`${name} must be a non-negative integer`)
} }
} }
/**
* 要求值為合法的 http/https URL,藉此避免設定來源指向格式錯誤或非預期協定的伺服器(降低 SSRF 風險)。
* @param {string} name 欄位名稱,用於組出錯誤訊息
* @param {string} value 待檢查的 URL 字串
* @throws {Error} 當 value 無法解析為 URL 時丟出 `${name} must be a valid URL`;
* 協定非 http/https 時丟出 `${name} must use http or https protocol`
*/
export function requireUrl(name, value) {
let url
try {
url = new URL(value)
} catch {
throw new Error(`${name} must be a valid URL`)
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(`${name} must use http or https protocol`)
}
}
/**
* 要求值為合法的 `owner/repo` 形式:僅允許英數字與 `. _ -`、恰好一個 `/`,且不可含路徑穿越片段 `..`。
* 用於防止 GITEA_REPOSITORY 被竄改造成 API 目標被導向其他儲存庫。
* @param {string} name 欄位名稱,用於組出錯誤訊息
* @param {string} value 待檢查的 repository 字串
* @throws {Error} 格式不符或含 `..` 時丟出 `${name} must be in the form owner/repo without path traversal`
*/
export function requireRepository(name, value) {
if (String(value).includes('..') || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(value)) {
throw new Error(`${name} must be in the form owner/repo without path traversal`)
}
}