fetch-guard.test.ts
1 import { describe, expect, it } from 'vitest' 2 3 import { createSafeFetchRequest, validateUrl } from '@/lib/web-core' 4 5 describe('validateUrl', () => { 6 it('accepts valid HTTPS URLs', () => { 7 const result = validateUrl('https://example.com') 8 expect(result.isValid).toBe(true) 9 expect(result.url?.hostname).toBe('example.com') 10 }) 11 12 it('accepts valid HTTP URLs', () => { 13 const result = validateUrl('http://example.com') 14 expect(result.isValid).toBe(true) 15 }) 16 17 it('rejects non-HTTP protocols', () => { 18 const result = validateUrl('ftp://example.com') 19 expect(result.isValid).toBe(false) 20 expect(result.error).toContain('Invalid protocol') 21 }) 22 23 it('rejects file:// URLs', () => { 24 const result = validateUrl('file:///etc/passwd') 25 expect(result.isValid).toBe(false) 26 expect(result.error).toContain('Invalid protocol') 27 }) 28 29 it('rejects data:// URLs', () => { 30 const result = validateUrl('data:text/html,<script>alert(1)</script>') 31 expect(result.isValid).toBe(false) 32 expect(result.error).toContain('Invalid protocol') 33 }) 34 35 it('rejects invalid URLs', () => { 36 const result = validateUrl('not-a-url') 37 expect(result.isValid).toBe(false) 38 expect(result.error).toContain('Invalid URL') 39 }) 40 41 describe('loopback protection', () => { 42 it('rejects 127.x.x.x range by default', () => { 43 const result = validateUrl('http://127.0.0.1:8080') 44 expect(result.isValid).toBe(false) 45 expect(result.error).toContain('Loopback') 46 }) 47 48 it('blocks 127.x.x.x even when allowLoopback is enabled', () => { 49 // The specific 127.x.x.x check is separate from the general loopback check 50 const result = validateUrl('http://127.0.0.1:8080', { 51 allowLoopback: true, 52 }) 53 expect(result.isValid).toBe(false) 54 }) 55 }) 56 57 describe('private network protection', () => { 58 it('rejects 10.x.x.x by default', () => { 59 const result = validateUrl('http://10.0.0.1:8080') 60 expect(result.isValid).toBe(false) 61 expect(result.error).toContain('Private network') 62 }) 63 64 it('rejects 172.16-31.x.x by default', () => { 65 const result = validateUrl('http://172.16.0.1:8080') 66 expect(result.isValid).toBe(false) 67 }) 68 69 it('rejects 192.168.x.x by default', () => { 70 const result = validateUrl('http://192.168.1.1:8080') 71 expect(result.isValid).toBe(false) 72 }) 73 74 it('allows private networks when explicitly enabled', () => { 75 const result = validateUrl('http://192.168.1.1:8080', { 76 allowPrivateNetworks: true, 77 }) 78 expect(result.isValid).toBe(true) 79 }) 80 }) 81 82 describe('multicast protection', () => { 83 it('rejects 224.x.x.x range', () => { 84 const result = validateUrl('http://224.0.0.1') 85 expect(result.isValid).toBe(false) 86 expect(result.error).toContain('Multicast') 87 }) 88 89 it('rejects 239.x.x.x range', () => { 90 const result = validateUrl('http://239.0.0.1') 91 expect(result.isValid).toBe(false) 92 }) 93 94 it('allows multicast when explicitly enabled', () => { 95 const result = validateUrl('http://224.0.0.1', { 96 allowMulticast: true, 97 }) 98 expect(result.isValid).toBe(true) 99 }) 100 }) 101 102 describe('credentials protection', () => { 103 it('rejects URLs with username', () => { 104 const result = validateUrl('http://user@example.com') 105 expect(result.isValid).toBe(false) 106 expect(result.error).toContain('credentials') 107 }) 108 109 it('rejects URLs with password', () => { 110 const result = validateUrl('http://:pass@example.com') 111 expect(result.isValid).toBe(false) 112 expect(result.error).toContain('credentials') 113 }) 114 115 it('rejects URLs with username and password', () => { 116 const result = validateUrl('http://user:pass@example.com') 117 expect(result.isValid).toBe(false) 118 expect(result.error).toContain('credentials') 119 }) 120 }) 121 }) 122 123 describe('createSafeFetchRequest', () => { 124 it('creates a Request for valid URLs', () => { 125 const request = createSafeFetchRequest('https://example.com') 126 expect(request.url).toBe('https://example.com/') 127 }) 128 129 it('throws for invalid URLs', () => { 130 expect(() => createSafeFetchRequest('not-a-url')).toThrow() 131 }) 132 133 it('applies custom headers', () => { 134 const request = createSafeFetchRequest('https://example.com', { 135 headers: { 'User-Agent': 'TestBot/1.0' }, 136 }) 137 expect(request.headers.get('User-Agent')).toBe('TestBot/1.0') 138 }) 139 140 it('strips sensitive headers by default', () => { 141 const request = createSafeFetchRequest('https://example.com', { 142 headers: { 143 Authorization: 'Bearer secret', 144 Cookie: 'session=abc123', 145 }, 146 }) 147 expect(request.headers.get('Authorization')).toBeNull() 148 expect(request.headers.get('Cookie')).toBeNull() 149 }) 150 151 it('preserves non-sensitive headers', () => { 152 const request = createSafeFetchRequest('https://example.com', { 153 headers: { 154 'User-Agent': 'TestBot/1.0', 155 Accept: 'application/json', 156 }, 157 }) 158 expect(request.headers.get('User-Agent')).toBe('TestBot/1.0') 159 expect(request.headers.get('Accept')).toBe('application/json') 160 }) 161 })