iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Build on Google AI

從 Vibe Coding 到 Production:用 Google AI 打造上線守門員系列 第 10

Day 10|輸入永遠不可信:Injection、驗證與安全輸出

  • 分享至 

  • xImage
  •  

Day 9,我們把 Gemini API Key 留在後端,避免 Secret 進入 Git 與瀏覽器。

但 Secret 沒有外洩,不代表系統就安全。只要程式把使用者輸入當成指令、查詢或 HTML,攻擊者仍可能改變原本預期的行為。

今天會用一個本機 Demo 回答三個問題:

  1. 為什麼只檢查「有沒有輸入」不夠?
  2. 為什麼字串拼接容易造成 Injection?
  3. 同一筆資料輸出到 HTML 時,為什麼還要另外編碼?

Injection 不是特殊字元造成的

Injection 發生在程式把資料送進另一種語言或直譯器,卻沒有保留「資料」與「指令」的界線。

常見情境包含:

  • 把搜尋條件拼進 SQL,造成 SQL Injection。
  • 把檔名拼進 Shell Command,造成 Command Injection。
  • 把留言直接插入 HTML,造成 Cross-Site Scripting(XSS)。
  • 把使用者文字拼進 LDAP、XPath 或其他查詢語言。
  • 把不可信內容放進 AI Prompt,使模型誤把內容當成指令。

問題不只是輸入包含分號、引號或角括號。真正的問題是接收資料的元件如何解讀這些字元。

例如分號在一般專案名稱中只是文字,在 Shell 中卻可以分隔兩個指令。<img> 在純文字中只是標記字串,交給瀏覽器的 HTML Parser 後則可能建立真正的元素。

因此,安全處理不能只靠一份「危險字元黑名單」。

建立 Day 10 的本機實驗

今天的程式放在:

demo-app/input-demo/scenario.js

進入 Demo 專案:

cd /media/mickey/777/ithome/demo-app

執行:

npm run input:demo

這個腳本不連線到外部服務,也不修改檔案。它會把受控的測試字串分別送進 Shell、參數化 Process API 與 HTML Template,比較安全與不安全的結果。

實驗一:字串拼接如何變成 Command Injection

假設系統要執行一個檢查工具,開發者直接把名稱拼進 Shell Command:

const unsafeCommand = `printf 'Checking %s\n' ${projectName}`;
const result = await exec(unsafeCommand);

測試輸入不是普通名稱,而是:

release-notes; printf 'INJECTED\n'

Shell 會把分號解讀成指令分隔符。實際輸出為:

Unsafe command output: "Checking release-notes\nINJECTED"

原本只想顯示專案名稱,結果卻執行了輸入中的第二個 printf

這個 Demo 使用無害指令,但正式系統若以高權限執行,攻擊者可能讀取檔案、呼叫內部服務、修改資料或取得執行環境中的 Secret。

問題不在 printf,而在 exec() 會把整段字串交給 Shell 解讀。

修正:不要讓 Shell 重新解析資料

Node.js 的 execFile() 可以把執行檔與參數分開傳遞:

const result = await execFile("printf", [
  "Checking %s\n",
  projectName
]);

同一筆輸入的結果為:

Safe argument output: "Checking release-notes; printf 'INJECTED\\n'"

分號、空白與引號仍然存在,但它們只是單一參數中的文字。Process API 沒有請 Shell 把它們當成另一個指令。

同樣原則也適用於 SQL。不要自行加入引號再拼接字串:

const sql = `SELECT * FROM projects WHERE owner_id = '${ownerId}'`;

應使用資料庫 Driver 提供的 Parameterized Query:

const result = await db.query(
  "SELECT * FROM projects WHERE owner_id = $1",
  [ownerId]
);

參數化不是把危險字元刪掉,而是由 API 明確區分 SQL 結構與資料值。

輸入驗證仍然必要

避免字串拼接可以阻止 Injection,但系統仍要確認資料是否符合業務規則。

假設部署 API 的 channel 只允許三種值:

const allowedChannels = new Set([
  "dev",
  "staging",
  "production"
]);

驗證函式採用 Allowlist:

function validateChannel(value) {
  if (!allowedChannels.has(value)) {
    throw new Error(
      "channel must be dev, staging, or production"
    );
  }
  return value;
}

Demo 結果如下:

"production" -> ACCEPTED as production
"release-notes; printf 'INJECTED\\n'" -> REJECTED: channel must be dev, staging, or production

Allowlist 適合狀態、角色、排序欄位、檔案類型與其他有限集合。它比列出所有危險字元可靠,因為攻擊語法與編碼方式會持續變化。

對自由文字則不能只允許少數固定值。系統可以驗證:

  • 型別是否正確。
  • 長度是否在合理範圍。
  • 必填欄位是否存在。
  • 數字與日期是否落在允許區間。
  • 巢狀結構與陣列數量是否受限。
  • 不應由 Client 控制的欄位是否被拒絕。

驗證失敗時應回傳明確的 4xx 錯誤,不要默默改成另一個值,也不要讓資料一路進入資料庫或其他服務後才失敗。

前端驗證不是安全邊界

HTML 提供 requiredmaxlengthtype="email" 等功能。這些檢查可以改善使用體驗,卻不能取代後端驗證。

攻擊者不必使用我們提供的表單。他可以透過 curl、瀏覽器開發者工具或自己的程式直接呼叫 API。

