仓颉三方库开发实战:sanitize_html 实现详解

项目背景

在现代Web开发中,HTML内容清理和消毒是防止XSS(跨站脚本)攻击的关键技术。sanitize_html项目使用仓颉编程语言实现了一个功能强大的HTML内容清理和消毒库,不仅提供了完善的HTML过滤功能,也探索了仓颉语言在Web安全领域的应用潜力。

本文将详细介绍sanitize_html的设计理念、核心功能实现、技术挑战与解决方案,为使用仓颉语言进行Web安全开发的开发者提供参考。

技术栈

  • 开发语言:仓颉编程语言 (cjc >= 1.0.3)
  • 核心库
    • std.collection: 数据结构(ArrayList, HashMap)
    • std.option: Option类型错误处理
    • 字符串处理:Rune类型处理、字符串操作

核心功能实现

1. 库架构设计

sanitize_html采用分层模块化设计,将功能分解为多个独立的模块:

sanitize_html
├── 数据结构定义模块          # Frame、Tag、SanitizeOptions等核心数据结构
├── 字符串工具模块            # 字符串处理、转义、匹配等工具函数
├── URL解析和验证模块          # URL解析、协议验证、域名检查等
├── HTML解析器模块            # HTML标签解析、属性提取、文本处理等
├── 标签过滤模块              # 标签白名单、属性过滤、类名过滤等
├── 标签转换模块              # 标签转换器、simpleTransform函数等
├── 文本过滤模块              # 文本过滤器、文本处理模式等
├── 排除过滤模块              # 排除过滤器、排除规则匹配等
└── 公共API模块               # sanitize、sanitizeHtml等公共接口

设计亮点

  • 模块化设计,提高代码可维护性和可测试性
  • 接口驱动设计,支持灵活的扩展机制
  • 类型安全,充分利用仓颉语言的类型系统

2. 核心数据结构设计

Frame类:标签层次结构跟踪

Frame类用于跟踪HTML标签的层次结构,确保标签正确闭合和嵌套:

public class Frame {
    public var tag: String  // 原始标签名(用于匹配结束标签)
    public var transformedTag: String  // 转换后的标签名(用于输出结束标签)
    public var attribs: HashMap<String, String>
    public var tagPosition: Int64
    public var text: String
    public var openingTagLength: Int64
    public var mediaChildren: ArrayList<String>
    public var innerText: Option<String>
    
    public init(tag: String, attribs: HashMap<String, String>) {
        this.tag = tag
        this.transformedTag = tag
        this.attribs = attribs
        this.tagPosition = 0
        this.text = ""
        this.openingTagLength = 0
        this.mediaChildren = ArrayList<String>()
        this.innerText = None
    }
}

设计考虑

  • 使用栈(ArrayList)跟踪标签嵌套关系
  • 区分原始标签名和转换后的标签名,确保结束标签匹配正确
  • 使用Option类型处理可选的innerText,避免空指针异常
Tag类:标签转换数据结构

Tag类用于标签转换器返回完整信息:

public class Tag {
    public var tagName: String
    public var attribs: HashMap<String, String>
    public var text: Option<String>  // 可选的文本内容
    
    public init(tagName: String, attribs: HashMap<String, String>, text: Option<String>) {
        this.tagName = tagName
        this.attribs = attribs
        this.text = text
    }
    
    // 便捷构造函数
    public static func of(tagName: String, attribs: HashMap<String, String>): Tag {
        return Tag(tagName, attribs, None)
    }
    
    public static func of(tagName: String, attribs: HashMap<String, String>, text: String): Tag {
        return Tag(tagName, attribs, Some(text))
    }
}

设计考虑

  • 支持标签名转换、属性修改、文本内容设置三种转换方式
  • 使用便捷构造函数简化Tag对象创建
  • Option类型处理可选的文本内容
SanitizeOptions类:配置选项管理

SanitizeOptions类包含所有可配置的选项:

public class SanitizeOptions {
    public var allowedTags: Option<ArrayList<String>>
    public var allowedAttributes: Option<HashMap<String, ArrayList<String>>>
    public var allowedSchemes: ArrayList<String>
    // ... 更多配置选项
    
    public init() {
        // 初始化所有字段为默认值
    }
    
    public static func defaults(): SanitizeOptions {
        let opts = SanitizeOptions()
        // 设置安全的默认配置
        opts.allowedTags = Some(SanitizeOptions.getDefaultTags())
        opts.allowedAttributes = Some(SanitizeOptions.getDefaultAttributes())
        // ... 设置其他默认值
        return opts
    }
}

