70 lines
1.6 KiB
TypeScript
70 lines
1.6 KiB
TypeScript
const HOUSEKEEPING_ORIGIN = "https://housekeeping.invalid";
|
|
|
|
function containsControlCharacter(value: string): boolean {
|
|
return Array.from(value).some((character) => {
|
|
const code = character.codePointAt(0) ?? 0;
|
|
return code < 32 || code === 127;
|
|
});
|
|
}
|
|
|
|
function decodedVariants(value: string): readonly string[] | null {
|
|
const variants = [value];
|
|
for (let pass = 0; pass < 3; pass += 1) {
|
|
let decoded: string;
|
|
try {
|
|
decoded = decodeURIComponent(variants.at(-1) ?? "");
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (decoded === variants.at(-1)) break;
|
|
variants.push(decoded);
|
|
}
|
|
return variants;
|
|
}
|
|
|
|
function containsDotSegment(path: string): boolean {
|
|
return path.split("/").some((segment) => segment === "." || segment === "..");
|
|
}
|
|
|
|
export function isSafeHousekeepingHref(href: string): boolean {
|
|
if (
|
|
href.length === 0 ||
|
|
!href.startsWith("/") ||
|
|
href.startsWith("//") ||
|
|
containsControlCharacter(href) ||
|
|
href.includes("\\")
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const variants = decodedVariants(href);
|
|
if (
|
|
variants === null ||
|
|
variants.some(
|
|
(value) => containsControlCharacter(value) || value.includes("\\"),
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const rawPath = href.split(/[?#]/u, 1)[0] ?? "";
|
|
const pathVariants = decodedVariants(rawPath);
|
|
if (
|
|
pathVariants === null ||
|
|
pathVariants.some((path) => containsDotSegment(path))
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
let normalized: URL;
|
|
try {
|
|
normalized = new URL(href, HOUSEKEEPING_ORIGIN);
|
|
} catch {
|
|
return false;
|
|
}
|
|
return (
|
|
normalized.origin === HOUSEKEEPING_ORIGIN &&
|
|
(normalized.pathname === "/ase" || normalized.pathname.startsWith("/ase/"))
|
|
);
|
|
}
|