/ tests / fetch-guard.test.ts
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  })