设计考虑

  • 使用Option类型表示可选配置,None表示使用默认行为
  • 提供defaults()静态方法,返回安全的默认配置
  • 支持用户配置与默认配置的智能合并

3. HTML解析器实现

HTML解析器是sanitize_html的核心模块,负责解析HTML标签、属性、文本内容等。

解析流程
private func parseAndSanitize(html: String, options: SanitizeOptions): String {
    // 1. 预处理CDATA(如果启用)
    var processedHtml = html
    if (options.recognizeCDATA) {
        processedHtml = preprocessCDATA(html)
    }
    
    // 2. 初始化状态
    var result = ""
    let stack = ArrayList<Frame>()
    var depth: Int64 = 0
    var skipText = false
    var skipTextDepth: Int64 = 0
    let htmlRunes = processedHtml.toRuneArray()
    var i: Int64 = 0
    
    // 3. 遍历HTML字符
    while (i < htmlRunes.size) {
        if (htmlRunes[i] == r'<') {
            // 处理标签
            let tagEnd = findTagEnd(htmlRunes, i + 1)
            if (tagEnd.isSome()) {
                let end = tagEnd.unwrap()
                let tagContentStr = runesToStringSlice(htmlRunes, i+1, end)
                if (stringStartsWith(tagContentStr, "/")) {
                    // 处理结束标签
                    handleClosingTag(...)
                } else {
                    // 处理开始标签
                    handleOpeningTag(...)
                }
            }
        } else {
            // 处理文本内容
            if (!skipText) {
                handleText(...)
            }
        }
    }
    
    // 4. 关闭所有未闭合的标签
    closeUnclosedTags(stack, result)
    
    // 5. 后处理CDATA(如果启用)
    if (options.recognizeCDATA) {
        result = postprocessCDATA(result)
    }
    
    return result
}

实现要点

  • 使用Rune数组进行字符处理,正确支持Unicode字符
  • 使用栈跟踪标签嵌套关系,确保标签正确闭合
  • 支持CDATA段的预处理和后处理
  • 支持skipText机制,跳过某些标签的内容(如script、style)
标签解析
private func parseTag(tagContent: String): (String, HashMap<String, String>) {
    let tagName = extractTagName(tagContent)
    let attrs = HashMap<String, String>()
    let runes = tagContent.toRuneArray()
    var i = stringSize(tagName)
    
    // 解析属性
    while (i < runes.size) {
        let oldI = i
        i = parseAttribute(runes, i, attrs)
        // 防止无限循环
        if (i <= oldI) {
            i = oldI + 1
        }
    }
    
    return (tagName, attrs)
}

实现要点

  • 支持带引号和不带引号的属性值
  • 处理属性的各种格式(单引号、双引号、无引号)
  • 防止解析死循环,确保解析进度

4. 标签过滤机制

标签过滤是sanitize_html的核心安全机制之一。

标签白名单检查
private func tagAllowed(name: String, options: SanitizeOptions): Bool {
    match (options.allowedTags) {
        case None => return true  // 未指定则允许所有
        case Some(allowed) => return isTagInList(name, allowed)
    }
}
禁用标签处理模式

sanitize_html支持三种禁用标签处理模式:

  • discard(默认):丢弃禁用标签,保留其内容
  • escape:转义禁用标签,子标签如果不被禁用则不转义
  • recursiveEscape:转义禁用标签及其所有子标签,不管子标签是否被允许
if (!tagAllowed(finalTagName, options)) {
    if (options.disallowedTagsMode == "discard" || options.disallowedTagsMode == "completelyDiscard") {
        if (containsInList(options.nonTextTags, tagName)) {
            newSkipText = true
            newSkipTextDepth = newDepth
        }
    } else if (options.disallowedTagsMode == "escape" || options.disallowedTagsMode == "recursiveEscape") {
        let tagStr = runesToStringSlice(htmlRunes, i, end+1)
        result = escapeHtml(tagStr, false)
    }
}

5. 属性过滤机制

属性过滤是sanitize_html的另一个核心安全机制。

属性白名单检查
private func attributeAllowed(tagName: String, attrName: String, options: SanitizeOptions): Bool {
    match (options.allowedAttributes) {
        case None => return true
        case Some(allowedAttrs) => return checkAttributesInMap(allowedAttrs, tagName, attrName)
    }
}
URL属性验证

对于href、src等URL属性,需要进行协议验证:

