mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-12 20:57:45 +02:00
Add SSFR guard for import endpoint
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
@@ -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<Array<{ address: string, family: number }>> => [
|
||||
{ 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<Array<{ address: string, family: number }>> => [
|
||||
{ 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<Array<{ address: string, family: number }>> => [
|
||||
{ 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<Array<{ address: string, family: number }>> => []
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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<ResolvedAddress[]>
|
||||
|
||||
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<string>(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<ResolvedAddress[]>((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<ResolvedAddress> {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+28
-22
@@ -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<https.RequestOptions | undefined> => {
|
||||
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<void> => {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user