Python urljoin 拼接地址:末尾斜杠、根路径与完整 URL 会怎样改变结果

10-01 6阅读

先判断第二个参数是哪一种引用

从列表页生成文章地址时,常见写法是把站点地址和文件名交给 urljoin。结果有时少了一层目录,有时整个主机都换了。这个函数按 URL 引用规则补全地址,并不是把两段字符串逐字连接。基址末尾是否有斜杠、引用开头是什么,都会决定哪些组件保留,哪些组件被替换。

以 guides 结尾的基址没有末尾斜杠,最后一段在合并普通相对路径时会被替换;以 guides/ 结尾,则会在这一层目录下继续解析。函数不会访问网站确认 guides 是目录还是文件,也不会根据文件扩展名猜测。服务器是否把某个地址重定向到带斜杠版本,是另一件事。

根路径与主机替换是规定行为

第二个参数以单个斜杠开头时,它提供的是从网站根部开始的路径,所以原来的 guides 目录不再参与。以双斜杠开头时,引用会提供自己的主机,同时继承基址的协议。若提供完整的 HTTP 或 HTTPS 地址,则相应协议、主机和路径都可由新引用决定。

这意味着给一个可信基址,并不能让所有输出自动留在可信站点。用户能控制的字符串如果本来就允许是完整地址,函数会忠实解析它。反过来,如果字段定义只是文章标识,就不应该把任意 URL 引用当成合法标识接收。输入契约要先于拼接方法确定。

下面的完整程序只做字符串解析,不发出网络请求。前半段验证基址和引用的组合,后半段实现一个范围很窄的文章地址构造器:只接受小写字母、数字以及分隔单词的连字符。这样既能展示拼接行为,也能清楚说明应用实际允许哪些输入。

Python urljoin 拼接地址:末尾斜杠、根路径与完整 URL 会怎样改变结果

AI概念配图,非真实界面

import re
from urllib.parse import urljoin, urlsplit

base = 'https://docs.example/guides/'
cases = [
    ('directory', base, 'install.html',
     'https://docs.example/guides/install.html'),
    ('no trailing slash', 'https://docs.example/guides', 'install.html',
     'https://docs.example/install.html'),
    ('root path', base, '/status', 'https://docs.example/status'),
    ('new host', base, '//other.example/page', 'https://other.example/page'),
    ('full URL', base, 'http://other.example/page', 'http://other.example/page'),
    ('parent path', base, '../about', 'https://docs.example/about'),
    ('new query', 'https://docs.example/guides/index.html?old=1#top', '?page=2',
     'https://docs.example/guides/index.html?page=2'),
    ('new fragment', 'https://docs.example/guides/index.html?old=1#top', '#api',
     'https://docs.example/guides/index.html?old=1#api'),
]
for label, origin, reference, expected in cases:
    actual = urljoin(origin, reference)
    assert actual == expected
    print(label + ':', actual)

changed = urljoin(base, '//other.example/page')
assert urlsplit(changed).hostname == 'other.example'
assert urlsplit(changed).scheme == 'https'

def article_url(slug):
    if re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*', slug) is None:
        raise ValueError('invalid article slug')
    return urljoin(base, slug + '.html')

assert article_url('install-python-3') == (
    'https://docs.example/guides/install-python-3.html')
for invalid in ['', '../admin', '/status', '//other.example/page',
                'https://other.example/page', 'a/b', 'a?x=1', 'a#part',
                '%2e%2e', 'a--b', 'UPPER', ' leading']:
    try:
        article_url(invalid)
    except ValueError:
        pass
    else:
        raise AssertionError('invalid slug accepted: ' + repr(invalid))
print('restricted article slug checks passed')

从输出还原各个组件的变化

前两行只差基址末尾一个斜杠,结果却分别位于 guides 目录内部与站点根部。第三行的根路径主动舍弃原目录,第四行只换主机,第五行同时换成另一协议和主机。第六行包含上级目录片段,说明即使没有新主机,相对引用也能离开原来的目录层级。

查询参数和片段同样参与引用解析。只给新的查询字符串时,示例保留当前文件路径,替换查询内容并去掉原片段;只给新片段时,则保留原查询内容。若业务要增量修改多个查询参数,应单独处理查询组件,不能把拼接整个地址当成参数更新操作。

解析完成不等于通过安全检查

urlsplit 能帮助取出协议和主机,但它自身也不是通用验证器。得到一个可拆分的结果,并不说明地址符合应用规则。需要下载任意外部资源的服务,应另外定义允许的协议、目标和端口,并考虑重定向及实际连接目标;仅检查最初字符串的主机,不能覆盖后续请求过程。

示例的 article_url 没有尝试实现这种通用下载策略。它把问题限制成生成固定站点下的一篇文章地址,因此直接拒绝斜杠、百分号、查询标记和片段标记。完整匹配很重要:若只判断开头有一段合法字符,后面的路径或地址语法仍可能被放行。

采用受限标识也意味着主动放弃部分输入能力,例如大写字母和连续连字符在本文规则下无效。这是示例的业务约束,并不是 URL 标准禁止它们。若以后需要中文标题,应为标题与标识设计独立映射,或者制定相应编码规则,再补充边界测试。

维护时先测试基址的目录语义,再测试允许的输入范围,最后测试实际发送请求的策略。不要为修复目录丢失而盲目给所有引用加减斜杠,也不要看到输出仍以熟悉的字符串开头就当作可信。把生成地址与决定是否访问分开,错误更容易定位。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。