mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
A provider link can now carry `iconUrl`, an https address of the site's own logo. The confirmation dialog downloads it as soon as it opens and shows it at the top; importing stores it as the card's custom icon. No extra confirmation step. Any failure is silent: the card falls back to the built-in `icon` name, or to the name's initials. The image goes through the same path as a local upload: the backend returns a data URI, the frontend redraws it on a canvas into a 96x96 PNG, and the provider save validation checks it again. The address itself is never stored or rendered, and the import command never downloads. The download (`deeplink/icon_fetch.rs`): - Parsing drops addresses that are not https, use an IP or a non-default port, carry credentials or exceed 2048 characters; the rest of the link still imports. - Every resolved address must be public, and the connection is pinned to them so a second DNS answer cannot point it elsewhere. Redirects are followed by hand, up to 3, and each hop is checked again. - No system proxy, no cookies, no Referer, 5 s overall timeout, body read as a stream and capped at 256 KB. - Content-Type must be PNG, JPEG, WebP or SVG and match the file's magic bytes; bitmap dimensions are read from the header and capped at 4096. - 198.18.0.0/15 is allowed on purpose: Clash and Surge fake-ip mode resolve every domain into it. The trade-off is written next to `is_public_ipv4`. - Errors are logged without the URL, which may carry query tokens. Closes #8117
126 lines
4.4 KiB
TypeScript
126 lines
4.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import {
|
||
CUSTOM_ICON_PREFIX,
|
||
CustomIconError,
|
||
MAX_CUSTOM_ICON_LENGTH,
|
||
MAX_UPLOAD_BYTES,
|
||
isCustomIcon,
|
||
normalizeIconDataUri,
|
||
normalizeIconFile,
|
||
} from "@/utils/customIcon";
|
||
import { pngDataUri } from "../fixtures/customIcon";
|
||
|
||
describe("isCustomIcon", () => {
|
||
it("accepts a PNG data URI within the size and dimension limits", () => {
|
||
expect(isCustomIcon(pngDataUri(96, 96))).toBe(true);
|
||
expect(isCustomIcon(pngDataUri(256, 1))).toBe(true);
|
||
});
|
||
|
||
it("rejects anything that is not our own PNG data URI", () => {
|
||
expect(isCustomIcon("claude")).toBe(false);
|
||
expect(isCustomIcon("data:image/svg+xml;base64,PHN2Zy8+")).toBe(false);
|
||
expect(isCustomIcon("https://example.com/logo.png")).toBe(false);
|
||
expect(isCustomIcon(undefined)).toBe(false);
|
||
expect(isCustomIcon("")).toBe(false);
|
||
// 前缀对、内容不是 PNG 文件头
|
||
expect(isCustomIcon(`${CUSTOM_ICON_PREFIX}R0lGODlhAQABAAAAACw=`)).toBe(
|
||
false,
|
||
);
|
||
expect(isCustomIcon(`${CUSTOM_ICON_PREFIX}!!!`)).toBe(false);
|
||
});
|
||
|
||
it("rejects other formats disguised with an IHDR marker at the PNG offsets", () => {
|
||
// 浏览器按内容识别格式:这是一张 512×512 的 GIF,只是第 12 字节起凑出了 IHDR 和小尺寸
|
||
const gif = [
|
||
..."GIF89a".split("").map((c) => c.charCodeAt(0)),
|
||
0x00,
|
||
0x02,
|
||
0x00,
|
||
0x02, // 512×512(小端)
|
||
0,
|
||
0,
|
||
..."IHDR".split("").map((c) => c.charCodeAt(0)),
|
||
0,
|
||
0,
|
||
0,
|
||
96,
|
||
0,
|
||
0,
|
||
0,
|
||
96,
|
||
0x3b,
|
||
];
|
||
expect(
|
||
isCustomIcon(CUSTOM_ICON_PREFIX + btoa(String.fromCharCode(...gif))),
|
||
).toBe(false);
|
||
|
||
// 签名对、IHDR 长度不是 13
|
||
const header = atob(pngDataUri(96, 96).slice(CUSTOM_ICON_PREFIX.length));
|
||
const badLength = header.slice(0, 11) + "\x0e" + header.slice(12);
|
||
expect(isCustomIcon(CUSTOM_ICON_PREFIX + btoa(badLength))).toBe(false);
|
||
});
|
||
|
||
it("rejects icons that bypassed the save-time checks", () => {
|
||
// 文件头声明的尺寸超限(解压炸弹的典型形态)
|
||
expect(isCustomIcon(pngDataUri(50000, 50000))).toBe(false);
|
||
expect(isCustomIcon(pngDataUri(0, 96))).toBe(false);
|
||
// 超过长度上限
|
||
const long = pngDataUri(96, 96) + "A".repeat(MAX_CUSTOM_ICON_LENGTH);
|
||
expect(isCustomIcon(long)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("normalizeIconFile", () => {
|
||
const rejectCode = async (file: File) => {
|
||
try {
|
||
await normalizeIconFile(file);
|
||
} catch (error) {
|
||
expect(error).toBeInstanceOf(CustomIconError);
|
||
return (error as CustomIconError).code;
|
||
}
|
||
throw new Error("expected normalizeIconFile to reject");
|
||
};
|
||
|
||
it("rejects formats outside the allowlist before reading the file", async () => {
|
||
const gif = new File(["GIF89a"], "logo.gif", { type: "image/gif" });
|
||
const html = new File(["<html>"], "logo.html", { type: "text/html" });
|
||
expect(await rejectCode(gif)).toBe("unsupported");
|
||
expect(await rejectCode(html)).toBe("unsupported");
|
||
});
|
||
|
||
it("rejects files larger than the upload limit", async () => {
|
||
const big = new File([new Uint8Array(MAX_UPLOAD_BYTES + 1)], "big.png", {
|
||
type: "image/png",
|
||
});
|
||
expect(await rejectCode(big)).toBe("tooLarge");
|
||
});
|
||
|
||
it("falls back to the extension when the browser leaves the type empty", async () => {
|
||
// 类型为空、扩展名是 .svg:通过格式检查,体积超限才被拒,说明扩展名被认出来了
|
||
const svg = new File([new Uint8Array(MAX_UPLOAD_BYTES + 1)], "logo.SVG");
|
||
expect(await rejectCode(svg)).toBe("tooLarge");
|
||
});
|
||
});
|
||
|
||
describe("normalizeIconDataUri", () => {
|
||
const expectCode = async (dataUri: string, code: string) => {
|
||
await expect(normalizeIconDataUri(dataUri)).rejects.toSatisfy(
|
||
(error: unknown) =>
|
||
error instanceof CustomIconError && error.code === code,
|
||
);
|
||
};
|
||
|
||
it("only takes the image types the backend download allows", async () => {
|
||
await expectCode("data:image/gif;base64,R0lGODlhAQABAAAAACw=", "unsupported");
|
||
await expectCode("data:text/html;base64,PHN2Zy8+", "unsupported");
|
||
await expectCode("https://example.com/logo.png", "unsupported");
|
||
// 前缀里少了 ;base64
|
||
await expectCode("data:image/png,abc", "unsupported");
|
||
});
|
||
|
||
it("rejects data larger than the 256 KB download limit", async () => {
|
||
const oversized = `data:image/png;base64,${"A".repeat(400 * 1024)}`;
|
||
await expectCode(oversized, "tooLarge");
|
||
});
|
||
});
|