CI / check (push) Canceled after 0s
CI / tests-unit (push) Canceled after 0s
CI / tests-integration (push) Canceled after 0s
CI / tests-ui (push) Canceled after 0s
CI / preflight (push) Canceled after 0s
CI / deploy (push) Canceled after 0s
Gitea Runner Clean Test / test-job (push) Canceled after 0s
Gitea Actions Runner Test / test-job (push) Canceled after 0s
- P1: new src/lib/admin/rank-guard.ts with tryGuardRank/guardRank + RankGuardTarget/Result; extracted from users.ts; applied to admin-bans (create/lift + rank redirect) and admin-badges (caps + rank guard + error surfacing); import-url-safety.ts SSRF guard wired into clone save+icon fetch; logAudit await in help-tickets; antiddos mutations SETTINGS_EDIT; markAllAlertsRead NOTIFICATIONS_EDIT; rankError/saveError in all 25 message files; tests rew/new for bans/badges/rank-guard/import-url-safety - P2: ticket-inbox merged pagination reworked to single UNION ALL SQL with LIMIT/OFFSET (avoids O(offset) materialization); getCachedAdminCount with cacheFiltered option applied to devops/errors/photos/items/guilds/help-tickets/moderation pages; admin-theme catches→?error=1+banner+25 locales; logger in silent catches (rare-values/writeable-boxes); catalog-items-loader CAST→plain equality; count caching helper extended with opt-in cacheFiltered - P3: removed dead modules is-staff/notice/recent-furni-resync/log-filters plus load-user-sanctions/user-sanction-timeline; removed orphaned CATALOG_PAGE_COLUMNS re-export; removed getFurniIconUrl from studio-furni-icons Build: lint 0, typecheck 0, 3480 tests pass, i18n:check exit 0, colors exit 0. Circular chunk warning (9452/10535/66463) is webpack code-splitting artifact; build succeeds.
83 lines
2.8 KiB
TypeScript
83 lines
2.8 KiB
TypeScript
import { lookup } from "node:dns/promises";
|
|
|
|
function isPrivateIpv4(hex: string): boolean {
|
|
const parts = hex.split(".").map((p) => Number(p));
|
|
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return true;
|
|
const [a, b] = parts;
|
|
if (a === 0 || a === 10) return true;
|
|
if (a === 127 || a === 255) return true;
|
|
if (a === 169 && b === 254) return true;
|
|
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
if (a === 192 && b === 168) return true;
|
|
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
if (a === 192 && b === 0 && parts[2] === 0 && parts[3] === 0) return true;
|
|
return false;
|
|
}
|
|
|
|
function isPrivateIpv6(addr: string): boolean {
|
|
const lower = addr.toLowerCase();
|
|
if (lower.startsWith("::") || lower === "::1") return true;
|
|
if (/^fe[89ab]/.test(lower) || /^fc|^fd/.test(lower)) return true;
|
|
if (lower.includes(":") && lower.split(":").length === 4) return false;
|
|
if (/^2001:0?db8/.test(lower)) return true;
|
|
return lower === "::" || lower.endsWith(":ffff:");
|
|
}
|
|
|
|
function isPrivateIp(ip: string): boolean {
|
|
return ip.includes(":") ? isPrivateIpv6(ip) : isPrivateIpv4(ip);
|
|
}
|
|
|
|
const BLOCKED_HOST_LABELS =
|
|
/^(localhost|.*\.local|.*\.localhost|.*\.internal|.*\.localdomain|.*\.lan)$/i;
|
|
|
|
/**
|
|
* Guard a remote asset URL before it is stored or fetched. Rejects anything
|
|
* that could reach the host itself: non-https, embedded credentials, private
|
|
* IP literals, loopback/local-only hostnames and any host that resolves to a
|
|
* private or loopback address. Throws TypeError with a human message so admin
|
|
* routes can surface it as a 400.
|
|
*/
|
|
export async function assertSafeImportUrl(
|
|
url: string,
|
|
label: string,
|
|
): Promise<void> {
|
|
const trimmed = url.trim();
|
|
if (!trimmed) return;
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(trimmed);
|
|
} catch {
|
|
throw new TypeError(`${label} is not a valid URL`);
|
|
}
|
|
if (parsed.protocol !== "https:") {
|
|
throw new TypeError(`${label} must be an https:// URL`);
|
|
}
|
|
if (parsed.username || parsed.password) {
|
|
throw new TypeError(`${label} must not embed credentials`);
|
|
}
|
|
const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
|
|
if (BLOCKED_HOST_LABELS.test(hostname)) {
|
|
throw new TypeError(`${label} points at a local host`);
|
|
}
|
|
if (hostname) {
|
|
const looksLiteral =
|
|
hostname.includes(":") || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname);
|
|
if (looksLiteral) {
|
|
if (isPrivateIp(hostname.toLowerCase())) {
|
|
throw new TypeError(`${label} points at a private address`);
|
|
}
|
|
return;
|
|
}
|
|
let addresses: string[];
|
|
try {
|
|
const result = await lookup(hostname, { all: true, verbatim: true });
|
|
addresses = result.map((r) => r.address);
|
|
} catch {
|
|
throw new TypeError(`${label} does not resolve to a public host`);
|
|
}
|
|
if (addresses.some((ip) => isPrivateIp(ip.toLowerCase()))) {
|
|
throw new TypeError(`${label} resolves to a private address`);
|
|
}
|
|
}
|
|
}
|