Files
homarr/packages/custom-widgets/src/core/url-policy.ts
T

117 lines
4.1 KiB
TypeScript

export type CustomWidgetHttpUrlIssue = "invalid" | "protocol" | "credentials";
const forbiddenUnicodeCodePointRanges = [
[0x0000, 0x0020],
[0x007f, 0x00a0],
[0x1680, 0x1680],
[0x2000, 0x200f],
[0x2028, 0x202f],
[0x205f, 0x206f],
[0x3000, 0x3000],
[0xfeff, 0xfeff],
] as const;
/**
* Applies the conservative URL syntax shared by Homarr and the Workshop
* validator. WHATWG URL parsing intentionally accepts and normalizes several
* ambiguous spellings (for example IPv4 integers, backslashes and escaped
* hostnames). Definitions are persisted and later used for network requests,
* so authoring accepts only an unambiguous authority spelling.
*/
export function getCustomWidgetHttpUrlIssue(value: string): CustomWidgetHttpUrlIssue | null {
if (hasForbiddenUrlCharacter(value) || value.includes("\\")) return "invalid";
if (!URL.canParse(value)) return "invalid";
let url: URL;
try {
url = new URL(value);
} catch {
return "invalid";
}
if (url.protocol !== "http:" && url.protocol !== "https:") return "protocol";
const authority = getRawAuthority(value);
if (authority === null) return "invalid";
if (authority.includes("@") || url.username || url.password) return "credentials";
if (authority.includes("%") || /[。.。]/u.test(authority)) return "invalid";
const hostAndPort = splitHostAndPort(authority);
if (!hostAndPort || !hasCanonicalPort(hostAndPort.port)) return "invalid";
const rawHostname = withoutTrailingRootDot(hostAndPort.hostname);
const parsedHostname = withoutTrailingRootDot(url.hostname);
if (!rawHostname || !hasCanonicalParsedHostname(parsedHostname)) return "invalid";
if (
rawHostname.startsWith("[") &&
parsedHostname.startsWith("[") &&
!rawHostname.includes(".") &&
rawHostname.toLowerCase() !== parsedHostname
) {
return "invalid";
}
if (looksNumeric(rawHostname) && !isStrictIpv4(rawHostname)) return "invalid";
if (isStrictIpv4(parsedHostname) && rawHostname !== parsedHostname) return "invalid";
return null;
}
function getRawAuthority(value: string): string | null {
const match = value.match(/^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]*)/u);
return match?.[1] ?? null;
}
function splitHostAndPort(authority: string): { hostname: string; port: string } | null {
if (authority.startsWith("[")) {
const closingBracket = authority.indexOf("]");
if (closingBracket < 0) return null;
const hostname = authority.slice(0, closingBracket + 1);
const suffix = authority.slice(closingBracket + 1);
if (suffix && !suffix.startsWith(":")) return null;
return { hostname, port: suffix ? suffix.slice(1) : "" };
}
const firstColon = authority.indexOf(":");
if (firstColon !== authority.lastIndexOf(":")) return null;
return {
hostname: firstColon < 0 ? authority : authority.slice(0, firstColon),
port: firstColon < 0 ? "" : authority.slice(firstColon + 1),
};
}
function hasCanonicalPort(value: string): boolean {
if (!value) return true;
if (!/^(?:0|[1-9]\d{0,4})$/u.test(value)) return false;
return Number(value) <= 65_535;
}
function hasCanonicalParsedHostname(value: string): boolean {
if (value.startsWith("[") && value.endsWith("]")) return true;
if (!value || value.length > 253 || !/^[A-Za-z0-9._-]+$/u.test(value)) return false;
return value
.split(".")
.every((label) => Boolean(label) && label.length <= 63 && !label.startsWith("-") && !label.endsWith("-"));
}
function looksNumeric(value: string): boolean {
const parts = value.split(".");
return parts.every((part) => /^(?:\d+|0[xX][0-9A-Fa-f]+)$/u.test(part));
}
function isStrictIpv4(value: string): boolean {
const parts = value.split(".");
return parts.length === 4 && parts.every((part) => /^(?:0|[1-9]\d{0,2})$/u.test(part) && Number(part) <= 255);
}
function withoutTrailingRootDot(value: string): string {
return value.endsWith(".") ? value.slice(0, -1) : value;
}
function hasForbiddenUrlCharacter(value: string): boolean {
return [...value].some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return forbiddenUnicodeCodePointRanges.some(([minimum, maximum]) => codePoint >= minimum && codePoint <= maximum);
});
}