文件整理 #4

Merged
admin merged 12 commits from feat/文件整理 into develop 2026-08-07 16:49:29 +00:00
7 changed files with 55 additions and 10 deletions
Showing only changes of commit dcd80750ba - Show all commits
+2 -2
View File
@@ -9,7 +9,7 @@ inputs:
description: '操作 Gitea Commit API 的 Token' description: '操作 Gitea Commit API 的 Token'
required: false required: false
model: model:
description: '使用的 AI 模型' description: '使用的 AI 模型,僅允許英數字、點、底線與連字號'
required: false required: false
runs: runs:
admin marked this conversation as resolved
Review

嚴重等級:🟡 警告
審查員:Assassin
問題:AI 模型參數(model)為使用者輸入但未驗證。攻擊者可透過 PR workflow 傳入任意字符串,縱然後續經 JSON 序列化理論上應轉義,仍增加了攻擊面且難以追蹤輸入來源。
建議:在 action.yml 中對 model 輸入進行描述性限制(說明只接受特定格式),並在 src/config.js 的 getLLMConfig() 加上白名單驗證或正則表達式檢查,拒絕包含特殊字符的模型名稱(如單引號、反斜線、括號等)。例:/^[a-zA-Z0-9._-]+$/。

**嚴重等級**:🟡 警告 **審查員**:Assassin **問題**:AI 模型參數(`model`)為使用者輸入但未驗證。攻擊者可透過 PR workflow 傳入任意字符串,縱然後續經 JSON 序列化理論上應轉義,仍增加了攻擊面且難以追蹤輸入來源。 **建議**:在 action.yml 中對 `model` 輸入進行描述性限制(說明只接受特定格式),並在 src/config.js 的 `getLLMConfig()` 加上白名單驗證或正則表達式檢查,拒絕包含特殊字符的模型名稱(如單引號、反斜線、括號等)。例:`/^[a-zA-Z0-9._-]+$/`。
Review

嚴重等級:🟡 警告
審查員:Assassin
問題:action.yml 中新增的 inputs.model 沒有在 GitHub Actions 層面進行輸入驗證。雖然描述寫著「僅允許英數字、點、底線與連字號」,但使用者可以提供任意字符(如 gpt-4; rm -rf /),這些惡意輸入會先被寫入環境變數 CLI_PROXY_API_MODEL,才在 Node.js 代碼中被驗證。違反了「最小信任原則」。
建議:在 action.yml 中的 inputs.model 新增驗證限制(GitHub Actions 層面無原生驗證機制,但可在文檔中強調風險,並確保 Node.js 驗證實作完備)。或改為使用 choices 列表限制可選值。目前的 Node.js 驗證雖然有效,但應在 GitHub Actions 文檔中明確說明:只有英數字、點、底線、連字號的 model 值才會被接受,其他值會被拒絕並導致工作流失敗。

**嚴重等級**:🟡 警告 **審查員**:Assassin **問題**:action.yml 中新增的 inputs.model 沒有在 GitHub Actions 層面進行輸入驗證。雖然描述寫著「僅允許英數字、點、底線與連字號」,但使用者可以提供任意字符(如 `gpt-4; rm -rf /`),這些惡意輸入會先被寫入環境變數 `CLI_PROXY_API_MODEL`,才在 Node.js 代碼中被驗證。違反了「最小信任原則」。 **建議**:在 action.yml 中的 inputs.model 新增驗證限制(GitHub Actions 層面無原生驗證機制,但可在文檔中強調風險,並確保 Node.js 驗證實作完備)。或改為使用 `choices` 列表限制可選值。目前的 Node.js 驗證雖然有效,但應在 GitHub Actions 文檔中明確說明:只有英數字、點、底線、連字號的 model 值才會被接受,其他值會被拒絕並導致工作流失敗。
using: 'docker' using: 'docker'
admin marked this conversation as resolved
Review

嚴重等級:🟡 警告
審查員:Bard
問題:把 Docker Action 的 image 參照改成小寫 dockerfile,讓原本業界慣用的 Dockerfile 檔名失去辨識度;這種大小寫改動會讓人讀配置時多停一下,也讓專案風格顯得不一致。
建議:把檔名與 action.yml 的 runs.image 都改回慣用的 Dockerfile,維持 Docker 生態的標準寫法。

