Skip to content

Commit fa851ac

Browse files
authored
Merge pull request from GHSA-39fp-mqmm-gxj6
fix: Language, Router, and Filters
2 parents 404e50b + a3572a7 commit fa851ac

File tree

16 files changed

+327
-24
lines changed

16 files changed

+327
-24
lines changed

app/Config/App.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,30 @@ class App extends BaseConfig
5959
*/
6060
public string $uriProtocol = 'REQUEST_URI';
6161

62+
/*
63+
|--------------------------------------------------------------------------
64+
| Allowed URL Characters
65+
|--------------------------------------------------------------------------
66+
|
67+
| This lets you specify which characters are permitted within your URLs.
68+
| When someone tries to submit a URL with disallowed characters they will
69+
| get a warning message.
70+
|
71+
| As a security measure you are STRONGLY encouraged to restrict URLs to
72+
| as few characters as possible.
73+
|
74+
| By default, only these are allowed: `a-z 0-9~%.:_-`
75+
|
76+
| Set an empty string to allow all characters -- but only if you are insane.
77+
|
78+
| The configured value is actually a regular expression character group
79+
| and it will be used as: '/\A[<permittedURIChars>]+\z/iu'
80+
|
81+
| DO NOT CHANGE THIS UNLESS YOU FULLY UNDERSTAND THE REPERCUSSIONS!!
82+
|
83+
*/
84+
public string $permittedURIChars = 'a-z 0-9~%.:_\-';
85+
6286
/**
6387
* --------------------------------------------------------------------------
6488
* Default Locale

phpstan-baseline.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13713,7 +13713,7 @@
1371313713
];
1371413714
$ignoreErrors[] = [
1371513715
'message' => '#^Assigning \'GET\' directly on offset \'REQUEST_METHOD\' of \\$_SERVER is discouraged\\.$#',
13716-
'count' => 35,
13716+
'count' => 36,
1371713717
'path' => __DIR__ . '/tests/system/Filters/FiltersTest.php',
1371813718
];
1371913719
$ignoreErrors[] = [

system/CodeIgniter.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,7 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache
449449

450450
$routeFilter = $this->tryToRouteIt($routes);
451451

452+
// $uri is URL-encoded.
452453
$uri = $this->determinePath();
453454

454455
if ($this->enableFilters) {
@@ -813,6 +814,7 @@ protected function tryToRouteIt(?RouteCollectionInterface $routes = null)
813814
// $routes is defined in Config/Routes.php
814815
$this->router = Services::router($routes, $this->request);
815816

817+
// $path is URL-encoded.
816818
$path = $this->determinePath();
817819

818820
$this->benchmark->stop('bootstrap');

system/Filters/Filters.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,9 @@ public function initialize(?string $uri = null)
245245
return $this;
246246
}
247247

248+
// Decode URL-encoded string
249+
$uri = urldecode($uri);
250+
248251
$this->processGlobals($uri);
249252
$this->processMethods();
250253
$this->processFilters($uri);
@@ -639,7 +642,7 @@ private function checkExcept(string $uri, $paths): bool
639642
/**
640643
* Check the URI path as pseudo-regex
641644
*
642-
* @param string $uri URI path relative to baseURL (all lowercase)
645+
* @param string $uri URI path relative to baseURL (all lowercase, URL-decoded)
643646
* @param array $paths The except path patterns
644647
*/
645648
private function checkPseudoRegex(string $uri, array $paths): bool
@@ -652,7 +655,7 @@ private function checkPseudoRegex(string $uri, array $paths): bool
652655
$path = strtolower(str_replace('*', '.*', $path));
653656

