Skip to content

feat: add filter to check invalid chars in user input #5227

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Nov 23, 2021
Merged
9 changes: 6 additions & 3 deletions app/Config/Filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use CodeIgniter\Filters\CSRF;
use CodeIgniter\Filters\DebugToolbar;
use CodeIgniter\Filters\Honeypot;
use CodeIgniter\Filters\InvalidChars;

class Filters extends BaseConfig
{
Expand All @@ -16,9 +17,10 @@ class Filters extends BaseConfig
* @var array
*/
public $aliases = [
'csrf' => CSRF::class,
'toolbar' => DebugToolbar::class,
'honeypot' => Honeypot::class,
'csrf' => CSRF::class,
'toolbar' => DebugToolbar::class,
'honeypot' => Honeypot::class,
'invalidchars' => InvalidChars::class,
];

/**
Expand All @@ -31,6 +33,7 @@ class Filters extends BaseConfig
'before' => [
// 'honeypot',
// 'csrf',
// 'invalidchars',
],
'after' => [
'toolbar',
Expand Down
123 changes: 123 additions & 0 deletions system/Filters/InvalidChars.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <[email protected]>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace CodeIgniter\Filters;

use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Security\Exceptions\SecurityException;

/**
* InvalidChars filter.
*
* Check if user input data ($_GET, $_POST, $_COOKIE, php://input) do not contain
* invalid characters:
* - invalid UTF-8 characters
* - control characters except line break and tab code
*/
class InvalidChars implements FilterInterface
{
/**
* Data source
*
* @var string
*/
protected $source;

/**
* Regular expressions for valid control codes
*
* @var string
*/
protected $controlCodeRegex = '/\A[\r\n\t[:^cntrl:]]*\z/u';

/**
* Check invalid characters.
*
* @param array|null $arguments
*
* @return void
*/
public function before(RequestInterface $request, $arguments = null)
{
if ($request->isCLI()) {
return;
}

$data = [
'get' => $request->getGet(),
'post' => $request->getPost(),
'cookie' => $request->getCookie(),
'rawInput' => $request->getRawInput(),
];

foreach ($data as $source => $values) {
$this->source = $source;
$this->checkEncoding($values);
$this->checkControl($values);
}
}

/**
* We don't have anything to do here.
*
* @param array|null $arguments
*
* @return void
*/
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
}

/**
* Check the character encoding is valid UTF-8.
*
* @param array|string $value
*
* @return array|string
*/
protected function checkEncoding($value)
{
if (is_array($value)) {
array_map([$this, 'checkEncoding'], $value);

return $value;
}

if (mb_check_encoding($value, 'UTF-8')) {
return $value;
}

throw SecurityException::forInvalidUTF8Chars($this->source, $value);
}

/**
* Check for the presence of control characters except line breaks and tabs.
*
* @param array|string $value
*
* @return array|string
*/
protected function checkControl($value)
{
if (is_array($value)) {
array_map([$this, 'checkControl'], $value);

return $value;
}

if (preg_match($this->controlCodeRegex, $value) === 1) {
return $value;
}

throw SecurityException::forInvalidControlChars($this->source, $value);
}
}
16 changes: 16 additions & 0 deletions system/Security/Exceptions/SecurityException.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ public static function forDisallowedAction()
return new static(lang('Security.disallowedAction'), 403);
}

public static function forInvalidUTF8Chars(string $source, string $string)
{
return new static(
'Invalid UTF-8 characters in ' . $source . ': ' . $string,
400
);
}

public static function forInvalidControlChars(string $source, string $string)
{
return new static(
'Invalid Control characters in ' . $source . ': ' . $string,
400
);
}

