Added Test coverage for ServeStatic.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
6
tests/Fixtures/static_data/test.css
Normal file
6
tests/Fixtures/static_data/test.css
Normal file
@@ -0,0 +1,6 @@
|
||||
html {
|
||||
background-color: #f0f0f0;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
12
tests/Fixtures/static_data/test.html
Normal file
12
tests/Fixtures/static_data/test.html
Normal 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>
|
||||
1
tests/Fixtures/static_data/test.js
Normal file
1
tests/Fixtures/static_data/test.js
Normal file
@@ -0,0 +1 @@
|
||||
const testFunc = () => 'test'
|
||||
3
tests/Fixtures/static_data/test.json
Normal file
3
tests/Fixtures/static_data/test.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"test": "test"
|
||||
}
|
||||
1
tests/Fixtures/static_data/test.md
Normal file
1
tests/Fixtures/static_data/test.md
Normal file
@@ -0,0 +1 @@
|
||||
# Test markdown
|
||||
1
tests/Fixtures/static_data/test.woff2
Normal file
1
tests/Fixtures/static_data/test.woff2
Normal file
@@ -0,0 +1 @@
|
||||
test
|
||||
1
tests/Fixtures/static_data/test/index.html
Normal file
1
tests/Fixtures/static_data/test/index.html
Normal file
@@ -0,0 +1 @@
|
||||
test_index.html
|
||||
1
tests/Fixtures/static_data/test2/test.html
Normal file
1
tests/Fixtures/static_data/test2/test.html
Normal file
@@ -0,0 +1 @@
|
||||
test
|
||||
151
tests/Libs/ServeStaticTest.php
Normal file
151
tests/Libs/ServeStaticTest.php
Normal 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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user