654657
// Does this rule apply here?
655-
if (preg_match('#^' . $path . '$#', $uri, $match) === 1) {
658+
if (preg_match('#\A' . $path . '\z#u', $uri, $match) === 1) {
656659
return true;
657660
}
658661
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/**
4+
* This file is part of CodeIgniter 4 framework.
5+
*
6+
* (c) CodeIgniter Foundation <[email protected]>
7+
*
8+
* For the full copyright and license information, please view
9+
* the LICENSE file that was distributed with this source code.
10+
*/
11+
12+
namespace CodeIgniter\HTTP\Exceptions;
13+
14+
use CodeIgniter\Exceptions\HTTPExceptionInterface;
15+
use RuntimeException;
16+
17+
/**
18+
* 400 Bad Request
19+
*/
20+
class BadRequestException extends RuntimeException implements HTTPExceptionInterface
21+
{
22+
/**
23+
* HTTP status code for Bad Request
24+
*
25+
* @var int
26+
*/
27+
protected $code = 400; // @phpstan-ignore-line
28+
}

system/Language/Language.php

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
namespace CodeIgniter\Language;
1313

1414
use Config\Services;
15-
use InvalidArgumentException;
15+
use IntlException;
1616
use MessageFormatter;
1717

1818
/**
@@ -194,9 +194,33 @@ protected function formatMessage($message, array $args = [])
194194

195195
$formatted = MessageFormatter::formatMessage($this->locale, $message, $args);
196196
if ($formatted === false) {
197-
throw new InvalidArgumentException(
198-
lang('Language.invalidMessageFormat', [$message, implode(',', $args)])
197+
// Format again to get the error message.
198+
try {
199+
$fmt = new MessageFormatter($this->locale, $message);
200+
$formatted = $fmt->format($args);
201+
$fmtError = '"' . $fmt->getErrorMessage() . '" (' . $fmt->getErrorCode() . ')';
202+
} catch (IntlException $e) {
203+
$fmtError = '"' . $e->getMessage() . '" (' . $e->getCode() . ')';
204+
}
205+
206+
$argsString = implode(
207+
', ',
208+
array_map(static fn ($element) => '"' . $element . '"', $args)
209+
);
210+
$argsUrlEncoded = implode(
211+
', ',
212+
array_map(static fn ($element) => '"' . rawurlencode($element) . '"', $args)
199213
);
214+
215+
log_message(
216+
'error',
217+
'Language.invalidMessageFormat: $message: "' . $message
218+
. '", $args: ' . $argsString
219+
. ' (urlencoded: ' . $argsUrlEncoded . '),'
220+
. ' MessageFormatter Error: ' . $fmtError
221+
);
222+
223+
return $message . "\n【Warning】Also, invalid string(s) was passed to the Language class. See log file for details.";
200224
}
201225

202226
return $formatted;

system/Router/Router.php

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Closure;
1515
use CodeIgniter\Exceptions\PageNotFoundException;
16+
use CodeIgniter\HTTP\Exceptions\BadRequestException;
1617
use CodeIgniter\HTTP\Exceptions\RedirectException;
1718
use CodeIgniter\HTTP\Request;
1819
use CodeIgniter\HTTP\ResponseInterface;
@@ -120,11 +121,23 @@ class Router implements RouterInterface
120121

121122
protected ?AutoRouterInterface $autoRouter = null;
122123

124+
/**
125+
* Permitted URI chars
126+
*
127+
* The default value is `''` (do not check) for backward compatibility.
128+
*/
129+
protected string $permittedURIChars = '';
130+
123131
/**
124132
* Stores a reference to the RouteCollection object.
125133
*/
126134
public function __construct(RouteCollectionInterface $routes, ?Request $request = null)
127135
{
136+
$config = config(App::class);
137+
if (isset($config->permittedURIChars)) {
138+
$this->permittedURIChars = $config->permittedURIChars;
139+
}
140+
128141
$this->collection = $routes;
129142

130143
// These are only for auto-routing
@@ -179,6 +192,8 @@ public function handle(?string $uri = null)
179192
// Decode URL-encoded string
180193
$uri = urldecode($uri);
181194

195+
$this->checkDisallowedChars($uri);
196+
182197
// Restart filterInfo
183198
$this->filterInfo = null;
184199
$this->filtersInfo = [];
@@ -433,7 +448,7 @@ protected function checkRoutes(string $uri): bool
433448
}, is_array($handler) ? key($handler) : $handler);
434449

435450
throw new RedirectException(
436-
preg_replace('#^' . $routeKey . '$#u', $redirectTo, $uri),
451+
preg_replace('#\A' . $routeKey . '\z#u', $redirectTo, $uri),
437452
$this->collection->getRedirectCode($routeKey)
438453
);
439454
}
@@ -487,7 +502,7 @@ protected function checkRoutes(string $uri): bool
487502
}
488503

489504
// Using back-references
490-
$handler = preg_replace('#^' . $routeKey . '$#u', $handler, $uri);
505+
$handler = preg_replace('#\A' . $routeKey . '\z#u', $handler, $uri);
491506
}
492507

493508
$this->setRequest(explode('/', $handler));
@@ -676,4 +691,20 @@ protected function setMatchedRoute(string $route, $handler): void
676691

677692
$this->matchedRouteOptions = $this->collection->getRoutesOptions($route);
678693
}
694+
695+
/**
696+
* Checks disallowed characters
697+
*/
698+
private function checkDisallowedChars(string $uri): void
699+
{
700+
foreach (explode('/', $uri) as $segment) {
701+
if ($segment !== '' && $this->permittedURIChars !== ''
702+
&& preg_match('/\A[' . $this->permittedURIChars . ']+\z/iu', $segment) !== 1
703+
) {
704+
throw new BadRequestException(
705+
'The URI you submitted has disallowed characters: "' . $segment . '"'
706+
);
707+
}
708+
}
709+
}
679710
}

tests/system/Filters/FiltersTest.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,6 +1056,52 @@ public function testMatchesURICaseInsensitively(): void
10561056
$this->assertSame($expected, $filters->initialize($uri)->getFilters());
10571057
}
10581058

