Skip to content
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

Detect CRLF URL attempts and fight back with an attempted humor #273

Merged
merged 2 commits into from
Feb 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions site/app/Application/WebApplication.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

namespace MichalSpacekCz\Application;

use MichalSpacekCz\EasterEgg\CrLfUrlInjections;
use MichalSpacekCz\Http\ContentSecurityPolicy\CspValues;
use MichalSpacekCz\Http\SecurityHeaders;
use Nette\Application\Application;
Expand All @@ -17,13 +18,15 @@ public function __construct(
private IResponse $httpResponse,
private SecurityHeaders $securityHeaders,
private Application $application,
private CrLfUrlInjections $crLfUrlInjections,
private string $fqdn,
) {
}


public function run(): void
{
$this->detectCrLfUrlInjectionAttempt();
$this->redirectToSecure();
$this->application->onResponse[] = function (): void {
$this->securityHeaders->sendHeaders();
Expand All @@ -42,4 +45,12 @@ private function redirectToSecure(): void
}
}


private function detectCrLfUrlInjectionAttempt(): void
{
if ($this->crLfUrlInjections->detectAttempt()) {
exit();
}
}

}
45 changes: 45 additions & 0 deletions site/app/EasterEgg/CrLfUrlInjections.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php
declare(strict_types = 1);

namespace MichalSpacekCz\EasterEgg;

use DateTimeImmutable;
use Nette\Http\IRequest;
use Nette\Http\IResponse;
use Nette\Utils\Strings;

readonly class CrLfUrlInjections
{

private const COOKIE_NAME = 'crlfinjection';


public function __construct(
private IRequest $httpRequest,
private IResponse $httpResponse,
) {
}


public function detectAttempt(): bool
{
$url = $this->httpRequest->getUrl()->getAbsoluteUrl();
if (!str_contains($url, "\r") && !str_contains($url, "\n")) {
return false;
}
$matches = Strings::matchAll($url, sprintf('/Set\-Cookie:%s=([a-z0-9]+)/i', self::COOKIE_NAME));
foreach ($matches as $match) {
// Don't use any cookie name from the request to avoid e.g. session fixation
$this->httpResponse->setCookie(
self::COOKIE_NAME,
$match[1],
new DateTimeImmutable('-3 years 1 month 3 days 3 hours 7 minutes'),
'/expired=3years/1month/3days/3hours/7minutes/ago',
);
}
$this->httpResponse->setCode(IResponse::S204_NoContent, 'U WOT M8');
$this->httpResponse->setHeader('Hack-the-Planet', 'https://youtu.be/u3CKgkyc7Qo?t=20');
return true;
}

}
30 changes: 25 additions & 5 deletions site/app/Test/Http/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ class Response implements IResponse

private int $code = IResponse::S200_OK;

private ?string $reason = null;

/** @var array<string, string> */
private array $headers = [];

/** @var array<string, array<int, string>> */
private array $allHeaders = [];

/** @var array<string, Cookie> */
/** @var array<string, list<Cookie>> */
private array $cookies = [];

public string $cookieDomain = '';
Expand All @@ -40,9 +42,10 @@ class Response implements IResponse


#[Override]
public function setCode(int $code, string $reason = null): self
public function setCode(int $code, ?string $reason = null): self
{
$this->code = $code;
$this->reason = $reason;
return $this;
}

Expand All @@ -54,6 +57,12 @@ public function getCode(): int
}


public function getReason(): ?string
{
return $this->reason;
}


