diff --git a/foundations/server/packages/core/src/__tests__/ssrf.test.ts b/foundations/server/packages/core/src/__tests__/ssrf.test.ts new file mode 100644 index 0000000000..b661a335f6 --- /dev/null +++ b/foundations/server/packages/core/src/__tests__/ssrf.test.ts @@ -0,0 +1,209 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { SsrfError, createPinnedLookup, isBlockedHost, resolveSafeAddress, validateFetchUrl } from '../ssrf' + +describe('isBlockedHost', () => { + it('blocks localhost and *.localhost', () => { + expect(isBlockedHost('localhost')).toBe(true) + expect(isBlockedHost('app.localhost')).toBe(true) + expect(isBlockedHost('LOCALHOST')).toBe(true) + }) + + it('blocks loopback IPv4', () => { + expect(isBlockedHost('127.0.0.1')).toBe(true) + expect(isBlockedHost('127.255.255.254')).toBe(true) + }) + + it('blocks private IPv4 ranges', () => { + expect(isBlockedHost('10.0.0.1')).toBe(true) + expect(isBlockedHost('172.16.0.1')).toBe(true) + expect(isBlockedHost('172.31.255.255')).toBe(true) + expect(isBlockedHost('192.168.1.1')).toBe(true) + }) + + it('blocks link-local and unspecified IPv4', () => { + expect(isBlockedHost('169.254.169.254')).toBe(true) + expect(isBlockedHost('0.0.0.0')).toBe(true) + }) + + it('does not block public IPv4', () => { + expect(isBlockedHost('8.8.8.8')).toBe(false) + expect(isBlockedHost('1.1.1.1')).toBe(false) + expect(isBlockedHost('172.32.0.1')).toBe(false) + expect(isBlockedHost('172.15.255.255')).toBe(false) + }) + + it('blocks CGNAT, multicast, reserved and broadcast IPv4', () => { + expect(isBlockedHost('100.64.0.1')).toBe(true) + expect(isBlockedHost('100.127.255.255')).toBe(true) + expect(isBlockedHost('224.0.0.1')).toBe(true) + expect(isBlockedHost('240.0.0.1')).toBe(true) + expect(isBlockedHost('255.255.255.255')).toBe(true) + }) + + it('does not block addresses just outside CGNAT', () => { + expect(isBlockedHost('100.63.255.255')).toBe(false) + expect(isBlockedHost('100.128.0.1')).toBe(false) + }) + + it('blocks loopback / unspecified / unique-local / link-local IPv6', () => { + expect(isBlockedHost('::1')).toBe(true) + expect(isBlockedHost('0:0:0:0:0:0:0:1')).toBe(true) + expect(isBlockedHost('::')).toBe(true) + expect(isBlockedHost('fc00::1')).toBe(true) + expect(isBlockedHost('fd12:3456::1')).toBe(true) + expect(isBlockedHost('fe80::1')).toBe(true) + expect(isBlockedHost('febf::1')).toBe(true) + }) + + it('blocks IPv6-mapped IPv4 loopback in both dotted and hex forms', () => { + expect(isBlockedHost('::ffff:127.0.0.1')).toBe(true) + expect(isBlockedHost('::ffff:7f00:1')).toBe(true) + expect(isBlockedHost('[::ffff:7f00:1]')).toBe(true) + }) + + it('blocks IPv4-compatible IPv6 and NAT64 embedded-IPv4 forms', () => { + // ::127.0.0.1 -> ::7f00:1, ::169.254.169.254 -> ::a9fe:a9fe + expect(isBlockedHost('::127.0.0.1')).toBe(true) + expect(isBlockedHost('::7f00:1')).toBe(true) + expect(isBlockedHost('::169.254.169.254')).toBe(true) + expect(isBlockedHost('::a9fe:a9fe')).toBe(true) + // NAT64 64:ff9b::/96 wrapping loopback / metadata + expect(isBlockedHost('64:ff9b::7f00:1')).toBe(true) + expect(isBlockedHost('64:ff9b::169.254.169.254')).toBe(true) + }) + + it('strips IPv6 zone ids before checking', () => { + expect(isBlockedHost('fe80::1%eth0')).toBe(true) + }) + + it('does not block public IPv6', () => { + expect(isBlockedHost('2606:4700:4700::1111')).toBe(false) + expect(isBlockedHost('2001:4860:4860::8888')).toBe(false) + }) + + it('handles bracketed and trailing-dot hostnames', () => { + expect(isBlockedHost('[::1]')).toBe(true) + expect(isBlockedHost('localhost.')).toBe(true) + }) +}) + +describe('validateFetchUrl', () => { + it('returns a parsed URL for an allowed public https URL', () => { + const url = validateFetchUrl('https://example.com/path?x=1') + expect(url).toBeInstanceOf(URL) + expect(url.hostname).toBe('example.com') + }) + + it('rejects malformed URLs', () => { + try { + validateFetchUrl('not a url') + fail('expected SsrfError') + } catch (e) { + expect(e).toBeInstanceOf(SsrfError) + expect((e as SsrfError).code).toBe('INVALID_URL') + } + }) + + it('rejects disallowed protocols', () => { + expect(() => validateFetchUrl('ftp://example.com')).toThrow(SsrfError) + try { + validateFetchUrl('file:///etc/passwd') + fail('expected SsrfError') + } catch (e) { + expect((e as SsrfError).code).toBe('INVALID_PROTOCOL') + } + }) + + it('honours a restricted allowedProtocols list', () => { + expect(() => validateFetchUrl('http://example.com', { allowedProtocols: ['https:'] })).toThrow(SsrfError) + expect(validateFetchUrl('https://example.com', { allowedProtocols: ['https:'] }).protocol).toBe('https:') + }) + + it('rejects internal/private IP-literal and localhost targets synchronously', () => { + expect(() => validateFetchUrl('https://127.0.0.1/admin')).toThrow(SsrfError) + expect(() => validateFetchUrl('https://[::ffff:7f00:1]/')).toThrow(SsrfError) + expect(() => validateFetchUrl('https://localhost:4060/api')).toThrow(SsrfError) + try { + validateFetchUrl('https://10.0.0.5/') + fail('expected SsrfError') + } catch (e) { + expect((e as SsrfError).code).toBe('BLOCKED_URL') + } + }) + + it('does NOT block an internal DNS name on its own (DNS resolution closes that gap)', () => { + // Hostname is not an IP literal, so the synchronous check passes; resolveSafeAddress must catch it. + expect(() => validateFetchUrl('https://link-preview.svc.cluster.local:4060/api')).not.toThrow() + }) +}) + +describe('resolveSafeAddress', () => { + it('returns the resolved address for a public host', async () => { + const resolver = async (): Promise> => [ + { address: '93.184.216.34', family: 4 } + ] + const result = await resolveSafeAddress('example.com', resolver) + expect(result.address).toBe('93.184.216.34') + expect(result.family).toBe(4) + }) + + it('blocks an internal DNS name that resolves to a private address (the report PoC)', async () => { + const resolver = async (): Promise> => [ + { address: '10.42.0.7', family: 4 } + ] + await expect(resolveSafeAddress('link-preview.svc.cluster.local', resolver)).rejects.toBeInstanceOf(SsrfError) + }) + + it('blocks if ANY resolved address is internal (split-horizon / rebinding defence)', async () => { + const resolver = async (): Promise> => [ + { address: '93.184.216.34', family: 4 }, + { address: '169.254.169.254', family: 4 } + ] + await expect(resolveSafeAddress('rebind.example.com', resolver)).rejects.toBeInstanceOf(SsrfError) + }) + + it('throws BLOCKED_URL when DNS returns no addresses', async () => { + const resolver = async (): Promise> => [] + try { + await resolveSafeAddress('nowhere.example.com', resolver) + fail('expected SsrfError') + } catch (e) { + expect((e as SsrfError).code).toBe('BLOCKED_URL') + } + }) +}) + +describe('createPinnedLookup', () => { + it('always returns the pinned address (single form)', (done) => { + const lookup = createPinnedLookup({ address: '93.184.216.34', family: 4 }) + lookup('evil.example.com', {}, (err, address, family) => { + expect(err).toBeNull() + expect(address).toBe('93.184.216.34') + expect(family).toBe(4) + done() + }) + }) + + it('always returns the pinned address (all form)', (done) => { + const lookup = createPinnedLookup({ address: '93.184.216.34', family: 4 }) + lookup('evil.example.com', { all: true }, (err, addresses) => { + expect(err).toBeNull() + expect(addresses).toEqual([{ address: '93.184.216.34', family: 4 }]) + done() + }) + }) +}) diff --git a/foundations/server/packages/core/src/index.ts b/foundations/server/packages/core/src/index.ts index 8ca7cb2739..2aebc11e16 100644 --- a/foundations/server/packages/core/src/index.ts +++ b/foundations/server/packages/core/src/index.ts @@ -32,6 +32,7 @@ export * from './dbAdapterManager' export * from './domainHelper' export * from './nullAdapter' export * from './service' +export * from './ssrf' export * from './stats' export * from './triggers' export * from './queue' diff --git a/foundations/server/packages/core/src/ssrf.ts b/foundations/server/packages/core/src/ssrf.ts new file mode 100644 index 0000000000..efc71a1a02 --- /dev/null +++ b/foundations/server/packages/core/src/ssrf.ts @@ -0,0 +1,280 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import dns from 'node:dns' +import net from 'node:net' +import type { LookupAddress, LookupOptions } from 'node:dns' +import type { LookupFunction } from 'node:net' + +// ============================================================================ +// SSRF protection +// +// Shared helpers used by any server-side component that fetches a URL supplied +// (directly or indirectly) by a caller. Blocks loopback, private, link-local +// and unique-local addresses, including IPv6-mapped-IPv4 representations. +// +// The synchronous checks cover IP literals and localhost-style hostnames. +// `resolveSafeAddress` additionally resolves DNS names and validates every +// returned address, which closes the internal-DNS-name vector (e.g. +// `link-preview.svc.cluster.local`) that an IP-literal check alone misses. +// ============================================================================ + +export type SsrfErrorCode = 'INVALID_URL' | 'INVALID_PROTOCOL' | 'BLOCKED_URL' + +export class SsrfError extends Error { + constructor ( + message: string, + readonly code: SsrfErrorCode, + readonly cause?: unknown + ) { + super(message) + this.name = 'SsrfError' + } +} + +export interface ValidateUrlOptions { + /** Allowed URL protocols, including the trailing colon. Defaults to `['http:', 'https:']`. */ + allowedProtocols?: string[] +} + +export interface ResolvedAddress { + address: string + family: number +} + +/** Resolves a hostname to one or more IP addresses. Injectable for testing. */ +export type HostResolver = (hostname: string) => Promise + +const DEFAULT_ALLOWED_PROTOCOLS = ['http:', 'https:'] + +function normalizeHostnameForChecks (hostname: string): string { + // URL.hostname is already punycode-normalized by WHATWG URL for IDNs. + // Keep it lowercase for comparisons. + const trimmed = hostname.trim().toLowerCase().replace(/\.+$/, '') + if (trimmed.startsWith('[') && trimmed.endsWith(']')) return trimmed.slice(1, -1) + return trimmed +} + +// Expands an IPv6 literal (possibly compressed, zone-suffixed, or carrying an +// embedded dotted-IPv4 tail) into eight 16-bit groups. Returns undefined if it +// cannot be parsed, so callers can fail closed. +function expandIpv6 (input: string): number[] | undefined { + let host = input.toLowerCase() + + // Strip a zone id, e.g. fe80::1%eth0 + const zone = host.indexOf('%') + if (zone !== -1) host = host.slice(0, zone) + + // Fold a trailing dotted-IPv4 suffix (::ffff:1.2.3.4 or ::1.2.3.4) into two hextets. + const v4 = host.match(/(\d{1,3}(?:\.\d{1,3}){3})$/) + if (v4 != null) { + const octets = v4[1].split('.').map((p) => Number.parseInt(p, 10)) + if (octets.length !== 4 || octets.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) return undefined + const hi = ((octets[0] << 8) | octets[1]).toString(16) + const lo = ((octets[2] << 8) | octets[3]).toString(16) + host = host.slice(0, host.length - v4[1].length) + `${hi}:${lo}` + } + + const halves = host.split('::') + if (halves.length > 2) return undefined + const head = halves[0] === '' ? [] : halves[0].split(':') + + let groups: string[] + if (halves.length === 2) { + const tail = halves[1] === '' ? [] : halves[1].split(':') + const missing = 8 - head.length - tail.length + if (missing < 0) return undefined + groups = [...head, ...new Array(missing).fill('0'), ...tail] + } else { + groups = head + } + if (groups.length !== 8) return undefined + + const hextets = groups.map((g) => Number.parseInt(g === '' ? '0' : g, 16)) + if (hextets.some((n) => !Number.isFinite(n) || n < 0 || n > 0xffff)) return undefined + return hextets +} + +// Renders the low 32 bits of an expanded IPv6 address as a dotted IPv4 string. +function embeddedIpv4 (h: number[]): string { + return `${(h[6] >> 8) & 0xff}.${h[6] & 0xff}.${(h[7] >> 8) & 0xff}.${h[7] & 0xff}` +} + +export function isBlockedIpv4 (ipv4: string): boolean { + const parts = ipv4.split('.').map((p) => Number.parseInt(p, 10)) + if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) return true + + const [a, b] = parts + + // 0.0.0.0/8 + if (a === 0) return true + // 127.0.0.0/8 loopback + if (a === 127) return true + // 10.0.0.0/8 + if (a === 10) return true + // 172.16.0.0/12 + if (a === 172 && b >= 16 && b <= 31) return true + // 192.168.0.0/16 + if (a === 192 && b === 168) return true + // 169.254.0.0/16 link-local (incl. cloud metadata 169.254.169.254) + if (a === 169 && b === 254) return true + // 100.64.0.0/10 carrier-grade NAT (common inside cloud/k8s networks) + if (a === 100 && b >= 64 && b <= 127) return true + // 224.0.0.0/4 multicast and 240.0.0.0/4 reserved (incl. 255.255.255.255 broadcast) + if (a >= 224) return true + + return false +} + +export function isBlockedIpv6 (ipv6: string): boolean { + const h = expandIpv6(ipv6) + if (h === undefined) return true // fail closed on anything we cannot parse + + // unspecified :: + if (h.every((x) => x === 0)) return true + // loopback ::1 + if (h.slice(0, 7).every((x) => x === 0) && h[7] === 1) return true + // unique-local fc00::/7 + if ((h[0] & 0xfe00) === 0xfc00) return true + // link-local fe80::/10 + if ((h[0] & 0xffc0) === 0xfe80) return true + + // Embedded-IPv4 forms — defer to the IPv4 blocklist for the low 32 bits: + // IPv4-compatible ::/96, IPv4-mapped ::ffff:0:0/96, NAT64 64:ff9b::/96. + const high96Zero = h[0] === 0 && h[1] === 0 && h[2] === 0 && h[3] === 0 && h[4] === 0 + const isV4Compatible = high96Zero && h[5] === 0 + const isV4Mapped = high96Zero && h[5] === 0xffff + const isNat64 = h[0] === 0x64 && h[1] === 0xff9b && h[2] === 0 && h[3] === 0 && h[4] === 0 && h[5] === 0 + if (isV4Compatible || isV4Mapped || isNat64) { + return isBlockedIpv4(embeddedIpv4(h)) + } + + return false +} + +/** + * Returns true if the given host (IP literal or hostname) must not be fetched. + * + * This performs no DNS resolution: it blocks IP literals in private/internal + * ranges and localhost-style names. Use `resolveSafeAddress` to also guard + * against DNS names that resolve to internal addresses. + */ +export function isBlockedHost (hostname: string): boolean { + const host = normalizeHostnameForChecks(hostname) + if (host === 'localhost') return true + + const ipType = net.isIP(host) + if (ipType === 4) return isBlockedIpv4(host) + if (ipType === 6) return isBlockedIpv6(host) + + // Some Node versions are stricter about IPv6 parsing. If it still looks like an IPv6 literal, + // apply our IPv6 checks anyway (covers IPv6-mapped IPv4 forms like ::ffff:7f00:1). + if (host.includes(':') && isBlockedIpv6(host)) return true + + // Hostname is not an IP literal. Keep explicit localhost-ish blocks. + if (host.endsWith('.localhost')) return true + + return false +} + +/** + * Parses and validates a URL for outbound fetching. + * + * Throws {@link SsrfError} on a malformed URL, a disallowed protocol, or an + * internal/private IP-literal or localhost target. Hostnames that are not IP + * literals pass this synchronous check and must additionally be validated with + * {@link resolveSafeAddress} before connecting. + */ +export function validateFetchUrl (urlString: string, options: ValidateUrlOptions = {}): URL { + let url: URL + try { + url = new URL(urlString) + } catch { + throw new SsrfError(`Invalid URL: ${urlString}`, 'INVALID_URL') + } + + const allowed = options.allowedProtocols ?? DEFAULT_ALLOWED_PROTOCOLS + if (!allowed.includes(url.protocol)) { + throw new SsrfError(`Invalid protocol: ${url.protocol}. Allowed: ${allowed.join(', ')}.`, 'INVALID_PROTOCOL') + } + + if (isBlockedHost(url.hostname)) { + throw new SsrfError('Blocked URL: access to internal addresses is not allowed.', 'BLOCKED_URL') + } + + return url +} + +const defaultResolver: HostResolver = async (hostname) => + await new Promise((resolve, reject) => { + dns.lookup(hostname, { all: true, verbatim: true }, (err, addresses) => { + if (err != null) { + reject(new SsrfError(`DNS resolution failed for ${hostname}`, 'BLOCKED_URL', err)) + return + } + resolve(addresses.map((a) => ({ address: a.address, family: a.family }))) + }) + }) + +/** + * Resolves a hostname and validates every returned address against the + * SSRF blocklist. Returns the first resolved address so the caller can pin the + * connection to it (defeating DNS rebinding between validation and connect). + * + * Throws {@link SsrfError} (`BLOCKED_URL`) if resolution yields no addresses or + * if any resolved address is internal/private. + */ +export async function resolveSafeAddress ( + hostname: string, + resolver: HostResolver = defaultResolver +): Promise { + // IP literals are fully validated synchronously; no resolution required. + if (net.isIP(normalizeHostnameForChecks(hostname)) !== 0) { + if (isBlockedHost(hostname)) { + throw new SsrfError('Blocked URL: access to internal addresses is not allowed.', 'BLOCKED_URL') + } + const literal = normalizeHostnameForChecks(hostname) + return { address: literal, family: net.isIP(literal) } + } + + const addresses = await resolver(hostname) + if (addresses.length === 0) { + throw new SsrfError(`Blocked URL: ${hostname} did not resolve to any address.`, 'BLOCKED_URL') + } + for (const { address } of addresses) { + if (isBlockedHost(address)) { + throw new SsrfError('Blocked URL: host resolves to an internal address.', 'BLOCKED_URL') + } + } + return addresses[0] +} + +/** + * Builds a `lookup` function (compatible with the `lookup` option of + * `http.get` / `https.get` / `net.connect`) that always returns the given, + * already-validated address. Pinning the connection to the validated address + * prevents DNS rebinding between {@link resolveSafeAddress} and the actual + * connect, where a hostile resolver could otherwise swap in an internal IP. + */ +export function createPinnedLookup (resolved: ResolvedAddress): LookupFunction { + return (_hostname: string, options: LookupOptions, callback): void => { + if (options.all === true) { + const result: LookupAddress[] = [{ address: resolved.address, family: resolved.family }] + callback(null, result, resolved.family) + return + } + callback(null, resolved.address, resolved.family) + } +} diff --git a/pods/link-preview/src/parse.ts b/pods/link-preview/src/parse.ts index 6fa75f960e..4bf5ab6e26 100644 --- a/pods/link-preview/src/parse.ts +++ b/pods/link-preview/src/parse.ts @@ -14,10 +14,10 @@ // import { MeasureContext } from '@hcengineering/core' +import { isBlockedHost } from '@hcengineering/server-core' import * as cheerio from 'cheerio' import { imageSize } from 'image-size' import oembedProviders from 'oembed-providers' -import net from 'node:net' // ============================================================================ // Types and Interfaces @@ -109,100 +109,8 @@ export class LinkPreviewError extends Error { // URL Validation // ============================================================================ -function normalizeHostnameForChecks (hostname: string): string { - // URL.hostname is already punycode-normalized by WHATWG URL for IDNs. - // Keep it lowercase for comparisons. - const trimmed = hostname.trim().toLowerCase().replace(/\.+$/, '') - if (trimmed.startsWith('[') && trimmed.endsWith(']')) return trimmed.slice(1, -1) - return trimmed -} - -function parseIpv6MappedIpv4 (ipv6: string): string | undefined { - const host = ipv6.toLowerCase() - - // Common form: ::ffff:127.0.0.1 - const dotted = host.match(/(?:^|:)ffff:(\d{1,3}(?:\.\d{1,3}){3})$/) - if (dotted?.[1] !== undefined) return dotted[1] - - // Hex form used in the report: ::ffff:7f00:1 (=> 127.0.0.1) - const hex = host.match(/(?:^|:)ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/) - if (hex?.[1] === undefined || hex?.[2] === undefined) return undefined - - const hi = Number.parseInt(hex[1], 16) - const lo = Number.parseInt(hex[2], 16) - if (!Number.isFinite(hi) || !Number.isFinite(lo)) return undefined - - const a = (hi >> 8) & 0xff - const b = hi & 0xff - const c = (lo >> 8) & 0xff - const d = lo & 0xff - return `${a}.${b}.${c}.${d}` -} - -function isBlockedIpv4 (ipv4: string): boolean { - const parts = ipv4.split('.').map((p) => Number.parseInt(p, 10)) - if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) return true - - const [a, b] = parts - - // 0.0.0.0/8 - if (a === 0) return true - // 127.0.0.0/8 loopback - if (a === 127) return true - // 10.0.0.0/8 - if (a === 10) return true - // 172.16.0.0/12 - if (a === 172 && b >= 16 && b <= 31) return true - // 192.168.0.0/16 - if (a === 192 && b === 168) return true - // 169.254.0.0/16 link-local - if (a === 169 && b === 254) return true - - return false -} - -function isBlockedIpv6 (ipv6: string): boolean { - const host = ipv6.toLowerCase() - // unspecified / loopback (compressed or expanded) - if (host === '::' || host === '0:0:0:0:0:0:0:0') return true - if (host === '::1' || host === '0:0:0:0:0:0:0:1') return true - if (host.startsWith('fc') || host.startsWith('fd')) return true // unique-local fc00::/7 (coarse but safe) - if ( - host.startsWith('fe80:') || - host.startsWith('fe8') || - host.startsWith('fe9') || - host.startsWith('fea') || - host.startsWith('feb') - ) { - // link-local fe80::/10 (coarse but safe) - return true - } - - const mapped = parseIpv6MappedIpv4(host) - if (mapped !== undefined) return isBlockedIpv4(mapped) - - return false -} - -function isBlockedHost (hostname: string): boolean { - const host = normalizeHostnameForChecks(hostname) - if (host === 'localhost') return true - - const ipType = net.isIP(host) - if (ipType === 4) return isBlockedIpv4(host) - if (ipType === 6) return isBlockedIpv6(host) - - // Some Node versions are stricter about IPv6 parsing. If it still looks like an IPv6 literal, - // apply our IPv6 checks anyway (covers IPv6-mapped IPv4 forms like ::ffff:7f00:1). - if (host.includes(':') && isBlockedIpv6(host)) return true - - // Hostname is not an IP literal. Keep legacy explicit localhost-ish blocks. - // (We intentionally do not attempt DNS resolution here.) - if (host.endsWith('.localhost')) return true - - return false -} - +// SSRF host/IP blocking (loopback, private, link-local, unique-local, IPv6-mapped +// IPv4) is shared with the other server-side fetchers via `@hcengineering/server-core`. function validateUrl (urlString: string): URL { let url: URL try { diff --git a/server/front/src/index.ts b/server/front/src/index.ts index 5edef6e5dd..4418fa91b5 100644 --- a/server/front/src/index.ts +++ b/server/front/src/index.ts @@ -19,6 +19,7 @@ import { MeasureContext, Blob as PlatformBlob, WorkspaceIds, metricsAggregate, t import platform, { PlatformError } from '@hcengineering/platform' import { TokenError, decodeToken } from '@hcengineering/server-token' import { StorageAdapter } from '@hcengineering/storage' +import { SsrfError, createPinnedLookup, resolveSafeAddress, validateFetchUrl } from '@hcengineering/server-core' import bp from 'body-parser' import cors from 'cors' import express, { Request, Response } from 'express' @@ -676,6 +677,25 @@ export function start ( } } + const buildSafeImportOptions = async ( + res: Response, + url: string, + cookie: string | undefined + ): Promise => { + try { + const parsed = validateFetchUrl(url, { allowedProtocols: ['https:'] }) + const lookup = createPinnedLookup(await resolveSafeAddress(parsed.hostname)) + return cookie !== undefined ? { lookup, headers: { Cookie: cookie } } : { lookup } + } catch (err) { + if (err instanceof SsrfError) { + ctx.warn('import blocked', { code: err.code, url }) + res.status(400).send(err.message) + return undefined + } + throw err + } + } + const handleImportGet = async (req: Request, res: Response): Promise => { try { const authHeader = req.headers.authorization @@ -697,17 +717,10 @@ export function start ( return } - console.log('importing from', url) - console.log('cookie', cookie) - - const options = - cookie !== undefined - ? { - headers: { - Cookie: cookie - } - } - : {} + const options = await buildSafeImportOptions(res, url, cookie) + if (options === undefined) { + return + } https .get(url, options, (response) => { @@ -783,17 +796,10 @@ export function start ( return } - console.log('importing from', url) - console.log('cookie', cookie) - - const options = - cookie !== undefined - ? { - headers: { - Cookie: cookie - } - } - : {} + const options = await buildSafeImportOptions(res, url, cookie) + if (options === undefined) { + return + } https.get(url, options, (response) => { console.log('status', response.statusCode)