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,

x

')).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:pass@example.com/', '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() }) })