/**
* @deprecated Use `CookieException::forInvalidSameSite()` instead.
*
Expand Down
163 changes: 163 additions & 0 deletions tests/system/Filters/InvalidCharsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<?php

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <[email protected]>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace CodeIgniter\Filters;

use CodeIgniter\HTTP\CLIRequest;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\URI;
use CodeIgniter\HTTP\UserAgent;
use CodeIgniter\Security\Exceptions\SecurityException;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\Mock\MockAppConfig;

/**
* @internal
*/
final class InvalidCharsTest extends CIUnitTestCase
{
/**
* @var InvalidChars
*/
private $invalidChars;

/**
* @var IncomingRequest
*/
private $request;

protected function setUp(): void
{
parent::setUp();

$_GET = [];
$_POST = [];
$_COOKIE = [];

$this->request = $this->createRequest();
$this->invalidChars = new InvalidChars();
}

protected function tearDown(): void
{
parent::tearDown();

$_GET = [];
$_POST = [];
$_COOKIE = [];
}

private function createRequest(): IncomingRequest
{
$config = new MockAppConfig();
$uri = new URI();
$userAgent = new UserAgent();
$request = $this->getMockBuilder(IncomingRequest::class)
->setConstructorArgs([$config, $uri, null, $userAgent])
->onlyMethods(['isCLI'])
->getMock();
$request->method('isCLI')->willReturn(false);

return $request;
}

/**
* @doesNotPerformAssertions
*/
public function testBeforeDoNothingWhenCLIRequest()
{
$cliRequest = new CLIRequest(new MockAppConfig());

$this->invalidChars->before($cliRequest);
}

/**
* @doesNotPerformAssertions
*/
public function testBeforeValidString()
{
$_POST['val'] = [
'valid string',
];
$_COOKIE['val'] = 'valid string';

$this->invalidChars->before($this->request);
}

public function testBeforeInvalidUTF8StringCausesException()
{
$this->expectException(SecurityException::class);
$this->expectExceptionMessage('Invalid UTF-8 characters in post:');

$sjisString = mb_convert_encoding('SJISの文字列です。', 'SJIS');
$_POST['val'] = [
'valid string',
$sjisString,
];

$this->invalidChars->before($this->request);
}

public function testBeforeInvalidControlCharCausesException()
{
$this->expectException(SecurityException::class);
$this->expectExceptionMessage('Invalid Control characters in cookie:');

$stringWithNullChar = "String contains null char and line break.\0\n";
$_COOKIE['val'] = $stringWithNullChar;

$this->invalidChars->before($this->request);
}

/**
* @doesNotPerformAssertions
*
* @dataProvider stringWithLineBreakAndTabProvider
*/
public function testCheckControlStringWithLineBreakAndTabReturnsTheString(string $input)
{
$_GET['val'] = $input;

$this->invalidChars->before($this->request);
}

public function stringWithLineBreakAndTabProvider()
{
yield from [
["String contains \n line break."],
["String contains \r line break."],
["String contains \r\n line break."],
["String contains \t tab."],
["String contains \t and \r line \n break."],
];
}

/**
* @dataProvider stringWithControlCharsProvider
*/
public function testCheckControlStringWithControlCharsCausesException(string $input)
{
$this->expectException(SecurityException::class);
$this->expectExceptionMessage('Invalid Control characters in get:');

$_GET['val'] = $input;

$this->invalidChars->before($this->request);
}

public function stringWithControlCharsProvider()
{
yield from [
["String contains null char.\0"],
["String contains null char and line break.\0\n"],
];
}
}
4 changes: 2 additions & 2 deletions user_guide_src/source/incoming/filters.rst
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,6 @@ In this example, the array ``['dual', 'noreturn']`` will be passed in ``$argumen
Provided Filters
****************

Three filters are bundled with CodeIgniter4: ``Honeypot``, ``CSRF``, and ``DebugToolbar``.
The filters bundled with CodeIgniter4 are: ``Honeypot``, ``CSRF``, ``DebugToolbar``, and ``InvalidChars``.

.. note:: The filters are executed in the declared order that is defined in the config file, but there is one exception to this and it concerns the ``DebugToolbar``, which is always executed last. This is because ``DebugToolbar`` should be able to register everything that happens in other filters.
.. note:: The filters are executed in the order defined in the config file. However, if enabled, ``DebugToolbar`` is always executed last because it should be able to capture everything that happens in the other filters.