从Python字符串中删除不在允许列表中的HTML标签。

78

我有一个包含文本和HTML的字符串。我希望移除或禁用某些HTML标签,例如<script>,同时允许其他标签,以便在网页上安全地呈现它。我有一个允许使用的标签列表,如何处理该字符串以删除任何其他标签?


6
它还应该删除未列入白名单的所有属性...考虑以下代码:<img src="heh.png" onload="(function(){/* do bad stuff */}());" /> - Dagg Nabbit
...还有那些无用的空标签,以及可能连续出现的br标签。 - ducu
1
请注意,前两个答案很危险,因为很容易从BS / lxml中隐藏XSS。 - fatal_error
9个回答

71
使用 lxml.html.clean 来清理 HTML,非常简单!
from lxml.html.clean import clean_html
print clean_html(html)

假设有以下 HTML 代码:
html = '''\
<html>
 <head>
   <script type="text/javascript" src="evil-site"></script>
   <link rel="alternate" type="text/rss" src="evil-rss">
   <style>
     body {background-image: url(javascript:do_evil)};
     div {color: expression(evil)};
   </style>
 </head>
 <body onload="evil_function()">
    <!-- I am interpreted for EVIL! -->
   <a href="javascript:evil_function()">a link</a>
   <a href="#" onclick="evil_function()">another link</a>
   <p onclick="evil_function()">a paragraph</p>
   <div style="display: none">secret EVIL!</div>
   <object> of EVIL! </object>
   <iframe src="evil-site"></iframe>
   <form action="evil-site">
     Password: <input type="password" name="password">
   </form>
   <blink>annoying EVIL!</blink>
   <a href="evil-site">spam spam SPAM!</a>
   <image src="evil!">
 </body>
</html>'''

The results...

<html>
  <body>
    <div>
      <style>/* deleted */</style>
      <a href="">a link</a>
      <a href="#">another link</a>
      <p>a paragraph</p>
      <div>secret EVIL!</div>
      of EVIL!
      Password:
      annoying EVIL!
      <a href="evil-site">spam spam SPAM!</a>
      <img src="evil!">
    </div>
  </body>
</html>

您可以定制您想要清理或保留的元素。

1
查看 lxml.html.clean.clean() 方法的文档字符串,它有很多选项! - Denilson Sá Maia
2
请注意,这里使用的是黑名单过滤恶意位,而不是白名单,但只有白名单方法才能保证安全。 - Søren Løvborg
6
清洁工具还支持白名单功能,使用allow_tags - Martijn Pieters
太好了!我喜欢默认配置,但是假设我想要删除所有的<span>标签,我该怎么做呢? - Ulf Gjerdingen

50

以下是使用BeautifulSoup的简单解决方案:

from bs4 import BeautifulSoup

VALID_TAGS = ['strong', 'em', 'p', 'ul', 'li', 'br']

def sanitize_html(value):

    soup = BeautifulSoup(value)

    for tag in soup.findAll(True):
        if tag.name not in VALID_TAGS:
            tag.hidden = True

    return soup.renderContents()

如果您想同时删除无效标签的内容,可以用tag.extract()替换tag.hidden。您还可以考虑使用lxmlTidy

谢谢,我暂时不需要这个ATM,但是我知道将来一定会需要找到类似的东西。 - John Farrell
1
导入语句应该改为 from BeautifulSoup import BeautifulSoup - Nikhil
9
您可能还希望限制属性的使用。为此,只需将以下内容添加到上述解决方案中: valid_attrs = 'href src'.split() for ...: ... tag.attrs = [(attr, val) for attr, val in tag.attrs if attr in valid_attrs] 希望对您有所帮助。 - Gerald Senarclens de Grancy
12
这不安全!请参考Chris Dost的答案:https://dev59.com/dnRB5IYBdhLWcg3wLUq3#812785 - Thomas
这太棒了!不过,要安装BeautifulSoup 4,请运行:easy_install beautifulsoup4然后导入:from bs4 import BeautifulSoup有关详细信息,请参见http://www.crummy.com/software/BeautifulSoup/bs4/doc/。 - somecallitblues
那些关于lxml和tidy链接的页面已经消失了。 - Zauberin Stardreamer

