含通配符的URL校验方案

998 阅读2分钟

今年是入行的第 5 个年头,首次在掘金发文。

前言

  这是一篇适用于 URL 黑白名单场景校验的解决方案分享,该方案适用场景如:(1)App 端 Webview 加载 URL 时的白名单 (2)小程序 SDK 中用于检验小程序请求的目标地址是否满足配置项 (3)其他用于校验 URL 是否满足黑白名单配置范围的场景。本文由前言、思路和代码、测试与用例三部分构成。

笔者提示:本文中的处理代码使用 Kotlin 编写,默认宽处理;且方案和代码都是笔者突发奇想随兴所思所写,仅本地验证未经大规模用例测试,慎原样拷贝食用。

思路和代码

  在 URL 黑白名单校验的场景中,数据角色分为:(1)规则串(2)待校验URL串。规则串通常关注 URI 中的协议(Scheme)、域名(Host)、端口(Port)、路径(Path)四部分构成,各部分需支持*通配符。而此处默认待校验URL串已经过标准化处理,即包含Scheme部分是一个合法的 URI。

  一个比较直观的思路是将规则串解析为正则表达式处理,但这样解析而来的正则可能过于复杂。若退一步:分别构建各部分的正则串,最后合并校验结果,在逻辑上也会相对更新清晰易理解些。比如:

  • Scheme:这部分指 URI 中 :// 前的串,如 http://www.juejin.cn 中的 http 部串
  • Host:这部分指 URI 中的域名部分,如 www.juejin.cn 这部分的通配支持通常要求有直接单级通配和多级通配,如 *.cn 可匹配 juejin.cn,而 **.cn 可匹配 www.juejin.cn
  • Port:端口部分通常指 :80 串,其值通常是 0~65535
  • Path:这部分指 URI 中的路径部分,如 /root/path/file,该部分的通配通常也有单级和多级的区别,如 /* 可匹配 /root,而 /root/path 则需规则 /** 才能满足匹配

Talk is cheap, let me show the code

import java.net.URI

object UriMatcher {
    private const val DEBUG = true
    private const val EMPTY = ""
    private val ANY = ".*".toRegex()
    private val STAR = "(?<![.])[*]".toRegex()

    /**
     * 校验url是否满足rule指定的规则
     *
     * @param rule 规则串,最终会解析成uri的scheme/host/port/path四个子规则,若留空则默认该匹配任意值
     *             规则串中可使用*表示通配,如 http* 可匹配 http 和 https
     *             单个*在不能匹配分隔符.|/,成对**则可通配分隔符,
     *             如域名部分 *.com 不能匹配 www.baidu.com,但 **.com 可以
     *             同理路径部分,如 /r* 不能匹配 /root/path 但 /r** 可以
     * @param url 需要是规范的uri格式,形如 scheme://host[:port][/path]
     */
    fun match(rule: String, url: String): Boolean {
        // 解析URL
        val uri = try {
            URI.create(url)
        } catch (e: RuntimeException) {
            null
        } ?: return false
        if (DEBUG) println("uri: ${uri.scheme} , ${uri.host} , ${uri.port} , ${uri.path}")
        // 解析Schema规则
        val rules = build(rule) ?: return false
        // 校验Scheme
        var str = rules[1].replace("://", "").replace("*", ".*")
        if (isInvalid(if (str.isBlank()) ANY else str.toRegex(), uri.scheme ?: EMPTY)) {
            return false
        }
        // 校验Host
        str = rules[2].replace(".", "[.]").replace("**", ".*")
            .replace(STAR, "[^.]*")
        if (isInvalid(if (str.isBlank()) ANY else str.toRegex(), uri.host ?: EMPTY)) {
            return false
        }
        // 校验Port
        str = rules[3].replace(":", "").replace("*", "\\d*")
        if (isInvalid(
                if (str.isBlank()) ANY else str.toRegex(), if (uri.port == -1) {
                    EMPTY
                } else {
                    uri.port.toString()
                }
            )
        ) return false
        // 校验Path
        str = rules[4].replace("**", ".*").replace(STAR, "[^/]*")
        if (isInvalid(
                if (str.isBlank()) ANY else str.toRegex(), if (uri.path.isNullOrBlank()) {
                    "/"
                } else {
                    uri.path
                }
            )
        ) return false
        return true
    }

    private fun build(rule: String): Array<String>? {
        val result =
            "([a-z*]+://)?([^/?:]+)?(:[\\d*]{1,5})?((?:/?|/[^/]+)*)?"
                .toRegex().matchEntire(rule.ifBlank { "*" })
                ?: return null
        return result.groupValues.toTypedArray()
    }

    private fun isInvalid(regex: Regex, input: String): Boolean {
        val matched = regex.matches(input)
        if (DEBUG) println("test($matched): $regex <- $input")
        return !matched
    }
}

测试与用例

以下为笔者测试的部分用例,如有发现问题欢迎评论区补充。

public class JMain {
    public static void main(String[] args) {
        UriMatcher matcher = UriMatcher.INSTANCE;
        // false
        System.out.println(matcher.match("http://juejin.cn/", "https://juejin.cn"));
        // true
        System.out.println(matcher.match("http*://juejin.cn/", "https://juejin.cn"));
        // true
        System.out.println(matcher.match("http*://*.cn/", "https://juejin.cn"));
        // false
        System.out.println(matcher.match("http*://*.juejin.cn/", "https://juejin.cn"));
        // true
        System.out.println(matcher.match("http*://*juejin.cn/", "https://juejin.cn"));
        // true
        System.out.println(matcher.match("http*://**juejin.cn/", "https://www.juejin.cn"));
        // true
        System.out.println(matcher.match("http*://juejin.*/", "https://juejin.cn"));
        // true
        System.out.println(matcher.match("http*://juejin.*:*", "https://juejin.cn:8080"));
        // true
        System.out.println(matcher.match("http*://juejin.*:*", "https://juejin.cn"));
        // true
        System.out.println(matcher.match("http*://juejin.*/post/*", "https://juejin.cn/post/123"));
        // true
        System.out.println(matcher.match("http*://juejin.*/**", "https://juejin.cn/post/123"));
    }
}