Skip to content

Commit c377ec6

Browse files
ukeloopdanharrin
andauthored
Improve Str::sanitizeUrl to reject malformed javascript URLs (#19892)
* Improve Str::sanitizeUrl to reject malformed javascript URLs * cleanup * Update SupportServiceProvider.php * Update UrlSanitizerTest.php * Update 06-security.md --------- Co-authored-by: Dan Harrin <git@danharrin.com>
1 parent 7f3e6ff commit c377ec6

3 files changed

Lines changed: 154 additions & 12 deletions

File tree

docs/09-advanced/06-security.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ Many Filament configuration methods accept closures that can return dynamic valu
6767

6868
For example, the `url()` method on columns, entries, and actions renders an `<a href="...">` tag with whatever value you provide. If you pass a URL sourced from user input without validation, a malicious value like `javascript:alert(document.cookie)` could be rendered as a clickable link, leading to XSS. Always validate that URLs use a safe scheme such as `http` or `https` before passing them to Filament.
6969

70-
Filament ships a `Str::sanitizeUrl()` helper that returns the URL when it is schemeless (relative) or uses the `http`/`https` scheme, and returns `null` for anything else. It also normalizes obfuscation tricks such as leading whitespace, embedded control characters (`\t`, `\n`, `\r`, NUL bytes), and mixed-case schemes before checking — so values like `"\tJaVa\nScRiPt:alert(1)"` are rejected, and even on a safe URL the return value has those bytes stripped so they cannot reach the rendered HTML:
70+
Filament ships a `Str::sanitizeUrl()` helper that returns the URL when it is schemeless (relative) or uses the `http`/`https` scheme, and returns `null` for anything else. Before checking the scheme, it accounts for the obfuscation tricks that browsers silently undo when parsing an `href` value — HTML entity references (numeric like `&#9;`/`&#x09;` and named like `&Tab;`/`&NewLine;`/`&colon;`), percent-encoded control characters (`%09`, `%0A`), embedded raw control characters and whitespace (`\t`, `\n`, `\r`, NUL bytes), and mixed-case schemes — so values like `"\tJaVa\nScRiPt:alert(1)"` or `"java&#x09;script:alert(1)"` are rejected. The return value is the original input unchanged when it passes the check; the helper never rewrites a URL.
7171

7272
```php
7373
use Filament\Tables\Columns\TextColumn;
@@ -93,7 +93,7 @@ TextColumn::make('contact')
9393

9494
- check that the host belongs to a domain you control (open-redirect protection),
9595
- check that the URL is safe for the server to fetch (SSRF protection),
96-
- decode percent-encoded or HTML-entity-encoded payloads (the browser's URL parser doesn't either, so this is intentional, but it means callers that decode the value before rendering need their own check on the decoded form),
96+
- guarantee safety for non-standard rendering contexts — the safety analysis assumes the URL will be placed in an HTML attribute like `href`, where the browser performs a single HTML-entity decode and strips whitespace/control characters before parsing the scheme. If your code applies additional transformations to the return value before rendering (for example, calling `urldecode()` and then setting `location.href`), apply your own scheme check to the transformed value,
9797
- validate that an `http(s)` URL is reachable or trusted in any other way.
9898

9999
If you need any of those guarantees, layer your own check on top of the helper's return value.

packages/support/src/SupportServiceProvider.php

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -327,20 +327,44 @@ public function packageBooted(): void
327327
return null;
328328
}
329329

330-
// Reject whitespace and control characters instead of stripping
331-
// them: browsers ignore those bytes when parsing URLs, so an
332-
// input like "\tjavascript:..." would resolve to a scheme the
333-
// visible string does not suggest.
334-
if (preg_match('/[\s\x00-\x1F\x7F]/', $url)) {
330+
// Reject URLs containing raw whitespace or control characters. Legitimate URLs
331+
// percent-encode these; a raw byte here is either a typo or an obfuscation attempt.
332+
if (preg_match('/[\x00-\x20\x7F]/', $url)) {
335333
return null;
336334
}
337335

338-
if (preg_match('#^([a-z][a-z0-9+\-.]*):#i', $url, $matches)) {
339-
$allowedSchemes = array_map(strtolower(...), $allowedSchemes);
336+
// Predict the scheme the browser will see after HTML-decoding the attribute value.
337+
// We pre-decode ASCII numeric entities ourselves because `html_entity_decode(ENT_HTML5)`
338+
// replaces "forbidden" C0 control character entities (e.g. `&#0;`) with U+FFFD, which
339+
// would mask them from the control-character strip below.
340+
$decoded = preg_replace_callback(
341+
'/&#(?:x([0-9a-f]+)|([0-9]+));?/i',
342+
function (array $match): string {
343+
$code = ($match[1] !== '') ? hexdec($match[1]) : (int) $match[2];
340344

341-
if (! in_array(strtolower($matches[1]), $allowedSchemes, strict: true)) {
342-
return null;
343-
}
345+
return ($code <= 127) ? chr((int) $code) : $match[0];
346+
},
347+
$url,
348+
);
349+
$decoded = html_entity_decode($decoded, ENT_QUOTES | ENT_HTML5, 'UTF-8');
350+
351+
// Reject if either the HTML-decoded form or its percent-decoded form contains
352+
// control characters — catches `%09`-style obfuscation that would be dangerous
353+
// if the URL is later decoded by other code.
354+
if (
355+
preg_match('/[\x00-\x1F\x7F]/', $decoded) ||
356+
preg_match('/[\x00-\x1F\x7F]/', rawurldecode($decoded))
357+
) {
358+
return null;
359+
}
360+
361+
// Scheme check uses the HTML-decoded form only — the browser does NOT percent-decode
362+
// the scheme, so a `%6A` literally in the scheme position is not dangerous.
363+
if (
364+
preg_match('/^([a-z][a-z0-9+\-.]*):/i', $decoded, $matches) &&
365+
(! in_array(strtolower($matches[1]), array_map(strtolower(...), $allowedSchemes), strict: true))
366+
) {
367+
return null;
344368
}
345369

346370
return $url;

tests/src/Support/UrlSanitizerTest.php

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,124 @@
163163
expect(Str::sanitizeUrl("javascript\x7F:alert(1)"))->toBeNull();
164164
});
165165

