Added Test coverage for ServeStatic.

This commit is contained in:
Abdulmhsen B. A. A.
2024-09-08 23:42:41 +03:00
parent ed802b98f3
commit 79f86293f5
11 changed files with 214 additions and 41 deletions

View File

@@ -10,11 +10,15 @@ use League\Route\Http\Exception\BadRequestException;
use League\Route\Http\Exception\NotFoundException;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use SplFileInfo;
use Throwable;
final class ServeStatic
final class ServeStatic implements LoggerAwareInterface
{
use LoggerAwareTrait;
private finfo|null $mimeType = null;
private const array CONTENT_TYPE = [
@@ -39,7 +43,6 @@ final class ServeStatic
private const array MD_IMAGES = [
'/screenshots' => __DIR__ . '/../../',
];
private array $looked = [];
public function __construct(private string|null $staticPath = null)
{
@@ -59,8 +62,6 @@ final class ServeStatic
*/
public function serve(iRequest $request): iResponse
{
$requestPath = $request->getUri()->getPath();
if (false === in_array($request->getMethod(), ['GET', 'HEAD', 'OPTIONS'])) {
throw new BadRequestException(
message: r("Method '{method}' is not allowed.", ['method' => $request->getMethod()]),
@@ -68,6 +69,11 @@ final class ServeStatic
);
}
// -- as we alter the static path for .md files, we need to keep the original path
// -- do not mutate the original path. as it may be used in other requests.
$staticPath = $this->staticPath;
$requestPath = $request->getUri()->getPath();
if (array_key_exists($requestPath, self::MD_FILES)) {
return $this->serveFile($request, new SplFileInfo(self::MD_FILES[$requestPath]));
}
@@ -75,44 +81,36 @@ final class ServeStatic
// -- check if the request path is in the MD_IMAGES array
foreach (self::MD_IMAGES as $key => $value) {
if (str_starts_with($requestPath, $key)) {
$this->staticPath = realpath($value);
$staticPath = realpath($value);
break;
}
}
$filePath = fixPath($this->staticPath . $requestPath);
if (false === ($realBasePath = realpath($staticPath))) {
throw new BadRequestException(
message: r("The static path '{path}' doesn't exists.", ['path' => $staticPath]),
code: Status::SERVICE_UNAVAILABLE->value
);
}
$filePath = fixPath($staticPath . $requestPath);
if (is_dir($filePath)) {
$filePath = $filePath . '/index.html';
}
if (!file_exists($filePath)) {
$checkIndex = $this->deepIndexLookup($this->staticPath, $requestPath);
if (!file_exists($checkIndex)) {
throw new NotFoundException(
message: r(
"File '{file}' is not found. {checkIndex} {looked}",
[
'file' => $requestPath,
'checkIndex' => $checkIndex,
'looked' => $this->looked,
]
),
code: Status::NOT_FOUND->value
);
$this->logger?->debug("File '{file}' is not found.", ['file' => $filePath]);
$checkIndex = fixPath($staticPath . $this->deepIndexLookup($staticPath, $requestPath));
if (false === file_exists($checkIndex) || false === is_file($checkIndex)) {
throw new NotFoundException(r("Path '{file}' is not found.", [
'file' => $requestPath,
]), code: Status::NOT_FOUND->value);
}
$filePath = $checkIndex;
}
if (false === ($realBasePath = realpath($this->staticPath))) {
throw new BadRequestException(
message: r("The static path '{path}' doesn't exists.", ['path' => $this->staticPath]),
code: Status::SERVICE_UNAVAILABLE->value
);
}
$filePath = realpath($filePath);
if (false === $filePath || false === str_starts_with($filePath, $realBasePath)) {
throw new BadRequestException(
message: r("Request '{file}' is invalid.", ['file' => $requestPath]),
@@ -183,23 +181,21 @@ final class ServeStatic
// -- paths may look like /parent/id/child, do a deep lookup for index.html at each level
// return the first index.html found
$path = fixPath($path);
if ('/' === $path) {
if ('/' === $path || empty($path)) {
return $path;
}
$paths = explode('/', $path);
$count = count($paths);
if ($count < 2) {
$index = $count - 1;
if ($index < 2) {
return $path;
}
$index = $count - 1;
for ($i = $index; $i > 0; $i--) {
$check = $base . implode('/', array_slice($paths, 0, $i)) . '/index.html';
$this->looked[] = $check;
if (file_exists($check)) {
$check = implode('/', array_slice($paths, 0, $i)) . '/index.html';
if (file_exists($base . $check)) {
return $check;
}
}

View File

@@ -16,13 +16,13 @@ use Symfony\Component\Process\Process;
*/
final class Server
{
public const CONFIG_HOST = 'host';
public const CONFIG_PORT = 'port';
public const CONFIG_ROOT = 'root';
public const CONFIG_PHP = 'php';
public const CONFIG_ENV = 'env';
public const CONFIG_ROUTER = 'router';
public const CONFIG_THREADS = 'threads';
public const string CONFIG_HOST = 'host';
public const string CONFIG_PORT = 'port';
public const string CONFIG_ROOT = 'root';
public const string CONFIG_PHP = 'php';
public const string CONFIG_ENV = 'env';
public const string CONFIG_ROUTER = 'router';
public const string CONFIG_THREADS = 'threads';
/**
* @var array $config The configuration settings for the server

View File

@@ -0,0 +1,6 @@
html {
background-color: #f0f0f0;
font-family: Arial, sans-serif;
font-size: 16px;
color: #333;
}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
</body>
</html>

View File

@@ -0,0 +1 @@
const testFunc = () => 'test'

View File

@@ -0,0 +1,3 @@
{
"test": "test"
}

View File

@@ -0,0 +1 @@
# Test markdown

View File

@@ -0,0 +1 @@
test

View File

@@ -0,0 +1 @@
test_index.html

View File

@@ -0,0 +1 @@
test

View File

@@ -0,0 +1,151 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
declare(strict_types=1);
namespace Tests\Libs;
use App\Libs\Config;
use App\Libs\Enums\Http\Status;
use App\Libs\ServeStatic;
use App\Libs\TestCase;
use Nyholm\Psr7\ServerRequest;
class ServeStaticTest extends TestCase
{
private ServeStatic|null $server = null;
private string $dataPath = __DIR__ . '/../Fixtures/static_data';
protected function setUp(): void
{
parent::setUp();
$this->server = new ServeStatic(realpath($this->dataPath));
}
private function createRequest(string $method, string $uri, array $headers = []): ServerRequest
{
return new ServerRequest($method, $uri, $headers);
}
public function test_responses()
{
$this->checkException(
closure: fn() => $this->server->serve($this->createRequest('GET', '/nonexistent')),
reason: 'If file does not exist, A NotFoundException should be thrown.',
exception: \League\Route\Http\Exception\NotFoundException::class,
exceptionMessage: 'not found',
exceptionCode: Status::NOT_FOUND->value,
);
$this->checkException(
closure: function () {
Config::save('webui.path', '/nonexistent');
return (new ServeStatic())->serve($this->createRequest('GET', '/nonexistent'));
},
reason: 'If file does not exist, A NotFoundException should be thrown.',
exception: \League\Route\Http\Exception\BadRequestException::class,
exceptionMessage: 'The static path',
exceptionCode: Status::SERVICE_UNAVAILABLE->value,
);
$response = $this->server->serve($this->createRequest('GET', '/test.html'));
$this->assertEquals(Status::OK->value, $response->getStatusCode());
$this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
$this->assertEquals(file_get_contents($this->dataPath . '/test.html'), (string)$response->getBody());
$this->assertSame(filesize($this->dataPath . '/test.html'), $response->getBody()->getSize());
// -- test screenshots serving, as screenshots path is not in public directory and not subject
// -- to same path restrictions as other files.
$response = $this->server->serve($this->createRequest('GET', '/screenshots/add_backend.png'));
$this->assertEquals(Status::OK->value, $response->getStatusCode());
$this->assertEquals('image/png', $response->getHeaderLine('Content-Type'));
$this->assertEquals(
file_get_contents(__DIR__ . '/../../screenshots/add_backend.png'),
(string)$response->getBody()
);
// -- There are similar rules for .md files test them.
$response = $this->server->serve($this->createRequest('GET', '/README.md'));
$this->assertEquals(Status::OK->value, $response->getStatusCode());
$this->assertEquals('text/markdown; charset=utf-8', $response->getHeaderLine('Content-Type'));
$this->assertEquals(file_get_contents(__DIR__ . '/../../README.md'), (string)$response->getBody());
$this->checkException(
closure: fn() => $this->server->serve($this->createRequest('PUT', '/nonexistent.md')),
reason: 'Non-idempotent methods should not be allowed on static files.',
exception: \League\Route\Http\Exception\BadRequestException::class,
exceptionMessage: 'is not allowed',
);
// -- Check directory serving.
$response = $this->server->serve($this->createRequest('GET', '/test'));
$this->assertEquals(Status::OK->value, $response->getStatusCode());
$this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
$this->assertEquals(
file_get_contents(__DIR__ . '/../Fixtures/static_data/test/index.html'),
(string)$response->getBody()
);
$response = $this->server->serve($this->createRequest('GET', '/test.html', [
'if-modified-since' => gmdate('D, d M Y H:i:s T', filemtime($this->dataPath . '/test.html')),
]));
$this->assertEquals(Status::NOT_MODIFIED->value, $response->getStatusCode());
// -- Check for LFI vulnerability.
$this->checkException(
closure: fn() => $this->server->serve($this->createRequest('GET', '/../../../composer.json')),
reason: 'Should not allow serving files outside the static directory.',
exception: \League\Route\Http\Exception\BadRequestException::class,
exceptionMessage: 'is invalid.',
exceptionCode: Status::BAD_REQUEST->value,
);
// -- Check for invalid root static path.
$this->checkException(
closure: fn() => (new ServeStatic('/nonexistent'))->serve($this->createRequest('GET', '/test.html')),
reason: 'Should throw exception if the static path does not exist.',
exception: \League\Route\Http\Exception\BadRequestException::class,
exceptionMessage: 'The static path',
exceptionCode: Status::SERVICE_UNAVAILABLE->value,
);
// -- check for deep index lookup.
$response = $this->server->serve($this->createRequest('GET', '/test/view/action/1'));
$this->assertEquals(Status::OK->value, $response->getStatusCode());
$this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
$this->assertEquals(
file_get_contents(__DIR__ . '/../Fixtures/static_data/test/index.html'),
(string)$response->getBody()
);
$response = $this->server->serve($this->createRequest('GET', '/test/view/1'));
$this->assertEquals(Status::OK->value, $response->getStatusCode());
$this->checkException(
closure: fn() => $this->server->serve($this->createRequest('GET', '/test2/foo/bar')),
reason: 'If file does not exist, A NotFoundException should be thrown.',
exception: \League\Route\Http\Exception\NotFoundException::class,
exceptionMessage: 'not found',
exceptionCode: Status::NOT_FOUND->value,
);
$this->checkException(
closure: fn() => $this->server->serve($this->createRequest('GET', '/')),
reason: 'If file does not exist, A NotFoundException should be thrown.',
exception: \League\Route\Http\Exception\NotFoundException::class,
exceptionMessage: 'not found',
exceptionCode: Status::NOT_FOUND->value,
);
$response = $this->server->serve($this->createRequest('GET', '/test.html', [
'if-modified-since' => '$$ INVALID DATA',
]));
$this->assertEquals(
Status::OK->value,
$response->getStatusCode(),
'If the date is invalid, the file should be served as normal.'
);
}
}