private func naughtyHref(tagName: String, href: String, options: SanitizeOptions): Bool {
    // 移除控制字符
    var cleaned = cleanUrl(href)
    
    // 移除注释
    cleaned = removeComments(cleaned)
    
    // 提取协议
    let scheme = extractScheme(cleaned)
    match (scheme) {
        case Some(sch) => return handleSchemeExists(tagName, sch, options)
        case None => return handleSchemeNone(cleaned, options)
    }
}

实现要点

  • 移除URL中的控制字符和注释,防止XSS攻击
  • 验证URL协议是否在允许列表中
  • 支持协议相对URL(//example.com)的处理
  • 支持按标签配置不同的协议规则
类名过滤

对于class属性,支持类名白名单过滤:

private func filterClasses(classes: String, allowed: Option<HashMap<String, ArrayList<String>>>, tagName: String): String {
    match (allowed) {
        case None => classes  // 没有限制,返回原值
        case Some(allowedMap) => filterClassesWithMap(classes, allowedMap, tagName)
    }
}

实现要点

  • 支持按标签配置允许的类名列表
  • 支持通配符匹配(如 “class-*” 匹配所有以 “class-” 开头的类名)
  • 支持正则表达式匹配(以 “regex:” 开头)

6. 标签转换功能

sanitize_html支持两种标签转换方式:

字符串映射

简单的标签名映射:

options.transformTags["ol"] = "ul"  // 将ol标签转换为ul
函数转换器

通过TagTransformer接口实现复杂的转换逻辑:

public interface TagTransformer {
    func transform(tagName: String, attributes: HashMap<String, String>): Option<Tag>
}

// 使用simpleTransform创建转换器
let transformer = simpleTransform("div", None, None)
options.transformTagFunctions["p"] = transformer

实现要点

  • 支持精确匹配和通配符匹配(如 “h*” 匹配所有h开头的标签)
  • 支持属性合并和替换两种模式
  • 支持在转换时设置标签的innerText

7. 文本过滤功能

sanitize_html支持两种文本过滤方式:

预设模式
options.textFilterMode = "trim|upper"  // 去除首尾空白并转大写

支持的预设模式:

  • trim:去除首尾空白
  • upper:转大写
  • lower:转小写
函数形式

通过TextFilter接口实现自定义文本过滤逻辑:

public interface TextFilter {
    func filter(text: String, tagName: String): String
}

class UpperCaseFilter <: TextFilter {
    public func filter(text: String, _: String): String {
        // 转换为大写
        return toUpperCase(text)
    }
}

8. 排除过滤功能

sanitize_html支持两种排除过滤方式:

配置化规则

通过ExclusionRule实现配置化的排除规则:

public class ExclusionRule {
    public var tag: String  // 要匹配的标签名
    public var attributeConditions: HashMap<String, String>  // 属性条件
    public var textCondition: Option<String>  // 文本内容条件
    public var mode: String  // "excludeTag" 或 "excludeAll"
}

let rule = ExclusionRule("a", HashMap<String, String>(), None, "excludeTag")
options.exclusiveFilterRules.add(rule)
函数形式

通过ExclusiveFilter接口实现自定义排除逻辑:

public interface ExclusiveFilter {
    func shouldExclude(frame: Frame): ExcludeResult
}

class EmptyLinkFilter <: ExclusiveFilter {
    public func shouldExclude(frame: Frame): ExcludeResult {
        if (frame.tag == "a" && frame.text == "") {
            return ExcludeResult.excludeTag()
        }
        return ExcludeResult.none()
    }
}

实现要点

  • 支持excludeTag(只排除标签)和excludeAll(排除标签和内容)两种模式
  • 支持按标签名、属性条件、文本内容进行匹配
  • 支持正则表达式匹配(属性值和文本内容)

技术挑战与解决方案

1. Unicode字符处理

挑战:HTML可能包含各种Unicode字符,需要正确处理。

解决方案:使用Rune类型进行字符处理:

private func toLowerCase(s: String): String {
    var result = ""
    for (r in s.runes()) {
        if (r >= r'A' && r <= r'Z') {
            result = result + String(Rune(UInt32(r) + 32))
        } else {
            result = result + String(r)
        }
    }
    return result
}

优势

  • 正确处理多字节Unicode字符
  • 避免字符串编码问题
  • 提高字符串处理性能

2. 标签层次结构管理

挑战:HTML标签可能嵌套,需要正确跟踪标签的层次结构,确保标签正确闭合。

解决方案:使用栈(ArrayList)跟踪标签嵌套关系:

let stack = ArrayList<Frame>()

// 开始标签时入栈
let frame = Frame(tagName, filteredAttrs)
stack.add(frame)

// 结束标签时出栈
let popResult = popMatchingTag(stack, tagName)

实现细节

  • 使用Frame类存储标签信息,包括原始标签名和转换后的标签名
  • popMatchingTag函数从栈顶向下查找匹配的标签,找到后删除从该位置到栈顶的所有元素(这是HTML标签匹配的标准行为)
  • 处理未闭合标签,在解析结束时自动关闭

3. URL协议验证

挑战:需要验证URL协议的安全性,防止javascript:、data:等危险协议。

解决方案:实现URL解析和协议验证机制:

private func extractScheme(url: String): Option<String> {
    // 首先查找 :// 模式(标准协议格式)
    let colonSlashIndex = findSubstring(url, "://")
    match (colonSlashIndex) {
        case Some(idx) => return validateAndExtractScheme(url, idx)
        case None => ()
    }
    // 如果没有找到 ://,查找单独的 :(用于javascript:等协议)
    let colonIndex = findSubstring(url, ":")
    match (colonIndex) {
        case Some(idx) => return checkColonScheme(url, idx)
        case None => return None
    }
}

安全措施

  • 移除URL中的控制字符和注释
  • 验证协议格式(字母开头,包含字母、数字、点、减号、加号)
  • 支持协议白名单机制
  • 支持按标签配置不同的协议规则

4. 标签转换的复杂性

挑战:标签转换需要支持多种转换方式,包括字符串映射、函数转换器、通配符匹配等。

解决方案:实现多层次的转换机制:

private func applyTransformTag(tagName: String, attributes: HashMap<String, String>,
                                transformTags: HashMap<String, String>,
                                transformTagFunctions: HashMap<String, TagTransformer>): Option<Tag> {
    // 1. 首先尝试函数转换器(精确匹配)
    // 2. 尝试函数转换器(通配符匹配)
    // 3. 尝试字符串映射(精确匹配)
    // 4. 尝试字符串映射(通配符匹配)
    // 5. 没有匹配的规则,返回None
}

实现要点

  • 优先级:函数转换器 > 字符串映射(精确匹配 > 通配符匹配)
  • 支持通配符模式匹配(如 “h*” 匹配所有h开头的标签)
  • 支持属性合并和替换两种模式
  • 支持在转换时设置标签的innerText

5. 性能优化

挑战:大型HTML文档的解析性能可能受到影响。

解决方案

  1. 使用HashMap存储配置:提高查找效率
  2. 使用Rune数组进行字符处理:避免多次字符串复制
  3. 使用ArrayList作为标签栈:高效管理标签层次结构
  4. 延迟字符串构建:只在需要时构建HTML字符串
// 使用HashMap存储配置
let allowedAttrs = HashMap<String, ArrayList<String>>()

// 使用Rune数组进行字符处理
let htmlRunes = processedHtml.toRuneArray()

// 使用ArrayList作为标签栈
let stack = ArrayList<Frame>()

使用示例

示例1:基本使用

import sanitize_html.*

main() {
    let dirty = "<script>alert('XSS')</script><p>Hello World</p>"
    let clean = sanitize(dirty)
    println(clean)  // 输出: <p>Hello World</p>
}

示例2:自定义配置

import sanitize_html.*
import std.collection.ArrayList
import std.collection.HashMap

main() {
    let options = SanitizeOptions()
    let allowedTags = ArrayList<String>()
    allowedTags.add("p")
    allowedTags.add("b")
    allowedTags.add("i")
    options.allowedTags = Some(allowedTags)
    
    let allowedAttrs = HashMap<String, ArrayList<String>>()
    let pAttrs = ArrayList<String>()
    pAttrs.add("class")
    allowedAttrs["p"] = pAttrs
    options.allowedAttributes = Some(allowedAttrs)
    
    let dirty = "<p class=\"text\">Hello <b>World</b></p><script>alert('XSS')</script>"
    let clean = sanitizeHtml(dirty, Some(options))
    println(clean)  // 输出: <p class="text">Hello <b>World</b></p>
}

示例3:标签转换

import sanitize_html.*
import std.collection.HashMap

main() {
    let options = SanitizeOptions()
    // 字符串映射
    options.transformTags["ol"] = "ul"
    
    // 函数转换器
    let transformer = simpleTransform("div", None, None)
    options.transformTagFunctions["p"] = transformer
    
    let dirty = "<ol><li>Item</li></ol><p>Hello</p>"
    let clean = sanitizeHtml(dirty, Some(options))
    println(clean)  // 输出: <ul><li>Item</li></ul><div>Hello</div>
}

示例4:文本过滤

import sanitize_html.*

class UpperCaseFilter <: TextFilter {
    public func filter(text: String, _: String): String {
        var result = ""
        for (r in text.runes()) {
            if (r >= r'a' && r <= r'z') {
                result = result + String(Rune(UInt32(r) - 32))
            } else {
                result = result + String(r)
            }
        }
        return result
    }
}

main() {
    let options = SanitizeOptions()
    options.textFilter = Some(UpperCaseFilter())
    
    let dirty = "<p>Hello World</p>"
    let clean = sanitizeHtml(dirty, Some(options))
    println(clean)  // 输出: <p>HELLO WORLD</p>
}

示例5:排除过滤

import sanitize_html.*
import std.collection.ArrayList
import std.collection.HashMap

class EmptyLinkFilter <: ExclusiveFilter {
    public func shouldExclude(frame: Frame): ExcludeResult {
        if (frame.tag == "a" && frame.text == "") {
            return ExcludeResult.excludeTag()
        }
        return ExcludeResult.none()
    }
}

main() {
    let options = SanitizeOptions()
    options.exclusiveFilter = Some(EmptyLinkFilter())
    
    let dirty = "<a href=\"#\"></a><a href=\"https://example.com\">Link</a>"
    let clean = sanitizeHtml(dirty, Some(options))
    println(clean)  // 输出: <a href="https://example.com">Link</a>
}

最佳实践

1. 使用默认配置

对于大多数场景,使用默认配置即可获得良好的安全保护:

let clean = sanitize(dirty)

2. 最小权限原则

只允许必要的标签和属性:

let options = SanitizeOptions()
let allowedTags = ArrayList<String>()
allowedTags.add("p")
allowedTags.add("b")
options.allowedTags = Some(allowedTags)

3. URL协议白名单

严格限制允许的URL协议:

options.allowedSchemes.add("http")
options.allowedSchemes.add("https")
// 不包含javascript:、data:等危险协议

4. 域名白名单

对于script和iframe标签,使用域名白名单:

options.allowedScriptDomains.add("cdn.example.com")
options.allowedIframeDomains.add("youtube.com")

5. 测试覆盖

为自定义配置编写测试用例:

@Test
public class SanitizeHtmlTests {
    @TestCase
    func testBasicSanitize(): Unit {
        let dirty = "<script>alert('XSS')</script><p>Hello</p>"
        let clean = sanitize(dirty)
        @Assert(clean == "<p>Hello</p>")
    }
}

总结

sanitize_html是一个功能强大的HTML内容清理和消毒库,使用仓颉编程语言实现。通过模块化设计、接口驱动架构、类型安全机制,实现了完善的HTML过滤功能,有效防止XSS攻击。

核心优势

  1. 类型安全:充分利用仓颉语言的类型系统,使用Option类型进行安全的错误处理
  2. 模块化设计:将功能分解为多个独立模块,提高代码可维护性和可测试性
  3. 接口驱动:提供丰富的接口(TagTransformer、TextFilter等),支持灵活的扩展机制
  4. 安全可靠:内置多层安全机制,有效防止XSS攻击
  5. 易于使用:提供简洁的API和安全的默认配置,开箱即用

适用场景

  • Web应用的用户输入过滤
  • 内容管理系统(CMS)的HTML清理
  • 富文本编辑器的内容处理
  • 邮件系统的HTML内容过滤
  • 任何需要HTML内容清理和消毒的场景

相关学习资源

仓颉标准库:https://gitcode.com/Cangjie/cangjie_runtime/tree/main/stdlib

仓颉扩展库:https://gitcode.com/Cangjie/cangjie_stdx

仓颉命令行工具:https://gitcode.com/Cangjie/cangjie_tools

仓颉语言测试用例:https://gitcode.com/Cangjie/cangjie_test

仓颉语言示例代码:https://gitcode.com/Cangjie/Cangjie-Examples

仓颉鸿蒙示例应用:https://gitcode.com/Cangjie/HarmonyOS-Examples

精品三方库:https://gitcode.com/org/Cangjie-TPC/repos

SIG 孵化库:https://gitcode.com/org/Cangjie-SIG/repos


sanitize_html展示了仓颉语言在Web安全领域的应用潜力,为使用仓颉语言进行Web开发的开发者提供了有价值的参考。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