166+
it('rejects URLs containing whitespace after HTML entity decoding', function (): void {
167+
expect(Str::sanitizeUrl('java&#x09;script:alert(1)'))->toBeNull();
168+
expect(Str::sanitizeUrl('java&#10;script:alert(1)'))->toBeNull();
169+
expect(Str::sanitizeUrl('java&#13;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&colon;alert(1)'))->toBeNull();
183+
expect(Str::sanitizeUrl('javascript&#58;alert(1)'))->toBeNull();
184+
});
185+
186+
it('rejects URLs containing control characters after HTML entity decoding', function (): void {
187+
expect(Str::sanitizeUrl('javascript&#x7F;:alert(1)'))->toBeNull();
188+
});
189+
190+
it('rejects URLs containing HTML5 named character entities for control characters', function (): void {
191+
expect(Str::sanitizeUrl('java&Tab;script:alert(1)'))->toBeNull();
192+
expect(Str::sanitizeUrl('java&NewLine;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&amp;b=2'))->toBe('https://example.com/?a=1&amp;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&amp;#9;script:1'))
212+
->toBe('https://example.com/?q=java&amp;#9;script:1');
213+
});
214+
215+
it('rejects schemes assembled entirely from numeric HTML entities', function (): void {
216+
// &#106; is `j`, &#x61; 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('&#106;&#x61;v&#97;script:alert(1)'))->toBeNull();
220+
});
221+
222+
it('rejects schemes assembled from mixed entity and percent encoding', function (): void {
223+
// `java&#9;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&#9;script%3Aalert(1)'))->toBeNull();
227+
});
228+
229+
it('rejects NULL byte hidden in a numeric entity', function (): void {
230+
// &#0; 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&#0;script:alert(1)'))->toBeNull();
233+
expect(Str::sanitizeUrl('java&#x00;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 `&amp` 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+
166284
it('rejects `javascript:` with whitespace before the colon', function (): void {
167285
expect(Str::sanitizeUrl('javascript :alert(1)'))->toBeNull()
168286
->and(Str::sanitizeUrl("javascript\t:alert(1)"))->toBeNull();

0 commit comments

Comments
 (0)