|
163 | 163 | expect(Str::sanitizeUrl("javascript\x7F:alert(1)"))->toBeNull(); |
164 | 164 | }); |
165 | 165 |
|
| 166 | +it('rejects URLs containing whitespace after HTML entity decoding', function (): void { |
| 167 | + expect(Str::sanitizeUrl('java	script:alert(1)'))->toBeNull(); |
| 168 | + expect(Str::sanitizeUrl('java script:alert(1)'))->toBeNull(); |
| 169 | + expect(Str::sanitizeUrl('java script:alert(1)'))->toBeNull(); |
| 170 | +}); |
| 171 | + |
| 172 | +it('rejects URLs containing encoded control characters', function (): void { |
| 173 | + expect(Str::sanitizeUrl('java%09script:alert(1)'))->toBeNull(); |
| 174 | + expect(Str::sanitizeUrl('java%0Ascript:alert(1)'))->toBeNull(); |
| 175 | +}); |
| 176 | + |
| 177 | +it('rejects URLs containing raw control characters', function (): void { |
| 178 | + expect(Str::sanitizeUrl("javascript\x7F:alert(1)"))->toBeNull(); |
| 179 | +}); |
| 180 | + |
| 181 | +it('rejects URLs containing HTML entity encoded separators', function (): void { |
| 182 | + expect(Str::sanitizeUrl('javascript:alert(1)'))->toBeNull(); |
| 183 | + expect(Str::sanitizeUrl('javascript:alert(1)'))->toBeNull(); |
| 184 | +}); |
| 185 | + |
| 186 | +it('rejects URLs containing control characters after HTML entity decoding', function (): void { |
| 187 | + expect(Str::sanitizeUrl('javascript:alert(1)'))->toBeNull(); |
| 188 | +}); |
| 189 | + |
| 190 | +it('rejects URLs containing HTML5 named character entities for control characters', function (): void { |
| 191 | + expect(Str::sanitizeUrl('java	script:alert(1)'))->toBeNull(); |
| 192 | + expect(Str::sanitizeUrl('java
script:alert(1)'))->toBeNull(); |
| 193 | +}); |
| 194 | + |
| 195 | +it('passes legitimate URLs with multiple query parameters through unchanged', function (): void { |
| 196 | + expect(Str::sanitizeUrl('https://example.com/?a=1&b=2'))->toBe('https://example.com/?a=1&b=2'); |
| 197 | + expect(Str::sanitizeUrl('https://example.com/search?q=hello&page=2&sort=desc')) |
| 198 | + ->toBe('https://example.com/search?q=hello&page=2&sort=desc'); |
| 199 | +}); |
| 200 | + |
| 201 | +it('passes legitimate URLs containing escaped ampersand entities through unchanged', function (): void { |
| 202 | + expect(Str::sanitizeUrl('https://example.com/?a=1&b=2'))->toBe('https://example.com/?a=1&b=2'); |
| 203 | +}); |
| 204 | + |
| 205 | +it('passes URLs whose query string literally contains the text `javascript:` through unchanged', function (): void { |
| 206 | + expect(Str::sanitizeUrl('https://example.com/?q=javascript%3Aalert(1)')) |
| 207 | + ->toBe('https://example.com/?q=javascript%3Aalert(1)'); |
| 208 | +}); |
| 209 | + |
| 210 | +it('does not recursively decode double-encoded entities — single decode matches browser behaviour', function (): void { |
| 211 | + expect(Str::sanitizeUrl('https://example.com/?q=java	script:1')) |
| 212 | + ->toBe('https://example.com/?q=java	script:1'); |
| 213 | +}); |
| 214 | + |
| 215 | +it('rejects schemes assembled entirely from numeric HTML entities', function (): void { |
| 216 | + // j is `j`, a is `a`, etc. Defends against an attacker |
| 217 | + // disguising the whole scheme name in entities so it doesn't read as |
| 218 | + // "javascript" in the raw source. |
| 219 | + expect(Str::sanitizeUrl('javascript:alert(1)'))->toBeNull(); |
| 220 | +}); |
| 221 | + |
| 222 | +it('rejects schemes assembled from mixed entity and percent encoding', function (): void { |
| 223 | + // `java	script%3Aalert(1)` — entity decodes to TAB (control-char |
| 224 | + // rejection); percent-encoded colon is irrelevant because the TAB |
| 225 | + // rejection fires first. |
| 226 | + expect(Str::sanitizeUrl('java	script%3Aalert(1)'))->toBeNull(); |
| 227 | +}); |
| 228 | + |
| 229 | +it('rejects NULL byte hidden in a numeric entity', function (): void { |
| 230 | + // � decodes to NUL via the manual pre-decode (html_entity_decode |
| 231 | + // would otherwise replace it with U+FFFD and hide the attack). |
| 232 | + expect(Str::sanitizeUrl('java�script:alert(1)'))->toBeNull(); |
| 233 | + expect(Str::sanitizeUrl('java�script:alert(1)'))->toBeNull(); |
| 234 | +}); |
| 235 | + |
| 236 | +it('does not decode named HTML entities that require a trailing semicolon when the semicolon is missing', function (): void { |
| 237 | + // `&Tab` without trailing `;` is not a valid HTML5 entity (only legacy |
| 238 | + // entities like `&` decode without the semicolon). Both the browser |
| 239 | + // and `html_entity_decode(ENT_HTML5)` leave it as literal text, so the |
| 240 | + // URL passes through. We document the behaviour either way. |
| 241 | + $result = Str::sanitizeUrl('https://example.com/?q=&Tab'); |
| 242 | + expect($result)->toBe('https://example.com/?q=&Tab'); |
| 243 | +}); |
| 244 | + |
| 245 | +it('rejects schemes containing `+` that are not on the allowlist', function (): void { |
| 246 | + // RFC 3986 allows `+` in schemes (e.g. `coap+tcp`, `git+ssh`). Make sure |
| 247 | + // unusual but valid-looking schemes are still gated by the allowlist. |
| 248 | + expect(Str::sanitizeUrl('git+ssh://example.com/repo.git'))->toBeNull(); |
| 249 | + expect(Str::sanitizeUrl('coap+tcp://example.com/'))->toBeNull(); |
| 250 | +}); |
| 251 | + |
| 252 | +it('passes through URLs whose path or fragment legitimately contains a literal `:`', function (): void { |
| 253 | + // A `:` inside a path segment isn't a scheme delimiter — only the |
| 254 | + // first `:` matters, and our regex correctly anchors with `^`. |
| 255 | + expect(Str::sanitizeUrl('https://example.com/path:with:colons')) |
| 256 | + ->toBe('https://example.com/path:with:colons'); |
| 257 | + expect(Str::sanitizeUrl('https://example.com/#section:1')) |
| 258 | + ->toBe('https://example.com/#section:1'); |
| 259 | +}); |
| 260 | + |
| 261 | +it('handles URLs that consist of only a scheme and colon, with no body', function (): void { |
| 262 | + expect(Str::sanitizeUrl('https:'))->toBe('https:'); |
| 263 | + expect(Str::sanitizeUrl('javascript:'))->toBeNull(); |
| 264 | +}); |
| 265 | + |
| 266 | +it('returns `null` when the allowlist is empty and the URL has an absolute scheme', function (): void { |
| 267 | + expect(Str::sanitizeUrl('https://example.com', []))->toBeNull(); |
| 268 | + expect(Str::sanitizeUrl('http://example.com', []))->toBeNull(); |
| 269 | +}); |
| 270 | + |
| 271 | +it('does not catastrophically backtrack on long pathological inputs', function (): void { |
| 272 | + // Sanity check against accidental ReDoS: a long URL of just allowed |
| 273 | + // scheme characters should complete in well under a millisecond. |
| 274 | + $long = 'https://example.com/' . str_repeat('a', 10000); |
| 275 | + |
| 276 | + $start = hrtime(true); |
| 277 | + $result = Str::sanitizeUrl($long); |
| 278 | + $elapsedMs = (hrtime(true) - $start) / 1_000_000; |
| 279 | + |
| 280 | + expect($result)->toBe($long); |
| 281 | + expect($elapsedMs)->toBeLessThan(50.0); |
| 282 | +}); |
| 283 | + |
166 | 284 | it('rejects `javascript:` with whitespace before the colon', function (): void { |
167 | 285 | expect(Str::sanitizeUrl('javascript :alert(1)'))->toBeNull() |
168 | 286 | ->and(Str::sanitizeUrl("javascript\t:alert(1)"))->toBeNull(); |
|
0 commit comments