はじめに
Node.jsのExpressでテンプレートエンジンejsを使って実装するWebアプリを実例に、XXS攻撃を受ける脆弱性がある状態と対策を講じた場合の実装を見ていく事で、XXS攻撃について理解を深めてみようと思う。
XXS(クロスサイト・スクリプティング)攻撃とは?
Webアプリケーションにスクリプト等を埋め込むことが可能な状態になっている=脆弱性がある時に、これを利用されて利用者のブラウザ上で不正なスクリプトが実行されてしまう可能性がある。
そのスクリプト等を埋め込むような攻撃をXXS(クロスサイト・スクリプティング)攻撃という。
詳細は以下のサイトを参照。
以下ではNode.jsのExpressでテンプレートエンジンejsを使った実装を例に、実際に脆弱性がある実装をやってみて、脆弱性がある時どのような事が起きるのか?またそれを防ぐためにどうするのか?をみていく。
見ていく内容としては、IPAのサイトに書かれている対策の一覧に書かれているものを順番に見ていくが、いくつか一般的なWebアプリでは実装する事は少なさそうなものは取り上げていない。
取り上げる内容
HTMLテキストの入力を許可しない場合の対策
【根本的解決】ウェブページに出力する全ての要素に対して、エスケープ処理を施す
【根本的解決】URLを出力するときは、「http://」や 「https://」で始まるURLのみを許可する
【根本的解決】<script>...</script> 要素の内容を動的に生成しない
【保険的対応】入力値の内容チェックを行う
全てのウェブアプリケーションに共通の対策
【根本的解決】HTTPレスポンスヘッダのContent-Typeフィールドに文字コード(charset)を指定する
【保険的解決】Cookie情報の漏えい対策として、発行するCookieにHttpOnly属性を加え、TRACEメソッドを無効化する
【保険的解決】クロスサイト・スクリプティングの潜在的な脆弱性対策として有効なブラウザの機能を有効にするレスポンスヘッダを返す
今回は取り上げない内容
HTMLテキストの入力を許可しない場合の対策
【根本的解決】スタイルシートを任意のサイトから取り込めるようにしない
HTMLテキストの入力を許可する場合の対策
【根本的解決】入力されたHTMLテキストから構文解析木を作成し、スクリプトを含まない必要な要素のみを抽出する
【保険的対応】入力されたHTMLテキストから、スクリプトに該当する文字列を排除する
【根本的解決】ウェブページに出力する全ての要素に対して、エスケープ処理を施す
未対策だとどうなるか?
まずは、仮にこの対応しなかった場合どうなるか?だが、以下の動画のように
クリックした時にJavaScriptを実行するようなリンク
scriptタグそのもの
を仕込む事ができてしまう。
動画のようになる場合の実装は以下。
regist-confirm.ejs
<body>
...
<div class="row mb-3">
<label for="description" class="col-sm-2 col-form-label">
XXS攻撃を受けるパターン
</label>
<div class="col-sm-10"><%- review.description %></div>
</div>
<div class="row mb-3">
<label for="description" class="col-sm-2 col-form-label">
XXS攻撃を受けないパターン
</label>
<div class="col-sm-10"><%= review.description %></div>
</div>
...
</body>
ソースコード全体は以下から参照。
対策を講じた場合どうなるか?
IPAが対応すべきと言っているように全てにエスケープ処理を施すをやってみる。が、実は未対策だとどうなるかで取り上げた実装の中にもエスケープをしている実装は既に入っており、
<div class="col-sm-10"><%= review.description %></div>
というのがエスケープをした時の実装。IPAのサイトにも書いてあるようにエスケープをすると、
特別な記号文字(「<」、「>」、「&」等)を、HTMLエンティティ(「<」、「>」、「&」等)に置換する
という事が行われるので、HTMLの要素(<a>や<script>など)として扱われず単なる文字列になり無害化される。これによりXXS攻撃を防げる。
※ejsにおけるエスケープの実装方法は、Embedded JavaScript templates Featuresを参照。<%= %>はエスケープする書き方で、<%- %>はエスケープしない書き方。
※HTMLエンティティについてはEntity (エンティティ)を参照。
※今回は特に扱わなかったが、属性値を動的に生成する場合、その属性値は必ず「"」(ダブルクォート)で括る+「"」で括られた属性値に含まれる「"」をHTMLエンティティ「"」にエスケープするという事も必須の対応になる。もしこれをしないと、以下のようにdataが" onclick="悪意のあるスクリプトだった場合、
<input type="button" value="<%- data %>">
↓
<input type="button" value="" onclick="悪意のあるスクリプト">
というように、悪意のあるスクリプトを埋め込まれたりする。ちゃんと対策(エスケープ)をしていれば、" onclick="悪意のあるスクリプト→" onclick="悪意のあるスクリプトのようになるので無害化できる。(フロントエンドのフレームワークVue.jsだと属性のバインディングに書かれているように、フレームワークでエスケープ処理をしてくれたりする場合もある)。
※SPAの世界(React、Vue、Angularなどで実装している場合)では、昔のようにサーバに値を送り、サーバ側でデータをHTMLに埋め込んでそれを描画するという事をしないのであまり問題にはならないと思われるが、例えばVueで言えばセキュリティに書かれているような事を守らないと、XXS攻撃を受けるので注意が必要だろう。
Vue.jsではユーザの入力をそのままHTMLに描画する以下のような実装をすると脆弱性が生まれる。
new Vue({
el: '#app',
template: `<div>` + userProvidedString + `</div>` // 絶対にしてはいけない
})
参考:実際にサンプルページで確認してみた!クロスサイトスクリプティング(XSS)の危険性!
参考:クロスサイトスクリプティング(XSS)
【根本的解決】URLを出力するときは、「http://」や 「https://」で始まるURLのみを許可する
未対策だとどうなるか?
ここでもまずは脆弱性がある状態を実装してみる。URLを登録できるフォームがあるとして、何も対策をしなかった場合どうなるか?だが、以下の動画のようにURLとしてJavaScriptを仕込む事ができてしまう。このリンクをクリックすると、動画のようにJavaScriptが実行されてしまう。
動画のようになる場合の実装は以下。
regist-form.ejs
<body>
...
<div class="row mb-3">
<label for="url" class="col-sm-2 col-form-label">URL</label>
<div class="col-sm-10">
<input
type="url"
class="form-control"
id="url"
name="url"
autocomplete="off"
pattern="https://.*"
value="<%= url %>"
/>
</div>
</div>
...
</body>
regist-confirm.ejs
...
<div class="row mb-3">
<label for="url" class="col-sm-2 col-form-label">URL</label>
<div class="col-sm-10">
<a href="<%= url %>"> <%= url %> </a>
</div>
</div>
...
account.reviews.js
const createReviewData = (req) => {
const { shopId, visit, score, description } = req.body;
const date = moment(visit, DATE_FORMAT);
const review = {
shopId,
score: parseFloat(score),
visit: date.isValid() ? date.toDate() : null,
post: new Date(),
description
};
return review;
};
const validate = (req) => {
const { visit, description } = req.body;
const error = {};
if (!visit) error.visit = '訪問日は必須です。';
else if (moment(visit, DATE_FORMAT).isAfter(moment(new Date())))
error.visit = '訪問日を未来の日付にする事はできません。';
if (!description) error.description = '本文は必須です。';
return error;
};
router.post('/regist/confirm', async (req, res) => {
const error = validate(req);
const { shopId, shopName, url } = req.body;
const review = createReviewData(req);
if (Object.keys(error).length !== 0) {
res.render('./account/reviews/regist-form.ejs', {
error,
shopId,
shopName,
review,
url
});
}
res.render('./account/reviews/regist-confirm.ejs', {
shopId,
shopName,
review,
url
});
});
ソースコード全体は以下から参照。
今回はエスケープをしていてもXXS攻撃は防ぐことはできない。また、<input type="url">に書かれているように、pattern="https://.*"のように入力値にvalidationをかける事もできるが、これは動画のように簡単に書き換え可能なので対策としては完全ではない。
対策を講じた場合どうなるか?
IPAが対応すべきと言っているように、URLを出力する際にはhttpやhttpsに限定化する対策を見ていく。これはサーバサイド側で既に実装されているvalidate()関数を拡張すれば対応できる。つまり、このvalidate()でURLを検証し、http・httpsではないもの(javascriptなど)であればエラーを返すという処理を実装すればいい。
具体的には以下のような実装になる。今回はvalid-urlを利用した。
account.reviews.js
import { isHttpUri, isHttpsUri } from 'valid-url';
const validate = (req) => {
const { visit, description, url } = req.body;
...
if (!isHttpUri(url) && !isHttpsUri(url)) error.url = 'URLが不正です。'; // ←これを追加
return error;
};
このような実装をすれば以下の動画のように不正なURLを受け付けないように実装でき、URLを出力するときはhttp or httpsからのものに限定できので、XXS攻撃を防ぐことができる。
【根本的解決】<script>...</script> 要素の内容を動的に生成しない
未対策だとどうなるか?
脆弱性がある状態を実装してみる。入力された文字列に応じて動的にJavaScriptが変わるような実装をしている時、何も対策をしなかった場合どうなるか?だが、以下の動画のように入力文字を</script><script>alert(1) //のようにする事で、scriptタグの部分が閉じた事になり、攻撃者が実行したいとしてJavaScriptを仕込む事ができてしまう。
動画のようになる場合の実装は以下。
regist-confirm.ejs
<body>
...
<div class="row mb-3">
<label for="description" class="col-sm-2 col-form-label">
本文
</label>
<div class="col-sm-10">
<textarea
readonly
class="form-control"
id="description"
name="description"
rows="5"
>
<%= (review.description || '') %>
</textarea
>
<div id="text-counter"></div>
</div>
</div>
<script>
var div = document.getElementById('text-counter');
var txt = "<%- (review.description || '') %>";
div.textContent = `${txt}の文字数は${txt.length}文字です`;
</script>
...
</body>
ソースコード全体は以下から参照。
対策を講じた場合どうなるか?
これは『<script>...</script> 要素の内容を動的に生成しない』が対策になるので上記で取り上げたような実装をしない事が対策になる。
つまり、今回で言えばサーバサイドでreview.descriptionに表示される内容の文字数をカウントし、その数値を<%= %>でエスケープ処理をした上でHTML上に埋め込むという事をするべき。
※今回の実装で言えば、scriptタグ内でエスケープ処理をすれば、</script><script>alert(1) //が</script><script>alert(1) //のように無害化されるのでいいように思えるが、リスクを考えると、IPAに書かれているようにscriptを動的に生成しないようにすべきだろう。
【根本的解決】スタイルシートを任意のサイトから取り込めるようにしない
こちらについては実例は取り上げないが、「スタイルシートを任意のサイトから取り込めるように」について少し見ていく。
cssではexpression()を使ってJavaScriptを書けるが、このような実装を利用し、任意のサイトに置かれたスタイルシートを取り込めるような設計をしないようにする。
width: expression ( document.body.clientWidth < 700 ? "700px" : "auto");
min-width: 700px;
ちなみに、上記は
ユーザーが利用しているブラウザの画面幅が700px未満の場合:width :700px
ユーザーが利用しているブラウザの画面幅が700px以上の場合:width :auto
にするというexpression関数。
※expression()関数を利用せずとも、以下のように<head></head>内で外部からstylesheetを読み込んだり、<script></script>でJavaScriptを読み込んだりする実装ができるが、これも任意のサイトからの取り込みと言えばそうであり、その中に悪意のあるものが混じらないとも限らないのでこうした実装にも実は注意が必要なのかもしれない。なのでwebpackのようなモジュールバンドラを使い、CDNで取り込むのではなくbuildして成果物が固定化されるようにする必要があるだろう。buildをしておけば少なくとも何か悪意のあるものが混入していても追跡可能にはなる。(実際、悪意のあるものが仕込まれたわけではないが、OSS「faker.js」と「colors.js」の開発者、自身でライブラリを意図的に改ざん 「ただ働きはもうしない」のような事も現実に発生しており、注意が必要だろう)。
<link
rel="stylesheet"
href="https://use.fontawesome.com/releases/v5.14.0/css/all.css"
/>
...
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
crossorigin="anonymous"
>
</script>
【保険的対応】入力値の内容チェックを行う
ここも詳細な実例は取り上げないが、どういうことか少し見ていく。この対策はあくまで保険的対応となり、根本的解決にならないので注意。
この対策はURLを出力するときは、「http://」や 「https://」で始まるURLのみを許可するで取り上げたようなvalidation(入力値の制限)をする方法。ただ、HTML上の属性値でvalidationをするような古典的な方法は書き換えできてしまうので無意味になる。また、フロントエンドのJavaScriptで入力値のチェックを行う事もできるが、IPAのサイトに書かれている通り、完全とは言えない(らしい)。
※一応具体的な実装も上げておくと、例えばemailか?の判定であれば、以下のように正規表現でチェックするとかが入力値の内容チェックにあたる。
const el = document.querySelector(`#email`);
if (/[\w\d_-]+@[\w\d_-]+\.[\w\d._-]+/.test(el.value)) {
...
}
HTMLテキストの入力を許可する場合の対策について
HTMLテキストそのもの(<h1>The Crushing Bore</h1>とか、<p>By Chris Mills</p>など)の入力を受け付けるWebアプリ自体が少ない気もするのでこのTopicについては本記事では省略する。
参考:HTML テキストの基礎
【根本的解決】HTTPレスポンスヘッダのContent-Typeフィールドに文字コード(charset)を指定する
未対策だとどうなるか?
具体的に実装として見対策パターンは取り扱わないが、IPAのサイトに書かれているようにJavaScriptが実行されてしまう危険がある。
たとえば、具体的な例として、HTMLテキストに、 「+ADw-script+AD4-alert(+ACI-test+ACI-)+ADsAPA-/script+AD4-」という文字列が埋め込まれた場合が考えられます。この場合、一部のブラウザはこれを「UTF-7」の文字コードでエンコードされた文字列として識別します。これがUTF-7として画面に表示されると 「」として扱われるため、スクリプトが実行されてしまいます。
対策を講じた場合どうなるか?
WebアプリでHTMLを出力する際に想定した文字コードでHTMLがブラウザに解釈されるため、上記でIPAのサイトから引用したような+ADw-script+AD4-alert(+ACI-test+ACI-)+ADsAPA-/script+AD4-が<script>alert('test');</script>として解釈され、XXS攻撃を受けるという事がなくなる。
実際の実装はどうなるのか?だが、Expressであればres.render(view [, locals] [, callback])でHTMLを返す(テンプレートエンジンでHTMLを生成してresponse bodyにセットする)を行えば、自動的にContent-Type: text/html; charset=utf-8になるので特に追加で実装が必要な事はない
※自動的にContent-Typeが設定されると言ったが、それはres.renderが実装されているresponse.jsで、(selfはthisなので)res.send()が実行されているが、res.sendの実装を見ると、こことここでレスポンスbodyがstringの場合(HMTLもstring)、Content-Typeをtext/htmlに設定し、かつcharset=utf-8にする実装がされている。
※3.x系では自分でContent-Typeのcharsetを設定できる関数が実装されていたが、Expressは4.x系になってからres.charset()がなくなったので注意(Other changesを参照)。
参考:第1回 UTF-7によるクロスサイトスクリプティング攻撃[前編]
参考:第2回 UTF-7によるクロスサイトスクリプティング攻撃[後編]
【保険的対応】Cookie情報の漏えい対策として、発行するCookieにHttpOnly属性を加え、TRACEメソッドを無効化する
未対策だとどうなるか?
以下の画像のように、簡単にJavaScriptからCookieの情報を抜き取れてしまう(このような状態だと、上記で見てきたような脆弱性があり、JavaScriptを実行されてしまうと、Cookieが外部に流出し、これがログインセッションに使われているのであればなりすましが成功してしまう)。
上記のようになる場合の実装は以下。
app.js
app.use(cookie());
app.use((req, res, next) => {
const {
cookies: { message }
} = req;
console.log(message);
res.cookie('message', 'hello world!');
next();
});
ソースコード全体は以下から参照。
対策を講じた場合どうなるか?
Expressであれば、res.cookie()にオプションが設定できるので、それをすればいい。実装としては以下のように変えるだけ。
app.use((req, res, next) => {
...
res.cookie('message', 'hello world!', { httpOnly: true });
...
});
参考:Cookie へのアクセス制限
参考:Document.cookie
参考:res.cookie(name, value [, options])
【保険的対応】クロスサイト・スクリプティングの潜在的な脆弱性対策として有効なブラウザの機能を有効にするレスポンスヘッダを返す
未対策だとどうなるか?
具体的に未対策の時の例は自明(上記のような脆弱性があった場合にXXS攻撃を受けやすくなる)なので取り上げないが、ブラウザに備わっているXXS(クロスサイト・スクリプティング)攻撃のブロックを試みる機能がユーザ設定により無効になる事があり、その場合利用者によってはXXS攻撃を受けやすくなってしまう。
対策を講じた場合どうなるか?
ExpressでIPAの資料通りに対応するのであれば以下のようにすればいい。
...
app.use((req, res, next) => {
res.set('X-XSS-Protection', '1; mode=block');
res.set('Content-Security-Policy', 'reflected-xss block');
next();
});
...
app.use('/public', express.static(appRoot.resolve('src/public')));
...
app.use('/', router);
...
※ポイントはHTMLを返す処理よりも上にmiddlewareを設定する事。そうしないとHTMLが返るような時全てで適用されない。詳細はapp.use([path,] callback [, callback...])を参照。
Middleware functions are executed sequentially, therefore the order of middleware inclusion is important.(ミドルウェアの機能は順次実行されるため、ミドルウェアを組み込む順番は重要です)
※今回はIPAの資料通りに対策を実装したが、実はこの辺りは議論があるようで特にX-XSS-Protectionはres.setHeader("X-XSS-Protection", "0");のように設定すべきと言われている。具体的にどういうことか見ていくと、まずExpressにはProduction Best Practices: Securityというページがあり、そこにはHelmetの利用について書かれている(Use Helmet)。そのHelmetのReferenceを読むと、helmet.xssFilter()という部分があり、ここには、
helmet.xssFilter disables browsers' buggy cross-site scripting filter by setting the X-XSS-Protection header to 0. See discussion about disabling the header here and documentation on MDN.(helmet.xssFilterは、X-XSS-Protectionヘッダを0に設定することで、ブラウザのバグであるクロスサイトスクリプティングフィルタを無効にします。 ヘッダの無効化に関する議論はこちらとMDNのドキュメントを参照してください。)
と書かれており、リンク先のX-XSS-Protection: header should be disabled by defaultを読んでみると、要は「XSSフィルタを利用することで、むしろ悪用されてXS-Leak攻撃を引き起こす危険性があるので無効化した方がいい」という事らしい。なので、helmetの実装としては、
import * as helmet from "helmet";
...
app.use(helmet.xssFilter());
となっている。
※Content-Security-Policy: reflected-xss blockだが、Chromeだと以下のようにエラーになるため設定できかった。では一体何を設定すべきか?だが、それはちょっと分からなかったので今後の課題。。。
参考:X-XSS-Protection
参考:Content-Security-Policy
参考:Express×Helmetでウェブセキュリティを学ぶ
まとめとして
XXS攻撃に関して、攻撃の実例とその対策となる実装を見る事で理解を深める事ができたと思う。ただ、Content-Security-Policy等で不明な部分もあるので今後そこの理解もしていきたいと思う。
おまけ
helmetでContent-Security-Policyを設定した時、なぜかhttpからhttpsでローカルのファイルを取りに行くようになってしまった…。何でこんなことになるのか?ご存知の方いましたらご教示頂けると幸いです。
helmetの設定は以下です。
app.use(
helmet.contentSecurityPolicy({
useDefaults: true,
directives: {
'script-src': ["'self'", 'https://cdn.jsdelivr.net'],
'style-src': ["'self'", 'http:', 'https:', "'unsafe-inline'"]
}
})
);
↧