Skip to content

Commit 0ae43f0

Browse files
github-actions[bot]copilot-pull-request-reviewer[bot]
andcommitted
fix(feed): sanitizeMediaUrl 走 URL 对象重写 protocol + 清 :80 (CR PR#345)
Copilot CR 指出两个问题: 1. http:// → https:// 用字符串拼接会保留显式端口 —— "http://x.com:80/" 升成 "https://x.com:80/" 后浏览器拿 80 走 TLS 必失败 2. 升级逻辑没有单测 修复: - 用 new URL(safe) 改 protocol = "https:",并在 port === "80" 时清空 - 非 80 端口保留(用户跑 https on 8443 等场景) - 相对路径不走 URL parser,原样返回 - 新增 tests/url-safety.test.ts,14 条 case 覆盖:https 原样 / http 升级 / 大小写 / :80 清空 / :8080 保留 / path-query-hash 保留 / 相对路径 / 协议相对 拒绝 / javascript: / data: / vbscript: / mailto: 在媒体场景拒 / 空值 49/49 vitest 通过。 Co-authored-by: copilot-pull-request-reviewer[bot] <copilot-pull-request-reviewer[bot]@users.noreply.github.com>
1 parent e0d75f1 commit 0ae43f0

2 files changed

Lines changed: 116 additions & 4 deletions

File tree

lib/url-safety.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,27 @@ export function sanitizeExternalUrl(
5454
* 这里是 defense-in-depth —— 万一某条历史数据漏网(或 LLM 兜底回填了
5555
* http:// 的封面),前端再升一次。HTTPS 页面加载 http:// 图片会被
5656
* mixed-content policy 拦掉,宁可不显示也别让浏览器报黄锁。
57+
*
58+
* 实现历史:最初版本用字符串拼接 `"https://" + safe.substring(7)`,被 CR
59+
* (#345) 指出会保留显式端口 —— `http://x.com:80/` 升成 `https://x.com:80/`
60+
* 后浏览器拿 80 端口走 TLS 必失败。改成走 URL 对象重写 protocol,
61+
* 并在 port === "80" 时清空端口(http 默认端口在 https 里没意义)。
5762
*/
5863
export function sanitizeMediaUrl(
5964
raw: string | undefined | null,
6065
): string | null {
6166
const safe = sanitize(raw, SAFE_MEDIA_PROTOCOLS, true);
6267
if (!safe) return null;
63-
// 显式判前缀避免误升级相对路径("/x.jpg" 不会进这里,但保险)
64-
if (safe.toLowerCase().startsWith("http://")) {
65-
return "https://" + safe.substring(7);
68+
// 相对路径("/x.jpg")走不到协议升级,原样返回
69+
if (!safe.toLowerCase().startsWith("http://")) return safe;
70+
try {
71+
const u = new URL(safe);
72+
u.protocol = "https:";
73+
// 显式 :80 在 https 下会让浏览器拿 80 端口握手 TLS,必挂;清空让它走默认 443
74+
if (u.port === "80") u.port = "";
75+
return u.toString();
76+
} catch {
77+
// 理论上 sanitize 已经保证 URL 合法可解析,走到这只是兜底
78+
return safe;
6679
}
67-
return safe;
6880
}

tests/url-safety.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* url-safety 单元测试(CR PR#345 要求补的覆盖)。
3+
*
4+
* sanitizeMediaUrl 现在做两件事:
5+
* 1. 协议白名单:只放 http/https + 站内相对路径,拒 javascript:/data:/协议相对
6+
* 2. http -> https 自动升级,顺手清显式 :80(http 默认端口在 https 下会挂 TLS)
7+
*
8+
* sanitizeExternalUrl 走的是 link 白名单(多个 mailto:),不在本次 PR 改动范围,
9+
* 但顺手补几条 smoke test 锁住边界。
10+
*/
11+
import { describe, expect, test } from "vitest";
12+
import { sanitizeMediaUrl, sanitizeExternalUrl } from "../lib/url-safety";
13+
14+
describe("sanitizeMediaUrl", () => {
15+
test("https 原样返回(normalizer 可能加 trailing slash,URL.toString 已稳定)", () => {
16+
expect(sanitizeMediaUrl("https://example.com/x.jpg")).toBe(
17+
"https://example.com/x.jpg",
18+
);
19+
});
20+
21+
test("http:// 自动升级到 https://", () => {
22+
expect(sanitizeMediaUrl("http://example.com/x.jpg")).toBe(
23+
"https://example.com/x.jpg",
24+
);
25+
});
26+
27+
test("HTTP:// 大小写不敏感升级", () => {
28+
expect(sanitizeMediaUrl("HTTP://example.com/x.jpg")).toBe(
29+
"https://example.com/x.jpg",
30+
);
31+
});
32+
33+
test("显式 :80 端口在升级时清空(防止 https 拿 80 走 TLS)", () => {
34+
expect(sanitizeMediaUrl("http://example.com:80/x.jpg")).toBe(
35+
"https://example.com/x.jpg",
36+
);
37+
});
38+
39+
test("非 80 的显式端口保留(用户可能跑了 https on 8443 这种)", () => {
40+
expect(sanitizeMediaUrl("http://example.com:8080/x.jpg")).toBe(
41+
"https://example.com:8080/x.jpg",
42+
);
43+
});
44+
45+
test("升级保留 path / query / hash", () => {
46+
expect(
47+
sanitizeMediaUrl(
48+
"http://mmbiz.qpic.cn/sz_mmbiz_jpg/abc/0?wx_fmt=jpeg&tp=webp#x",
49+
),
50+
).toBe("https://mmbiz.qpic.cn/sz_mmbiz_jpg/abc/0?wx_fmt=jpeg&tp=webp#x");
51+
});
52+
53+
test("站内相对路径原样返回,不走 URL parser", () => {
54+
expect(sanitizeMediaUrl("/logo.png")).toBe("/logo.png");
55+
expect(sanitizeMediaUrl("/event/cover.webp?v=1")).toBe(
56+
"/event/cover.webp?v=1",
57+
);
58+
});
59+
60+
test("协议相对 URL 被拒(//evil.com 会继承当前页协议跳到攻击者域)", () => {
61+
expect(sanitizeMediaUrl("//evil.com/x.jpg")).toBeNull();
62+
});
63+
64+
test("javascript: / data: / vbscript: 被拒", () => {
65+
expect(sanitizeMediaUrl("javascript:alert(1)")).toBeNull();
66+
expect(sanitizeMediaUrl("data:image/png;base64,AAA")).toBeNull();
67+
expect(sanitizeMediaUrl("vbscript:msgbox(1)")).toBeNull();
68+
});
69+
70+
test("mailto: 在媒体场景被拒(不在 SAFE_MEDIA_PROTOCOLS)", () => {
71+
expect(sanitizeMediaUrl("mailto:a@b.com")).toBeNull();
72+
});
73+
74+
test("空 / null / undefined / 仅空白 → null", () => {
75+
expect(sanitizeMediaUrl(null)).toBeNull();
76+
expect(sanitizeMediaUrl(undefined)).toBeNull();
77+
expect(sanitizeMediaUrl("")).toBeNull();
78+
expect(sanitizeMediaUrl(" ")).toBeNull();
79+
});
80+
81+
test("升级行为幂等:已是 https 不改", () => {
82+
const out1 = sanitizeMediaUrl("https://example.com/x.jpg");
83+
const out2 = sanitizeMediaUrl(out1!);
84+
expect(out2).toBe(out1);
85+
});
86+
});
87+
88+
describe("sanitizeExternalUrl", () => {
89+
test("mailto 允许(媒体场景拒、链接场景允许,区分两个白名单)", () => {
90+
expect(sanitizeExternalUrl("mailto:a@b.com")).toBe("mailto:a@b.com");
91+
});
92+
93+
test("协议相对 URL 被拒(同 media)", () => {
94+
expect(sanitizeExternalUrl("//evil.com/x")).toBeNull();
95+
});
96+
97+
test("站内相对路径原样返回", () => {
98+
expect(sanitizeExternalUrl("/about")).toBe("/about");
99+
});
100+
});

0 commit comments

Comments
 (0)