mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
6072b969d6
* fix: collab chat input hidden by mobile bottom nav bar Closes #939 * chore: prepare database for nest + typeorm * fix(ssrf): relax internal network resolution (#947) * docs(ssrf): update Internal-Network-Access wiki to reflect relaxed guard Loopback, link-local, and .local/.internal hostnames are now all overridable with ALLOW_INTERNAL_NETWORK=true (commit9a08368). Merge the two-tier "always blocked / conditionally blocked" structure into a single table, add a warning about cloud metadata exposure. * fix(ssrf): let .local/.internal hostnames pass to IP-level checks The pre-DNS hostname block was redundant: any .local/.internal host that resolves to a private IP is already gated by isPrivateNetwork + ALLOW_INTERNAL_NETWORK, and any that resolves to loopback/link-local is caught by isAlwaysBlocked unconditionally. Dropping the hostname pre-check means Docker/LAN deployments can reach services on .local hostnames (e.g. immich.local) with ALLOW_INTERNAL_NETWORK=true, while loopback and link-local IPs (including 169.254.169.254) remain hard-blocked with no override. Reverts the isAlwaysBlocked guard loosening from9a08368. * fix(auth): trim username and email on all write paths Self-registration stored values verbatim, so trailing whitespace could produce rows that lookup code (which trims input) silently misses. Trim username and email before validation and INSERT in registerUser, adminService.updateUser, and oidcService.findOrCreateUser. updateSettings and adminService.createUser already trimmed correctly. Adds a one-shot backfill migration (trimUserWhitespace) that trims existing dirty rows; collisions are resolved by appending __migrated_<id> to the value with a loud console.warn so operators can review affected accounts. 18 new tests covering registration trim, duplicate detection, admin update trim, trip-member lookup regression, and all migration branches. * feat(notices): add v3014-whitespace-collision admin notice Adds a dismissible banner for admins on v3.0.14+ that fires only when the whitespace-trimming migration detected a username/email collision (stored in app_settings as whitespace_migration_collision=true). Notice conditions: existingUserBeforeVersion(3.0.14) + role=admin + custom predicate reading the app_settings flag. Predicate registered in registry.ts; migration step writes the flag when hadCollision=true. All 15 translation files updated with title/body keys. 7 integration tests added (SN-COLLISION-1 through -7) covering all condition branches: shown when all conditions met, hidden when flag absent/false, hidden for non-admin, hidden for new user, hidden below min app version, hidden after dismissal.
155 lines
5.0 KiB
TypeScript
155 lines
5.0 KiB
TypeScript
import dns from 'node:dns/promises';
|
|
import { Agent } from 'undici';
|
|
|
|
const ALLOW_INTERNAL_NETWORK = process.env.ALLOW_INTERNAL_NETWORK?.toLowerCase() === 'true';
|
|
|
|
export interface SsrfResult {
|
|
allowed: boolean;
|
|
resolvedIp?: string;
|
|
isPrivate: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
// Always blocked — no override possible
|
|
function isAlwaysBlocked(ip: string): boolean {
|
|
// Strip IPv6 brackets
|
|
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
|
|
|
|
// Loopback
|
|
if (addr.startsWith("127.") || addr === '::1') return true;
|
|
// Unspecified
|
|
if (addr.startsWith("0.")) return true;
|
|
// Link-local / cloud metadata
|
|
if (addr.startsWith("169.254.") || /^fe80:/i.test(addr)) return true;
|
|
// IPv4-mapped loopback / link-local: ::ffff:127.x.x.x, ::ffff:169.254.x.x
|
|
if (/^::ffff:127\./i.test(addr) || /^::ffff:169\.254\./i.test(addr)) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
// Blocked unless ALLOW_INTERNAL_NETWORK=true
|
|
function isPrivateNetwork(ip: string): boolean {
|
|
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
|
|
|
|
// RFC-1918 private ranges
|
|
if (addr.startsWith("10.")) return true;
|
|
if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
|
|
if (addr.startsWith("192.168.")) return true;
|
|
// CGNAT / Tailscale shared address space (100.64.0.0/10)
|
|
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) return true;
|
|
// IPv6 ULA (fc00::/7)
|
|
if (/^f[cd]/i.test(addr)) return true;
|
|
// IPv4-mapped RFC-1918
|
|
if (/^::ffff:10\./i.test(addr)) return true;
|
|
if (/^::ffff:172\.(1[6-9]|2\d|3[01])\./i.test(addr)) return true;
|
|
if (/^::ffff:192\.168\./i.test(addr)) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
function isInternalHostname(hostname: string): boolean {
|
|
const h = hostname.toLowerCase();
|
|
return h.endsWith('.local') || h.endsWith('.internal') || h === 'localhost';
|
|
}
|
|
|
|
export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean = false): Promise<SsrfResult> {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(rawUrl);
|
|
} catch {
|
|
return { allowed: false, isPrivate: false, error: 'Invalid URL' };
|
|
}
|
|
|
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
return { allowed: false, isPrivate: false, error: 'Only HTTP and HTTPS URLs are allowed' };
|
|
}
|
|
|
|
const hostname = url.hostname.toLowerCase();
|
|
|
|
// Resolve hostname to IP
|
|
let resolvedIp: string;
|
|
try {
|
|
const result = await dns.lookup(hostname);
|
|
resolvedIp = result.address;
|
|
} catch {
|
|
return { allowed: false, isPrivate: false, error: 'Could not resolve hostname' };
|
|
}
|
|
|
|
if (isAlwaysBlocked(resolvedIp)) {
|
|
return {
|
|
allowed: false,
|
|
isPrivate: true,
|
|
resolvedIp,
|
|
error: 'Requests to loopback and link-local addresses are not allowed',
|
|
};
|
|
}
|
|
|
|
if (isPrivateNetwork(resolvedIp) || isInternalHostname(hostname)) {
|
|
if (!ALLOW_INTERNAL_NETWORK || bypassInternalIpAllowed) {
|
|
return {
|
|
allowed: false,
|
|
isPrivate: true,
|
|
resolvedIp,
|
|
error: 'Requests to private/internal network addresses are not allowed. Set ALLOW_INTERNAL_NETWORK=true to permit this for self-hosted setups.',
|
|
};
|
|
}
|
|
return { allowed: true, isPrivate: true, resolvedIp };
|
|
}
|
|
|
|
return { allowed: true, isPrivate: false, resolvedIp };
|
|
}
|
|
|
|
/**
|
|
* Thrown by safeFetch() when the URL is blocked by the SSRF guard.
|
|
*/
|
|
export class SsrfBlockedError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'SsrfBlockedError';
|
|
}
|
|
}
|
|
|
|
export interface SafeFetchOptions {
|
|
rejectUnauthorized?: boolean;
|
|
}
|
|
|
|
/**
|
|
* SSRF-safe fetch wrapper. Validates the URL with checkSsrf(), then makes
|
|
* the request using a DNS-pinned dispatcher so the resolved IP cannot change
|
|
* between the check and the actual connection (DNS rebinding prevention).
|
|
*
|
|
* Pass `{ rejectUnauthorized: false }` for targets that use self-signed TLS
|
|
* certificates (e.g. a Synology NAS on a local network). The SSRF guard still
|
|
* applies — only the TLS certificate check is relaxed.
|
|
*/
|
|
export async function safeFetch(url: string, init?: RequestInit, options?: SafeFetchOptions): Promise<Response> {
|
|
const ssrf = await checkSsrf(url);
|
|
if (!ssrf.allowed) {
|
|
throw new SsrfBlockedError(ssrf.error ?? 'Request blocked by SSRF guard');
|
|
}
|
|
const dispatcher = createPinnedDispatcher(ssrf.resolvedIp!, options?.rejectUnauthorized ?? true);
|
|
return fetch(url, { ...init, dispatcher } as any);
|
|
}
|
|
|
|
/**
|
|
* Returns an undici Agent whose connect.lookup is pinned to the already-validated
|
|
* IP. This prevents DNS rebinding (TOCTOU) by ensuring the outbound connection
|
|
* goes to the IP we checked, not a re-resolved one.
|
|
*/
|
|
export function createPinnedDispatcher(resolvedIp: string, rejectUnauthorized = true): Agent {
|
|
return new Agent({
|
|
connect: {
|
|
rejectUnauthorized,
|
|
lookup: (_hostname: string, opts: Record<string, unknown>, callback: Function) => {
|
|
const family = resolvedIp.includes(':') ? 6 : 4;
|
|
// Node.js 18+ may call lookup with `all: true`, expecting an array of address objects
|
|
if (opts?.all) {
|
|
callback(null, [{ address: resolvedIp, family }]);
|
|
} else {
|
|
callback(null, resolvedIp, family);
|
|
}
|
|
},
|
|
},
|
|
});
|
|
}
|