**嚴重等級**:🟡 警告 **審查員**:Bard **問題**:把 Docker Action 的 image 參照改成小寫 `dockerfile`,讓原本業界慣用的 `Dockerfile` 檔名失去辨識度;這種大小寫改動會讓人讀配置時多停一下,也讓專案風格顯得不一致。 **建議**:把檔名與 `action.yml` 的 `runs.image` 都改回慣用的 `Dockerfile`,維持 Docker 生態的標準寫法。
@@ -19,4 +19,4 @@ runs:
GITEA_COMMENT_TOKEN: ${{ inputs.comment_token || inputs.token || secrets.TOKEN || gitea.token }} GITEA_COMMENT_TOKEN: ${{ inputs.comment_token || inputs.token || secrets.TOKEN || gitea.token }}
CLI_PROXY_API: ${{ vars.CLI_PROXY_API }} CLI_PROXY_API: ${{ vars.CLI_PROXY_API }}
CLI_PROXY_API_KEY: ${{ secrets.CLI_PROXY_API_KEY }} CLI_PROXY_API_KEY: ${{ secrets.CLI_PROXY_API_KEY }}
CLI_PROXY_API_MODEL: ${{ inputs.model || vars.CLI_PROXY_API_MODEL }} CLI_PROXY_API_MODEL: ${{ inputs.model || vars.CLI_PROXY_API_MODEL }}
+18 -6
View File
@@ -42,6 +42,16 @@ export const LLM_PROVIDER = 'cliproxyapi';
export const FINDINGS_PATH = '.gitea/ai-review/findings.json'; export const FINDINGS_PATH = '.gitea/ai-review/findings.json';
export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json'; export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json';
Review

嚴重等級:🟡 警告
審查員:Assassin
問題:這裡只限制字元種類,卻還放行 . 與 /,因此像 ../foo、foo/../../bar 這類路徑式字串仍可通過。攻擊者只要能控制 inputs.model 或 CLI_PROXY_API_MODEL,就能把惡意 model 值送進 CLIProxyAPI;若後端拿 model 名稱去拼路徑、呼叫指令或做檔名查找,這個輸入就可能被拿來做路徑穿越或指令注入。
建議:不要只做字元白名單,應改成明確白名單比對可用模型 slug,並額外拒絕 ..、前導/結尾 /、連續 /、反斜線與控制字元;如果可行,直接用 /v1/models 回傳清單做嚴格選擇,而不是接受任意形狀的字串。

**嚴重等級**:🟡 警告 **審查員**:Assassin **問題**:這裡只限制字元種類,卻還放行 `.` 與 `/`,因此像 `../foo`、`foo/../../bar` 這類路徑式字串仍可通過。攻擊者只要能控制 `inputs.model` 或 `CLI_PROXY_API_MODEL`,就能把惡意 model 值送進 CLIProxyAPI;若後端拿 model 名稱去拼路徑、呼叫指令或做檔名查找,這個輸入就可能被拿來做路徑穿越或指令注入。 **建議**:不要只做字元白名單,應改成明確白名單比對可用模型 slug,並額外拒絕 `..`、前導/結尾 `/`、連續 `/`、反斜線與控制字元;如果可行,直接用 `/v1/models` 回傳清單做嚴格選擇,而不是接受任意形狀的字串。
const MODEL_NAME_RE = /^[A-Za-z0-9._-]+$/;
admin marked this conversation as resolved
Review

嚴重等級:🔵 建議
審查員:Assassin
問題:正則表達式 MODEL_NAME_RE = /^[A-Za-z0-9._-]+$/ 不允許 / 字符。某些合法的模型名稱格式(如 openrouter/openai/gpt-4o 或 providers/openai/models/gpt-4o)會被拒絕,導致功能受限。雖然不是直接的安全漏洞,但可能造成合法請求被誤判為異常。
建議:評估是否需要在正則表達式中允許 / 字符。若允許,應同時確保不會引入新的安全風險(例如路徑穿越攻擊)。改為 /^[A-Za-z0-9._/-]+$/ 並增加單元測試確認邊界情況。

