Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion lib/url-safety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,32 @@ export function sanitizeExternalUrl(
* 媒体(<img src> / <video src> / <iframe src>)场景:只允许 http(s)。
* mailto 无意义;data: 虽然对 <img> 较常用但体积和审计风险高,默认不放;
* 站内相对路径允许(/logo.png、/event/cover.webp 这些)。
*
* 自动 http -> https 升级:后端 OgFetchService 已在抓取阶段做一次升级,
* 这里是 defense-in-depth —— 万一某条历史数据漏网(或 LLM 兜底回填了
* http:// 的封面),前端再升一次。HTTPS 页面加载 http:// 图片会被
* mixed-content policy 拦掉,宁可不显示也别让浏览器报黄锁。
*
* 实现历史:最初版本用字符串拼接 `"https://" + safe.substring(7)`,被 CR
* (#345) 指出会保留显式端口 —— `http://x.com:80/` 升成 `https://x.com:80/`
* 后浏览器拿 80 端口走 TLS 必失败。改成走 URL 对象重写 protocol,
* 并在 port === "80" 时清空端口(http 默认端口在 https 里没意义)。
*/
export function sanitizeMediaUrl(
raw: string | undefined | null,
): string | null {
return sanitize(raw, SAFE_MEDIA_PROTOCOLS, true);
const safe = sanitize(raw, SAFE_MEDIA_PROTOCOLS, true);
if (!safe) return null;
// 相对路径("/x.jpg")走不到协议升级,原样返回
if (!safe.toLowerCase().startsWith("http://")) return safe;
try {
const u = new URL(safe);
u.protocol = "https:";
// 显式 :80 在 https 下会让浏览器拿 80 端口握手 TLS,必挂;清空让它走默认 443
if (u.port === "80") u.port = "";
return u.toString();
} catch {
// 理论上 sanitize 已经保证 URL 合法可解析,走到这只是兜底
return safe;
}
}
100 changes: 100 additions & 0 deletions tests/url-safety.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* url-safety 单元测试(CR PR#345 要求补的覆盖)。
*
* sanitizeMediaUrl 现在做两件事:
* 1. 协议白名单:只放 http/https + 站内相对路径,拒 javascript:/data:/协议相对
* 2. http -> https 自动升级,顺手清显式 :80(http 默认端口在 https 下会挂 TLS)
*
* sanitizeExternalUrl 走的是 link 白名单(多个 mailto:),不在本次 PR 改动范围,
* 但顺手补几条 smoke test 锁住边界。
*/
import { describe, expect, test } from "vitest";
import { sanitizeMediaUrl, sanitizeExternalUrl } from "../lib/url-safety";

describe("sanitizeMediaUrl", () => {
test("https 原样返回(normalizer 可能加 trailing slash,URL.toString 已稳定)", () => {
expect(sanitizeMediaUrl("https://example.com/x.jpg")).toBe(
"https://example.com/x.jpg",
);
});

test("http:// 自动升级到 https://", () => {
expect(sanitizeMediaUrl("http://example.com/x.jpg")).toBe(
"https://example.com/x.jpg",
);
});

test("HTTP:// 大小写不敏感升级", () => {
expect(sanitizeMediaUrl("HTTP://example.com/x.jpg")).toBe(
"https://example.com/x.jpg",
);
});

test("显式 :80 端口在升级时清空(防止 https 拿 80 走 TLS)", () => {
expect(sanitizeMediaUrl("http://example.com:80/x.jpg")).toBe(
"https://example.com/x.jpg",
);
});

test("非 80 的显式端口保留(用户可能跑了 https on 8443 这种)", () => {
expect(sanitizeMediaUrl("http://example.com:8080/x.jpg")).toBe(
"https://example.com:8080/x.jpg",
);
});

test("升级保留 path / query / hash", () => {
expect(
sanitizeMediaUrl(
"http://mmbiz.qpic.cn/sz_mmbiz_jpg/abc/0?wx_fmt=jpeg&tp=webp#x",
),
).toBe("https://mmbiz.qpic.cn/sz_mmbiz_jpg/abc/0?wx_fmt=jpeg&tp=webp#x");
});

test("站内相对路径原样返回,不走 URL parser", () => {
expect(sanitizeMediaUrl("/logo.png")).toBe("/logo.png");
expect(sanitizeMediaUrl("/event/cover.webp?v=1")).toBe(
"/event/cover.webp?v=1",
);
});

test("协议相对 URL 被拒(//evil.com 会继承当前页协议跳到攻击者域)", () => {
expect(sanitizeMediaUrl("//evil.com/x.jpg")).toBeNull();
});

test("javascript: / data: / vbscript: 被拒", () => {
expect(sanitizeMediaUrl("javascript:alert(1)")).toBeNull();
expect(sanitizeMediaUrl("data:image/png;base64,AAA")).toBeNull();
expect(sanitizeMediaUrl("vbscript:msgbox(1)")).toBeNull();
});

test("mailto: 在媒体场景被拒(不在 SAFE_MEDIA_PROTOCOLS)", () => {
expect(sanitizeMediaUrl("mailto:a@b.com")).toBeNull();
});

test("空 / null / undefined / 仅空白 → null", () => {
expect(sanitizeMediaUrl(null)).toBeNull();
expect(sanitizeMediaUrl(undefined)).toBeNull();
expect(sanitizeMediaUrl("")).toBeNull();
expect(sanitizeMediaUrl(" ")).toBeNull();
});

test("升级行为幂等:已是 https 不改", () => {
const out1 = sanitizeMediaUrl("https://example.com/x.jpg");
const out2 = sanitizeMediaUrl(out1!);
expect(out2).toBe(out1);
});
});

describe("sanitizeExternalUrl", () => {
test("mailto 允许(媒体场景拒、链接场景允许,区分两个白名单)", () => {
expect(sanitizeExternalUrl("mailto:a@b.com")).toBe("mailto:a@b.com");
});

test("协议相对 URL 被拒(同 media)", () => {
expect(sanitizeExternalUrl("//evil.com/x")).toBeNull();
});

test("站内相对路径原样返回", () => {
expect(sanitizeExternalUrl("/about")).toBe("/about");
});
});
Loading