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 - Python

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()

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

属性をパースする正規表現が二度出てくるのが美しくないですが・・・このあたりを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()

素直にライブラリを使いましたが、お題によれば無効にしたタグ部分は
置き換えた文字以外、そのままにすべきなんでしょうが
(例えば大文字小文字など)、ライブラリの都合上、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

お題の更新に対応してみました。
一応「&」も「&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>''')

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>""")

Index

Feed

Other

Link

Pathtraq

loading...