#[Override]
public function setHeader(string $name, string $value): self
{
Expand Down Expand Up @@ -135,7 +144,7 @@ public function deleteHeader(string $name): self
#[Override]
public function setCookie(string $name, string $value, $expire, string $path = null, string $domain = null, bool $secure = null, bool $httpOnly = null, string $sameSite = null): self
{
$this->cookies[$name] = new Cookie(
$this->cookies[$name][] = new Cookie(
$name,
$value,
$expire,
Expand All @@ -154,9 +163,12 @@ public function deleteCookie(string $name, string $path = null, string $domain =
}


public function getCookie(string $name): ?Cookie
/**
* @return list<Cookie>
*/
public function getCookie(string $name): array
{
return $this->cookies[$name] ?? null;
return $this->cookies[$name] ?? [];
}


Expand Down Expand Up @@ -204,4 +216,12 @@ public function sent(bool $isSent): void
$this->isSent = $isSent;
}


public function reset(): void
{
$this->headers = [];
$this->allHeaders = [];
$this->cookies = [];
}

}
1 change: 1 addition & 0 deletions site/config/services.neon
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ services:
- MichalSpacekCz\DateTime\DateTimeFactory
- MichalSpacekCz\DateTime\DateTimeFormatter(@translation.translator::getDefaultLocale())
- MichalSpacekCz\DateTime\DateTimeZoneFactory
- MichalSpacekCz\EasterEgg\CrLfUrlInjections
- MichalSpacekCz\EasterEgg\FourOhFourButFound
- MichalSpacekCz\EasterEgg\NetteCve202015227
- MichalSpacekCz\EasterEgg\WinterIsComing
Expand Down
1 change: 1 addition & 0 deletions site/disallowed-calls.neon
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ parameters:
- 'MichalSpacekCz\Http\Cookies\Cookies::getString()'
- 'MichalSpacekCz\Http\Cookies\Cookies::set()'
- 'MichalSpacekCz\Http\Cookies\Cookies::deleteCookie()'
- 'MichalSpacekCz\EasterEgg\CrLfUrlInjections::detectAttempt()' # Bot trolling, not for humans, the cookie is always expired
-
method:
- 'Nette\Application\Request::getPost()'
Expand Down
4 changes: 2 additions & 2 deletions site/tests/Application/ThemeTest.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ class ThemeTest extends TestCase
public function testSetDarkMode(): void
{
$this->theme->setDarkMode();
Assert::same('dark', $this->response->getCookie('future')?->getValue());
Assert::same('dark', $this->response->getCookie('future')[0]->getValue());
}


public function testSetLightMode(): void
{
$this->theme->setLightMode();
Assert::same('bright', $this->response->getCookie('future')?->getValue());
Assert::same('bright', $this->response->getCookie('future')[0]->getValue());
}


Expand Down
80 changes: 80 additions & 0 deletions site/tests/EasterEgg/CrLfUrlInjectionsTest.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php
declare(strict_types = 1);

namespace MichalSpacekCz\EasterEgg;

use MichalSpacekCz\Test\Http\Request;
use MichalSpacekCz\Test\Http\Response;
use MichalSpacekCz\Test\TestCaseRunner;
use Nette\Http\IResponse;
use Nette\Http\UrlScript;
use Override;
use Tester\Assert;
use Tester\TestCase;

require __DIR__ . '/../bootstrap.php';

/** @testCase */
class CrLfUrlInjectionsTest extends TestCase
{

public function __construct(
private readonly CrLfUrlInjections $crLfUrlInjections,
private readonly Request $request,
private readonly Response $response,
) {
}


#[Override]
protected function tearDown()
{
$this->response->reset();
}


/**
* @return non-empty-list<array{0:string, 1:bool, 2:int}>
*/
public function getUrls(): array
{
return [
['/foo', false, 0],
['/foo/Set-Cookie:crlfinjection=1337', false, 0],
['/foo%0A', true, 0],
['/foo%0ASetCookie:crlfinjection=1337', true, 0],
['/foo%0ASet-Cookie:crlfinjection=1337', true, 1],
['/foo%0D', true, 0],
['/foo%0DSetCookie:crlfinjection=1337', true, 0],
['/foo%0DSet-Cookie:crlfinjection=1337', true, 1],
['/foo%0D%0A', true, 0],
['/foo%0D%0ASetCookie:crlfinjection=1337', true, 0],
['/foo%0D%0ASet-Cookie:crlfinjection=1337', true, 1],
['/foo%0D%0ASet-Cookie:PHPSESSID=1338', true, 0],
];
}


/** @dataProvider getUrls */
public function testDetectAttempt(string $path, bool $attempt, int $cookies): void
{
$this->request->setUrl((new UrlScript())->withPath(urldecode($path)));
if ($attempt) {
Assert::same($attempt, $this->crLfUrlInjections->detectAttempt());
Assert::same(IResponse::S204_NoContent, $this->response->getCode());
Assert::same('U WOT M8', $this->response->getReason());
Assert::count($cookies, $this->response->getCookie('crlfinjection'));
if ($cookies > 1) {
Assert::same('1337', $this->response->getCookie('crlfinjection')[0]->getValue());
}
} else {
Assert::false($this->crLfUrlInjections->detectAttempt());
Assert::same(IResponse::S200_OK, $this->response->getCode());
Assert::null($this->response->getReason());
Assert::same([], $this->response->getCookie('crlfinjection'));
}
}

}

TestCaseRunner::run(CrLfUrlInjectionsTest::class);
Loading