今天要讓玩家自己輸入勇者名字。這是遊戲第一次接受「外面來的資料」,而 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() 之後才拿到即時的值。
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

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
主控台其實有給線索:數字是藍色的,字串是白色的。但如果不看顏色,這兩個真的分不出來。
程式我故意先不加任何驗證,然後照平常測產品的方式虐它一遍:
| 測試 | 結果 |
|---|---|
| 什麼都不輸入按出征 | 畫面變成「勇者 出征!」,名字消失了 |
| 只輸入空白鍵 | 一樣壞掉,但更難察覺 |
| 超長字串 | 全部顯示出來,還會自動分行,排版整個爛掉 |
| 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,字串也有)。

我在 <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 變成粗體了,標籤不見了。標籤被執行了。

問題出在這一行——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("<", "<").replaceAll(">", ">");
}
logText.innerHTML =
`<b>${escapeHtml(hero.name)}</b> 造成 <span style="color:red">${damage}</span> 點傷害!`;
這個動作叫轉義(escape):< 和 > 是 HTML 裡「小於」和「大於」的特殊寫法,顯示出來還是 < 和 >,但不會被當成標籤的開頭。
原則變成:所有要塞進 innerHTML 的使用者資料,都必須先經過轉義;而 textContent 的地方不需要,因為它天生只當文字。
修好之後同樣輸入 <b>hl</b>:

<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() 轉換typeof NaN 是 "number";"" + 1 是字串 "1",和數字 1 印出來一模一樣(靠顏色分辨)trim() 處理空白、length 限字數;HTML 的 maxlength 只是體驗,可被繞過innerHTML 的使用者資料都要先轉義(< / >)遊戲現在能自訂名字了。明天要讓傷害不再是固定的 17——加入亂數,讓每一次攻擊都有變化。