Skip to content

Commit

Permalink
Detect CRLF URL attempts and fight back with maybe humor
Browse files Browse the repository at this point in the history
URLs like `/%0DSet-Cookie:...` would throw "PHP Warning: Header may not contain more than a single header, new line detected in [...]Http/Response.php:98" because of the `IResponse::redirect()` call in `WebApplication::redirectToSecure()`. Let's detect such attempts and stop them in their tracks.
  • Loading branch information
spaze committed Feb 6, 2024
1 parent 4133734 commit ed46475
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 1 deletion.
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;
}

}
19 changes: 18 additions & 1 deletion site/app/Test/Http/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class Response implements IResponse

private int $code = IResponse::S200_OK;

private ?string $reason = null;

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

Expand Down Expand Up @@ -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 @@ -207,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
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);

0 comments on commit ed46475

Please sign in to comment.