1059+
public function testMatchesURIWithUnicode(): void
1060+
{
1061+
$_SERVER['REQUEST_METHOD'] = 'GET';
1062+
1063+
$config = [
1064+
'aliases' => [
1065+
'foo' => '',
1066+
'bar' => '',
1067+
'frak' => '',
1068+
'baz' => '',
1069+
],
1070+
'globals' => [
1071+
'before' => [
1072+
'foo' => ['except' => '日本語/*'],
1073+
'bar',
1074+
],
1075+
'after' => [
1076+
'foo' => ['except' => '日本語/*'],
1077+
'baz',
1078+
],
1079+
],
1080+
'filters' => [
1081+
'frak' => [
1082+
'before' => ['日本語/*'],
1083+
'after' => ['日本語/*'],
1084+
],
1085+
],
1086+
];
1087+
$filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config);
1088+
$filters = $this->createFilters($filtersConfig);
1089+
1090+
// URIs passed to Filters are URL-encoded.
1091+
$uri = '%E6%97%A5%E6%9C%AC%E8%AA%9E/foo/bar';
1092+
$expected = [
1093+
'before' => [
1094+
'bar',
1095+
'frak',
1096+
],
1097+
'after' => [
1098+
'baz',
1099+
'frak',
1100+
],
1101+
];
1102+
$this->assertSame($expected, $filters->initialize($uri)->getFilters());
1103+
}
1104+
10591105
/**
10601106
* @see https://github.com/codeigniter4/CodeIgniter4/issues/1907
10611107
*/

tests/system/HTTP/URITest.php

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -473,8 +473,8 @@ public static function providePathGetsFiltered(): iterable
473473
{
474474
return [
475475
'dot-segment' => [
476-
'/./path/to/nowhere',
477-
'/path/to/nowhere',
476+
'/./path/to/nowhere', // path
477+
'/path/to/nowhere', // expectedPath
478478
],
479479
'double-dots' => [
480480
'/../path/to/nowhere',
@@ -484,18 +484,30 @@ public static function providePathGetsFiltered(): iterable
484484
'./path/to/nowhere',
485485
'/path/to/nowhere',
486486
],
487-
'start-double' => [
487+
'start-double-dot' => [
488488
'../path/to/nowhere',
489489
'/path/to/nowhere',
490490
],
491-
'decoded' => [
492-
'../%41path',
491+
'decode-percent-encoded-chars' => [
492+
'/%41path',
493493
'/Apath',
494494
],
495-
'encoded' => [
495+
'decode-slash' => [
496+
'/a%2Fb',
497+
'/a/b',
498+
],
499+
'encode-unreserved-chars' => [
496500
'/path^here',
497501
'/path%5Ehere',
498502
],
503+
'encode-multibyte-chars' => [
504+
'/あいう',
505+
'/%E3%81%82%E3%81%84%E3%81%86',
506+
],
507+
'encode-invalid-percent-encoding' => [
508+
'/pa%2-th',
509+
'/pa%252-th',
510+
],
499511
];
500512
}
501513

tests/system/Language/LanguageTest.php

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
use CodeIgniter\Test\CIUnitTestCase;
1515
use CodeIgniter\Test\Mock\MockLanguage;
1616
use Config\Services;
17-
use InvalidArgumentException;
1817
use MessageFormatter;
1918
use Tests\Support\Language\SecondMockLanguage;
2019

@@ -137,18 +136,14 @@ public function testGetLineInvalidFormatMessage(): void
137136
$this->markTestSkipped('No intl support.');
138137
}
139138

140-
$this->expectException(InvalidArgumentException::class);
141-
$this->expectExceptionMessage(
142-
'Invalid message format: "تم الكشف عن كلمة المرور {0} بسبب اختراق البيانات وشوهدت {1 ، عدد} مرة في {2} في كلمات المرور المخترقة.", args: "password,hits,wording"'
143-
);
144-
145139
$this->lang->setLocale('ar');
146140

147-
$this->lang->setData('Auth', [
148-
'errorPasswordPwned' => 'تم الكشف عن كلمة المرور {0} بسبب اختراق البيانات وشوهدت {1 ، عدد} مرة في {2} في كلمات المرور المخترقة.',
149-
]);
141+
$line = 'تم الكشف عن كلمة المرور {0} بسبب اختراق البيانات وشوهدت {1 ، عدد} مرة في {2} في كلمات المرور المخترقة.';
142+
$this->lang->setData('Auth', ['errorPasswordPwned' => $line]);
143+
144+
$output = $this->lang->getLine('Auth.errorPasswordPwned', ['password', 'hits', 'wording']);
150145

151-
$this->lang->getLine('Auth.errorPasswordPwned', ['password', 'hits', 'wording']);
146+
$this->assertSame($line . "\n【Warning】Also, invalid string(s) was passed to the Language class. See log file for details.", $output);
152147
}
153148

154149
/**

0 commit comments

Comments
 (0)