challenge 一部のHTMLタグを通すフィルタ

ユーザが入力した文字列から、一部のタグだけを許可して他をエスケープするコードを書いてください。要件は次のようになります。
  • 通すタグはAとBRとSTRONGのみ。大文字小文字は区別しない。
  • それ以外のタグとして意味を持ちうる文字列は<を&lt;に変換することで無効化する(削除するのではない。>は変換してもしなくてもよい)
  • Aタグのhrefとname以外の属性は削除する。BRやSTRONGの属性はすべて削除する。

このお題はperezvonさんの提案を元にしています。ありがとうございました。 ただ、いきなりだと難しいかと思ったので、肝の部分以外を先に出題しました。このお題は続編で徐々に難しくなっていきます。

追記:属性に<や>が含まれてしまうケースに漏れのある解答が多いようなのでテストケースを追加します。
これは「この出力なら十分」という意味です。この出力の通りでなければいけないという意味ではありません。

<script foo="<script>alert('bar')</script>">alert('foo')</script>
&lt;script foo="&lt;script&gt;alert('bar')&lt;/script&gt;"&gt;alert('foo')&lt;/script&gt;


<script foo="<a href='link'>link</a>">alert('foo')</script>
&lt;script foo="&lt;a href='link'&gt;link&lt;/a&gt;"&gt;alert('foo')&lt;/script&gt;

<a href='www.g>oogle.com'>link</a>

<a href="./www.g%3Eoogle.com">link</a>

Posted feedbacks - Flatten

Nested Hidden
タグを無効化するために「<」のサニタイズするのなら
「;」も対象にしたほうが良い予感?


TripletaiL フレームワークには一部のタグだけを許可する機能があります.
ただ,題意のサンプルのように,属性値を '' で括った場合は
うまく対応できないので,そこを変更しています.

また,閉じタグの自動修復機能が少しバグっていたので
以下のパッチを当てて実行しています.

--- lib/Tripletail/TagCheck.pm  27 Jun 2007 03:01:50 -0000      1.15
+++ lib/Tripletail/TagCheck.pm  31 Aug 2007 01:03:31 -0000
@@ -253,7 +253,9 @@
                                }

                                # スタックにプッシュ
-                               push @$open_stack, $elem;
+                               if(!$taginfo->mustBeEmpty) {
+                                       push @$open_stack, $elem;
+                               }
                        }
                }
        }

----
実行結果
<<<<

<a href="www.google.com">link</a> <blink>and</blink>
<strong onClick='alert("NG")'>click<br>me!</strong>
>>>>

<a href="www.google.com">link</a> &lt;blink&gt;and&lt;/blink&gt;
<strong>click<br>me!</strong>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#!/usr/bin/perl

use Tripletail qw(/dev/null);

my $input = q{
<a href="www.google.com">link</a> <blink>and</blink>
<strong onClick='alert("NG")'>click<br>me!</strong>
};

my $tc = $TL->newTagCheck;
$tc->setATarget(undef);
$tc->setAllowTag(':BR;A(HREF,NAME);STRONG()');
my $output = $tc->check($input);

print "<<<<\n";
print $input;
print ">>>>\n";
print $output;

validityチェックなしの手抜きですが。。。
Tag Soup というライブラリを使う
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import Data.Char
import Text.HTML.TagSoup

proc :: Tag Char -> Tag Char
proc tag@(TagOpen t attrs)
 | t' == "a"      = TagOpen t (filter allowA attrs)
 | t' == "br"     = TagOpen t []
 | t' == "strong" = TagOpen t []
 | otherwise      = tag
 where t' = map toLower t
proc tag = tag

allowA (a,_) = a == "href" || a == "name"

pprTags :: [Tag Char] -> String
pprTags [] = ""
pprTags tags@(TagOpen s attrs : TagClose e : ts)
 | map toLower s == "br" = "<br/>" ++ pprTags ts
pprTags (t:ts) = pprTag t ++ pprTags ts

pprTag :: Tag Char -> String
pprTag tag = case tag of
  TagOpen t attrs | ignore t  -> "&lt;"++pprOpen' t attrs++">"
                  | otherwise -> pprOpen t attrs
  TagClose t      | ignore t  -> "&lt;"++pprClose' t++">"
                  | otherwise -> pprClose t
  TagText s                   -> s
  TagComment s                -> "<!--"++s++"-->"
  TagSpecial s c              -> "<!"++s++' ':c++">"
  TagWarning s                -> ""

pprOpen' t []    = t
pprOpen' t attrs = t++' ':unwords (map pprAttr attrs)
pprOpen  t attrs = "<" ++ pprOpen' t attrs ++">"

pprClose' t = t
pprClose  t = "</" ++ t ++ ">"

pprAttr (a,v) = a++"='"++v++"'"

ignore :: String -> Bool
ignore t = notElem (map toLower t) ["a","strong","br"] 

testdata="<a href='www.google.com'>link</a> <blink>and</blink> <strong onClick='alert(\"NG\")'>click<br/>me!</strong>"

-- main = putStrLn . pprTags . map proc . parseTags =<< getContents
main = putStrLn . pprTags . map proc . parseTags $ testdata

{-
*Main> :main
<a href='www.google.com'>link</a> &lt;blink>and&lt;blink> <strong>click<br/>me!</strong>
-}

ライブラリを使ったら面白くないので、自分で書いてみた。思ったよりすっきりかけた気がする。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import java.util.regex._
class ExtendedString(self:String) {
  def gsub(reg:Pattern, f:(Matcher)=>String):String = {
    val result = new StringBuffer
    val m = reg.matcher(self)
    while(m.find) m.appendReplacement(result, f(m))
    m.appendTail(result)
    result.toString
  }
  def gsub(reg:String, f:(Matcher)=>String):String = gsub(Pattern.compile(reg), f)
}
implicit def string2ext(self:String) = new ExtendedString(self);


object htmlEscape{
  lazy val tagRegex = Pattern.compile(
    """(</?)([^"'< >]*)([^"'<>]*(?:"[^"]*"[^"'<>]*|'[^']*'[^"'<>]*)*)((?:>|(?=<)|$(?!\n)))"""
  )
  lazy val attrRegex = Pattern.compile(
    """[\s'"](\w+)\s*=\s*([^\s'">]+|'[^']+'|\"[^"]+")"""
  , Pattern.DOTALL | Pattern.CASE_INSENSITIVE)
  lazy val tagAllowed = Set("a", "br", "strong")
  lazy val attrAllowed = Map("a" -> Set("href", "name"))

  def apply(html:String) = {
    html.gsub(tagRegex, (m:Matcher) => {
      val tag = m.group(2).toLowerCase.replace("/","")
      (if(tagAllowed.contains(tag)){
        val attrs = m.group(3).gsub(attrRegex, (m2:Matcher) => {
          if(attrAllowed.getOrElse(tag, Set[String]()).contains(m2.group(1).toLowerCase)) {
            m2.group(0)
          }else {
            ""
          }
        })
        List(m.group(1),m.group(2), attrs, m.group(4))
      }else {
        List(m.group(1).replace("<", "&lt;"), m.group(2), m.group(3), m.group(4))
      }).mkString("")
    })
  }
}
println(htmlEscape("""<a href='www.google.com'>link</a> <blink>and</blink> <strong onClick='alert("NG")'>click<br/>me!</strong>"""))

正規表現置換でごり押し。
可読性最悪ですみません。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
var input = "<a href='www.google.com' name='hoge' title=\"fuga\">\
link</a><blink>and</blink> <strong onClick='alert(\"NG\")'>click<br/>me!</strong>";