**嚴重等級**:🔵 建議 **審查員**:Assassin **問題**:正則表達式 `MODEL_NAME_RE = /^[A-Za-z0-9._-]+$/` 不允許 `/` 字符。某些合法的模型名稱格式(如 `openrouter/openai/gpt-4o` 或 `providers/openai/models/gpt-4o`)會被拒絕,導致功能受限。雖然不是直接的安全漏洞,但可能造成合法請求被誤判為異常。 **建議**:評估是否需要在正則表達式中允許 `/` 字符。若允許,應同時確保不會引入新的安全風險(例如路徑穿越攻擊)。改為 `/^[A-Za-z0-9._/-]+$/` 並增加單元測試確認邊界情況。
function normalizeModelName(raw) {
const model = String(raw || '').trim();
if (!model) return { model: null, modelError: null };
if (!MODEL_NAME_RE.test(model)) {
return { model: null, modelError: '無效的 model 參數,僅允許英數字、點、底線與連字號' };
}
return { model, modelError: null };
}
let _insecureHttpsAgent = null; let _insecureHttpsAgent = null;
/** /**
@@ -67,26 +77,28 @@ export const getOpenCodeHttpsAgent = getInsecureHttpsAgent;
* 優先讀取 `INPUT_CLI_PROXY_API` / `CLI_PROXY_API` 作為 base URL(會 trim 並移除結尾斜線), * 優先讀取 `INPUT_CLI_PROXY_API` / `CLI_PROXY_API` 作為 base URL(會 trim 並移除結尾斜線),
* `INPUT_MODEL` / `CLI_PROXY_API_MODEL` / `MODEL` / `OPENCODE_MODEL`(依序 fallback, * `INPUT_MODEL` / `CLI_PROXY_API_MODEL` / `MODEL` / `OPENCODE_MODEL`(依序 fallback,
* 相容 action input、舊 OpenCode 設定與環境變數)作為可選模型名稱;若未提供, * 相容 action input、舊 OpenCode 設定與環境變數)作為可選模型名稱;若未提供,
* 則交由 CLIProxyAPI 自動選擇模型,`INPUT_CLI_PROXY_API_KEY` / `CLI_PROXY_API_KEY` * 則交由 CLIProxyAPI 自動選擇模型。若提供的名稱含非法字元,會被視為無效並於
* 作為存取金鑰(會 trim)。 * `modelError` 回報,`INPUT_CLI_PROXY_API_KEY` / `CLI_PROXY_API_KEY` 作為存取金鑰(會 trim)。
* *
* 若 base URL 無法解析出任何值,視為沒有可用的 proxy 設定:`provider`/`baseURL` 回傳 `null`、 * 若 base URL 無法解析出任何值,視為沒有可用的 proxy 設定:`provider`/`baseURL` 回傳 `null`、
* `apiKeys` 回傳空陣列,但 `model`(若有解析到)仍會回傳,不會被清空。 * `apiKeys` 回傳空陣列,但 `model`(若有解析到)仍會回傳,不會被清空。
* *
* @returns {{ provider: ('cliproxyapi'|null), apiKeys: string[], baseURL: (string|null), model: (string|null), command: null }} * @returns {{ provider: ('cliproxyapi'|null), apiKeys: string[], baseURL: (string|null), model: (string|null), modelError: (string|null), command: null }}
* 設定物件;`provider` 為 `null` 表示沒有可用的 proxy 設定。 * 設定物件;`provider` 為 `null` 表示沒有可用的 proxy 設定。
*/ */
export function getLLMConfig() { export function getLLMConfig() {
const baseURL = String(process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API || '').trim().replace(/\/$/, ''); const baseURL = String(process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API || '').trim().replace(/\/$/, '');
const model = process.env.INPUT_MODEL || process.env.CLI_PROXY_API_MODEL || process.env.MODEL || process.env.OPENCODE_MODEL || ''; const rawModel = process.env.INPUT_MODEL || process.env.CLI_PROXY_API_MODEL || process.env.MODEL || process.env.OPENCODE_MODEL || '';
const { model, modelError } = normalizeModelName(rawModel);
const apiKey = String(process.env.INPUT_CLI_PROXY_API_KEY || process.env.CLI_PROXY_API_KEY || '').trim(); const apiKey = String(process.env.INPUT_CLI_PROXY_API_KEY || process.env.CLI_PROXY_API_KEY || '').trim();
if (!baseURL) return { provider: null, apiKeys: [], baseURL: null, model: model || null, command: null }; if (!baseURL) return { provider: null, apiKeys: [], baseURL: null, model, modelError, command: null };
return { return {
provider: LLM_PROVIDER, provider: LLM_PROVIDER,
apiKeys: apiKey ? [apiKey] : [], apiKeys: apiKey ? [apiKey] : [],
baseURL, baseURL,
model: model || null, model,
modelError,
command: null, command: null,
}; };
} }
+2 -1
View File
4
@@ -209,8 +209,9 @@ async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) {
*/ */
export async function chat(systemPrompt, userContent) { export async function chat(systemPrompt, userContent) {
const cfg = getLLMConfig(); const cfg = getLLMConfig();
const { provider, baseURL, model } = cfg; const { provider, baseURL, model, modelError } = cfg;
if (!provider || !baseURL) throw new Error('未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API'); if (!provider || !baseURL) throw new Error('未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API');
if (modelError) throw new Error(modelError);
line(`[LLM] provider=${provider} baseURL=${baseURL} model=${model || 'auto'}`); line(`[LLM] provider=${provider} baseURL=${baseURL} model=${model || 'auto'}`);
+2 -1
View File
@@ -177,8 +177,9 @@ export async function fetchLLMModels({
* @remarks 設定來源為 config.js 的 getLLMConfig()。 * @remarks 設定來源為 config.js 的 getLLMConfig()。
*/ */
export async function verifyLLM({ fetchLLMModelsFn = fetchLLMModels } = {}) { export async function verifyLLM({ fetchLLMModelsFn = fetchLLMModels } = {}) {
const { provider, command, model } = getLLMConfig(); const { provider, command, model, modelError } = getLLMConfig();
if (!provider) return { ok: false, error: '未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API' }; if (!provider) return { ok: false, error: '未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API' };
if (modelError) return { ok: false, provider, command, model, error: modelError };
if (provider === 'cliproxyapi') { if (provider === 'cliproxyapi') {
const models = await fetchLLMModelsFn(); const models = await fetchLLMModelsFn();
+10
View File
@@ -62,6 +62,16 @@ describe('getLLMConfig', () => {
assert.equal(cfg.model, 'gpt-5.4-mini'); assert.equal(cfg.model, 'gpt-5.4-mini');
}); });
it('rejects invalid model names', () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.MODEL = 'gpt-5.5; rm -rf /';
const cfg = getLLMConfig();
assert.equal(cfg.model, null);
assert.match(cfg.modelError, /無效的 model 參數/);
});
it('returns null provider when CLI_PROXY_API is missing', () => { it('returns null provider when CLI_PROXY_API is missing', () => {
process.env.MODEL = 'gpt-5.5'; process.env.MODEL = 'gpt-5.5';
const cfg = getLLMConfig(); const cfg = getLLMConfig();
+7
View File
@@ -88,6 +88,13 @@ describe('chat - CLIProxyAPI', async () => {
await assert.rejects(() => chat('sys', 'user'), /401/); await assert.rejects(() => chat('sys', 'user'), /401/);
await assert.rejects(() => chat('sys', 'user'), /access token revoked/); await assert.rejects(() => chat('sys', 'user'), /access token revoked/);
}); });
it('throws when the configured model name is invalid', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.MODEL = 'gpt-5.5; rm -rf /';
await assert.rejects(() => chat('sys', 'user'), /無效的 model 參數/);
});
}); });
describe('chatJSON', async () => { describe('chatJSON', async () => {
+14
View File
@@ -208,6 +208,20 @@ describe('verifyLLM', () => {
assert.match(result.error, /不在 CLIProxyAPI 可用清單/); assert.match(result.error, /不在 CLIProxyAPI 可用清單/);
assert.match(result.error, /gpt-9-imaginary/); assert.match(result.error, /gpt-9-imaginary/);
}); });
it('fails when the configured model name is invalid', async () => {
clearLLMEnv();
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.MODEL = 'gpt-5.5; rm -rf /';
const result = await verifyLLM({
fetchLLMModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5'] }),
});
assert.equal(result.ok, false);
assert.equal(result.provider, 'cliproxyapi');
assert.match(result.error, /無效的 model 參數/);
});
}); });
describe('runPreflight', () => { describe('runPreflight', () => {