Node.js のCSRF対策

Rails の学習を進めていたら、Rails は CSRF 対策が組み込まれていることを学んだのですが、Node.js はどうなっているか調べました。
Express のミドルウェアとしてcsurfが提供されていました。
今回はそれを使ってみます。


目次

参考

環境

  • Node v8.12.0

実装(フォームへ埋め込み)

CSRF トークンをフォームの中に埋め込む場合です。

導入パッケージ

以下のコマンドでパッケージをインストール。

1
npm install express cookie-parser body-parser ejs csurf --save

ソースコード(サーバー)

以下の通り server_form.js を作成します。

src/server_form.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
let cookieParser = require("cookie-parser");
const bodyParser = require("body-parser");
const csrf = require("csurf");
const express = require("express");

const app = express();

app.use(cookieParser());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(csrf({ cookie: true }));

app.set("view engine", "ejs");

//基本のページ
app.get("/", (req, res) => {
res.render("index_form.ejs", { token: req.csrfToken() });
});
//ポスト先
app.post("/post", (req, res) => {
console.log(req.body._csrf);
res.render("ok.ejs", { token: req.body._csrf });
});
//エラー時処理
app.use(function (err, req, res, next) {
//scrfのエラーコードはEBADCSRFTOKENで返ってくる
if (err.code == "EBADCSRFTOKEN") {
//console.log("Redirect")
res.render("ng.ejs", { token: req._csrf, errCode: err.code });
//res.redirect("/")
}
});

const port = 5000;
app.listen(port);
console.log(`Liston port:${port}`);

ソースコード(テンプレート)

今回フォームを送信するページ 1 つ、送信された後のエラー処理でページを 2 つ用意します。

view/index.ejs にはフォームが 2 つ、<input type="hidden" name="_csrf" value="<%= token %>">を含むかが違いです。

view/index_form.ejs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<title>index</title>
</head>

<body>
<section>
<div>
<form action="/post" method="post">
<input type="hidden" name="_csrf" value="<%= token %>">
<input type:"text" name="name" value="田中">
<button>トークンあり送信</button>
</form>
</div>
</section>
<hr>
<section>
<div>
<form action="/post" method="post">
<input type:"text" name="name" value="佐藤">
<button>トークンなし送信</button>
</form>
</div>
</section>
</body>

</html>

view/ok.ejs はチェックが成功したときの画面です。
送られてきたトークンを表示します。

view/ok.ejs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<title>OK</title>
</head>

<body>
<section>
<p>OK - <%- token %><p>
</section>
</body>

</html>

view/ng.ejs はチェックが失敗したときの画面です。
送られてきたトークンとエラーコードを表示します。(実際は表示されません。)

view/ng.ejs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<title>NG</title>
</head>

<body>
<section>
<p>NG - <%- token %><p>
<p>ErrCode : <%- errCode %></p>
</section>
</body>

</html>

確認

node src/server_form.jsで実行し、http://localhost:5000 にアクセスします。
以下の画面が出るはずです。

「トークンあり送信」を押すと views/ok.ejs が表示されます。
送られてきたトークンが確認できます。

「トークンなし送信」を押すと views/ng.ejs が表示されます。
トークンは表示されず、エラーコードが確認できます。

トークンを持っているかでハンドリングができました。
res.render("ng.ejs",{token:req._csrf,errCode:err.code})を、res.redirect("/")とすることで、エラー時にはリダイレクトもできます。


実装(メタタグへ埋め込み)

CSRF トークンを meta タグに埋め込む場合です。

ソースコード(サーバー)

以下の通り server_meta.js を作成します。

src/server_meta.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
let cookieParser = require("cookie-parser");
const bodyParser = require("body-parser");
const csrf = require("csurf");
const express = require("express");

const app = express();

app.use(cookieParser());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(csrf({ cookie: true }));

app.set("view engine", "ejs");

//基本のページ
app.get("/", (req, res) => {
res.render("index_meta.ejs", { token: req.csrfToken() });
});

//ポスト先
app.post("/post", (req, res) => {
res.json({ message: "OK", token: req.headers["csrf-token"] });
});

//エラー時処理
app.use(function (err, req, res, next) {
//scrfのエラーコードはEBADCSRFTOKENが返ってくる
if (err.code == "EBADCSRFTOKEN") {
res.status(401).json({ message: "NG" });
}
});

const port = 5000;
app.listen(port);
console.log(`Liston port:${port}`);

ソースコード(テンプレート)

今回は、フォームを送信するページ 1 つ用意します。

view/index_meta.ejs にはボタンが 2 つ、呼び出した処理の中で、csrf トークンを加工するかが違いです。

view/index_meta.ejs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<title>index</title>
<meta name="csrf-token" content="<%= token %>">
<script>

const sendWithToken=()=>{
const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content')
const value = document.querySelector('input[name="name1"]').getAttribute('value')
fetch(`/post`,{
credentials:'include',
headers: {
'CSRF-Token': token,
},
method:'POST',
body:{
name:value
},
cache:"no-cache"
}).then((res)=>{
json = res.json()
return json
}).then((body)=>{
if(body.message!="OK"){
throw(body.message)
}
document.querySelector('div[id="response"]').textContent=body.token
})
.catch((err)=>{
console.error(err)
document.querySelector('div[id="response"]').textContent=err
})
}

const sendNoToken=()=>{
const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content')
const value = document.querySelector('input[name="name1"]').getAttribute('value')
fetch(`/posts`,{
credentials:'include',
headers: {
//トークンに文字列を追加してみる、いうなればこれは改ざん
'CSRF-Token': `${token}123`,
},
method:'POST',
body:{
name:value
},
cache:"no-cache"
}).then((res)=>{
return res.json()
}).then((body)=>{
if(body.message!="OK"){
throw(body.message)
}
document.querySelector('div[id="response"]').textContent=body.token
})
.catch((err)=>{
console.error(err)
document.querySelector('div[id="response"]').textContent=err
})
}

