|
1 | 1 | import { describe, expect, it } from 'vitest' |
2 | | -import { httpUrlSchema } from '@/core/shared/schemas/url' |
| 2 | +import { |
| 3 | + httpUrlSchema, |
| 4 | + isLoopbackUrl, |
| 5 | + loopbackUrlSchema, |
| 6 | +} from '@/core/shared/schemas/url' |
3 | 7 |
|
4 | 8 | describe('httpUrlSchema', () => { |
5 | 9 | describe('accepts valid http/https URLs', () => { |
@@ -71,3 +75,91 @@ describe('httpUrlSchema', () => { |
71 | 75 | }) |
72 | 76 | }) |
73 | 77 | }) |
| 78 | + |
| 79 | +describe('isLoopbackUrl', () => { |
| 80 | + describe('accepts genuine loopback URLs', () => { |
| 81 | + it('accepts http localhost with port and path', () => { |
| 82 | + expect(isLoopbackUrl('http://localhost:3000/callback')).toBe(true) |
| 83 | + }) |
| 84 | + |
| 85 | + it('accepts http localhost without port', () => { |
| 86 | + expect(isLoopbackUrl('http://localhost')).toBe(true) |
| 87 | + }) |
| 88 | + |
| 89 | + it('accepts http 127.0.0.1 with port', () => { |
| 90 | + expect(isLoopbackUrl('http://127.0.0.1:55021')).toBe(true) |
| 91 | + }) |
| 92 | + |
| 93 | + it('accepts http IPv6 loopback', () => { |
| 94 | + expect(isLoopbackUrl('http://[::1]:9000')).toBe(true) |
| 95 | + }) |
| 96 | + |
| 97 | + it('accepts https loopback', () => { |
| 98 | + expect(isLoopbackUrl('https://localhost:3000/callback')).toBe(true) |
| 99 | + }) |
| 100 | + }) |
| 101 | + |
| 102 | + describe('rejects host-confusion bypass attempts', () => { |
| 103 | + it('rejects a subdomain of an attacker host', () => { |
| 104 | + expect(isLoopbackUrl('http://localhost.evil.com')).toBe(false) |
| 105 | + }) |
| 106 | + |
| 107 | + it('rejects a subdomain with a port', () => { |
| 108 | + expect(isLoopbackUrl('http://localhost.evil.com:3000/callback')).toBe( |
| 109 | + false |
| 110 | + ) |
| 111 | + }) |
| 112 | + |
| 113 | + it('rejects a hyphenated attacker host', () => { |
| 114 | + expect(isLoopbackUrl('http://localhost-evil.com')).toBe(false) |
| 115 | + }) |
| 116 | + |
| 117 | + it('rejects userinfo pointing at an attacker host', () => { |
| 118 | + expect(isLoopbackUrl('http://localhost@evil.com')).toBe(false) |
| 119 | + }) |
| 120 | + |
| 121 | + it('rejects an attacker host with localhost in the path', () => { |
| 122 | + expect(isLoopbackUrl('http://evil.com/localhost')).toBe(false) |
| 123 | + }) |
| 124 | + }) |
| 125 | + |
| 126 | + describe('rejects non-http(s) and malformed inputs', () => { |
| 127 | + it('rejects a non-loopback https host', () => { |
| 128 | + expect(isLoopbackUrl('https://evil.com')).toBe(false) |
| 129 | + }) |
| 130 | + |
| 131 | + it('rejects javascript URLs', () => { |
| 132 | + expect(isLoopbackUrl('javascript:alert(1)')).toBe(false) |
| 133 | + }) |
| 134 | + |
| 135 | + it('rejects file URLs to loopback-looking paths', () => { |
| 136 | + expect(isLoopbackUrl('file://localhost/etc/passwd')).toBe(false) |
| 137 | + }) |
| 138 | + |
| 139 | + it('rejects protocol-relative URLs', () => { |
| 140 | + expect(isLoopbackUrl('//localhost')).toBe(false) |
| 141 | + }) |
| 142 | + |
| 143 | + it('rejects plain strings', () => { |
| 144 | + expect(isLoopbackUrl('not-a-url')).toBe(false) |
| 145 | + }) |
| 146 | + |
| 147 | + it('rejects empty strings', () => { |
| 148 | + expect(isLoopbackUrl('')).toBe(false) |
| 149 | + }) |
| 150 | + }) |
| 151 | +}) |
| 152 | + |
| 153 | +describe('loopbackUrlSchema', () => { |
| 154 | + it('parses a genuine loopback URL', () => { |
| 155 | + expect(loopbackUrlSchema.safeParse('http://localhost:3000').success).toBe( |
| 156 | + true |
| 157 | + ) |
| 158 | + }) |
| 159 | + |
| 160 | + it('fails on a host-confusion bypass attempt', () => { |
| 161 | + expect( |
| 162 | + loopbackUrlSchema.safeParse('http://localhost.evil.com').success |
| 163 | + ).toBe(false) |
| 164 | + }) |
| 165 | +}) |
0 commit comments