Skip to content

Commit

Permalink
Merge branch 'develop' into 4.6
Browse files Browse the repository at this point in the history
  • Loading branch information
paulbalandan committed Jan 19, 2025
2 parents 80719d4 + 97a6d66 commit 6414090
Show file tree
Hide file tree
Showing 5 changed files with 253 additions and 24 deletions.
72 changes: 70 additions & 2 deletions system/HTTP/Header.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace CodeIgniter\HTTP;

use InvalidArgumentException;
use Stringable;

/**
Expand Down Expand Up @@ -54,7 +55,7 @@ class Header implements Stringable
*/
public function __construct(string $name, $value = null)
{
$this->name = $name;
$this->setName($name);
$this->setValue($value);
}

Expand All @@ -81,9 +82,12 @@ public function getValue()
* Sets the name of the header, overwriting any previous value.
*
* @return $this
*
* @throws InvalidArgumentException
*/
public function setName(string $name)
{
$this->validateName($name);
$this->name = $name;

return $this;
Expand All @@ -95,10 +99,16 @@ public function setName(string $name)
* @param array<int|string, array<string, string>|string>|string|null $value
*
* @return $this
*
* @throws InvalidArgumentException
*/
public function setValue($value = null)
{
$this->value = is_array($value) ? $value : (string) $value;
$value = is_array($value) ? $value : (string) $value;

$this->validateValue($value);

$this->value = $value;

return $this;
}
Expand All @@ -110,13 +120,17 @@ public function setValue($value = null)
* @param array<string, string>|string|null $value
*
* @return $this
*
* @throws InvalidArgumentException
*/
public function appendValue($value = null)
{
if ($value === null) {
return $this;
}

$this->validateValue($value);

if (! is_array($this->value)) {
$this->value = [$this->value];
}
Expand All @@ -135,13 +149,17 @@ public function appendValue($value = null)
* @param array<string, string>|string|null $value
*
* @return $this
*
* @throws InvalidArgumentException
*/
public function prependValue($value = null)
{
if ($value === null) {
return $this;
}

$this->validateValue($value);

if (! is_array($this->value)) {
$this->value = [$this->value];
}
Expand Down Expand Up @@ -193,4 +211,54 @@ public function __toString(): string
{
return $this->name . ': ' . $this->getValueLine();
}

/**
* Validate header name.
*
* Regex is based on code from a guzzlehttp/psr7 library.
*
* @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2
*
* @throws InvalidArgumentException
*/
private function validateName(string $name): void
{
if (preg_match('/^[a-zA-Z0-9\'`#$%&*+.^_|~!-]+$/D', $name) !== 1) {
throw new InvalidArgumentException('The header name is not valid as per RFC 7230.');
}
}

/**
* Validate header value.
*
* Regex is based on code from a guzzlehttp/psr7 library.
*
* @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2
*
* @param array<int|string, array<string, string>|string>|int|string $value
*
* @throws InvalidArgumentException
*/
private function validateValue(array|int|string $value): void
{
if (is_int($value)) {
return;
}

if (is_array($value)) {
foreach ($value as $key => $val) {
$this->validateValue($key);
$this->validateValue($val);
}

return;
}

// The regular expression excludes obs-fold per RFC 7230#3.2.4, as sending folded lines
// is deprecated and rare. This obscure HTTP/1.1 feature is unlikely to impact legitimate
// use cases. Libraries like Guzzle and AMPHP follow the same principle.
if (preg_match('/^[\x20\x09\x21-\x7E\x80-\xFF]*$/D', $value) !== 1) {
throw new InvalidArgumentException('The header value is not valid as per RFC 7230.');
}
}
}
17 changes: 10 additions & 7 deletions system/Security/Security.php
Original file line number Diff line number Diff line change
Expand Up @@ -307,26 +307,29 @@ private function getPostedToken(RequestInterface $request): ?string
// Does the token exist in POST, HEADER or optionally php:://input - json data or PUT, DELETE, PATCH - raw data.

if ($tokenValue = $request->getPost($this->config->tokenName)) {
return $tokenValue;
return is_string($tokenValue) ? $tokenValue : null;
}

if ($request->hasHeader($this->config->headerName)
&& $request->header($this->config->headerName)->getValue() !== ''
&& $request->header($this->config->headerName)->getValue() !== []) {
return $request->header($this->config->headerName)->getValue();
if ($request->hasHeader($this->config->headerName)) {
$tokenValue = $request->header($this->config->headerName)->getValue();

return (is_string($tokenValue) && $tokenValue !== '') ? $tokenValue : null;
}

$body = (string) $request->getBody();

if ($body !== '') {
$json = json_decode($body);
if ($json !== null && json_last_error() === JSON_ERROR_NONE) {
return $json->{$this->config->tokenName} ?? null;
$tokenValue = $json->{$this->config->tokenName} ?? null;

return is_string($tokenValue) ? $tokenValue : null;
}

parse_str($body, $parsed);
$tokenValue = $parsed[$this->config->tokenName] ?? null;

return $parsed[$this->config->tokenName] ?? null;
return is_string($tokenValue) ? $tokenValue : null;
}

return null;
Expand Down
81 changes: 81 additions & 0 deletions tests/system/HTTP/HeaderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

use CodeIgniter\Test\CIUnitTestCase;
use Error;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use stdClass;

Expand Down Expand Up @@ -234,4 +236,83 @@ public function testHeaderToStringShowsEntireHeader(): void

$this->assertSame($expected, (string) $header);
}

/**
* @param string $name
*/
#[DataProvider('invalidNamesProvider')]
public function testInvalidHeaderNames($name): void
{
$this->expectException(InvalidArgumentException::class);

new Header($name, 'text/html');
}

/**
* @return list<list<string>>
*/
public static function invalidNamesProvider(): array
{
return [
["Content-Type\r\n\r\n"],
["Content-Type\r\n"],
["Content-Type\n"],
["\tContent-Type\t"],
["\n\nContent-Type\n\n"],
["\r\nContent-Type"],
["\nContent-Type"],
["Content\r\n-Type"],
["\n"],
["\r\n"],
["\t"],
[' Content-Type '],
['Content - Type'],
["Content\x00Type"],
[':Content-Type'],
['Content-Type:'],
[''],
];
}

/**
* @param array<int|string, array<string, string>|string>|string|null $value
*/
#[DataProvider('invalidValuesProvider')]
public function testInvalidHeaderValues($value): void
{
$this->expectException(InvalidArgumentException::class);

new Header('X-Test-Header', $value);
}

/**
* @return list<list<array<(int|string), string>|string>>
*/
public static function invalidValuesProvider(): array
{
return [
["Header\n Value"],
["Header\r\n Value"],
["Header\r Value"],
["Header Value\n"],
["\nHeader Value"],
["Header Value\r\n"],
["\n\rHeader Value"],
["\n\nHeader Value\n\n"],
[
["Header\n Value"],
["Header\r\n Value"],
],
[
[
"Header\n" => 'Value',
],
],
[
[
'Header' => "Value\r\n",
],
],
];
}
}
98 changes: 83 additions & 15 deletions tests/system/Security/SecurityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use CodeIgniter\Test\Mock\MockSecurity;
use Config\Security as SecurityConfig;
use PHPUnit\Framework\Attributes\BackupGlobals;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;

/**
Expand All @@ -42,13 +43,23 @@ protected function setUp(): void
$this->resetServices();
}

private function createMockSecurity(?SecurityConfig $config = null): MockSecurity
private static function createMockSecurity(SecurityConfig $config = new SecurityConfig()): MockSecurity
{
$config ??= new SecurityConfig();

return new MockSecurity($config);
}

private static function createIncomingRequest(): IncomingRequest
{
$config = new MockAppConfig();

return new IncomingRequest(
$config,
new SiteURI($config),
null,
new UserAgent(),
);
}

public function testBasicConfigIsSaved(): void
{
$security = $this->createMockSecurity();
Expand Down Expand Up @@ -108,18 +119,6 @@ public function testCSRFVerifyPostThrowsExceptionOnNoMatch(): void
$security->verify($request);
}

private function createIncomingRequest(): IncomingRequest
{
$config = new MockAppConfig();

return new IncomingRequest(
$config,
new SiteURI($config),
null,
new UserAgent(),
);
}

public function testCSRFVerifyPostReturnsSelfOnMatch(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
Expand Down Expand Up @@ -315,4 +314,73 @@ public function testGetters(): void
$this->assertIsString($security->getCookieName());
$this->assertIsBool($security->shouldRedirect());
}

public function testGetPostedTokenReturnsTokenFromPost(): void
{
$_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a';
$request = $this->createIncomingRequest();
$method = $this->getPrivateMethodInvoker($this->createMockSecurity(), 'getPostedToken');

$this->assertSame('8b9218a55906f9dcc1dc263dce7f005a', $method($request));
}

public function testGetPostedTokenReturnsTokenFromHeader(): void
{
$_POST = [];
$request = $this->createIncomingRequest()->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005a');
$method = $this->getPrivateMethodInvoker($this->createMockSecurity(), 'getPostedToken');

$this->assertSame('8b9218a55906f9dcc1dc263dce7f005a', $method($request));
}

public function testGetPostedTokenReturnsTokenFromJsonBody(): void
{
$_POST = [];
$jsonBody = json_encode(['csrf_test_name' => '8b9218a55906f9dcc1dc263dce7f005a']);
$request = $this->createIncomingRequest()->setBody($jsonBody);
$method = $this->getPrivateMethodInvoker($this->createMockSecurity(), 'getPostedToken');

$this->assertSame('8b9218a55906f9dcc1dc263dce7f005a', $method($request));
}

public function testGetPostedTokenReturnsTokenFromFormBody(): void
{
$_POST = [];
$formBody = 'csrf_test_name=8b9218a55906f9dcc1dc263dce7f005a';
$request = $this->createIncomingRequest()->setBody($formBody);
$method = $this->getPrivateMethodInvoker($this->createMockSecurity(), 'getPostedToken');

$this->assertSame('8b9218a55906f9dcc1dc263dce7f005a', $method($request));
}

#[DataProvider('provideGetPostedTokenReturnsNullForInvalidInputs')]
public function testGetPostedTokenReturnsNullForInvalidInputs(string $case, IncomingRequest $request): void
{
$method = $this->getPrivateMethodInvoker($this->createMockSecurity(), 'getPostedToken');

$this->assertNull(
$method($request),
sprintf('Failed asserting that %s returns null on invalid input.', $case),
);
}

/**
* @return iterable<string, array{string, IncomingRequest}>
*/
public static function provideGetPostedTokenReturnsNullForInvalidInputs(): iterable
{
$testCases = [
'empty_post' => self::createIncomingRequest(),
'invalid_post_data' => self::createIncomingRequest()->setGlobal('post', ['csrf_test_name' => ['invalid' => 'data']]),
'empty_header' => self::createIncomingRequest()->setHeader('X-CSRF-TOKEN', ''),
'invalid_json_data' => self::createIncomingRequest()->setBody(json_encode(['csrf_test_name' => ['invalid' => 'data']])),
'invalid_json' => self::createIncomingRequest()->setBody('{invalid json}'),
'missing_token_in_body' => self::createIncomingRequest()->setBody('other=value&another=test'),
'invalid_form_data' => self::createIncomingRequest()->setBody('csrf_test_name[]=invalid'),
];

foreach ($testCases as $case => $request) {
yield $case => [$case, $request];
}
}
}
Loading

0 comments on commit 6414090

Please sign in to comment.