P1-P3 admin cleanup: rank guard, SSRF guard, count caching, dead code removal, pagination rewrite
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
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.
This commit is contained in:
1 parent
57529fd1ea
commit
7b249a7222
71 files changed
+1031
-1078
No files matched your search
@@ -0,0 +1,82 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user