はじめに
これは一部のHTMLタグだけ許可する(正規表現編)の続編で、正規表現でCKEditer5 classicが吐き出すHTMLタグを許可してみようという試みである。
対象となるWebアプリケーションの構造
Webアプリケーションの管理画面などにWebページの(お知らせやニュースやFAQなどの)編集画面があるようなWebアプリケーション
その際に文章を装飾するjsライブラリ(リッチテキストライブラリ)からはHTML形式のデータがWebアプリケーションに送られるわけで、ここで不正行為者はHTML形式のデータを改変できる場合、そのデータを表示する際にXSS脆弱性の可能性が出てくるので、なにかしらの対策が必要になる。というのが本文書のテーマである。
注目するべき箇所は、
- 文章の装飾が可能なjsライブラリから受け取ったデータを、編集のために再度jsライブラリに読みこせる(①)
- 文章の装飾が可能なjsライブラリから受け取ったデータを、(お知らせなどの)WebページにHTMLの一部として埋め込む(②)
の2か所のXSS対策について考えてみる
①編集のために呼び戻される箇所
上図の「①」の箇所だが、基本的には通常のHTMLエンコードで問題ないはずだ。
後述するサニタイズ処理(無害化処理)したデータを(さらにHTMLエンコードして)送り出してもよいが、基本的には通常のHTMLエンコードで問題ないはずだ
もし、通常のHTMLエンコードを施したデータでXSSが発現するのであれば、それはjsライブラリの脆弱性であり、jsライブラリを脆弱性のない最新バージョンに置き換える必要がある
②(装飾された文章として)HTMLの一部として埋め込む箇所
上図の「②」の箇所。本文書のメインテーマです。
通常のHTMLエンコードでは、文章の装飾も全てエンコードされてしまうので、使えない。
ということで、どうしましょうか。というのが、本文書のテーマとなるわけです
アーキテクチャ(基本方針)
こんな感じ
- 全体をHTMLエンコードしておく
- 許可タグだけ、HTMLデコードで、元のタグに戻していく
- 1でHTMLエンコードしてしまったので、無駄に多重にエンコードしている箇所を見つけて多重エンコード処理をほどく
こうすれば、許可タグとして認識できる文字列だけ、ホワイトリスト方式の考え方で、元のタグに戻るので、想定していないタグが有効になるなどの予想もしていない脆弱性は生まれないだろう。
アーキテクチャ(基本方針)(HTMLのあいまいな書式は不要)
CkEditer Classicが吐き出すHTML形式の装飾されたテキストをXSSのような脆弱性を生むことなく利用しよう、というのが前提なので、自由度のあるあいまいな解釈(例えば、ダブルクォートだけではなくシングルクォートで括ってもよいとか、そもそも括らなくてもなんとなく解釈してくれるとか)に対応する必要はなくて、そのWebページ上のリッチテキストライブラリ(文章を装飾できるjsライブラリ)が吐き出す形式だけ許可するようにすればよい。
それでは実装してみる1(①まずは全体のHTMLエンコード)
まずは、全体をHTMLエンコードしてしまおう
それでは実装してみる2(②必要な部分だけをデコード)(CkEditorの吐き出すHTMLタグと属性を羅列する)
CKEditer5 classicは上段の赤枠のような装飾ができる。
- 文字の大きさ
- 太字
- 斜体
- リンク
- 一覧
- ナンバリングの一覧
- 表
が装飾可能な機能で羅列するとこんな感じ
(画像の埋め込みとメディアの埋め込みは次回)
それで、これをWebアプリで受け取る際は、下図のテキストボックスのようなデータ(HTML形式)を受け取り、、、
そのままHTMLの一部として埋め込むと、下図の下側の赤枠のようにグラフィックスが表示される
それでは実装してみる2(②必要な部分だけをデコード)(CkEditorの吐き出すHTMLタグと属性を羅列する)2
ということで、許可するタグは
- h[0-9]
- ul
- li
- ol
- p
- a href="http://[^\"]+?"
- i
- strong
- blockquote
- figure class="table"
- table
- thead
- tr
- th
- tbody
- td colspan="[0-9]{1,2}"
- td rowspan="[0-9]{1,2}"
こんな感じかね...
なので、まずは、タグの名前の正規表現は
</?((h[0-9])|(ul)|(li)|(ol)|(p)|(a)|(i)|(strong)|(blockquote)|(figure)|(table)|(thead)|(tr)|(th)|(tbody)|(td))
こんな感じ
これは
「<」「/が0個か1個」「h0~h9またはulまたはliまたはolまたはpまたはaまたはiまたはstringまたはblockquoteまたはfigureまたはtableまたはtheadまたはtrまたはthまたはtbodyまたはtd」
という意味
んで、属性部分は、
((href)|(class)|(colspan)|(rowspan))=\"((table)|(https?://[^\"]+?)|([0-9]{1,2}))\"
この属性部分は、0個以上なので、
(((href)|(class)|(colspan)|(rowspan))=\"((table)|(https?://[^\"]+?)|([0-9]{1,2}))\")*
属性部分を区切る空白も考えると
(\s((href)|(class)|(colspan)|(rowspan))=\"((table)|(https?://[^\"]+?)|([0-9]{1,2}))\")*
まとめると、
</?((h[0-9])|(ul)|(li)|(ol)|(p)|(a)|(i)|(strong)|(blockquote)|(figure)|(table)|(thead)|(tr)|(th)|(tbody)|(td))(\s((href)|(class)|(colspan)|(rowspan))=\"((table)|(https?://[^\"]+?)|([0-9]{1,2}))\")*>
な感じ。
事前にHTMLデコードしているので、正規表現もHTMLエンコードに対応する必要があり、
<\;/?((h[0-9])|(ul)|(li)|(ol)|(p)|(a)|(i)|(strong)|(blockquote)|(figure)|(table)|(thead)|(tr)|(th)|(tbody)|(td))(\s((href)|(class)|(colspan)|(rowspan))="\;((table)|(https?://[^\"]+?)|([0-9]{1,2}))"\;)*>\;
URL部分の「ダブルクォート以外」は後程検討する
それでは実装してみる2(属性の値がURLの正規表現)
上記の
(https?://[^\"]+?)
は、HTMLエンコードされているところにヒットさせるので、これではダメ。
「"」が「"」になっているから、上記の正規表現ではダメ。
属性の中に「"(ダブルクォート)(HTMLエンコードされているから「"」)」があってはダメ。
という正規表現を書く必要がある。
・・・そこで、否定先読みという正規表現の仕組みを使ってみる。
つまり、
&(?=(?!quot\;))
は、「&」にはヒットするけど、「"」にはヒットしない。
この否定先読みという仕組みを使って、
(https?://(((&(?=(?!quot\;)))|([^"&]))+?))
にした方がよいだろう
ということで、最終的な正規表現は
<\;/?((h[0-9])|(ul)|(li)|(ol)|(p)|(a)|(i)|(strong)|(blockquote)|(figure)|(table)|(thead)|(tr)|(th)|(tbody)|(td))(\s((href)|(class)|(colspan)|(rowspan))="\;((table)|(https?://(((&(?=(?!quot\;)))|([^"&]))+?))|([0-9]{1,2}))"\;)*>\;
この正規表現にヒットした箇所だけ、HTMLデコードして元に戻す処理を記述することで、不正行為者の改変行為から防護できるだろう
それでは実装してみる2(無意味な属性)
上記の正規表現だと
<li colspan="http://www.ntt.com">
のようなタグ名とは無関係な属性が有効になる場合もあるが、特に不利益はもたらさないだろうと思う
それでは実装してみる3(③最後に全体をHTMLエンコードしてしまったことによる多重エンコードの解除)簡易版
最初に全体をHTMLエンコードしているので、
- 元々HTMLエンコードされていた箇所はさらにHTMLエンコードされている。
ということになる
多重エンコードは、「&quot;」とか「&lt;」とかになっているのだから
- 「&」→「&」に置換処理することだけを実施する
それでは実装してみる3(③最後に全体をHTMLエンコードしてしまったことによる多重エンコードの解除)厳密版
簡易版では、「&」の一回目のエンコードも対象になってしまっている。
一つ一つ
- 「&quot;」→「"」
- 「&lt;」→「<」
- 「&gt;」→「>」
- 「&#39;」→「'」
- 「&amp;」→「&」
という置換を実施してもよい。
これを正規表現で実施することも可能だ。
つまり、正規表現の先読みという機能を使って、置換処理を実施してもよいだろう
つまり
&\;(?=((quot)|(lt)|(gt)|(\#39)|(amp))\;)
でヒットした箇所を「&」に置換することでも、同様のことができる
テスト結果A(まずは何もしないで、XSSが誘発されることを示す)
中段のテキストボックスに「<script>alert(123)</script>」を挿入して送信した結果
当然だが、装飾された文章をHTMLに埋め込めば、そのままスクリプトが実行されている
テスト結果B(正規表現で対策する)(scriptタグの追記)
こんな感じで、不正行為者が挿入した箇所はHTMLエンコードされている
テスト結果C(正規表現で対策する)(AタグにonClickイベントハンドラ属性を与える書き換えを行う)
不正行為者が書き換えたAタグだけHTMLエンコードされ、XSSが防護された
(相手のAタグの閉じタグは、元に復元されているが、閉じタグなので、影響はないだろう)
残りの画像と動画の貼り付けについて
今回は画像と動画の貼り付けの装飾は除外していたが、おそらくIMGタグとEMBEDタグで、属性はSRC。属性の値はURLだと強く推定される。
よって、タグの名前として「IMG」と「EMBED」を追加して、属性の名前として「SRC」を追加するだけで対応できるだろう
再び「①編集のために呼び戻される箇所」について
本文書では「②(装飾された文章として)HTMLの一部として埋め込む箇所」での無害化処理について考察してきたが、これを「①編集のために呼び戻される箇所」にも適用しても問題はないと思う。
「①編集のために呼び戻される箇所」については
- 通常の全面的なHTMLエンコード
- 最新バージョンのjsライブラリの運用
と上述したが、
- 「②(装飾された文章として)HTMLの一部として埋め込む箇所」での無害化処理
- 通常の全面的なHTMLエンコード
- 最新バージョンのjsライブラリの運用
でもよいと思う
サンプルコードについて
サンプルはC#/.NET Frameworkだけど、JavaとかでPHPには移植しやすいと思う。
サンプルコード(ckeditor.aspx)
<%@ Page Language="C#" ValidateRequest="false" %>
<%@ Import Namespace="System.Text.RegularExpressions" %>
<html>
<head>
<title>てすと</title>
</head>
<body>
<a href="https://cdn.ckeditor.com/ckeditor5/18.0.0/classic/ckeditor.js">https://cdn.ckeditor.com/ckeditor5/18.0.0/classic/ckeditor.js</a>
<%
String str = Request["editor"];
if(String.IsNullOrEmpty(str) == true){
str = "ここに書いてある文章が初期に表示される文だよーー。編集してねー";
}
%>
<!--<script src="https://cdn.ckeditor.com/ckeditor5/18.0.0/classic/ckeditor.js"></script> -->
<hr>
<script src="ckeditor.js"></script>
<hr>
<form action="ckeditor.aspx" method="get">
<textarea rows="7" cols="40" name="editor" id="editor"><%= HttpUtility.HtmlEncode(str) %></textarea>
<br>
<input type="submit" name="send" value="send0">
</form>
<script>
ClassicEditor
.create( document.querySelector( '#editor' ) )
.then( editor => {
console.log( editor );
} )
.catch( error => {
console.error( error );
} );
</script>
<hr>
<form action="ckeditor.aspx" method="get">
editor is<br>
<textarea rows="7" cols="40" name="editor" id="aftereditor">
<%
Response.Write(HttpUtility.HtmlEncode(str));
%>
</textarea><br>
<input type="submit" name="send" value="send1">
</form>
<hr>
入力データを無害化した結果<br>
<%
// ひとまず全部をHTMLエンコード
str = HttpUtility.HtmlEncode(str);
// jsライブラリが吐き出すタグ/属性だけをHTMLデコードして戻す
Regex regex = new Regex("<\\;/?((h[0-9])|(ul)|(li)|(ol)|(p)|(a)|(i)|(strong)|(blockquote)|(figure)|(table)|(thead)|(tr)|(th)|(tbody)|(td))(\\s((href)|(class)|(colspan)|(rowspan))="\\;((table)|(https?://(((&(?=(?!quot\\;)))|([^\"&]))+?))|([0-9]{1,2}))"\\;)*>\\;", RegexOptions.IgnoreCase);
str = regex.Replace(str, (MatchEvaluator)delegate (Match m) {
return HttpUtility.HtmlDecode(m.Value);
});
// 最初に全体をHTMLエンコードしたので、二重にHTMLエンコードしている箇所を元(HTMLエンコード1回だけ)に戻す
// 簡易版
// str = str.Replace("&","&");
// 厳密版
// 文字列置換だけの厳密版
// str = str.Replace("&quot;",""").Replace("&lt;","<").Replace("&gt;",">").Replace("&#39;","'").Replace("&amp;","&");
// 正規表現で実現する厳密版
Regex regexA = new Regex("&\\;(?=((quot)|(lt)|(gt)|(\\#39)|(amp))\\;)",RegexOptions.IgnoreCase);
str = regexA.Replace(str,"&");
//
Response.Write(HttpUtility.HtmlEncode(str));
%>
<hr>
ここに(無害化された)装飾されたテキストが表示される<br>
<%
Response.Write(str);
%>
</body>
</html>
次回はDOMでも実装してみる
一部のHTMLタグだけ許可する(CKEditer編)(DOM編)