因此:

Browser validation  -> 提早提醒正常使用者
Server validation   -> 保護系統的信任邊界
Database constraint -> 保護資料最終狀態

三層可以同時存在,但後端不能因為前端已經檢查就相信輸入。

驗證通過,也不代表任何地方都能安全輸出

有些內容本來就允許自由輸入,例如專案名稱、留言或文章標題。這些資料即使符合長度限制,輸出到不同位置時仍要安全處理。

Demo 使用以下文字:

<img src=x onerror="alert('XSS')">

不安全版本直接插入 HTML:

const html = `<p>Project: ${projectName}</p>`;

產生結果:

<p>Project: <img src=x onerror="alert('XSS')"></p>

瀏覽器會把輸入解析成元素,事件屬性可能執行 JavaScript。這就是 Stored XSS 或 Reflected XSS 的常見起點,取決於資料是先保存再顯示,還是立即反射到 Response。

安全版本在放入 HTML Text Context 前執行編碼:

function escapeHtml(value) {
  return value
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

輸出變成:

<p>Project: &lt;img src=x onerror=&quot;alert(&#39;XSS&#39;)&quot;&gt;</p>

瀏覽器會顯示原始文字,而不是建立 <img> 元素。

實際產品應優先使用框架預設的文字綁定,例如 DOM 的 textContent。如果產品允許部分 HTML,則需要採用經過維護的 HTML Sanitizer 與明確的標籤、屬性 Allowlist,不要自行用 Regex 清理 HTML。

編碼必須符合輸出情境

HTML Text、HTML Attribute、URL、CSS 與 JavaScript 是不同的解析情境。適用於 HTML 文字的編碼方式,不一定能安全地放進 JavaScript:

<script>
  const project = "使用者輸入";
</script>

也不能因為 URL 經過 HTML Encoding,就允許任意 javascript: Scheme。

更安全的原則是:

  1. 避免把不可信資料放進危險情境。
  2. 使用框架提供的安全 API。
  3. 依最終輸出情境執行對應編碼。
  4. 需要接受 HTML 時,再使用專門的 Sanitizer。

Content Security Policy 可以降低部分 XSS 的影響,但它是額外防線,不是直接插入不可信 HTML 的理由。

驗證、參數化、編碼與清理不一樣

控制措施 回答的問題 範例
驗證 Validation 這筆資料符合業務允許的格式嗎? Channel 只能是 devstagingproduction
參數化 Parameterization 資料與指令是否保持分離? SQL Placeholder、execFile() 參數
編碼 Encoding 資料放進特定輸出情境後,會不會被當成語法? HTML Entity Encoding
清理 Sanitization 允許部分主動內容時,哪些結構必須移除? HTML 標籤與屬性 Allowlist

它們不能互相取代。

一個有效的專案名稱仍然要用參數化查詢寫入資料庫,也要在顯示時安全編碼。反過來說,HTML Encoding 不會讓不合法的部署 Channel 突然符合業務規則。

Production Readiness Agent 要檢查什麼?

Agent 不應看到 req.body 就直接回報 Injection。使用者輸入本來就是許多功能的必要資料。

Agent 要沿著資料流找到真正的 Interpreter 或輸出位置:

  1. 輸入來自 HTTP、CLI、檔案、資料庫,還是第三方 API?
  2. 是否有 Schema、型別、長度與 Allowlist 驗證?
  3. 資料是否透過字串拼接進入 SQL、Shell、Template 或其他語言?
  4. 程式是否使用 Parameterized API?
  5. 資料輸出到 HTML、URL 或 JavaScript 時,是否使用正確的安全 API?
  6. 驗證失敗是否明確停止處理並回傳適當錯誤?
  7. 執行身分與工具權限是否限制成功利用後的影響?

可操作的 Finding 應包含:

問題:專案名稱被拼接後交給 Shell 執行
來源:API Request 的 projectName
Sink:child_process.exec(unsafeCommand)
證據:Template Literal 直接建立完整 Shell Command
攻擊輸入:release-notes; printf 'INJECTED\n'
驗證:npm run input:demo
結果:輸出出現第二個指令產生的 INJECTED
影響:攻擊者可能以服務身分執行任意指令
修正:改用 execFile 並以獨立參數傳值

如果程式已使用參數化 API,Agent 就不能只因輸入包含特殊字元而判定存在 Injection。報告必須證明不可信資料可以跨越資料與指令的界線。

今天的結論

輸入永遠不可信,不代表所有使用者都懷有惡意,而是系統不能把信任建立在 Client 會乖乖使用表單。

今天建立了三層防線:

  1. 使用 Allowlist、型別與範圍檢查資料是否符合業務規則。
  2. 使用 Parameterized API,避免資料被 SQL 或 Shell 當成指令。
  3. 根據 HTML、URL 等最終情境安全輸出資料。

刪除幾個特殊字元無法解決所有 Injection。真正可靠的設計,是從 API 層就保留資料與程式語法的界線。

明天,我們會檢查相依套件與供應鏈風險。即使自己的程式碼安全,下載進專案的套件、版本與安裝流程仍可能成為攻擊入口。

參考資料


上一篇
Day 9|別把 Secret 推上 GitHub:正式環境的密鑰管理入門
系列文
從 Vibe Coding 到 Production:用 Google AI 打造上線守門員10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言