</script>
</head>

<body>
<section>
<div>
<input type:"text" name="name1" value="田中">
<button type="button" onclick="sendWithToken()">トークンあり送信</button>
</div>
</section>
<hr>
<section>
<div>
<input type:"text" name="name2" value="佐藤">
<button type="button" onclick="sendNoToken()">トークンなし送信</button>
</div>
</section>
<section>
<div id="response">
</div>
</section>
</body>

</html>

確認

node src/server_form.jsで実行し、http://localhost:5000 にアクセスします。
以下の画面が出るはずです。

「トークンあり送信」を押すと message にOKが返ってきます。
一緒に送信したトークンがレスポンスとして返ってくるので、表示します。

「トークンあり送信」を押すと message にNGが返ってきます。

トークンを持っているかでレスポンスをハンドリングできました。


実装(Cookies にトークンを埋め込み)

これまでは、CSRF トークンを HTML の中忍ばせてきました。
今度は CSRF トークンを HTML の中に含めず Cookie の中に含める場合を作成してみます。

参考

ソースコード

以下の通り server_cookies.js を作成します。
ポイントは、res.cookie('CSRF-TOKEN',req.csrfToken())の部分です。
この処理で CSRF トークンがクライアントのクッキーに設定します。

src/server_cookies.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
let cookieParser = require("cookie-parser");
const bodyParser = require("body-parser");
const csrf = require("csurf");
const express = require("express");

const app = express();

app.use(cookieParser());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(csrf({ cookie: true }));

app.set("view engine", "ejs");

//基本のページ
app.get("/", (req, res) => {
res.cookie("CSRF-TOKEN", req.csrfToken());
res.render("index_cookies.ejs");
});

//ポスト先
app.post("/post", (req, res) => {
res.json({ message: "OK", token: req.headers["csrf-token"] });
});

//エラー時処理
app.use(function (err, req, res, next) {
//scrfのエラーコードはEBADCSRFTOKENが返ってくる
if (err.code == "EBADCSRFTOKEN") {
res.status(401).json({ message: "NG" });
}
});

const port = 5000;
app.listen(port);
console.log(`Liston port:${port}`);

ソースコード(テンプレート)

今回も、フォームを送信するページ 1 つ用意します。

view/index_cookies.ejs にはボタンが 2 つ、呼び出した処理の中で、csrf トークンを heders に含めるかが違いです。
getCookie(name)は、参考にしたサイトを真似ています。

views/index_cookies.ejs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<title>index</title>
<script>
function getCookie(name) {
if (!document.cookie) {
return null;
}

const cookie = document.cookie.split(';')
.map(c => c.trim())
.filter(c => c.startsWith(name + '='));

if (cookie.length === 0) {
return null;
}

return decodeURIComponent(cookie[0].split('=')[1]);
}

const sendWithToken=()=>{
const csrfToken = getCookie('CSRF-TOKEN');
const value = document.querySelector('input[name="name1"]').getAttribute('value')
fetch(`/post`,{
credentials:'include',
method:'POST',
headers:{
'Content-Type': 'x-www-form-urlencoded',
'CSRF-TOKEN': csrfToken
},
body:{
name:value
},
cache:"no-cache"
}).then((res)=>{
json = res.json()
return json
}).then((body)=>{
if(body.message!="OK"){
throw(body.message)
}
document.querySelector('div[id="response"]').textContent=body.token
})
.catch((err)=>{
console.error(err)
document.querySelector('div[id="response"]').textContent=err
})
}

const sendNoToken=()=>{
const value = document.querySelector('input[name="name1"]').getAttribute('value')
fetch(`/posts`,{
credentials:'include',
method:'POST',
headers:{
'Content-Type': 'x-www-form-urlencoded',
},
body:{
name:value
},
cache:"no-cache"
}).then((res)=>{
return res.json()
}).then((body)=>{
if(body.message!="OK"){
throw(body.message)
}
document.querySelector('div[id="response"]').textContent=body.token
})
.catch((err)=>{
console.error(err)
document.querySelector('div[id="response"]').textContent=err
})
}

</script>
</head>

<body>
<section>
<div>
<input type:"text" name="name1" value="田中">
<button type="button" onclick="sendWithToken()">トークンあり送信</button>
</div>
</section>
<hr>
<section>
<div>
<input type:"text" name="name2" value="佐藤">
<button type="button" onclick="sendNoToken()">トークンなし送信</button>
</div>
</section>
<section>
<div id="response">
</div>
</section>
</body>

</html>

確認

node src/server-_cookies.js で実行し、http://localhost:5000 にアクセスします。
以下の画面が出るはずです。

「トークンあり送信」を押すと message に OK が返ってきます。
一緒に送信したトークンがレスポンスとして返ってくるので、表示します。

「トークンなし送信」を押すと message に NG が返ってきます。

トークンを Cookie に入れたので、POST リクエストに Cookie を含めるだけでいいのかと思って試したら、ダメでした。
リクエストのcookiesの中にトークンが含まれていることが確認できますが、サーバ処理で弾かれてしまします。

この段階で参考にしたサイトの記述を見つけました。
headers に Cookie から取得したトークンを当ててやる事で解決しました。


今回は、Node.js 環境での CSRF 対策について確認しました。
Rails だと標準で提供されるものを、改めて他の環境でどう実装するのか見直す事は全体の学びを深められると思いました。

ではでは。