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

190 lines
6.9 KiB
JavaScript

require('module-alias/register')
const UrlSafety = require('@helpers/UrlSafety')
describe('UrlSafety.isPrivateIp', () => {
const privateAddresses = [
'0.0.0.0', '10.0.0.1', '10.255.255.254', '127.0.0.1', '127.1.1.1',
'169.254.1.1', '169.254.169.254', // cloud metadata endpoint
'172.16.0.1', '172.31.255.254', '192.168.1.1', '192.0.0.1', '192.0.2.5',
'198.18.0.1', '100.64.0.1', '100.127.255.254', '224.0.0.1', '255.255.255.255',
'::1', '::', 'fc00::1', 'fd12:3456::1', 'fe80::1', 'ff02::1',
'::ffff:127.0.0.1', '::ffff:10.0.0.1', '64:ff9b::127.0.0.1',
]
privateAddresses.forEach(address => {
test(`blocks ${address}`, () => {
expect(UrlSafety.isPrivateIp(address)).toBe(true)
})
})
const publicAddresses = ['1.1.1.1', '8.8.8.8', '93.184.216.34', '172.32.0.1', '2606:4700::1111']
publicAddresses.forEach(address => {
test(`allows ${address}`, () => {
expect(UrlSafety.isPrivateIp(address)).toBe(false)
})
})
test('treats a non-IP string as unsafe', () => {
expect(UrlSafety.isPrivateIp('not-an-ip')).toBe(true)
expect(UrlSafety.isPrivateIp('')).toBe(true)
})
})
describe('UrlSafety.parse', () => {
test('rejects non-http schemes', () => {
expect(UrlSafety.parse('file:///etc/passwd')).toBeNull()
expect(UrlSafety.parse('ftp://example.com/x')).toBeNull()
expect(UrlSafety.parse('javascript:alert(1)')).toBeNull()
expect(UrlSafety.parse('data:text/html,<h1>x</h1>')).toBeNull()
})
test('rejects garbage and empty input', () => {
expect(UrlSafety.parse('')).toBeNull()
expect(UrlSafety.parse(null)).toBeNull()
expect(UrlSafety.parse('not a url at all')).toBeNull()
})
test('rejects URLs longer than the attachment.url column', () => {
const long = 'https://example.com/' + 'a'.repeat(1200)
expect(UrlSafety.parse(long)).toBeNull()
})
test('accepts ordinary http and https', () => {
expect(UrlSafety.parse('https://example.com/a').href).toBe('https://example.com/a')
expect(UrlSafety.parse('http://example.com/a').href).toBe('http://example.com/a')
})
})
describe('UrlSafety.normalizeForKey / urlKey', () => {
test('strips tracking params so the cache key is stable', () => {
const bare = UrlSafety.urlKey('https://example.com/post')
const noisy = UrlSafety.urlKey('https://example.com/post?utm_source=x&utm_medium=y&fbclid=z')
expect(noisy).toBe(bare)
})
test('keeps meaningful query params', () => {
const a = UrlSafety.urlKey('https://example.com/search?q=cats')
const b = UrlSafety.urlKey('https://example.com/search?q=dogs')
expect(a).not.toBe(b)
})
test('is insensitive to param order, host case, fragment and trailing host dot', () => {
const canonical = UrlSafety.urlKey('https://example.com/p?a=1&b=2')
expect(UrlSafety.urlKey('https://example.com/p?b=2&a=1')).toBe(canonical)
expect(UrlSafety.urlKey('https://EXAMPLE.com/p?a=1&b=2')).toBe(canonical)
expect(UrlSafety.urlKey('https://example.com/p?a=1&b=2#section')).toBe(canonical)
expect(UrlSafety.urlKey('https://example.com./p?a=1&b=2')).toBe(canonical)
})
test('distinguishes http from https', () => {
expect(UrlSafety.urlKey('http://example.com/')).not.toBe(UrlSafety.urlKey('https://example.com/'))
})
test('returns a 64-char hex digest', () => {
expect(UrlSafety.urlKey('https://example.com/')).toMatch(/^[0-9a-f]{64}$/)
})
test('returns null for an unusable URL', () => {
expect(UrlSafety.urlKey('javascript:alert(1)')).toBeNull()
})
})
describe('UrlSafety.sameResource', () => {
// This is what stops scanTextForWebsites deleting and re-scraping a row
// every time a note is saved.
test('treats http/https, www. and trailing slash as the same resource', () => {
expect(UrlSafety.sameResource('http://foo.com/a', 'https://foo.com/a')).toBe(true)
expect(UrlSafety.sameResource('https://www.foo.com/a', 'https://foo.com/a')).toBe(true)
expect(UrlSafety.sameResource('https://foo.com/a/', 'https://foo.com/a')).toBe(true)
expect(UrlSafety.sameResource('https://foo.com', 'https://foo.com/')).toBe(true)
})
test('does not conflate different paths, hosts or queries', () => {
expect(UrlSafety.sameResource('https://foo.com/a', 'https://foo.com/b')).toBe(false)
expect(UrlSafety.sameResource('https://foo.com/a', 'https://bar.com/a')).toBe(false)
expect(UrlSafety.sameResource('https://foo.com/a?x=1', 'https://foo.com/a?x=2')).toBe(false)
})
test('is false when either side is unusable', () => {
expect(UrlSafety.sameResource('https://foo.com/a', 'javascript:x')).toBe(false)
})
})
describe('UrlSafety.assertFetchable', () => {
const expectRejection = async (url, reason) => {
await expect(UrlSafety.assertFetchable(url)).rejects.toMatchObject({ name: 'SafetyError', reason })
}
test('refuses non-http schemes', async () => {
await expectRejection('file:///etc/passwd', 'bad_url')
})
test('refuses embedded credentials', async () => {
await expectRejection('https://user:[email protected]/', 'credentials_in_url')
})
test('refuses localhost and internal suffixes', async () => {
await expectRejection('http://localhost:3000/', 'blocked_host')
await expectRejection('http://db.internal/', 'blocked_host')
await expectRejection('http://thing.local/', 'blocked_host')
await expectRejection('http://box.lan/', 'blocked_host')
})
test('refuses literal private addresses without touching DNS', async () => {
await expectRejection('http://127.0.0.1:3000/', 'private_ip')
await expectRejection('http://169.254.169.254/latest/meta-data/', 'private_ip')
await expectRejection('http://10.1.2.3/', 'private_ip')
await expectRejection('http://[::1]:8080/', 'private_ip')
})
test('allows a literal public address', async () => {
await expect(UrlSafety.assertFetchable('http://1.1.1.1/')).resolves.toEqual(['1.1.1.1'])
})
test('refuses a hostname that resolves to a private address', async () => {
const dns = require('dns').promises
const spy = jest.spyOn(dns, 'lookup').mockResolvedValue([{ address: '192.168.0.5', family: 4 }])
await expectRejection('https://rebind.example.com/', 'private_ip')
spy.mockRestore()
})
test('refuses when ANY address in a round-robin record is private', async () => {
const dns = require('dns').promises
const spy = jest.spyOn(dns, 'lookup').mockResolvedValue([
{ address: '93.184.216.34', family: 4 },
{ address: '127.0.0.1', family: 4 },
])
await expectRejection('https://mixed.example.com/', 'private_ip')
spy.mockRestore()
})
test('allows a hostname that resolves entirely to public addresses', async () => {
const dns = require('dns').promises
const spy = jest.spyOn(dns, 'lookup').mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
await expect(UrlSafety.assertFetchable('https://example.com/')).resolves.toEqual(['93.184.216.34'])
spy.mockRestore()
})
test('surfaces a DNS failure as dns_failed', async () => {
const dns = require('dns').promises
const spy = jest.spyOn(dns, 'lookup').mockRejectedValue(Object.assign(new Error('nope'), { code: 'ENOTFOUND' }))
await expectRejection('https://nonexistent.example/', 'dns_failed')
spy.mockRestore()
})
})