はじめに
これは一部のHTMLタグだけ許可する(CKEditer編)(正規表現編)の続編で、今度はDOMを使ってCKEditer5 classicが吐き出すHTMLタグを許可してみようという試みである。
対象となるWebアプリケーションの構造
一部のHTMLタグだけ許可する(CKEditer編)(正規表現編)を参照してほしい
①編集のために呼び戻される箇所
一部のHTMLタグだけ許可する(CKEditer編)(正規表現編)を参照してほしい
②(装飾された文章として)HTMLの一部として埋め込む箇所
一部のHTMLタグだけ許可する(CKEditer編)(正規表現編)を参照してほしい
アーキテクチャ(基本方針)
こんな感じ
- DOMとして読み込む
- DOMオブジェクトを走査していき、許可タグと許可属性だけ残す。それ以外は削除する
- DOMをXML(HTML)文字列にシリアライズする
こうすれば、許可タグとして認識できる文字列だけ、ホワイトリスト方式の考え方で残るので、想定していないタグが有効になるなどの予想もしていない脆弱性は生まれないだろう。
アーキテクチャ(基本方針)(System.Xmlは使えない)
HTMLはXMLのようなものなので、System.Xmlは使えないので、一部のHTMLタグだけ許可する(DOM編)でも使用した「SgmlReader」を使う。
それでは実装してみる
どんな許可タグ、許可属性なのかは一部のHTMLタグだけ許可する(CKEditer編)(正規表現編)を参照してほしい
サンプルコードを見る通り、XMLのDOMオブジェクトを走査していき、ホワイトリスト方式で許可されているもの以外(switch文のdefaultブロック)は削除している
それでは実装してみる(URLのスクリプトスキーム)
今回は属性の値はチェックしていないので、javascript:〇〇というようなスキームも許可されているので、実際にはここのチェックを追記した方がよいでしょう
それでは実装してみる2(無意味な属性)
一部のHTMLタグだけ許可する(CKEditer編)(正規表現編)と同じように、許可タグと許可属性はリンクしていないので、タグとは無関係な属性が有効になる場合もあるが、特に不利益はもたらさないだろうと思う
それでは実装してみる3(例外処理)
XMLの解析は例外が起きる場合があるので、例外処理が起きた場合は、全域をHTMLエンコードすることでFailSafeとした方がよいだろう
テスト結果A(まずは何もしないで、XSSが誘発されることを示す)
一部のHTMLタグだけ許可する(CKEditer編)(正規表現編)を参照してほしい
テスト結果B(DOMで対策する)(scriptタグやonclickイベントハンドラの追記)
こんな感じで、不正行為者が挿入した箇所は削除されている
残りの画像と動画の貼り付けについて
今回は画像と動画の貼り付けの装飾は除外していたが、おそらくIMGタグとEMBEDタグで、属性はSRC。属性の値はURLだと強く推定される。
よって、タグの名前として「IMG」と「EMBED」を追加して、属性の名前として「SRC」を追加するだけで対応できるだろう
再び「①編集のために呼び戻される箇所」について
本文書では「②(装飾された文章として)HTMLの一部として埋め込む箇所」での無害化処理について考察してきたが、これを「①編集のために呼び戻される箇所」にも適用しても問題はないと思う。
「①編集のために呼び戻される箇所」については
- 通常の全面的なHTMLエンコード
- 最新バージョンのjsライブラリの運用
と上述したが、
- 「②(装飾された文章として)HTMLの一部として埋め込む箇所」での無害化処理
- 通常の全面的なHTMLエンコード
- 最新バージョンのjsライブラリの運用
でもよいと思う
サンプルコードについて
サンプルはC#/.NET Frameworkだけど、JavaとかでPHPには移植しやすいと思う。
サンプルコード(ckeditorxml.aspx)
binフォルダにSgmlReader.dllを配置
<%@ Page Language="C#" ValidateRequest="false" %>
<%@ Import Namespace="System.Xml" %>
<%@ Import Namespace="System.IO" %>
<%@ Import Namespace="Sgml" %>
<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="ckeditorxml.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="ckeditorxml.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>
<%
try{
// XMLはルート属性は一つだけという制限があるので全体を囲んでルート要素を1つに強制する
StringReader tempStringReader = new StringReader("<p>" + str + "</p>");
SgmlReader tempSgmlReader = new SgmlReader();
tempSgmlReader.DocType = "HTML";
tempSgmlReader.IgnoreDtd = true;
tempSgmlReader.InputStream = tempStringReader;
XmlDocument tempXmlDocument = new XmlDocument();
tempXmlDocument.XmlResolver = null;
tempXmlDocument.Load(tempSgmlReader);
this.checkforxml(tempXmlDocument.FirstChild);
str = tempXmlDocument.FirstChild.InnerXml;
}catch {
// エラーが発生した場合は、全域をHTMLエンコードする(FailSafe)
str = HttpUtility.HtmlEncode(str);
}
//
Response.Write(HttpUtility.HtmlEncode(str));
%>
<hr>
ここに(無害化された)装飾されたテキストが表示される<br>
<%
Response.Write(str);
%>
<script runat="server">
private String AreYouAsp()
{
return "No, I'm ASPX!";
}
</script>
<script runat="server">
private void checkforxml(XmlNode node)
{
XmlNode txtnode = null;
String str = "";
for(int i= node.ChildNodes.Count-1; 0 <= i;i--)
{
XmlNode child = node.ChildNodes[i];
switch (child.Name.ToLower()) {
case "#text":
txtnode = child;
break;
case "h1":
case "h2":
case "h3":
case "h4":
case "h5":
case "h6":
case "h7":
case "h8":
case "h9":
case "ul":
case "li":
case "ol":
case "p":
case "a":
case "i":
case "strong":
case "figure":
case "table":
case "thead":
case "tr":
case "th":
case "tbody":
case "td":
for(int j=child.Attributes.Count-1;0 <=j;j--)
{
XmlAttribute attr = child.Attributes[j];
switch (attr.Name)
{
case "href":
case "class":
case "colspan":
case "rowspan":
break;
default:
child.Attributes.Remove(attr);
break;
}
}
this.checkforxml(child);
break;
default:
node.RemoveChild(child);
/*
str += child.OuterXml;
XmlDocument xmlDocument = new XmlDocument();
XmlElement xmlElement = xmlDocument.CreateElement("#text");
xmlElement.Value = str;
child = xmlElement;
*/
break;
}
}
/*
if (txtnode != null) {
txtnode.Value += str;
}
*/
}
</script>
</body>
</html>