Skip to content

Commit f42bdba

Browse files
authored
Merge pull request #345 from InvolutionHell/fix/og-cover-https-upgrade
fix(feed): sanitizeMediaUrl 自动把 http:// 升级到 https://
2 parents 1dcd8ce + 0ae43f0 commit f42bdba

2 files changed

Lines changed: 124 additions & 1 deletion

File tree

lib/url-safety.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,32 @@ export function sanitizeExternalUrl(
4949
* 媒体(<img src> / <video src> / <iframe src>)场景:只允许 http(s)。
5050
* mailto 无意义;data: 虽然对 <img> 较常用但体积和审计风险高,默认不放;
5151
* 站内相对路径允许(/logo.png、/event/cover.webp 这些)。
52+
*
53+
* 自动 http -> https 升级:后端 OgFetchService 已在抓取阶段做一次升级,
54+
* 这里是 defense-in-depth —— 万一某条历史数据漏网(或 LLM 兜底回填了
55+
* http:// 的封面),前端再升一次。HTTPS 页面加载 http:// 图片会被
56+
* 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 里没意义)。
5262
*/
5363
export function sanitizeMediaUrl(
5464
raw: string | undefined | null,
5565
): string | null {
56-
return sanitize(raw, SAFE_MEDIA_PROTOCOLS, true);
66+
const safe = sanitize(raw, SAFE_MEDIA_PROTOCOLS, true);
67+
if (!safe) return null;
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;
79+
}
5780
}

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)