41
通过Beautiful Soup提供的解决方案无法解决此问题。你可能能够利用Beautiful Soup提供的解析树来实现超越这些方案的一些特殊处理。我认为过一段时间我会试着解决这个问题,但需要大约一周的时间,而我近期没有空闲时间。
仅仅是为了明确,Beautiful Soup不仅会抛出某些解析错误,上述代码未能捕获;而且还有很多非常真实的XSS漏洞也未被发现,例如:
<<script>script> alert("Haha, I hacked your page."); </</script>script>

最好的做法是将<元素剥离成&lt;,以禁止所有HTML,然后使用受限制的子集(如Markdown)来正确呈现格式。特别地,你还可以使用正则表达式重新引入常见的HTML。大致上,该过程如下:

_lt_     = re.compile('<')
_tc_ = '~(lt)~'   # or whatever, so long as markdown doesn't mangle it.     
_ok_ = re.compile(_tc_ + '(/?(?:u|b|i|em|strong|sup|sub|p|br|q|blockquote|code))>', re.I)
_sqrt_ = re.compile(_tc_ + 'sqrt>', re.I)     #just to give an example of extending
_endsqrt_ = re.compile(_tc_ + '/sqrt>', re.I) #html syntax with your own elements.
_tcre_ = re.compile(_tc_)

def sanitize(text):
    text = _lt_.sub(_tc_, text)
    text = markdown(text)
    text = _ok_.sub(r'<\1>', text)
    text = _sqrt_.sub(r'&radic;<span style="text-decoration:overline;">', text)
    text = _endsqrt_.sub(r'</span>', text)
    return _tcre_.sub('&lt;', text)

我还没有测试过那段代码,所以可能会有漏洞。但是你可以看到大致思路:在允许的内容之前,需要先将所有HTML标签列入黑名单。


3
如果您首次尝试此操作,请执行以下步骤:import re from markdown import markdown如果您没有安装markdown,可以尝试使用easy_install进行安装。 - Luke Stanley

25

这是我在自己的项目中使用的内容。可接受的元素/属性来自于feedparser,而BeautifulSoup则完成了工作。

from BeautifulSoup import BeautifulSoup

acceptable_elements = ['a', 'abbr', 'acronym', 'address', 'area', 'b', 'big',
      'blockquote', 'br', 'button', 'caption', 'center', 'cite', 'code', 'col',
      'colgroup', 'dd', 'del', 'dfn', 'dir', 'div', 'dl', 'dt', 'em',
      'font', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 
      'ins', 'kbd', 'label', 'legend', 'li', 'map', 'menu', 'ol', 
      'p', 'pre', 'q', 's', 'samp', 'small', 'span', 'strike',
      'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th',
      'thead', 'tr', 'tt', 'u', 'ul', 'var']

acceptable_attributes = ['abbr', 'accept', 'accept-charset', 'accesskey',
  'action', 'align', 'alt', 'axis', 'border', 'cellpadding', 'cellspacing',
  'char', 'charoff', 'charset', 'checked', 'cite', 'clear', 'cols',
  'colspan', 'color', 'compact', 'coords', 'datetime', 'dir', 
  'enctype', 'for', 'headers', 'height', 'href', 'hreflang', 'hspace',
  'id', 'ismap', 'label', 'lang', 'longdesc', 'maxlength', 'method',
  'multiple', 'name', 'nohref', 'noshade', 'nowrap', 'prompt', 
  'rel', 'rev', 'rows', 'rowspan', 'rules', 'scope', 'shape', 'size',
  'span', 'src', 'start', 'summary', 'tabindex', 'target', 'title', 'type',
  'usemap', 'valign', 'value', 'vspace', 'width']

