Files
SolidScribe/server/helpers/UrlSafety.js
2026-11-03 11:20:03 -07:00

247 lines
8.2 KiB
JavaScript

//
// URL normalisation and the SSRF gate.
//
// Scraped URLs come from untrusted note text and from the /pushmebaby query
// param, so every URL is validated before a socket is opened - and again after
// every redirect hop, which is the hop that actually matters (a public URL that
// 302s to 127.0.0.1 is the classic bypass).
//
// Pure except for assertFetchable, which resolves DNS.
//
const crypto = require('crypto')
const dns = require('dns').promises
const net = require('net')
const config = require('@config/scrape')
let UrlSafety = module.exports = {}
const FETCHABLE_SCHEMES = new Set(['http:', 'https:'])
// Hostname suffixes that never belong to a public site.
const BLOCKED_SUFFIXES = ['.localhost', '.local', '.internal', '.home.arpa', '.lan']
// Query params that change nothing about the page but would fragment the cache.
const TRACKING_PARAMS = [
/^utm_/i, /^fbclid$/i, /^gclid$/i, /^dclid$/i, /^msclkid$/i,
/^mc_(e|c)id$/i, /^igshid$/i, /^ref_src$/i, /^_hs(enc|mi)$/i, /^vero_/i,
]
class SafetyError extends Error {
constructor(reason, message) {
super(message || reason)
this.name = 'SafetyError'
this.reason = reason
}
}
UrlSafety.SafetyError = SafetyError
//
// Parse to a URL object, or null. Does NOT mutate anything that would change
// which resource is addressed - callers may store the result.
//
UrlSafety.parse = (raw, base) => {
if (!raw || typeof raw !== 'string') { return null }
let url = null
try {
url = new URL(raw.trim(), base || undefined)
} catch (error) {
return null
}
if (!FETCHABLE_SCHEMES.has(url.protocol)) { return null }
if (url.href.length > config.maxUrlLength) { return null }
return url
}
//
// Canonical form used ONLY for cache keys. Never store this - it deliberately
// discards tracking params and case, which would change what the user wrote.
//
UrlSafety.normalizeForKey = (raw) => {
const url = UrlSafety.parse(raw)
if (!url) { return null }
url.hash = ''
url.username = ''
url.password = ''
// Trailing dot on the host is legal DNS but splits the cache key.
if (url.hostname.endsWith('.')) {
url.hostname = url.hostname.slice(0, -1)
}
url.hostname = url.hostname.toLowerCase()
// new URL already strips the default port, but be explicit.
if ((url.protocol === 'http:' && url.port === '80') ||
(url.protocol === 'https:' && url.port === '443')) {
url.port = ''
}
for (const key of [...url.searchParams.keys()]) {
if (TRACKING_PARAMS.some(pattern => pattern.test(key))) {
url.searchParams.delete(key)
}
}
url.searchParams.sort()
if (url.pathname === '') { url.pathname = '/' }
return url
}
// sha256 of the normalised href. This is scrape_cache.url_hash - char(64),
// because a UNIQUE index on varchar(1000) utf8mb4 would be 4000 bytes and
// blow InnoDB's 3072-byte key limit.
UrlSafety.urlKey = (raw) => {
const url = UrlSafety.normalizeForKey(raw)
if (!url) { return null }
return crypto.createHash('sha256').update(url.href).digest('hex')
}
//
// True when two URLs address the same resource modulo scheme, "www." and a
// trailing slash.
//
// Load-bearing: scanTextForWebsites diffs the URLs currently in a note against
// the URLs already stored as attachments. Raw string equality would treat
// http://foo.com and https://foo.com as different resources and delete-then-
// rescrape the row on every save.
//
UrlSafety.sameResource = (a, b) => {
const left = UrlSafety.normalizeForKey(a)
const right = UrlSafety.normalizeForKey(b)
if (!left || !right) { return false }
const host = (url) => url.hostname.replace(/^www\./, '')
const path = (url) => url.pathname.replace(/\/+$/, '')
return host(left) === host(right) &&
path(left) === path(right) &&
left.search === right.search
}
UrlSafety.registrableHost = (hostname) => {
return String(hostname || '').toLowerCase().replace(/^www\./, '').replace(/\.$/, '')
}
//
// Private / non-routable address detection.
//
// Covers the ranges that matter for SSRF against this app: loopback, RFC1918,
// link-local (including the 169.254.169.254 cloud metadata endpoint), CGNAT,
// and the IPv6 equivalents.
//
UrlSafety.isPrivateIp = (address) => {
if (!address) { return true }
const version = net.isIP(address)
if (version === 0) { return true } // not an IP - caller resolves first
if (version === 4) { return isPrivateIpv4(address) }
return isPrivateIpv6(address)
}
const isPrivateIpv4 = (address) => {
const parts = address.split('.').map(Number)
if (parts.length !== 4 || parts.some(n => !Number.isInteger(n) || n < 0 || n > 255)) {
return true
}
const [a, b] = parts
if (a === 0) { return true } // 0.0.0.0/8 "this host"
if (a === 10) { return true } // RFC1918
if (a === 127) { return true } // loopback
if (a === 169 && b === 254) { return true } // link-local + metadata
if (a === 172 && b >= 16 && b <= 31) { return true } // RFC1918
if (a === 192 && b === 168) { return true } // RFC1918
if (a === 192 && b === 0) { return true } // 192.0.0/24 + 192.0.2/24
if (a === 198 && (b === 18 || b === 19)) { return true } // benchmarking
if (a === 100 && b >= 64 && b <= 127) { return true } // CGNAT
if (a === 198 && b === 51) { return true } // TEST-NET-2
if (a === 203 && b === 0) { return true } // TEST-NET-3
if (a >= 224) { return true } // multicast + reserved
return false
}
const isPrivateIpv6 = (address) => {
const lower = address.toLowerCase().replace(/^\[|\]$/g, '')
// Unwrap v4-mapped (::ffff:127.0.0.1) and NAT64 (64:ff9b::/96) before
// judging, otherwise ::ffff:127.0.0.1 reads as "some v6 address".
const mapped = lower.match(/^(?:::ffff:|64:ff9b::)(\d+\.\d+\.\d+\.\d+)$/)
if (mapped) { return isPrivateIpv4(mapped[1]) }
if (lower === '::' || lower === '::1') { return true } // unspecified, loopback
if (/^f[cd]/.test(lower)) { return true } // fc00::/7 unique-local
if (/^fe[89ab]/.test(lower)) { return true } // fe80::/10 link-local
if (/^ff/.test(lower)) { return true } // multicast
return false
}
//
// The gate. Throws SafetyError; resolves to the vetted addresses on success.
//
// Known limitation (documented, accepted): this resolves DNS and then hands the
// hostname to fetch, which resolves again. A hostile 0-TTL record can answer
// differently the second time. Closing that properly needs a custom undici
// Agent with connect.lookup - deliberately deferred, see the plan's Phase 5.
// Residual risk is blind SSRF only: no credentials are sent and no response
// body is returned to whoever planted the URL.
//
UrlSafety.assertFetchable = async (raw) => {
const url = raw instanceof URL ? raw : UrlSafety.parse(raw)
if (!url) {
throw new SafetyError('bad_url', `Not a fetchable URL: ${raw}`)
}
if (!FETCHABLE_SCHEMES.has(url.protocol)) {
throw new SafetyError('bad_scheme', `Refusing scheme ${url.protocol}`)
}
if (url.username || url.password) {
throw new SafetyError('credentials_in_url', 'Refusing URL with embedded credentials')
}
const hostname = url.hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase()
if (!hostname) {
throw new SafetyError('bad_url', 'URL has no host')
}
if (hostname === 'localhost' || BLOCKED_SUFFIXES.some(suffix => hostname.endsWith(suffix))) {
throw new SafetyError('blocked_host', `Refusing internal host ${hostname}`)
}
// Literal IP in the URL - no DNS needed.
if (net.isIP(hostname)) {
if (UrlSafety.isPrivateIp(hostname)) {
throw new SafetyError('private_ip', `Refusing non-routable address ${hostname}`)
}
return [hostname]
}
let resolved = []
try {
resolved = await dns.lookup(hostname, { all: true, verbatim: true })
} catch (error) {
throw new SafetyError('dns_failed', `Could not resolve ${hostname}: ${error.code || error.message}`)
}
if (!resolved.length) {
throw new SafetyError('dns_failed', `No addresses for ${hostname}`)
}
// Every answer must be public. One private address is enough to refuse -
// otherwise a round-robin record gets us some of the time.
for (const entry of resolved) {
if (UrlSafety.isPrivateIp(entry.address)) {
throw new SafetyError('private_ip', `${hostname} resolves to non-routable ${entry.address}`)
}
}
return resolved.map(entry => entry.address)
}