概要
みなさんこんにちはcandleです。今回はReactで許可したhtmlタグだけを表示し、それ以外のタグは削除するプログラムを書いてみます。
注意、本来ユーザーなどが投稿した内容をそのまま表示させるのはXSSなどのセキュリティ上危険になる可能性があります。
また、私自身も1プログラマでセキュリティの専門家では無いのでコードにバグがあったりする可能性があります。もちろん、検証し、十分にテストはしていますが、もしもこの記事を閲覧している方でコードの脆弱性を発見された方がおりましたらコメント欄で指摘していただけると助かります。
前提
なし
サンプルの準備
もしも、すでにReactプロジェクトがある方はそちらを、無い方はサンプルプロジェクトを作って見てください
create-react-app htmltag_sample cd htmltag_sample
準備が整いました。
実装
適当なコンポーネントファイルを開きます。サンプルコードを実践する方はsrc/App.js
を開きます。
最初に、ユーザーの投稿をテキストエリアから読み込み、表示するコードを記述します。
import React, { Component } from 'react' class App extends Component { constructor(props) { super(props) this.state = { content: '', } this.handleFormInputChanged = this.handleFormInputChanged.bind(this) } handleFormInputChanged(event) { this.setState({ [event.target.name]: event.target.value, }) } render() { return ( <div style={{ textAlign: 'center' }}> <div>サンプル</div> <textarea name="content" value={this.state.content} onChange={this.handleFormInputChanged} style={{ width: '400px', height: '300px', fontSize: '15px', }} /> <div style={{ marginTop: '10px' }}>{this.state.content}</div> </div> ) } } export default App
こうすると、テキストエリアで記述した内容が表示されます。
しかし、見た所、pタグがそのまま表示されています。これではタグ許可云々の前にタグが表示できないので、dangerouslySetInnerHTML
を使います。しかしこれはXSSの脆弱性を持っています。
コードを変更しましょう。
<div style={{ marginTop: '10px' }}>{this.state.content}</div>
をこうします。
<div dangerouslySetInnerHTML={{ __html: this.state.content }} />
これはこの記事によると<script>
タグのXSSの対処はできています。
https://github.com/facebook/react/issues/8838
<a>
や<iframe>
のXSSは対応していません。
テキストエリアに試しに記述して見てください。
<script>alert("1");</script>
スクリプトタグは問題ないです。
aタグのXSSは機能してしまいます。
<a onmouseover="alert(1)">XSS</a>
iframeも同様に発動します。
<iframe src="javascript:alert('1');"></iframe>
指定したタグ以外削除
最初に指定したタグ以外を削除する関数を作ります。プログラムのベースはこちらの記事を参考にさせていただきました。これを、コンポーネントのどこかに書きましょう。
strip_tags(input, allowed) { allowed = (((allowed || '') + '').toLowerCase().match(/<[a-z][a-z0-9]*>/g) || []).join('') const tags = /<\/?([a-z][a-z0-9]*)\b[^>]*>/gi return input.replace(tags, ($0, $1) => (allowed.indexOf('<' + $1.toLowerCase() + '>') > -1 ? $0 : '')) }
使い方はrender関数内で呼び出しそのままdangerouslySetInnerHTML
に投げます。
<div style={{ marginTop: '10px' }}> <div dangerouslySetInnerHTML={{ __html: this.strip_tags(this.state.content, '<a>') }} /> </div>
上の例ではaタグは許可してそれ以外は削除しています。
試しに動かして、こんな内容を書いて見ましょう。
<iframe src="javascript:alert('XSS');">hello</iframe> <h1>H1 tag</h1> <a href="https://www.google.com" target="_blank">google</a>
許可されたタグだけ表示されていますね。
しかし、これもいくつか問題があります。許可されたタグであればonEventやsytleが実行できてしまいます。そこで次にこういったtagのプロパティを削除する関数を作ります。
<a onmouseover="alert(document.cookie)">XSS</a>
タグのプロパティの削除と許可
上で見た通り、タグを許可するだけだと、悪意のあるユーザにonmouseover="alert(document.cookie)"
やstyle="font-size: 800px"
などで攻撃される可能性があります。
この関数を追加します。
strip_properties(input, allowed) { allowed = (((allowed || '') + '').toLowerCase().match(/[a-z][a-z0-9]*/g) || []).join('') const properties = /\s([a-z][a-z0-9]*)="[^"]*"/gi return input.replace(properties, ($0, $1) => (allowed.indexOf($1.toLowerCase()) > -1 ? $0 : '')) }
使うときはこのようにします。 aタグのみを許可しているのでhref
とtarget
プロパティを許可しています。
<div style={{ marginTop: '10px' }}> <div dangerouslySetInnerHTML={{ __html: this.strip_properties(this.strip_tags(this.state.content, '<a>'), ['href', 'target']), }} /> </div>
先ほどのXSSコードを試しても問題なさそうです。
<script>alert("1");</script> <a onmouseover="alert(1)">XSS</a> <iframe src="javascript:alert('1');"></iframe>
完成版サンプルコード
最後にサンプルコードを記述しておきます。
import React, { Component } from 'react' class App extends Component { constructor(props) { super(props) this.state = { content: '', } this.handleFormInputChanged = this.handleFormInputChanged.bind(this) } handleFormInputChanged(event) { this.setState({ [event.target.name]: event.target.value, }) } strip_properties(input, allowed) { allowed = (((allowed || '') + '').toLowerCase().match(/[a-z][a-z0-9]*/g) || []).join('') const properties = /\s([a-z][a-z0-9]*)="[^"]*"/gi return input.replace(properties, ($0, $1) => (allowed.indexOf($1.toLowerCase()) > -1 ? $0 : '')) } strip_tags(input, allowed) { allowed = (((allowed || '') + '').toLowerCase().match(/<[a-z][a-z0-9]*>/g) || []).join('') const tags = /<\/?([a-z][a-z0-9]*)\b[^>]*>/gi return input.replace(tags, ($0, $1) => (allowed.indexOf('<' + $1.toLowerCase() + '>') > -1 ? $0 : '')) } render() { return ( <div style={{ textAlign: 'center' }}> <div>サンプル</div> <textarea name="content" value={this.state.content} onChange={this.handleFormInputChanged} style={{ width: '400px', height: '300px', fontSize: '15px', }} /> <div style={{ marginTop: '10px' }}> <div dangerouslySetInnerHTML={{ __html: this.strip_properties(this.strip_tags(this.state.content, '<a>'), ['href', 'target']), }} /> </div> </div> ) } } export default App
まとめ
どうでしょうか?
もしも、問題等があればコメント欄で報告お願いします。