def clean_html( fragment ):
    while True:
        soup = BeautifulSoup( fragment )
        removed = False        
        for tag in soup.findAll(True): # find all tags
            if tag.name not in acceptable_elements:
                tag.extract() # remove the bad ones
                removed = True
            else: # it might have bad attributes
                # a better way to get all attributes?
                for attr in tag._getAttrMap().keys():
                    if attr not in acceptable_attributes:
                        del tag[attr]

        # turn it back to html
        fragment = unicode(soup)

        if removed:
            # we removed tags and tricky can could exploit that!
            # we need to reparse the html until it stops changing
            continue # next round

        return fragment

一些小测试,以确保它的行为正确:

tests = [   #text should work
            ('<p>this is text</p>but this too', '<p>this is text</p>but this too'),
            # make sure we cant exploit removal of tags
            ('<<script></script>script> alert("Haha, I hacked your page."); <<script></script>/script>', ''),
            # try the same trick with attributes, gives an Exception
            ('<div on<script></script>load="alert("Haha, I hacked your page.");">1</div>',  Exception),
             # no tags should be skipped
            ('<script>bad</script><script>bad</script><script>bad</script>', ''),
            # leave valid tags but remove bad attributes
            ('<a href="good" onload="bad" onclick="bad" alt="good">1</div>', '<a href="good" alt="good">1</a>'),
]

for text, out in tests:
    try:
        res = clean_html(text)
        assert res == out, "%s => %s != %s" % (text, res, out)
    except out, e:
        assert isinstance(e, out), "Wrong exception %r" % e

3
这不安全!请查看Chris Dost的答案:https://dev59.com/dnRB5IYBdhLWcg3wLUq3#812785 - Thomas
1
@Thomas:你有什么支持这个说法的证据吗?Chris Dost的“不安全”代码实际上只是引发了一个异常,所以我猜你并没有真正尝试过它。 - Jochen Ritzel
3
抱歉,我忘了提到我必须修改这个例子。这是一个可以使用的示例:<<script></script>script> alert("哈哈,我黑进了你的页面。"); <<script></script>script> - Thomas
此外,tag.extract() 修改了正在迭代的列表。这使循环混乱,并导致它跳过下一个子项。 - Thomas
@Thomas:非常好的发现!我认为我已经修复了这两个问题,非常感谢! - Jochen Ritzel

25

Bleach提供了更多实用的选项,它基于html5lib构建,可用于生产环境。请查看文档中的bleach.clean函数。默认配置会转义不安全的标签,如<script>,同时允许使用有用的标签,比如<a>

import bleach
bleach.clean("<script>evil</script> <a href='http://example.com'>example</a>")
# '&lt;script&gt;evil&lt;/script&gt; <a href="http://example.com">example</a>'

漂白剂在默认情况下是否仍然允许通过html5lib使用data:urls?例如,可以嵌入具有HTML内容类型的data: url。 - Antti Haapala -- Слава Україні
2019年,我遇到了这个问题:https://dev59.com/wFvUa4cB1Zd3GeqPvJcP - 对我来说,lxml.html.cleaner更加可靠,可以完全删除样式标签,而bleach则会让你的CSS作为内容可见。 - benzkji

10

我使用BeautifulSoup修改了Bryan解决方案,以解决Chris Drost提出的问题。有点粗糙,但起到了作用:

from BeautifulSoup import BeautifulSoup, Comment

VALID_TAGS = {'strong': [],
              'em': [],
              'p': [],
              'ol': [],
              'ul': [],
              'li': [],
              'br': [],
              'a': ['href', 'title']
              }

def sanitize_html(value, valid_tags=VALID_TAGS):
    soup = BeautifulSoup(value)
    comments = soup.findAll(text=lambda text:isinstance(text, Comment))
    [comment.extract() for comment in comments]
    # Some markup can be crafted to slip through BeautifulSoup's parser, so
    # we run this repeatedly until it generates the same output twice.
    newoutput = soup.renderContents()
    while 1:
        oldoutput = newoutput
        soup = BeautifulSoup(newoutput)
        for tag in soup.findAll(True):
            if tag.name not in valid_tags:
                tag.hidden = True
            else:
                tag.attrs = [(attr, value) for attr, value in tag.attrs if attr in valid_tags[tag.name]]
        newoutput = soup.renderContents()
        if oldoutput == newoutput:
            break
    return newoutput