var reg = /<\s*(a|strong|br)((?:\s+\w+\s*=\s*(["']).*?\3)*)?\s*(?:>(([^<]*|<[^<>]*[^\/]>|<[^<>]*\/>)*)<\/s*\1\s*>|\/>)/gmi;

function deleteAttr(attr) {
  return attr.replace(/\s+(\w+)\s*=\s*(["'])(.*?)\2/g, function(all, name, q, value) {
    if(!name.match(/name|href/i)) return '';
    else return all;
  });
}
function eacapeTags(str) {
  var escaped = [];
  str = str.replace(reg, function(all, tag, attr, q, inner) {
    attr = tag.toUpperCase() == 'A' ? deleteAttr(attr) : '';
    escaped .push(tag+ " " + attr, inner);
    return "<A/>";
  }).replace(/</g, "&lt;");
  str = str.replace(/&lt;A\/>/g, function() {
    var tag = escaped.shift(), inner = escaped.shift();
    if(inner) return "<" + tag + ">" + eacapeTags(inner) + "</" + tag + ">";
    else return "<" + tag + "/>";
  });
  return str;
}
document.body.innerHTML=(eacapeTags(input));

inとoutにWSHを使ってます。

 cscript "javascriptファイル"  フィルタしたい文字列

で実行可能です。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
if (WScript.Arguments.length != 1) 
	WScript.Quit();
var target = WScript.Arguments.item(0);

var ret = target.replace(
	/<((\/?)([a-z]+)(.*?)(\/?))>/ig,
    function(all, ins, head, tag, elms, tail){
    	switch (tag.toUpperCase())
    	{
			case "A":
				var filterElms = elms.match(/ ?(href|name) *= *[^ \/>]+/ig);
				var newElement = "";
				if (filterElms)
					for(var i = 0; i < filterElms.length; i++)
						newElement += filterElms[i];
				
				return "<" + head + tag + newElement + tail +">";

			case "BR":
			case "STRONG":
				return "<" + head + tag + tail + ">";

			default:
				return "&lt;" + ins + ">";
				break;
    	}
    });

WScript.Echo(ret);

それはなぜですか?

実体参照の話であれば ; というよりも & を &amp; にしなくていいいのかということですよね。違うかな。。

エンティティの無効化です。
このお題で必要かと問われると怪しいですけど、
きっとタグが有効になると何か影響でちゃうのですよね?
タグが特別なものであるとしてとらえると
タグを無効化する必要のある処理であるならば
エンティティも一緒に無効化しても良いかなと。

個人的な好みが強いですが片手落ちというイメージが。。。

CGI.escapeElementが使えるかなと思ったが微妙に要求が異なる
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def filter(html)
  html.gsub(%r!<((/?)([a-z]+)[^>/]*(/?)>)!i){
    rest, endtagp, tagname, slash = Regexp.last_match.captures
    case tagname
    when "br", "BR", "strong", "STRONG"
      "<#{endtagp}#{tagname}#{slash}>"
    when "a", "A"
      endtagp.empty? ? "<a #{$&.scan(/(?:href|name)=(?:".+?"|'.+?'|[^ >]+)/).join ' '}>" : '</a>'
    else
      "&lt;#{rest}"
    end
  }
end

ちょっと無駄に難しく作りすぎました。
開きタグと閉じタグは個別に扱っても構わなかったのか。
ところで、(要件に含まれるのかわかりませんが)属性に">"が含まれるケースを考慮してない回答が結構ありますね。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
var input = "<a title=\"(>_<;)\" href='www.google.com' name='hoge'>\
link</a><blink>and</blink> <strong onClick='alert(\"NG\")'>click<br/>me!</strong>";

function deleteAttr(attr) {
  return attr.replace(/\s+(\w+)\s*=\s*(["'])(.*?)\2/g, function(all, name, q, value) {
    return name.match(/name|href/i) ? all : '';
  });
}
function filter(html) {
  return html.replace(/<(\/?)(\w+)((?:\s+\w+\s*=\s*(["']).*?\4)*)?(\/?)>/gmi,
      function(all, fslash, tag, attrs, q, rslash) {
         switch(tag.toUpperCase()) {
           case 'STRONG' :  // drop through
           case 'BR' : attrs = ''; break;
           case 'A' : attrs = deleteAttr(attrs); break;
           default : return all.replace('&', '&amp;').replace('<', '&lt;');
         }
         return '<' + fslash + tag + attrs + rslash + '>';
      });
}

document.body.innerHTML=filter(input);

htmlって曖昧だから、あまり自信なし。例えば、
<a href=foo.com> みたいに属性がクオートされてない場合はサポートしていない。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import re

def filter(html):
    def repl(m):
        content = m.group(1)
        m = re.match('(/?)\s*(a|br|strong)(\s.*|(/?))', content, re.I + re.S)
        if m:
            beg_slash, tag, other, end_slash = m.groups()
            def attrs(*names):
                yield ""
                for m in re.finditer(r"""(\S+)\s*=\s*(['"]).+?\2""", other, re.S):
                    if m.group(1).lower() in names:
                        yield m.group(0)
            def combine(*names):
                return "<%s%s%s%s>" % (beg_slash, tag, " ".join(attrs(*names)), end_slash or "")
            if tag.lower() == "a":
                return combine("href", "name")
            else:
                return combine()
        return "&lt;" + content + ">"
    return re.compile('<([^>]*)>', re.S).sub(repl, html)

def main():
    print filter("""<a href='www.google.com'>link</a> <blink>and</blink> <strong onClick='alert("NG")'>click<br/>me!</strong>""")

if __name__ == '__main__':
    main()

タグに大文字小文字が混在している場合や、属性が大文字の場合が考慮されていないのでは?少なくともtagnameはdowncaseしてから比較したほうがよい気がします。

属性値の中の">"が考慮されていませんね。

<a href='www.g>oogle.com'>link</a>

というようなケースで

<a>oogle.com'>link</a>

となってしまいます。

修正。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def filter(html)
  html.gsub(%r!<((/?)([a-z]+)[^>/]*(/?)>)!i){
    rest, endslash, tagname, slash = Regexp.last_match.captures
    case tagname.downcase
    when "br", "strong"
      "<#{endslash}#{tagname}#{slash}>"
    when "a"
      endslash.empty? ? "<a #{$&.scan(/(?:href|name)=(?:".+?"|'.+?'|[^ >]+)/i).join ' '}>" : '</a>'
    else
      "&lt;#{rest}"
    end
  }
end

なるほど、そんな場合があるのか・・・というわけで修正版です。

属性をパースする正規表現が二度出てくるのが美しくないですが・・・このあたりをPythonでうまく処理する方法ってあるのかな。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import re

def filter(html):
    def repl(m):
        beg_slash, tag_name, attrs, _, end_slash = m.groups()
        def combine(*names):
            s = "<" + beg_slash + tag_name
            for m in re.finditer(r"""(\w+)\s*=\s*(["']).+?\2""", attrs, re.S):
                if m.group(1).lower() in names:
                    s += " " + m.group(0)
            return s + end_slash + ">"
        if tag_name.lower() == 'a':
            return combine("href", "name")
        elif tag_name.lower() in ('br', 'strong'):
            return combine()
        else:
            return "&lt;" + m.group(0)[1:]
    return re.compile(r"""<(/?)(\w+)((?:\s*\w+\s*=\s*(["']).+?\4)*)\s*(/?)>""", re.S).sub(repl, html)

def main():
    print filter("""<a href='www.google.com'>link</a> <blink>and</blink> <strong onClick='alert("NG")'>click<br/>me!</strong>""")

if __name__ == '__main__':
    main()

br以外の通したタグの閉じ忘れにも対応してみた。
(閉じる順番が間違ってる場合は今回は大目に見るとして。)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<?php
function safehtml($str)
{
	$r=array();
	$tags=array();
	$offs=0;
	while(preg_match('!<\s*(/|)\s*(([^>\'"]+|\'[^\']*\'|"[^"]*")*)>!',$str,$m1,PREG_OFFSET_CAPTURE,$offs))
	{	$r[]=substr($str,$offs,$m1[0][1]-$offs);
		$offs=$m1[0][1]+strlen($m1[0][0]);
		preg_match_all('!([a-z0-9_]+)(\s*=\s*("[^"]*"|\'[^\']*\'|[^\s]+)|)!im',$m1[2][0],$m2,PREG_SET_ORDER);
		switch(strtolower($m2[0][1]))
		{
		case 'a':
		case 'strong':
			if($m1[1][0])
			{	if(($i=array_search($m2[0][1],$tags))!==false)
					unset($tags[$i]);
			}
			else
				array_unshift($tags,$m2[0][1]);
			$t=array($m1[1][0].$m2[0][1]);
			if(strtolower($m2[0][1])=='a' && !$m1[1][0])
			{	array_shift($m2);
				while($param=array_shift($m2))
				{	switch(strtolower($param[1]))
					{
					case 'href':
					case 'name':
						$t[]=$param[0];
						break;
					}
				}
			}
			$r[]='<'.implode(" ",$t).'>';
			break;
		case 'br':
			$r[]='<br/>';
			break;
		default:
			$r[]='&lt;'.$m1[1][0].$m1[2][0].'&gt;';
			break;
		}
	}
	$r[]=substr($str,$offs);
	while($tag=array_shift($tags)) // 閉じわすれタグを閉じる
		$r[]="</$tag>";
	return implode("",$r);
}

echo safehtml(<<<EOT
<a href='www.google.com' target=_blank>link</a> <blink>and</blink> <strong onClick='alert("NG")'>click<br/>me!</strong>
EOT
);
?>

oceanさんのコードで確かめたのでここにぶら下げますが、他のポストにも同じ問題があるかもしれません。

入力文字列が次のようなものだとまずいのでは:

  <z foo='<script>alert("Boo")</script>'>

>http://ja.doukaku.org/comment/2722/
失念してました。

文中に">"等が入っても通るように正規表現修正。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
if (WScript.Arguments.length != 1) 
	WScript.Quit();
var target = WScript.Arguments.item(0);

var ret = target.replace(
	/<(\/?)(\w+)((\s*|\w+|\w+\s*=\s*('[^']*'|"[^"]*"|\w+))*)(\/?)>/ig,
    function(all, head, tag, attr, nouse1, nouse2, tail){
    	switch (tag.toUpperCase())
    	{
			case "A":
				var attrs = attr.match(/\s?(href|name)\s*=\s*('[^']*'|"[^"]*"|\w+)/ig);
				var newAttr = "";
				if (attrs)
					for(var i = 0; i < attrs.length; i++)
						newAttr += attrs[i];
				return "<" + head + tag + newAttr + tail +">";

			case "BR":
			case "STRONG":
				return "<" + head + tag + tail + ">";

			default:
				return "&lt;" + head + tag + attr + tail + ">";
    	}
    });

WScript.Echo(ret);

あ、いけね。開いたタグと閉じたタグで大文字小文字が合ってないと閉じ忘れ扱いになってしまう。
かさばるので修正版は続編のときに…。

なるほど、タグを無効化した場合、属性に含まれる < を放置すると今度はそちらがタグとみなされるということですか。

というわけで再修正パッチです。でもきっとまだあるな・・・
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
--- a.py.orig	Fri Aug 31 16:20:21 2007
+++ a.py	Fri Aug 31 16:20:35 2007
@@ -14,11 +14,12 @@
         elif tag_name.lower() in ('br', 'strong'):
             return combine()
         else:
-            return "&lt;" + m.group(0)[1:]
+            return m.group(0).replace("<", "&lt;")
     return re.compile(r"""<(/?)(\w+)((?:\s*\w+\s*=\s*(["']).+?\4)*)\s*(/?)>""", re.S).sub(repl, html)
 
 def main():
     print filter("""<a href='www.google.com'>link</a> <blink>and</blink> <strong onClick='alert("NG")'>click<br/>me!</strong>""")
+    print filter(""" <z foo='<script>alert("Boo")</script>'>""")
 
 if __name__ == '__main__':
     main()

 DOM を使った方がより JavaScript らしいのかもしれないが…。

javascript:document.body.innerHTML=(function(s,r,x){r=/\b(?:name|href) *= *(?:".*?"|'.*?'|[^ >]*)/gi;x=/^\/?(a)|br|strong\b/i;return s.replace(/<([^ >]+ ?)((?:".*?"|'.*?'|[^\/>])*)/g,function(m,t,a){return x.test(t)?'<'+t+(RegExp.$1&&(m=a.match(r))?m.join(' '):''):'&lt;'+t+a})})(document.body.innerHTML)
1
2
3
4
5
6
7
8
function doukaku54(s){
  var rx_nh = /\b(?:name|href) *= *(?:".*?"|'.*?'|[^ >]*)/gi;
  var rx_ok = /^\/?(a)|br|strong\b/i, LT = '<', lt = '&lt;', sp = ' ';
  return s.replace(/<([^ >]+ ?)((?:".*?"|'.*?'|[^\/>])*)/g, function(m, tag, ats){
    return rx_ok.test(tag)
      ? LT + tag + (RegExp.$1 && (m = ats.match(rx_nh)) ? m.join(sp) : '')
      : lt + tag + ats });
}

素直にライブラリを使いましたが、お題によれば無効にしたタグ部分は
置き換えた文字以外、そのままにすべきなんでしょうが
(例えば大文字小文字など)、ライブラリの都合上、end側タグまわりの情報が劣化しています(27行目あたり)。
これは基底クラスのソース見てなんとかする必要がありそうなので
とりあえず手抜き版として投稿させていただきます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from HTMLParser import HTMLParser

class HTMLParser2(HTMLParser):

    def reset(self):
        HTMLParser.reset(self)
        self.buf = ''

    def handle_starttag(self, tag, attrs):
        if tag == 'a':
            self.buf += '<a '+' '.join(["%s='%s'" % t for t in attrs if t[0] in ['href', 'name']])+'>'
        elif tag in ['br', 'strong']:
            self.buf += '<%s>' % tag
        else:
            self.buf += self.get_starttag_text().replace('<', '&lt;')

    def handle_startendtag(self, tag, attrs):
        if tag == 'br':
            self.buf += '<br/>'
        else:
            self.buf += self.get_starttag_text().replace('<', '&lt;')

    def handle_endtag(self, tag):
        if tag in ['a', 'br', 'strong']:
            self.buf += '</%s>' % tag
        else:
            self.buf += '&lt;/%s>' % tag

    def handle_data(self, data):
        self.buf += data

s = """<a href='www.google.com'>link</a> <blink>and</blink> <strong onClick='al
ert("NG")'>click<br/>me!</strong>"""
h = HTMLParser2()
h.feed(s)
print h.buf

#2727の指摘に対応。
タグの大文字小文字の問題修正と通すタグに関する情報をまとめて汎用化してみた。
タグは開いたのと逆順に閉じることを強制する。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<?php
function safehtml($str)
{
	$safetag=array('a'=>array(1,array('href','name')),'strong'=>array(1),'br'=>array(0));
	$r=array();
	$tags=array();
	$offs=0;
	while(preg_match('!<(\s*(/|)\s*(([^>\'"/]+|\'[^\']*\'|"[^"]*")*)(/|)\s*)>!',$str,$m1,PREG_OFFSET_CAPTURE,$offs))
	{	$r[]=substr($str,$offs,$m1[0][1]-$offs);
		$offs=$m1[0][1]+strlen($m1[0][0]);
		preg_match_all('!([^\s\'"=]+)(\s*=\s*("[^"]*"|\'[^\']*\'|[^\s]+)|)!im',$m1[3][0],$m2,PREG_SET_ORDER);
		$tag=strtolower($m2[0][1]);
		if(isset($safetag[$tag]))
		{	if($safetag[$tag][0]&1)
			{	if($m1[2][0])
				{	if(array_search($tag,$tags)===false)
						continue; // 開いてないタグは閉じない
					while(($t=array_shift($tags))) // 開いたのと逆順に閉じる
					{	$r[]="</$t>";
						if($t==$tag)
							break;
					}
					continue;
				}
				if(!$m1[5][0])
					array_unshift($tags,$tag);
			}
			$t=array($tag);
			if(isset($safetag[$tag][1]) && !$m1[2][0])
			{	array_shift($m2);
				while($param=array_shift($m2))
				{	if(array_search(strtolower($param[1]),$safetag[$tag][1])!==false)
						$t[]=$param[0];
				}
			}
			$r[]='<'.$m1[2][0].implode(" ",$t).$m1[5][0].'>';
		}
		else
			$r[]=str_replace(array('<','>'),array('&lt;','&gt;'),$m1[0][0]);
	}
	$r[]=substr($str,$offs);
	while(($tag=array_shift($tags))) // 閉じわすれタグを閉じる
		$r[]="</$tag>";
	return implode("",$r);
}

echo safehtml(<<<EOT
<a href='www.google.com' target=_blank>link</a> <blink dummy='<'>and</blink> <strong onClick='alert("NG")'>click<br/>me!</strong> <z foo='<script>alert("Boo")</script>'>
EOT
);
?>

そういえば
  <z foo='<a href='www.google.com'>link</a>'>
みたいなのは全部エスケープしちゃいますけどそれでいいですよね…

>属性に">"が含まれるケースを考慮してない回答が結構ありますね。

ちなみに僕はどのコードがダメなのか公表してしまった方が(そのコードを書いた人が将来その罠に気づかずに脆弱性のあるプログラムを書いてしまうよりは)いいと思っているので、その手の指摘はたいがいプラス評価しています。


はい。
お題に追記してみました。

なるほど。エンティティや実体参照を許すとどういう脆弱性につながるのかは寡聞にして知りませんが、セミコロンをエスケープしても要件を満たしていそうなので気になるのならそうしてもいいと思います。

ちなみに&hearts;のセミコロンを&semicolon;に置き換えてもハートマークは表示されるみたいですね。

このサイトはセミコロンをエスケープしていませんが、アンパサンドはエスケープしています。


横に長く貫通しているのはpreを使っているせいなのかと思ったら、単に空白が間に入ってないから改行されないだけなんですねぇ。
とりあえずスクロールバーをつけてみました。

 色々と間違っていたのを修整。(要素中の<>にエスケープが要らないとは知らなんだ。)
 この程度の処理だと大して効果が無いようなので,文字列のキャッシュはやめにした。

javascript:with(document.body)(function(s,r,g,x){r=/\b(?:name|href) *= *(?:".*?"|'.*?'|[^ >]*)/gi;g=/</g;x=/^<\/?(?:(a)|br|strong)\b/i;innerHTML=s.replace(/(<[^ >]+ ?)((?:".*?"|'.*?'|[^>])*?)(?=\/?>)/g,function(m,t,a){return x.test(t)?t+(RegExp.$1&&(m=a.match(r))?m.join(' '):''):m.replace(g,'&lt;')})})(innerHTML)
1
2
3
4
5
6
7
8
function doukaku54(s){
  var rx_nh = /\b(?:name|href) *= *(?:".*?"|'.*?'|[^ >]*)/gi,
      rx_ok = /^<\/?(?:(a)|br|strong)\b/i, rx_lt = /</g;
  return s.replace(/(<[^ >]+ ?)((?:".*?"|'.*?'|[^>])*?)(?=\/?>)/g, function(m, tag, ats){
    return rx_ok.test(tag)
      ? tag + (RegExp.$1 && (m = ats.match(rx_nh)) ? m.join(' ') : '')
      : m.replace(rx_lt, '&lt;') });
}

きゅ~勘違いしてました。
「;」のエンティティって存在しないみたいです。
「&」で十分です。

昔、エンティティを無効にするときは「;」というのを読んだ気がしたんだけど、うーんソースが見つからない。

なんでエンティティを無効にしないといけなかったんだっけな~?
何か理由があったはずなんだけど。

バグありました… orz
1
2
3
4
5
@@ -15,3 +15,3 @@
            case 'A' : attrs = deleteAttr(attrs); break;
-           default : return all.replace('&', '&amp;').replace('<', '&lt;');
+           default : return all.replace(/&/g, '&amp;').replace(/</g, '&lt;');
          }

 ありゃ,すみません。Firefox だと突き抜けるんですね。

些細な突っ込みですが…
HTMLだとするとサンプルあった“<br/>”は“&lt;br/>”にしなきゃいけないんじゃないんでしょうか?
無論“<br>”は“<br>”として

あ、なるほど、IEだと折り返されているんですねぇ…。
PREで囲っちゃうとFirefoxではOKになったけども
IEでは逆に突き抜けるように…うむむ。
とりあえずPREは外しておきます。

コードとコメント本文に対して下のスクリプトを適用するといいのかも…。

うむむ。「一部の属性を生かす必要があるタグ」「閉じタグのあるタグ」「閉じタグのないタグ」という選定のつもりでしたが、確かに<br/>がエスケープされるべきかどうか明言しておいた方がよかったですね。今回はどちらの解答でも題意を満たしているものとします。

「日記サービスでユーザに一部のHTMLを許可する」というユースケースをイメージして作ったお題なので、続編からは「<br/>も<br>も両方許容せよ」というようになると思います。


1. パーザ (文字列→構文木)
2. トランスレータ (構文木→構文木)
3. プリンタ (構文木→文字列)
と分けて

パーザは Tag Soupライブラリのものを使い、
タグのエスケープはトランスレータで行うようにし、
プリンタは自前で書いてみました。
コメントは保存されます。

こちらの方が前の投稿のものよりモジュラリティが
高くなった気がします。

urlエンコーディングによるuriの値表現の
validatingは手を抜いておこなっていません。
(元元要求にはなかったような気がしますと言い訳してみる:p)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
module Main (main) where

import Data.Char
import Data.Maybe
import Text.HTML.TagSoup

-- Parsing
-- Text.HTML.TagSoup.parseTags :: String -> [Tag Char]

-- Translating
translate :: [Tag Char] -> [Tag Char]
translate = map trans

trans :: Tag Char -> Tag Char
trans tag = case tag of
  TagOpen t attrs | ignore t  -> escapeTagOpen t attrs
                  | otherwise -> TagOpen t (filterAttr t attrs)
  TagClose t      | ignore t  -> escapeTagClose t
  _                           -> tag

ignore :: String -> Bool
ignore = flip notElem (map fst filterTable) . map toLower

escapeTagOpen t attrs
 = TagText $ "&lt;"++t++escape (' ':unwords (map showAttr attrs))++"&gt;"
escapeTagClose t
 = TagText $ "&lt;/"++t++"&gt;"

filterAttr :: String -> [Attribute Char] -> [Attribute Char]
filterAttr t = filter ((maybe (const True) id (lookup t filterTable)) . fst)

filterTable :: [(String,String->Bool)]
filterTable = [("a",flip elem ["href","name"])
	      ,("strong",const False)
	      ,("br",const False)]

-- Showing

showTags :: [Tag Char] -> String
showTags [] = ""
showTags (TagOpen s attrs : TagClose e : ts) | isEmptyTag e 
 = angle (s ++ ' ':unwords (map showAttr attrs)++" /")++showTags ts
showTags (t:ts) 
 = showTag t ++ showTags ts

showTag tag = case tag of
  TagOpen t attrs -> angle $ t ++ ' ':unwords (map showAttr attrs)
  TagClose t      -> angle $ t ++ "/"
  TagText s       -> s
  TagComment c    -> angle $ "!--" ++ c ++ "--"
  TagSpecial s t  -> angle $ "!" ++ s ++ ' ':t
  TagWarning s    -> ""

angle :: String -> String
angle s = "<"++s++">"

isEmptyTag :: String -> Bool
isEmptyTag = flip elem ["br","hr"]     -- not full fledged

showAttr :: Attribute Char -> String
showAttr (a,v) = a ++ "=" ++ q v
  where q v = if elem sq v then dq:v++[dq]
              else sq:v++[sq]
        sq = '\''
        dq = '\"'

escape :: String -> String
escape = concatMap esc
 where esc '<' = "&lt;"
       esc '>' = "&gt;"
       esc '&' = "&amp;"
       esc c   = [c]

--

main :: IO ()
-- main = putStrLn . showTags . translate . parseTags =<< getContents

main = do { putStrLn . showTags . translate . parseTags $ testdata1
          ; putStrLn . showTags . translate . parseTags $ testdata2
          ; putStrLn . showTags . translate . parseTags $ testdata3
	  }

testdata1 = "<script foo=\"<script>alert('bar')</script>\">alert('foo')</script>"
testdata2 = "<script foo=\"<a href='link'>link</a>\">alert('foo')</script>"
testdata3 = "<a href='www.g>oogle.com'>link</a>"

{-
*Main> :main
&lt;script foo="&lt;script&gt;alert('bar')&lt;/script&gt;"&gt;alert('foo')&lt;/script&gt;
&lt;script foo="&lt;a href='link'&gt;link&lt;/a&gt;"&gt;alert('foo')&lt;/script&gt;
<a href='www.g>oogle.com'>link<a/>
-}

お題の更新に対応してみました。
一応「&」も「&amp;」に置き換えるようにしました。
<BR>と<BR/>を受付け</BR>は置き換えるように変更しました。

ただし、サイズが少々でかすぎますね。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
from HTMLParser import HTMLParser
from urllib import quote

class HTMLParser2(HTMLParser):

  def reset(self):
    HTMLParser.reset(self)
    self.buf = ''
    self.__endtag_text = ''

  def parse_endtag(self, i):
    try:
      j = self.rawdata.index('>', i+1)
      self.__endtag_text = self.rawdata[i:j+1] 
    except:
      pass
    return HTMLParser.parse_endtag(self, i)

  def get_endtag_text(self):
    return self.__endtag_text

  def replace(self, s):
    return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')

  def handle_starttag(self, tag, attrs):
    if tag == 'a':
      self.buf += '<a '+' '.join(['%s="%s"' % (a, quote(b)) for a, b in attrs if a in ['href', 'name']])+'>'
    elif tag in ['br', 'strong']:
      self.buf += '<%s>' % tag
    else:
      self.buf += self.replace(self.get_starttag_text())

  def handle_startendtag(self, tag, attrs):
    if tag == 'br':
      self.buf += '<br/>'
    else:
      self.buf += self.replace(self.get_starttag_text())

  def handle_endtag(self, tag):
    if tag in ['a', 'strong']:
      self.buf += '</%s>' % tag
    else:
      self.buf += self.replace(self.get_endtag_text())

  def handle_data(self, data):
    self.buf += data

def f(s):
  h = HTMLParser2()
  h.feed(s)
  print s
  print h.buf

f('''<script foo="<script>alert('bar')</script>">alert('foo')</script>''')
f('''<script foo="<a href='link'>link</a>">alert('foo')</script>''')
f('''<a href='www.g>oogle.com'>link</a>''')

ぴったりなライブラリはどこかにあると思うのですが、
見付けられませんでした…。
とりあえず、みつけられたurl-rewiteと
allegroのparse-htmlを使って作成してみました。
長くてぐちゃぐちゃですいません…。
嗚呼…。

ライブラリ:
http://opensource.franz.com/xmlutils/xmlutils-dist/phtml.htm
http://weitz.de/url-rewrite/
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
#+allegro (progn (require :phtml) (use-package :net.html.parser))

(defun html-filter (string &optional in-prop-p)
  (let ((form (if in-prop-p 
		  (parse-html string)
		  (sanitize-html (parse-html string)))))
    (apply #'concatenate 'string (build-html form in-prop-p))))

(defun sanitize-html (form)
  (mapcar (lambda (item)
	    (if (atom item)
		item
		(let ((keys `(,(car item) ,(and (consp (car item)) (caar item)))))
		  (cond ((member :a keys)
			 `(,(car item) ,@(sanitize-html (cdr item))))
			((member :strong keys)
			 `(:strong ,@(sanitize-html (cdr item))))
			((member :br keys) :br)
			('T item)))))
	  form))

(defun build-html (form &optional in-prop-p)
  (if (atom form) 
      form
      (cond ((and (atom (car form)) (eq :br (car form)))
	     `(,(br in-prop-p) ,@(build-html (cdr form) in-prop-p)))
	    ((keywordp (car form)) (build-other form in-prop-p))
	    ((consp (car form))
	     `(,(let* ((top (car form))
		       (keys `(,(car top) ,(and (consp (car top)) (caar top)))))
		      (if in-prop-p
			  (cond ((member-if #'keywordp keys) (build-other top in-prop-p))
				('T top))
			  (cond (member :a keys) (build-a top))
				((member :strong keys) (build-strong top))
				((member-if #'keywordp keys) (build-other top in-prop-p))
				('T top))))
		,@(build-html (cdr form) in-prop-p)))
	    ('T `(,(build-html (car form) in-prop-p)
		   ,@(build-html (cdr form) in-prop-p))))))

(defun br (&optional in-prop-p)
  (if in-prop-p "&lt;br /&gt;" "<br />"))

(defun build-strong (form)
  (format nil "<strong>~{~A~}</strong>" (build-html (cdr form))))

(defun build-a (form)
  (let ((tag (car form))
	(body (build-html (cdr form))))
    (if (and (consp (car form)) 
	     (member (second tag) '(:href :name)))
	(format nil "<a ~(~A~)='~A'>~{~A~}</a> " 
		(second tag) 
		(put-dot-slash-if-need (url-rewrite:url-encode (third tag))) body)
	(format nil "<a>~{~A~}</a> " (build-html (cdr form))))))

(defun put-dot-slash-if-need (str)
  (let ((s (cl-ppcre:create-scanner "^[Hh][Tt][Tt][Pp][Ss]*://")))
    (if (cl-ppcre:scan s str)
	str
	(concatenate 'string "./" str))))

(defun build-other (form &optional in-prop-p)
  (let ((tag (car form))
	(body (build-html (cdr form))))
    (if (consp tag)
	(format nil "~(&lt;~A~{ ~A~}~)&gt;~{~A~}~0@*&lt;/~(~A~)&gt; " 
		(car tag) (prop-maker (cdr tag)) body)
	(if (and in-prop-p (eq :br tag))
	    "&lt;br&gt;"
	    (format nil "~(&lt;~A&gt;~)~{~A~}~0@*&lt;/~(~A~)&gt;"
		    tag (build-html (cdr form)))))))

(defun prop-maker (lst)
  (do ((l lst (cddr l))
       result)
      ((endp l) (nreverse result))
  (push (format nil "~A=\"~A\"" 
		(car l) (html-filter (cadr l) t)) result)))

Squeak Smalltalk で。

例によって正規表現が使えないので手続き的に。この調子だと、より複雑なことが要求される続編が思いやられます…(^_^;)。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| string in tag out save rest |
string := '<a href=''www.google.com''>link</a> <blink>and</blink> <strong onClick=''alert("NG")''>click<br/>me!</strong>'.

in := string readStream.
out := String new writeStream.
[in atEnd] whileFalse: [
	out nextPutAll: (in upTo: $<).
	in back.
	save := in position.
	tag := in upTo: Character space.
	(tag includes: $/) ifTrue: [in position: save. tag := in upTo: $>. in back].
	out nextPutAll: ((#('<a' '<br/' '<strong' '</a' '</strong') includes: tag asLowercase)
		ifTrue: [tag] ifFalse: [tag := '&lt;', tag allButFirst]).
	tag := tag asLowercase.
	[save := in position. (rest := in upTo: $>) includes: $=] whileTrue: [
		| attr quote data |
		in position: save.
		attr := in upTo: $=.
		quote := (#($' $") includes: in peek) ifTrue: [in next] ifFalse: [Character space].
		data := in upTo: quote.
		quote := quote = Character space ifTrue: [''] ifFalse: [quote asString].
		data := attr, '=', quote, data, quote.
		in skipSeparators.
		(tag = '<a' and: [#(href name) includes: attr]) ifTrue: [out space; nextPutAll: data].
		(#('<a' '<br/' '<strong') includes: tag) ifFalse: [
			out space.
			data do: [:chr | chr = $< ifTrue: [out nextPutAll: '&lt;'] ifFalse: [out nextPut: chr]]]].
	out nextPutAll: rest, '>'].
^out contents

"=> '<a href=''www.google.com''>link</a> &lt;blink>and&lt;/blink> <strong>click<br/>me!</strong>' "

正規表現ないとこれはキツそうですね・・・

<br>がエスケープされるので<brをリストにいれたほうがいいと思います。あとattrがasLowercaseされていないような。HREFが削除されてしまいます。


HTMLParser,htmllibを使わずに、スクラッチから書いてみました。
フィルタの登録が簡単にできます。

※ 字句解析部分は、十分にテストしてないので、全然 自信なしです。エンティティ等も未サポート・・・どころか誤動作起こす恐れもあり。

※ フィルタの適用部分は、余計なメソッド呼び出しがたくさん発生する、冗長(無駄の多い)な実装です。

※ 終了タグの扱いがad-hoc。(scan_htmlとpack_tag)

ということなので、最初は汎用性とか考えて書いてた割りに、下の実装の品質は低いです。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
import sys
import string
from cgi import escape
from urllib import quote
from itertools import imap

letters = string.letters + string.digits
TAG, ATTR, QUOTE, TEXT = range(4)

def scan_html(html):

    tag = ''
    text = ''
    attr = ''
    attrs = []
    value = ''
    state = TEXT
    quoted = ''
    closed = False
    escaped = False

    for c in html:
        if state == TAG:
            if c == ">":
                yield TAG, tag, attrs, None
                state = TEXT
            elif c == '/' and tag == '':
                tag += c
            elif c in letters:
                tag += c
            elif c in string.whitespace:
                state = ATTR
            else:
                pass

        elif state == ATTR:
            if c in letters:
                attr += c
            elif c in string.whitespace:
                pass
            elif c == '=' and not quoted:
                state = QUOTE
            elif c == '/':
                tag += c
                state = TAG
            elif c == '>':
                yield TAG, tag, attrs, None
                state = TEXT
            
        elif state == QUOTE:
            if quoted:
                if c == quoted:
                    if escaped:
                        escaped = False
                        value += c
                    else:
                        attrs.append((attr,value))
                        state = TAG
                elif c == '\\':
                    escaped = True
                    value += c
                else:
                    value += c
            elif c in ('"', "'"):
                quoted = c

        else: # TEXT
            if c == "<":
                if text:
                    yield TEXT, tag, None, text
                tag = ''
                text = ''
                attr = ''
                attrs = []
                state = TAG
            else:
                text += c

class MyHTMLParser:
    def __init__(self):
        self.tag_filters = {}
        self.attr_filters = {}        
        self.text_filters = {}
        self.allow_tags = []
        self.forbid_tags = []

    def filter(self, (state, tag, attrs, text)):
        find_filter = lambda x:x.get((state,tag.lower()), lambda x:x)

        tag,attrs = find_filter(self.tag_filters)((tag,attrs))
        attrs = find_filter(self.attr_filters)(attrs)
        text = find_filter(self.text_filters)(text)

        return state, tag, attrs, text

    def parse(self, html, output=sys.stdout.write):

        def is_allowed_tag(tag):
            if self.allow_tags and tag in self.allow_tags:
                return True
            if self.forbid_tags and not tag in self.forbid_tags:
                return True
            return False
        
        def pack_tag(tag, attrs):
            if tag.startswith("/") and not attrs:
                return "<%s>" % tag
            elif tag.endswith("/"):
                if not attrs:
                    return "<%s />" % tag.strip('/ ')
                return "<%s %s />" % (tag.strip('/ ')," ".join(map('%s="%s"'.__mod__,attrs)))
            else:
                if not attrs:
                    return "<%s>" % tag.strip('/ ')
                return "<%s %s>" % (tag," ".join(map('%s="%s"'.__mod__,attrs)))

        for event in imap(self.filter, scan_html(html)):
            state,tag,attrs,text = event
            if state == TAG:
                if is_allowed_tag(tag.lower().strip(' /')):
                    output(pack_tag(tag,attrs))
                else:
                    output(escape(pack_tag(tag,attrs)))
            elif state == TEXT:
                output(text)


def test(html):

    def allow_attrs(*names):
        return lambda attrs: [(k,quote(v)) for k,v in attrs if k.lower() in names]

    def remove_all_attrs(attrs):
        return []

    p = MyHTMLParser()
    p.allow_tags += ['a', 'br', 'strong']
    p.attr_filters[(TAG,'a')] = allow_attrs('href', 'name') 
    p.attr_filters[(TAG,'br')] = remove_all_attrs
    p.attr_filters[(TAG,'strong')] = remove_all_attrs
    p.parse(html)
    print


test('''<script foo="<script>alert('bar')</script>">alert('foo')</script>''')
test('''<script foo="<a href='link'>link</a>">alert('foo')</script>''')
test('''<a href='www.g>oogle.com'>link</a>''')
test('''<br />''')
test('''<img src="foo.jpg" />''')
test("""<a href='www.google.com'>link</a> <blink>and</blink> <strong onClick='alert("NG")'>click<br/>me!</strong>""")
test("""<strong style="color:red;">ok</strong>""")
test("""<br /><a href='localhost'>link</a><strong onClick='alert("NG")'>ok</strong>""")

まあ普通こういう処理はawkではしませんが・・・

% awk -f filter.awk
<script foo="<script>alert('bar')</script>">alert('foo')</script>
&lt;script foo="&lt;script&gt;alert('bar')&lt;/script&gt;"&gt;alert('foo')&lt;/script&gt;

<script foo="<a href='link'>link</a>">alert('foo')</script>
&lt;script foo="&lt;a href='link'&gt;link&lt;/a&gt;"&gt;alert('foo')&lt;/script&gt;

<a href='www.g>oogle.com'>link</a>
<a href="./www.g%3Eoogle.com">link</a>

なお、タグの途中に改行が含まれるケースには対応していません。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
BEGIN {
	ok_tag["A"] = ok_tag["BR"] = ok_tag["STRONG"] = 1
}

/<[A-Za-z]+/ {
	delete attributes
	s = $0
	while (match(s,/<[A-Za-z]+/)) {
		if (RSTART > 1) print substr(s,1,RSTART-1)
		tag = substr(s,RSTART+1,RLENGTH-1)
		s = substr(s,RSTART+RLENGTH)
		if (s ~ /^>/) {
			s = substr(s,2)
		} else {
			while (s != "") {
				gsub(/^[ \t\r\n]*/,"",s)
				if (match(s,/^[A-Za-z]+=/)) {
					attribute = substr(s,1,RLENGTH-1)
					s = substr(s,RLENGTH+1)
					if (match(s,/^("[^"]*"|'[^']*'|[^"'> ]*)/)) {
						value = substr(s,1,RLENGTH)
						attributes[attribute] = value
						s = substr(s,RLENGTH+1)
					}
				} else if (s ~ /^>/) {
					s = substr(s,2)
					break
				} else {
					break
				}
			}
		}

		tag_ = toupper(tag)
		if (ok_tag[tag_]) {
			if (tag_ == "A") {
				for (attr in attributes)
					if (toupper(attr) !~ /^(HREF|NAME)$/) delete attributes[attr]
			} else { #if (tag ~ /^(BR|STRONG)$/)
				delete attributes
			}
			printf("<%s", tag)
			for (attr in attributes) {
				value = urlencode(attributes[attr])
				gsub(/(^["']|["']$)/,"",value)
				if (toupper(attr) == "HREF") {
					printf(" %s=\"./%s\"", attr, value)
				} else {
					printf(" %s=\"%s\"", attr, value)
				}
			}
			printf(">")
		} else {
			printf("&lt;%s", tag) # substr(s,RSTART+RLENGTH)
			for (attr in attributes) {
				printf(" %s=%s", attr, escape(attributes[attr]))
			}
			printf("&gt;")
		}
	}
	
	if (match(s,/<\/[A-Za-z]+>/)) {
		content = substr(s,1,RSTART-1)
		close_tag = substr(s,RSTART+2,RLENGTH-3)
		
		s = substr(s,RSTART+RLENGTH)
		if (content != "") printf("%s", escape(content))

		tag_ = toupper(close_tag)
		if (ok_tag[tag_]) {
			printf("</%s>", close_tag)
		} else {
			printf("&lt;/%s&gt;", close_tag)
		}
	} else if (s != "") {
		printf("%s", s)
	}

	printf "\n"
	next
}

{ print }

function escape(s)
{
	gsub(/</,"\\&lt;",s)
	gsub(/>/,"\\&gt;",s)
	return s
}

function urlencode(s)
{
#	gsub(/ /,"%20",s)
	gsub(/</,"%3C",s)
#	gsub(/=/,"%3D",s)
	gsub(/>/,"%3E",s)
	return s
}

適用しました。

すいません、このコードの34行に開き括弧が一つ足りませんでした。
(cond ((mem
が正しいものです。

SGMLのタグは非常に多様な形式を持つようです。もちろん全部の形式に対応すれば良いのですが、必ずしもブラウザが対応しているとは限らないため、正しい形式のタグであっても万一ブラウザが対応していない事によってセキュリティホールになってしまっては意味がありません。そこでごく一般的な形式のタグしか想定しない事にします。

方針
間違っても、未処理のタグが残らないようにする(想定外であっても)。
想定外の構造のタグが誤って変換されてしまう事はやむを得ないとする。

この条件を満たすため、以下の方法で処理する

変換対象のタグを前処理する
全ての <, > を変換する
前処理したタグを元に戻す

&は題意から変換しないことにしました。尚、HTMLではURL中であっても文字参照は有効(ブラウザが解釈しなければならない)ため特別扱いはしていません。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import java.util.regex.*;

public class Sample {
    private static final Pattern TAG_FILTER = Pattern.compile
        ("<(¥¥w+)((¥¥s+¥¥w+(¥¥s*=¥¥s*(¥"[^¥"]*¥"|'[^']*'|[¥¥w-:]*))?)*)¥¥s*/?¥¥s*>");
    private static final Pattern END_TAG_FILTER = Pattern.compile
        ("(?i)</(A|BR|STRONG)¥¥s*>");
    private static final Pattern ATTR_FILTER = Pattern.compile
        ("(¥¥w+)¥¥s*=¥¥s*(¥"[^¥"]*¥"|'[^']*'|[¥¥w-:]*)");
    public static String sanitizing(String fragment) {
        fragment = fragment.replaceAll("[¥¥p{Cntrl}&&[^¥¥s]]", "");
        Matcher m = TAG_FILTER.matcher(fragment);
        StringBuffer sb = new StringBuffer();
        while (m.find()) {
            if ("A".equalsIgnoreCase(m.group(1))) {
                String href = null, name = null;
                Matcher m2 = ATTR_FILTER.matcher(m.group(2));
                while (m2.find()) {
                    if ("href".equalsIgnoreCase(m2.group(1))) {
                        href = m2.group(2);
                    } else if ("name".equalsIgnoreCase(m2.group(1))) {
                        name = m2.group(2);
                    }
                }
                String tag = "¥001"+m.group(1) + ((href != null)?" href="+href
                    : "") + ((name != null)? " name="+name : "") + "¥002";
                m.appendReplacement(sb, m.quoteReplacement(tag));
            } else if ("BR".equalsIgnoreCase(m.group(1)) || 
                       "STRONG".equalsIgnoreCase(m.group(1))) {
                m.appendReplacement(sb, "¥001" + m.group(1) + "¥002");
            }
        }
        m.appendTail(sb);
        m = END_TAG_FILTER.matcher(sb.toString());
        fragment = m.replaceAll("¥001/$1¥002");
        fragment = fragment.replaceAll("<", "&lt;");
        fragment = fragment.replaceAll(">", "&gt;");
        fragment = fragment.replaceAll("¥001", "<");
        fragment = fragment.replaceAll("¥002", ">");
        return fragment;
    }
    public static void main(String[] args) throws Exception {
        System.out.println(sanitizing("<script><abc><def ghi=jkl>"));
        System.out.println(sanitizing("<script foo=¥"<script>alert(¥'bar¥')</script>¥">alert(¥'foo¥')</script>"));
        System.out.println(sanitizing("<script foo=¥"<a href=¥'link¥'>link</a>¥" center>alert(¥'foo¥')</script><BR/>"));
        System.out.println(sanitizing("<a href='www.g>oogle.com' id=125>link</a>"));
    }
}

ocamllex で。
こんなに長くなるとは思わなかった。
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
{
type attributes = (string * string) list (* name, value *)
type input =
    | String of string
    | SingleTag of string * attributes * int * int
    | OpenTag of string * attributes * int * int
    | CloseTag of string * int * int
    | Eof
}

let tag_start = "<"
let close_tag_start = "</"
let tag_end = ">"
let single_tag_end = "/>"
let tag_constituent = ['a' - 'z' 'A' - 'Z']
let attr_constituent = ['a' - 'z' 'A' - 'Z' '-']
let white = [' ' '\t' '\n' '\r']
let quote_omittable = ['a' - 'z' 'A' - 'Z' '0' - '9' '.' '-']

rule main = parse
  | tag_start           { tag (Lexing.lexeme_start lexbuf) lexbuf }
  | close_tag_start     { close_tag (Lexing.lexeme_start lexbuf) lexbuf }
  | [^'<']+ as s        { String s }
  | eof                 { Eof }
and tag p = parse
  | tag_constituent+ as name    { attr_list p name [] lexbuf }
  | (_#tag_constituent)+ as s   { String ("<"^s) }
and attr_list p tagname attrs = parse
  | white+                      { attr_list p tagname attrs lexbuf }
  | (attr_constituent+ as name) white* '=' {
      let value = attr_value lexbuf in
        attr_list p tagname ((name, value)::attrs) lexbuf
    }
  | tag_end             {
      let endpos = Lexing.lexeme_end lexbuf in
        OpenTag(tagname, List.rev attrs, p, endpos)
    }
  | single_tag_end      {
      let endpos = Lexing.lexeme_end lexbuf in
        SingleTag(tagname, List.rev attrs, p, endpos)
    }
  | _                   { attr_list p tagname attrs lexbuf }
and attr_value = parse
  | white+                      { attr_value lexbuf }
  | '\'' ([^'\'']* as value) '\'' { value }
  | '"' ([^'"']* as value) '"'  { value }
  | (quote_omittable* as value) { value }
  | _                           { attr_value lexbuf }
and close_tag p = parse
  | (tag_constituent+ as name) [^'>']* tag_end  {
      CloseTag (name, p, Lexing.lexeme_start lexbuf)
    }
  | (_#tag_constituent)+ as text                {
      String ("</"^text)
    }

{
let parse_input s =
  let lexbuf = Lexing.from_string s in
  let rec loop inputs =
    match main lexbuf with
      | Eof -> List.rev inputs
      | x -> loop (x::inputs)
  in loop []

let (===) s1 s2 = String.uppercase s1 = String.uppercase s2

let is_allowed_tag tagname =
  List.exists (fun s -> tagname === s) ["a"; "br"; "strong"]

let is_allowed_attribute tagname attrname =
  (* めんどくさくなったので ad-hoc *)
  tagname === "a" && (attrname === "href" || attrname === "name")

let sanitizing_output buf string p q =
  for x = p to q-1 do
    match string.[x] with
      | '<' -> Buffer.add_string buf "&lt;"
      | '>' -> Buffer.add_string buf "&gt;"
      | '&' -> Buffer.add_string buf "&amp;"
      | c -> Buffer.add_char buf c
  done

let output_attribute_if_allowed buf tagname (attrname, value) =
  if is_allowed_attribute tagname attrname then
    let quote = if String.contains value '"' then '\'' else '"' in
      Printf.bprintf buf " %s=%c%s%c" attrname quote value quote

let output_tag buf name attrs is_single =
  Printf.bprintf buf "<%s" name;
  List.iter (output_attribute_if_allowed buf name) attrs;
  if is_single then Buffer.add_char buf '/';
  Buffer.add_char buf '>'

let output_text_fragment buf src = function
  | String s ->
      sanitizing_output buf s 0 (String.length s)
  | SingleTag (s, attrs, p, q) ->
      if is_allowed_tag s then
        output_tag buf s attrs true
      else
        sanitizing_output buf src p q
  | OpenTag (s, attrs, p, q) ->
      if is_allowed_tag s then
        output_tag buf s attrs false
      else
        sanitizing_output buf src p q
  | CloseTag (s, p, q) ->
      if is_allowed_tag s then
        Printf.bprintf buf "</%s>" s
      else
        sanitizing_output buf src p q
  | Eof -> ()

let filter_text text =
  let list = parse_input text in
  let buf = Buffer.create (String.length text) in
    List.iter (output_text_fragment buf text) list;
    Buffer.contents buf
}

次のお題向けに整理して書き直しました。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| string accepts in out upToAnyOf letters separators |
string := '<a title="(>_<;)" href=''www.google.com'' name=''hoge'' target=_blank>link</a> <blink>and</blink> <strong onClick=''alert("NG")''>click<br/>me!</strong>'.

accepts := {#a->#(name href). #strong->#(). #br->#()} as: Dictionary.
string := string copyReplaceAll: '<br>' with: '<br/>'.
in := string readStream.
out := String new writeStream.
upToAnyOf := [:arr | String streamContents: [:ss |
    arr := arr copyWith: nil.
    [arr includes: in peek] whileFalse: [ss nextPut: in next]]].
letters := Character alphabet asArray, Character alphabet asUppercase.
separators := Character separators, #($/ $>).

[out nextPutAll: (in upTo: $<) escapeEntities. in atEnd] whileFalse: [
    | tag lt isClose isAccepted blank rest |
    (isClose := in peek == $/) ifTrue: [in next].
    tag := upToAnyOf value: separators.
    lt := '<', (isClose ifTrue: ['/'] ifFalse: ['']).
    (isAccepted := accepts keys includes: tag asLowercase) ifFalse: [lt := lt escapeEntities].
    out nextPutAll: lt, tag.
    [blank := upToAnyOf value: letters, '>'. {nil. $>} includes: in peek] whileFalse: [
        | attr equal value quote |
        attr := upToAnyOf value: #($= $>).
        equal := in peek == $= ifTrue: [in next asString] ifFalse: [''].
        value := (#($' $") includes: (quote := in peek))
            ifTrue: [quote asString, (in next; upTo: quote), quote asString]
            ifFalse: [upToAnyOf value: #($  $>)].
        out nextPutAll: (isAccepted
            ifFalse: [blank, attr, equal, value escapeEntities]
            ifTrue: [((accepts at: tag) includes: attr)
                ifTrue: [blank, attr, equal, value] ifFalse: ['']])].
    rest := blank, (in peek == $> ifTrue: [in next asString] ifFalse: ['']).
    out nextPutAll: (isAccepted ifTrue: [rest] ifFalse: [rest escapeEntities])].
World findATranscript: nil.
Transcript cr; show: out contents

"=> <a href='www.google.com' name='hoge'>link</a> &lt;blink&gt;and&lt;/blink&gt; <strong>click<br/>me!</strong> "

html-parseを使用しました。確認はclispで行ないました。
HTMLの属性値の変換は、< > & " のHTML escapeだけを行なってます。

(html-filter 
"<a href=\"www.google.com\">link</a> <blink>and</blink>\n<strong onClick='alert(\"NG\")'>click<br>me!</strong>")
  => "<a href=\"www.google.com\">link</a>&lt;blink&gt;and&lt;/blink&gt;\n<strong>click<br/>me!</strong>" 
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
(asdf:oos 'asdf:load-op :cl-html-parse)

(defconstant *enable-tags* '(("a" "href" "name")
                             ("br")
                             ("strong")))

(defun html-filter (html)
  (html-element-filter (html-parse:parse-html html)))

(defun html-element-filter (elements)
  (cond 
    ((stringp elements)
     (convert-not-tag elements))
    ((symbolp elements)
     (convert-tag elements nil))
    (t

      (let* ((element-type (cond ((and (listp (car elements))
                                       (symbolp (caar elements))
                                       (not (null (cadar elements)))
                                       (symbolp (cadar elements)))
                                  :tag-attr)
                                 ((symbolp (car elements)) :tag)
                                 (t :not-tag)))
             (tag (case element-type
                    (:tag-attr (caar elements))
                    (:tag      (car elements))))
             (attr-lst (if (equal element-type :tag-attr)
                         (cdar elements)))
             (elems (case element-type
                      (:tag-attr (cdr elements))
                      (:tag      (cdr elements))
                      (:not-tag  elements))))
        (case element-type
          (:tag-attr (convert-tag-attr tag attr-lst elems))
          (:tag      (convert-tag tag elems))
          (:not-tag  (string-join (mapcar #'html-element-filter elems))))))))

(defun convert-tag (tag elems)
  (let* ((enable-tag (assoc (string-downcase tag) *enable-tags* :test #'equal))
         (< (if enable-tag "<" "&lt;"))
         (> (if enable-tag ">" "&gt;")))
    (cond ((null elems)
           (format nil "~a~(~a~)/~a" < tag >))
          (t
            (string-join
              (format nil "~a~(~a~)~a" < tag >)
              (html-element-filter elems)
              (format nil "~a/~(~a~)~a" < tag >))))))

(defun convert-tag-attr (tag attr-lst elems)
  (let* ((enable-tag (assoc (string-downcase tag) *enable-tags* :test #'equal))
         (< (if enable-tag "<" "&lt;"))
         (> (if enable-tag ">" "&gt;"))
         (attr (loop for e in attr-lst by #'cddr
                     when (or (null enable-tag)
                              (member (string-downcase e) (cdr enable-tag)
                                      :test #'equal))
                     collect (format nil "~(~a~)=\"~a\""
                                     e
                                     (escape-html (getf attr-lst e))))))
    (cond 
      (elems
        (string-join
          (if attr
            (format nil "~a~(~a~) ~{~a~^, ~}~a" < tag attr >)
            (format nil "~a~(~a~)~a" < tag >))
          (html-element-filter elems)
          (format nil "~a/~(~a~)~a" < tag >)))
      (t
        (if attr
          (format nil "~a~(~a~) ~{~a~^, ~}/~a" < tag attr >)
          (format nil "~a~(~a~)/~a" < tag >))))))

(defun convert-not-tag (element)
  (format nil "~a" (escape-html element)))

(defun string-join (&rest lst)
  (let ((l (if (listp (car lst))
             (car lst)
             lst)))
    (apply #'concatenate (cons 'string l))))

(defun escape-html (str)
  (let ((in (make-string-input-stream str))
        (out (make-string-output-stream)))
    (loop for c = (read-char in nil nil)
          while c
          do (case c
               (#\< (write-string "&lt;" out))
               (#\> (write-string "&gt;" out))
               (#\& (write-string "&amp;" out))
               (#\" (write-string "&quot;" out))
               (t (write-char c out)))
          finally (return (get-output-stream-string out)))))

この課題、非常に勉強になります。閉じタグ忘れに対応。
他の方ので、"<!--" がエスケープされないのがいくつか上がってる気がします。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
using System;
using System.Text;
using System.Text.RegularExpressions;

class HtmlFilter {
    private Regex _regFilter;
        
    public HtmlFilter() {
        _regFilter = new Regex(@"(<(?<begin>\w+)(?:\s+(?<attr>\w+\s*=\s*"
             + "(?:'[^']*'|\"[^\"]*\")" + @"\s*)*)?/?>)|(</(?<end>\w+)\s*>)",
            RegexOptions.IgnoreCase | RegexOptions.Compiled);
    }
    public string escape(string s) {
        return s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
    }
    
    public string filter(string input) {
        bool tagA = false;
        int index = 0, tagStrong = 0;
        StringBuilder sb = new StringBuilder(input.Length * 2);

        for (Match m = _regFilter.Match(input); m.Success; m = _regFilter.Match(input, index)) {
            sb.Append(escape(input.Substring(index, m.Index - index)));
            index = m.Index + m.Length;

            if (m.Groups["begin"].Success) {
                switch (m.Groups["begin"].Value.ToLower()) {
                    case "a":
                        string href = "";
                        foreach (Capture capture in m.Groups["attr"].Captures) {
                            string[] attribute = capture.Value.Split('=');
                            if (attribute[0].Trim().Equals("href", StringComparison.OrdinalIgnoreCase)) {
                                href = " href=" + attribute[1].Trim();
                                break;
                            }
                        }
                        sb.Append(tagA ? "</a><a" : "<a").Append(href).Append(">");
                        tagA = true;
                        break;
                    case "strong":
                        tagStrong++;
                        sb.Append("<strong>");
                        break;
                    case "br":
                        sb.Append("<br/>");
                        break;
                    default:
                        sb.Append(escape(m.Value));
                        break;
                }
            } else {
                switch (m.Groups["end"].Value.ToLower()) {
                    case "a":
                        if (tagA) {
                            sb.Append("</a>");
                            tagA = false;
                        } else {
                            sb.Append(escape(m.Value));
                        }
                        break;
                    case "strong":
                        if (tagStrong > 0) {
                            tagStrong--;
                            sb.Append("</strong>");
                        } else {
                            sb.Append(escape(m.Value));
                        }
                        break;
                    default:
                        sb.Append(escape(m.Value));
                        break;
                }
            }
        }

        sb.Append(escape(input.Substring(index, input.Length - index)));

        while (tagStrong-- > 0) sb.Append("</strong>");
        if (tagA) sb.Append("</a>");

        return sb.ToString();
    }
}

class Program {
    static void Main(string[] args) {
        HtmlFilter f = new HtmlFilter();
        string text = System.IO.File.ReadAllText("hoge.txt");
        Console.WriteLine(f.filter(text));
    }
}


ごり押しですが、strncasecmp に救われた気がします。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define BUFSIZE 1024
char in[BUFSIZE];


int main( void ){

   char *p, *tmp;
   char q, attr=0, s='<';
   int len=0, remain;

   while( (remain = fgets( in + len, BUFSIZE - len, stdin )) || *p ) {
      for(p=in;*p;p++){
         if( remain && (len = strlen( p )) < strlen("<string") ){
            tmp = (char*)malloc( sizeof(char) * len );
            strcpy( tmp, p );
            strcpy( in, tmp );
            free(tmp);
            break;
         }
         if( s == '<' ){
            if( *p == '>' ){ printf("&gt;"); }
            else if( *p == '\'' || *p == '"' ){ putchar(s=*p); }
            else if( *p != '<' ){ putchar(*p); }
            else if( !strncasecmp( p, "</a>", 4) ){ printf("</a>"), attr=0; p+=3; }
            else if( !strncasecmp( p, "<br", 3 ) ){
               if( *(p+3) == '>' ){ printf("<br>"); p+=3; }
               else if( *(p+3) == ' ' ){ printf("<br");  p+=3; s='>'; }
            }
            else if( !strncasecmp( p, "<strong", 7 ) ){
               if( *(p+7) == '>' ){ printf("<strong>"); p+=7; }
               else if( *(p+7) == ' ' ){ printf("<strong");  p+=7; s='>'; }
            }
            else if( !strncasecmp( p, "<a", 2 ) ){
               if( *(p+2) == '>' ){ printf("<a>"); p+=2; }
               else if( *(p+2) == ' ' ){ printf("<a");  p+=2; s='@'; }
            }
            else{ printf("&lt;"); }
         }
         else if( s == '>' && *p == '>' ){ putchar(*p); s='<'; }
         else if( s == '@' ){
            q = *(p+5);
            if( *p == '>' ){ putchar(*p); s='<'; }
            else if( !strncasecmp( p, "href=", 5 ) && (q == '\'' || q == '"') ){
               printf(" href=%c", s=q ); p+=6; attr='h';
            }
            else if( !strncasecmp( p, "name=", 5 ) && (q == '\'' || q == '"') ){
               printf(" name=%c", s=q ); p+=6; attr='n';
            }
         }
         else if( s == '"' || s == '\'' ){
            if( !attr ){
               if (*p == '<' ){ printf("&lt;"); }
               else if( *p == '>' ){  printf("&gt;"); }
               else{ putchar(*p); }
            }
            else{
               if( *p == s ){ putchar(s); s='@'; }
               else if( *p == '\\' && *(p+1) == s ){ printf("\\%c",s), p+=1; }
               else if( attr=='h' && (tmp = strchr("!#$%'=|^\\[]`{}+<>", *p )) ){ printf("%%%X", *tmp); }
               else{ putchar(*p); }
            }
         }
      }
   }

   return 0;

}

無効なタグの引用符で囲まれた部分の扱いなどにバグがありましたので、続きの問題への投稿 #3650 で修正しています。

HTML::Parserで割ときっちりと。
Dan the Perl Monger
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#!/usr/local/bin/perl
use strict;
use warnings;
use HTML::Parser;
use HTML::Entities;

sub parse {
    my $str = shift;
    my @parsed;
    my $p = HTML::Parser->new(
        start_h => [
            sub {
                my ( $t, $a, $text ) = @_;
                if ($t eq 'a'){
                    my @a =
                        map { qq/$_="/ . encode_entities($a->{$_}) . qq/"/ }
                            grep /^name|href$/, keys %$a;
                    push @parsed, "<a ".join(" ", @a). ">"
                }elsif($t eq 'br' || $t eq 'strong'){
                    push @parsed, "<$t>"
                }else{
                    push @parsed, encode_entities($text)
                }
            }, "tagname, attr, text"
        ],
        end_h => [
            sub {
                my $t = shift;
                push @parsed, $t =~ /^a|br|strong$/ ? "</$t>" : "&lt'/$t&gt;";
            }, "tagname"
        ],
        text_h => [
            sub {
                my $t = shift;
                $t =~ s{\n}{</br>}g;
                push @parsed, $t;
            }, "text"
        ]
    )->parse($str);
    join '', @parsed;
}

# for test
local $/;
my $str = <>;
print parse($str);

短かさにこだわってみました. 
&#x3C; とかは反則ですか?
1
2
3
4
5
6
7
8
#! /usr/bin/perl
sub E($){ local $_ = shift; s/([&<>"'])/sprintf('&#x%02X;',ord $1)/eg; $_; }
sub T($){ local $_ = shift; no warnings;
          s#^<(/?(?:br|strong))\b.*$#<$1>#i ? $_ :
            s#^<(/?a\b)[^>]*?(\s?(?:href|name)=(?:'[^']*'|"[^"]*"|[^>\s]*))?
              [^>]*?(\s?(?:href|name)=(?:'[^']*'|"[^"]*"|[^>\s]*))?[^>]*?>
              #<$1$2$3>#xi ? $_ : E $_;  }
$_ = join '', <>; s#(.*?)(</?\w+\b[^>]*>)#E($1).T($2)#eg; print;

Gauche で書いてみました。まず、 HTML を HtmlPrag を使って SXML 形式に変換し、その上で属性のフィルタリングをします。タグの < や > を &lt;、 &gt; に変換するのは正規表現を使っています。ひとつのタグが複数行に分けて書かれている場合には対応していません。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
(load "htmlprag")

(use util.list)
(use sxml.serializer)
(use sxml.sxpath)
(use sxml.tools)
(use sxml.tree-trans)

(define (filter-html shtml)
  (pre-post-order shtml
    `((*default* . ,(lambda c c))
      (*text* . ,(lambda (_ c) c))
      (br . ,(lambda br
               (sxml:change-attrlist! br '())
               br))
      (strong . ,(lambda strong
                   (sxml:change-attrlist! strong '())
                   strong))
      (a . ,(lambda a
              (sxml:change-attrlist! a
                                     (cond-list
                                      (((if-car-sxpath "./@href") a) => values)
                                      (((if-car-sxpath "./@name") a) => values)))
              a)))))

(define (main args)
  (display
   (call-with-input-file (cadr args)
     (lambda (iport)
       (regexp-replace-all
        #/<((?!\/?a\b|\/?br\b|\/?strong\b|!--)[^>]*(?!--))>/
        (srl:sxml->xml (filter-html (html->sxml iport)))
        "&lt;\\1&gt;")))))

たぶんこれでできているのではないかな?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#!/usr/bin/env groovy

String.metaClass.define{
    escapeHtml{
        delegate.replace("<", "&lt;").replace(">", "&gt;")
    }
}

def text = System.in.text
// 属性値の置換
text = text.replaceAll(/(?<="|').+?[^\\](?='|")/){
    it.escapeHtml()
}
// HTMLタグの置換
text = text.replaceAll(/<\/?(\w+).*?\/?>/){ all, name ->
    def ret = ""
    switch( name ){
        case ~/(?i)a/:
            // aタグhref属性のみURLエンコード処理
            ret = all.replaceAll(/(?<=href="|').+?[^\\](?='|")/){
                it.replace("&lt;","%3C").replace("&gt;", "%3E")
            }
            break
        case ~/(?i)br|strong/:
            ret = "<${name} />"
            break
        default:
            ret = all.escapeHtml()
            break
    }
    ret
}

println text

Index

Feed

Other

Link

Pathtraq

loading...