iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0

今天的主題:表單與輸入——以及我在自己的程式裡找到一個真的漏洞

今天要讓玩家自己輸入勇者名字。這是遊戲第一次接受「外面來的資料」,而 Day 13 我才剛親手做過一次 XSS——所以這一天原本應該很順利。結果我測出了一個漏洞,而且是我自己寫的。

熱身:還掉昨天的技術債

昨天血量比例的判斷我寫死了 enemy.hp <= 18,並且記下「這是隱形的技術債」。今天先還:

const hero  = { name: "勇者", hp: 100, maxHp: 100, atk: 20, def: 5 };
const enemy = { name: "小怪", hp: 60,  maxHp: 60,  atk: 15, def: 3 };

if (enemy.hp <= enemy.maxHp * 0.3) {   // 不管滿血多少都對

抓輸入框的值:.value

<input type="text" id="nameInput" placeholder="輸入勇者名字" maxlength="10">
<button id="startBtn">出征!</button>
const name = nameInput.value;   // 讀取使用者打的字

<p> 用 textContent,<input> 用 .value。而 .value 讀到的永遠是字串,即使玩家打的是數字。

一個我以為會動、結果不會動的東西

我一開始把測試用的 console.log 寫在 script 最外層,想看輸入的值。結果不管我在輸入框打什麼,它印出來永遠是空字串。

追查之後才發現:寫在 script 最外層的程式碼,只在頁面載入的那一刻執行一次。那時候輸入框當然是空的,而我後來打字,那幾行不會重跑。

寫在哪 什麼時候執行
script 最外層 載入時跑一次,之後永不再跑
事件處理函式裡 每次玩家操作時都跑

這是 Day 11 學到的「程式形狀改變」的另一面。我原本潛意識以為「程式碼寫在那裡就會一直監看」——其實不會。把那幾行搬進 startGame() 之後才拿到即時的值。

又一個 JS 經典怪癖:typeof NaN 是 "number"

搬進去之後,我印出了這幾行:

console.log("字串相加:", nameInput.value + 1, "/ 轉數字再加:", Number(nameInput.value) + 1);
console.log("兩者型別:", typeof (nameInput.value + 1), typeof (Number(nameInput.value) + 1));

輸入「出征」兩個字,結果:

字串相加: 出征1 / 轉數字再加: NaN
兩者型別: string number

插入 day15-2.png:主控台顯示 NaN,以及兩者型別 string / number

Number("出征") 轉不出數字,得到 NaN(Not a Number)。但它的型別是——"number"。

「不是數字」這個值的型別是「數字」。這和 Day 2 那個 typeof null === "object" 是同一家族的荒謬。而 NaN 我在 Day 8 已經遇過一次(它跟任何數字比較都是 false),今天算是正式認識它。

另外還有個很陰險的地方:輸入框空著的時候,"" + 1 的結果是字串 "1",而 Number("") + 1 是數字 1。兩者印出來都是 1,長得一模一樣,但拿去計算差很多:

"1" + 1    // "11"  ← 繼續拼接
 1  + 1    // 2

主控台其實有給線索:數字是藍色的,字串是白色的。但如果不看顏色,這兩個真的分不出來。

我的 QA 時間:把自己的輸入框測到壞掉

程式我故意先不加任何驗證,然後照平常測產品的方式虐它一遍:

測試 結果
什麼都不輸入按出征 畫面變成「勇者 出征!」,名字消失了
只輸入空白鍵 一樣壞掉,但更難察覺
超長字串 全部顯示出來,還會自動分行,排版整個爛掉
emoji 正常顯示
數字 12345 正常變成名字(型別是字串)
<b>粗體</b> 後面詳述
打到一半改名字 按了出征就改成功,已造成的傷害保留

前三個是典型的輸入驗證問題,修法也直接:

const name = nameInput.value.trim();   // 去掉前後空白

if (name === "") {
  logText.textContent = "請先輸入名字!";
  return;
}
if (name.length > 10) {
  logText.textContent = `名字太長了(${name.length} 字),請縮短到 10 字以內`;
  return;
}

.trim() 去掉前後空白,所以「只打空白鍵」會被空值檢查抓到。.length 用在字串上是字數(Day 5 學的是陣列的 length,字串也有)。

插入 day15.png:空輸入時顯示紅色的「請先輸入名字!」

一個很 QA 的細節:HTML 的 maxlength 不可靠

我在 <input> 上加了 maxlength="10",它會直接阻止玩家打超過 10 個字,體驗比「打完才被罵」好。

但它擋不住有心的人:打開 DevTools 把 maxlength 改掉、或直接用 JS 設定 nameInput.value,就繞過了。我自己實測過一次,確實繞得過去。

所以正確做法是兩層都做:maxlength 負責體驗,JS 檢查負責把關。而如果之後真的做了雲端英雄榜,伺服器端還要再檢查一次——因為前端的一切都可以被繞過。這個原則我在工作上聽過,今天第一次自己驗證。

然後我找到了漏洞

Day 13 學到的原則是「使用者輸入一律用 textContent」。我確實照做了:

heroHpText.textContent = `${hero.name} HP: ${hero.hp}`;

測試 <b>粗體</b> 的時候,名字顯示的地方原樣印出標籤,沒有被解析——防線成功。

[插入 day15-3.png:名字顯示處原樣顯示 hl,標籤沒被解析]

我本來以為這樣就過關了。結果按下攻擊按鈕,戰鬥訊息顯示的是:

hl 造成 17 點傷害!

hl 變成粗體了,標籤不見了。標籤被執行了。

插入 day15-4.png:戰鬥訊息裡的 hl 變成粗體,標籤被解析

問題出在這一行——Day 13 我自己寫的:

logText.innerHTML =
  `<b>${hero.name}</b> 造成 <span style="color:red">${damage}</span> 點傷害!`;

hero.name 現在是 <b>hl</b>,塞進去變成 <b><b>hl</b></b>,innerHTML 就把它當標籤解析了。如果我把 maxlength 繞過、輸入 <img src=x onerror="alert(1)">,alert 會從戰鬥訊息這行跳出來。

最讓我在意的不是漏洞,是那行註解

那行程式碼上面,寫著我 Day 13 留下的註解:

這串字完全由我自己寫死,沒有任何使用者輸入,所以 innerHTML 是安全的

這句話今天已經變成謊言了。 Day 13 寫下它的時候是真的——那時 hero.name 是我在程式裡寫死的「勇者」。但今天加了輸入框之後,hero.name 的來源改變了,而那行註解沒有跟著改。

更糟的是:它看起來像是一個已經思考過的安全決策。如果不是我自己測到,下次看到這行的人(包括未來的我)會直接相信它。

從測試的角度看,這種「過期的假設」比單純的 bug 危險得多。程式碼會演進,但註解和假設不會自動更新。

真正的教訓:安全要追蹤資料流,不是看單點

同一份資料(玩家的名字)在我的程式裡有兩個出口:

出口 用什麼 結果
名字顯示 heroHpText textContent 安全
戰鬥訊息 logText innerHTML 漏洞

我只顧著把「輸入的入口」做安全,卻沒回頭問:這份資料後來流到哪裡去了?

這正好是 QA 最核心的能力——追蹤一筆資料經過的所有路徑,而不是只驗證某一個畫面。以前做測試我知道要這樣做,今天第一次在自己寫的程式裡體會到為什麼。

修補:轉義

// 把 HTML 的特殊字元換成「看起來一樣但不會被當標籤」的寫法
function escapeHtml(str) {
  return str.replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}

logText.innerHTML =
  `<b>${escapeHtml(hero.name)}</b> 造成 <span style="color:red">${damage}</span> 點傷害!`;

這個動作叫轉義(escape):&lt; 和 &gt; 是 HTML 裡「小於」和「大於」的特殊寫法,顯示出來還是 < 和 >,但不會被當成標籤的開頭。

原則變成:所有要塞進 innerHTML 的使用者資料,都必須先經過轉義;而 textContent 的地方不需要,因為它天生只當文字。

修好之後同樣輸入 <b>hl</b>:

插入 day15-5.png:修補後,標籤原樣顯示,hl 不再變粗體

<b>hl</b> 造成 17 點傷害!

標籤原樣顯示了。(整串字是粗體,那是我自己寫的外層 <b> 造成的,玩家的標籤已經變成純文字。)

我也把那行說謊的註解改成誠實的版本,直接寫明「Day 13 寫這行時前提還成立,加了輸入框之後就變成漏洞」。註解要記錄的不只是「現在怎樣」,還有「為什麼」——這樣前提改變時才有機會被發現。

順帶一提:這個漏洞其實還有另一種處理方式,不是把它堵起來,而是把它變成一個功能。我留到系列最後一天再說。

最後一個設計決定:中途改名該不該允許?

測試時我發現「打到一半改名字」是可以的,而且已造成的傷害會保留。我最後決定戰鬥開始後鎖定:

let battleStarted = false;

function attack() {
  if (!battleStarted) {
    battleStarted = true;
    nameInput.disabled = true;
    startBtn.disabled = true;
  }
  // ...

理由是名字屬於「開局設定」,不該中途更換。disabled = true 會讓輸入框和按鈕變灰、無法操作——這是昨天學的 style 之外,另一個直接改元素狀態的屬性。

(!battleStarted 的 ! 是「否」:!false 就是 true。)

小結

今天搞懂的事:

  • <input> 用 .value 讀值,而且永遠是字串;要當數字用得先 Number() 轉換
  • 寫在 script 最外層的程式碼只在載入時跑一次,想拿即時的值必須寫在事件函式裡
  • typeof NaN 是 "number";"" + 1 是字串 "1",和數字 1 印出來一模一樣(靠顏色分辨)
  • 輸入驗證:trim() 處理空白、length 限字數;HTML 的 maxlength 只是體驗,可被繞過
  • 同一份資料可能有多個出口,安全要追蹤資料流,不能只看單點
  • 所有進 innerHTML 的使用者資料都要先轉義(&lt; / &gt;)
  • 註解會過期:程式碼演進了,舊註解裡的假設不會自動更新,而它看起來像可信的結論

遊戲現在能自訂名字了。明天要讓傷害不再是固定的 17——加入亂數,讓每一次攻擊都有變化。


上一篇
Day14|讓顏色說故事:白、灰、黑、紅的四階段戰鬥
系列文
QA 的 JavaScript 初心之路:30 天做出一個能玩的戰鬥小遊戲《勇者 vs 小怪》 共 15 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言