编辑:已更新以支持有效属性。


1
tag.attrs = [(attr, value) for attr, value in tag.attrs if attr in valid_tags[tag.name]] -- 这里的 tag.attrs 是一个字典,所以应该改为 tag.attrs = {attr: value for attr, value in tag.attrs.items() if attr in valid_tags[tag.name]},使用 bs4。 - Wee

4

我使用FilterHTML。它简单易用,可以定义一个良好控制的白名单,清除URL,甚至可以与正则表达式匹配属性值或每个属性都有自定义过滤函数。如果小心使用,它可以是一个安全的解决方案。以下是自读文件中的简化示例:

import FilterHTML

# only allow:
#   <a> tags with valid href URLs
#   <img> tags with valid src URLs and measurements
whitelist = {
  'a': {
    'href': 'url',
    'target': [
      '_blank',
      '_self'
    ],
    'class': [
      'button'
    ]
  },
  'img': {
    'src': 'url',
    'width': 'measurement',
    'height': 'measurement'
  },
}

filtered_html = FilterHTML.filter_html(unfiltered_html, whitelist)

2
你可以使用html5lib来进行清洁操作,它采用白名单方法进行消毒。
下面是一个示例:
import html5lib
from html5lib import sanitizer, treebuilders, treewalkers, serializer

def clean_html(buf):
    """Cleans HTML of dangerous tags and content."""
    buf = buf.strip()
    if not buf:
        return buf

    p = html5lib.HTMLParser(tree=treebuilders.getTreeBuilder("dom"),
            tokenizer=sanitizer.HTMLSanitizer)
    dom_tree = p.parseFragment(buf)

    walker = treewalkers.getTreeWalker("dom")
    stream = walker(dom_tree)

    s = serializer.htmlserializer.HTMLSerializer(
            omit_optional_tags=False,
            quote_attr_values=True)
    return s.render(stream) 

为什么需要 sanitizer_factory 存在?你应该直接传递 HTMLSanitizer - Chris Morgan
@ChrisMorgan 很好的问题。我想我是从html5lib网站上得到这个例子的,他们在将清理程序返回工厂之前对其进行了一些处理。但是他们所做的工作只存在于开发版本中,在发布版本中无法使用。所以我只是删除了那一行。它看起来很奇怪。我会进一步研究并可能更新答案。 - Brian Neal
@ChrisMorgan 看起来我提到的功能(剥离令牌而不是转义它们)从未被合并到上游,所以我只是删除了工厂业务。谢谢。 - Brian Neal

1
我更倾向于使用 lxml.html.clean 解决方案,就像nosklo 指出的那样。还可以删除一些空标签:
from lxml import etree
from lxml.html import clean, fromstring, tostring

remove_attrs = ['class']
remove_tags = ['table', 'tr', 'td']
nonempty_tags = ['a', 'p', 'span', 'div']

cleaner = clean.Cleaner(remove_tags=remove_tags)

def squeaky_clean(html):
    clean_html = cleaner.clean_html(html)
    # now remove the useless empty tags
    root = fromstring(clean_html)
    context = etree.iterwalk(root) # just the end tag event
    for action, elem in context:
        clean_text = elem.text and elem.text.strip(' \t\r\n')
        if elem.tag in nonempty_tags and \
        not (len(elem) or clean_text): # no children nor text
            elem.getparent().remove(elem)
            continue
        elem.text = clean_text # if you want
        # and if you also wanna remove some attrs:
        for badattr in remove_attrs:
            if elem.attrib.has_key(badattr):
                del elem.attrib[badattr]
    return tostring(root)

最好使用“return _transform_result(type(clean_html), root)”而不是“return tostring(root)”。它将处理类型检查。 - luckyjazzbo
@luckyjazzbo:是的,但那样我会使用一个以下划线开头的方法。这些是私有实现细节,不应该使用,因为它们可能会在未来版本的lxml中更改。 - nosklo
显然,_transform_result在当前的lxml中已经不存在了。 - Simon Steinberger

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接