一部のHTMLタグだけ許可する(DOM編)

# はじめに

Webアプリケーションの要求仕様に、利用者が投稿する文章に、ある程度の装飾ができるようにしたいというものがあると思う。
まぁ、いまどきならMarkdownとかあるのだけど、入力したHTML形式のデータから、許可できるタグ(と属性)だけ抜き出すというプログラムを考えてみた。

参考程度にはなると思う

# アーキテクチャ(基本方針)

基本方針はXMLDocumentオブジェクトなど、タグの構造(DOM)を分解するライブラリに掛けることで、DOMの名前と値(と属性)に分離して、特定の名前(と属性)だけをタグとして出力する。

1. データをDOM化する
2. nodeごとに再帰する
3. node.Nameが許可するものだけタグとして出力
4. node.Attributesを回して、許可タグの許可属性のみ出力する(属性が必要な場合)
5. 子供のノードがなくなったら(#textノードになると思う)、node.ValueをHTML/XMLエスケープして出力
6. 子供のノードがあれば、2へ戻る

こんな感じ

# テスト結果

![test.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/182822/d2dc164f-948d-aec0-1e27-d118ac342db1.png)

こんな感じ。・・・まぁまぁ、いい感じだと思う。

利用しているライブラリ「[SGMLReader](https://github.com/mindtouch/sgmlreader)」の関係上、入力データ全体をHTMLタグで囲んでいる。

# サンプルコードについて

サンプルはC#/.NET Frameworkだけど、JavaとかでPHPには移植しやすいと思う。

サンプルはHTML形式の解析に[SGMLReader](https://github.com/mindtouch/sgmlreader)を使った。
標準の[System.Xml.XmlDocuemtクラス](https://docs.microsoft.com/ja-jp/dotnet/api/system.xml.xmldocument?view=netframework-4.8)だと、HTMLでは有効だけど、XMLではダメな形式のやつとかだと例外が出たりして厳密過ぎて面倒なので、ある程度よきに計らってくれる[SGMLReader](https://github.com/mindtouch/sgmlreader)にしてみたけど、好きなXML解析ライブラリを使えばよいと思う。

サンプルは、Bタグと、Iタグと、FONTタグのCOLORとSIZE属性だけ許可するようにしているので、そのswichブロック(56行目あたり)を、改造すればよいと思う。

サンプルは、単純なfor文だけど、Linqやラムダ式とかで、並列実行できるようにすれば、処理速度は上がると思うので、実際に使う際は、そういう改造をした方がよいと思う。

サンプルは、HTMLエスケープとして[System.Security.SecurityElement.Escape()メソッド](https://docs.microsoft.com/ja-jp/dotnet/api/system.security.securityelement.escape?view=netframework-4.8)を使っているけど、[System.Net.WebUtility.HtmlEncode()メソッド](https://docs.microsoft.com/ja-jp/dotnet/api/system.net.webutility.htmlencode?view=netframework-4.8)などの方が適切かもしれない。

# サンプルコードについて(属性値がURLの場合のチェック)

例えば、AタグのHREF属性とか、属性値がURLの場合、**javascript:...**とかで任意のスクリプトが動作するかもしれないので、そういう属性を許可する場合は、サンプルコードには記述されていないが、属性値の入力チェックが必要だ。
まぁ、大まかな方向性としては先頭一致で「https://」と「http://」のみ許可。とか、先頭に「/./」または「./」を付与とかでいいと思う。

# サンプルコードについて(XXE)

実際にこういう方針でプログラムを組む場合、入力データをDOMとして読み込むのだから、XXE攻撃には対処しておく必要がある。
XXE攻撃については、[XXE基本編](https://qiita.com/tomoki0sanaki/items/015b6e8b807ed256a6e4)なり、**XXE**で検索すればよいと思う。

[SGMLReader](https://github.com/mindtouch/sgmlreader)とXXEについては、「[XXEと.NET Framework (SgmlReaderDll.dll)](https://qiita.com/tomoki0sanaki/items/8a4e0442c70628d8a1e8)」として別枠として設けた。

# サンプルコード(Program.cs)

[SGMLReader](https://github.com/mindtouch/sgmlreader)を参照設定すること

```csharp
using System;
using System.IO;
using System.Xml;
namespace StripHTMLs{
    class Program{
        static void Main(string[] args){
            StreamReader reader = new StreamReader(new FileStream(args[0], FileMode.Open, FileAccess.Read));
            String motoData = reader.ReadToEnd();
            Console.WriteLine("FileName: " + args[0]);
            Console.WriteLine("Input Data is: ");
            Console.WriteLine(motoData);
            Console.WriteLine("========================");
            Console.WriteLine("ans is");

            Sgml.SgmlReader tempSgmlReader = new Sgml.SgmlReader();
            tempSgmlReader.DocType = "HTML";
            tempSgmlReader.IgnoreDtd = true;

            StringReader tempStringReader = new StringReader(motoData);
            tempSgmlReader.InputStream = tempStringReader;

            XmlDocument myXmlDocument = new XmlDocument();
            // myXmlDocument.XmlResolver = null;
            myXmlDocument.Load(tempSgmlReader);

            try{
                String str = "";
                XmlNodeList myXmlNodeList = myXmlDocument.ChildNodes;
                for(int i=0;i<myXmlNodeList.Count;i++){
                    str += EscapeHTML(myXmlNodeList[i]);
                }
                Console.WriteLine(str);
            }catch (Exception ee){
                Console.Error.WriteLine(ee.ToString());
            }
        }

        private static String EscapeHTML(XmlNode node) {
            // B and I and Font/Color/Size permit only
            String ansStr = "";
            String tagStart = "";
            String tagEnd = "";
#if DEBUG
            Console.WriteLine("node.name:" + node.Name);
            Console.WriteLine("node.value:" + node.Value);
            Console.WriteLine("node.HasChildNodes:" + node.HasChildNodes.ToString());
#endif
            //
            switch (node.Name.ToLower()) { 
                case "b":
                    tagStart = "<b>";
                    tagEnd = "</b>";
                    break;
                case "i":
                    tagStart = "<i>";
                    tagEnd = "</i>";
                    break;
                case "font":
                    tagStart = "<font";
                    tagEnd = "</font>";
                    XmlAttributeCollection xmlAttributeCollection = node.Attributes;
                    foreach(XmlAttribute attr in xmlAttributeCollection){
                        switch (attr.Name.ToLower()) {
                            case "color":
                            case "size":
                                tagStart += " " + attr.Name + "=\"" + System.Security.SecurityElement.Escape(attr.Value) + "\"";
                                break;
                        }
                    }
                    tagStart += ">";
                    break;
            }
            if (node.HasChildNodes == true){
                XmlNodeList list = node.ChildNodes;
                for (int i = 0; i < list.Count; i++) {
                    ansStr += EscapeHTML(list[i]);
                }
            }else {
                ansStr = System.Security.SecurityElement.Escape(node.Value);
            }
            return tagStart + ansStr + tagEnd;
        }
    }
}
```

# サンプルコードの修正

不許可なタグは、エスケープするのが、適切かもしれない。
そういう場合は、タグ(ノード)の名前のswitch文のところに、
以下の感じのコードを入れるといいかもしれない。

```csharp
default:
  if (node.Name.StartsWith("#") == false) { 
    // "#"で始まらないノードのみ
    tagStart = "<" + node.Name;
    tagEnd = System.Security.SecurityElement.Escape("</" + node.Name + ">");
    XmlAttributeCollection xmlAttributeCollection0 = node.Attributes;
    foreach (XmlAttribute attr in xmlAttributeCollection0){
      tagStart += " " + attr.Name + "=\"" + System.Security.SecurityElement.Escape(attr.Value) + "\"";
    }
    tagStart = System.Security.SecurityElement.Escape(tagStart + ">");
  }
  break;
```

これでも、元データを適切に再現しているかというと、以下の点で、そうではなかったりするので、**入力されたデータを改変しない**という情報処理の基本からは外れてしまうのが、心残り

- 元データに閉じタグのない場合は、閉じタグが出現してしまう
- 属性値の順番が for文で一致しているといいけど、保証されていないと思う

## サンプルコードの修正の蛇足 ～タグ内部での属性のエスケープ～

これは無理かと思う。

つまり、許可タグの中の不正な属性は、出力されたデータからは排除するしかないと思う。
これも、**入力されたデータを改変しない**という情報処理の基本からは外れてしまうけど・・・これはさすがにしょうがないのではないかという部分ではないかと思う

# 次回は正規表現を試す

[一部のHTMLタグだけ許可する(正規表現編)](https://qiita.com/tomoki0sanaki/items/7fa956ae3030a76b797b)

# 以上