feat(nextcloud-status): http-Logo-Adresse wird einmalig abgeholt, Formularfehler mit Kennung (j9f)
- gemeinsamer SSRF-Schutz in common/public-url-guard.ts (Favoriten unveraendert)
- fetchLogoImage: Schutz je Sprung, Zeitlimit, 1-MiB-Deckel, Magic Bytes
- alle Formularfehler als { code, message } mit deutschem Text
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
/**
|
||||
* Gemeinsamer Schutz gegen Server-Side-Request-Forgery (SSRF).
|
||||
*
|
||||
* Herkunft: favorites/icon-discovery.service.ts (T-08-05), unveraendert hierher
|
||||
* verschoben, damit auch der Logo-Abruf von Nextcloud-Status (quick-261008-j9f)
|
||||
* dieselbe Pruefung nutzt. Eine Adresse gilt nur als oeffentlich, wenn sie
|
||||
* http/https ist, der Name nicht localhost/.local/0.0.0.0 ist und jede
|
||||
* aufgeloeste Adresse ausserhalb privater, Loopback-, Link-Local-, CGNAT- und
|
||||
* Multicast-Bereiche liegt.
|
||||
*/
|
||||
|
||||
function isPrivateIpv4(address: string): boolean {
|
||||
const parts = address.split('.').map((part) => Number.parseInt(part, 10));
|
||||
|
||||
if (
|
||||
parts.length !== 4 ||
|
||||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [a, b] = parts;
|
||||
|
||||
return (
|
||||
a === 0 ||
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
(a === 100 && b !== undefined && b >= 64 && b <= 127) ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b !== undefined && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 192 && b === 0) ||
|
||||
(a === 198 && (b === 18 || b === 19)) ||
|
||||
a >= 224
|
||||
);
|
||||
}
|
||||
|
||||
function isPrivateIpv6(address: string): boolean {
|
||||
const lower = address.toLowerCase();
|
||||
|
||||
if (
|
||||
lower === '::' ||
|
||||
lower === '::1' ||
|
||||
lower.startsWith('fc') ||
|
||||
lower.startsWith('fd') ||
|
||||
lower.startsWith('fe80:') ||
|
||||
lower.startsWith('ff')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// IPv4-mapped IPv6 (::ffff:<ipv4>) — delegate to isPrivateIpv4 to cover all
|
||||
// RFC 1918 ranges (10.x, 172.16-31.x, 192.168.x) and 169.254.x link-local
|
||||
const v4MappedMatch = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (v4MappedMatch) {
|
||||
return isPrivateIpv4(v4MappedMatch[1]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPrivateIpAddress(address: string): boolean {
|
||||
const version = isIP(address);
|
||||
|
||||
if (version === 4) return isPrivateIpv4(address);
|
||||
if (version === 6) return isPrivateIpv6(address);
|
||||
|
||||
return true; // Unknown format → block by default
|
||||
}
|
||||
|
||||
function isBlockedHostname(hostname: string): boolean {
|
||||
const h = hostname.trim().toLowerCase();
|
||||
|
||||
return h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h === '0.0.0.0';
|
||||
}
|
||||
|
||||
export async function isPublicHttpUrl(url: URL): Promise<boolean> {
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isBlockedHostname(url.hostname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const directVersion = isIP(url.hostname);
|
||||
|
||||
if (directVersion !== 0) {
|
||||
return !isPrivateIpAddress(url.hostname);
|
||||
}
|
||||
|
||||
try {
|
||||
const addresses = await lookup(url.hostname, { all: true });
|
||||
|
||||
if (addresses.length === 0) return false;
|
||||
|
||||
return addresses.every((a) => !isPrivateIpAddress(a.address));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user