- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
477 lines
15 KiB
PHP
477 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Framework\DateTime\Clock;
|
|
use App\Framework\Http\Headers;
|
|
use App\Framework\Http\HttpRequest;
|
|
use App\Framework\Http\HttpResponse;
|
|
use App\Framework\Http\Method;
|
|
use App\Framework\Http\MiddlewareContext;
|
|
use App\Framework\Http\Middlewares\CsrfMiddleware;
|
|
use App\Framework\Http\Next;
|
|
use App\Framework\Http\RequestBody;
|
|
use App\Framework\Http\RequestStateManager;
|
|
use App\Framework\Http\Session\SessionId;
|
|
use App\Framework\Http\Session\SessionInterface;
|
|
use App\Framework\Http\Status;
|
|
use App\Framework\Security\CsrfToken;
|
|
use App\Framework\Security\CsrfTokenGenerator;
|
|
|
|
beforeEach(function () {
|
|
// Create test CSRF protection mock
|
|
$this->csrfProtection = new class () {
|
|
public array $validatedTokens = [];
|
|
|
|
public bool $shouldValidate = true;
|
|
|
|
public function validateToken(string $formId, CsrfToken $token): bool
|
|
{
|
|
$this->validatedTokens[] = ['formId' => $formId, 'token' => $token->toString()];
|
|
|
|
return $this->shouldValidate;
|
|
}
|
|
|
|
public function rotateToken(string $formId): CsrfToken
|
|
{
|
|
return CsrfToken::fromString(str_repeat('a', 64));
|
|
}
|
|
};
|
|
|
|
// Create test session
|
|
$this->session = new class ($this->csrfProtection) implements SessionInterface {
|
|
public function __construct(public $csrf)
|
|
{
|
|
}
|
|
|
|
public function get(string $key, mixed $default = null): mixed
|
|
{
|
|
return $default;
|
|
}
|
|
|
|
public function set(string $key, mixed $value): void
|
|
{
|
|
}
|
|
|
|
public function has(string $key): bool
|
|
{
|
|
return false;
|
|
}
|
|
|
|
public function remove(string $key): void
|
|
{
|
|
}
|
|
|
|
public function clear(): void
|
|
{
|
|
}
|
|
|
|
public function all(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
public static function fromArray(SessionId $sessionId, Clock $clock, CsrfTokenGenerator $csrfTokenGenerator, array $data): self
|
|
{
|
|
return new self(new class () {
|
|
public function validateToken(string $formId, CsrfToken $token): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
public function rotateToken(string $formId): CsrfToken
|
|
{
|
|
return CsrfToken::fromString(str_repeat('a', 64));
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
// Create test container mock
|
|
$this->container = new class ($this->session) implements \App\Framework\DI\Container {
|
|
public \App\Framework\DI\MethodInvoker $invoker {
|
|
get => new \App\Framework\DI\MethodInvoker(new \App\Framework\DI\DependencyResolver(new \App\Framework\DI\DefaultContainer()));
|
|
}
|
|
|
|
public function __construct(private $session)
|
|
{
|
|
}
|
|
|
|
public function get(string $class): object
|
|
{
|
|
if ($class === \App\Framework\Http\Session\SessionInterface::class) {
|
|
return $this->session;
|
|
}
|
|
|
|
throw new \RuntimeException("Service not found: $class");
|
|
}
|
|
|
|
public function has(string $class): bool
|
|
{
|
|
return $class === \App\Framework\Http\Session\SessionInterface::class;
|
|
}
|
|
|
|
public function bind(string $abstract, callable|string|object $concrete): void
|
|
{
|
|
}
|
|
|
|
public function singleton(string $abstract, callable|string|object $concrete): void
|
|
{
|
|
}
|
|
|
|
public function instance(string $abstract, object $instance): void
|
|
{
|
|
}
|
|
|
|
public function forget(string $class): void
|
|
{
|
|
}
|
|
};
|
|
|
|
$this->middleware = new CsrfMiddleware($this->container);
|
|
|
|
// Create test request
|
|
$this->getRequest = new HttpRequest(
|
|
method: Method::GET,
|
|
path: '/test'
|
|
);
|
|
|
|
$this->stateManager = new RequestStateManager(new WeakMap(), $this->getRequest);
|
|
});
|
|
|
|
it('allows GET requests without CSRF validation', function () {
|
|
$context = new MiddlewareContext($this->getRequest);
|
|
|
|
$next = new class () implements Next {
|
|
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
|
{
|
|
return $context->withResponse(new HttpResponse(Status::OK, new Headers(), 'success'));
|
|
}
|
|
};
|
|
|
|
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
|
|
|
|
expect($result->hasResponse())->toBeTrue();
|
|
expect($result->response->status)->toBe(Status::OK);
|
|
expect($this->csrfProtection->validatedTokens)->toBeEmpty();
|
|
});
|
|
|
|
it('validates CSRF token for POST requests', function () {
|
|
// Create POST request with CSRF data
|
|
// Use multipart/form-data content type so the $post array is used
|
|
$headers = new Headers([
|
|
'Content-Type' => 'multipart/form-data',
|
|
]);
|
|
|
|
$requestBody = new RequestBody(Method::POST, $headers, '', [
|
|
'_form_id' => 'contact-form',
|
|
'_token' => str_repeat('b', 64),
|
|
]);
|
|
|
|
$postRequest = new HttpRequest(
|
|
method: Method::POST,
|
|
headers: $headers,
|
|
path: '/contact',
|
|
parsedBody: $requestBody
|
|
);
|
|
|
|
$context = new MiddlewareContext($postRequest);
|
|
$stateManager = new RequestStateManager(new WeakMap(), $postRequest);
|
|
|
|
$next = new class () implements Next {
|
|
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
|
{
|
|
return $context->withResponse(new HttpResponse(Status::OK, new Headers(), 'success'));
|
|
}
|
|
};
|
|
|
|
$result = $this->middleware->__invoke($context, $next, $stateManager);
|
|
|
|
expect($result->hasResponse())->toBeTrue();
|
|
expect($result->response->status)->toBe(Status::OK);
|
|
expect($this->csrfProtection->validatedTokens)->toHaveCount(1);
|
|
expect($this->csrfProtection->validatedTokens[0]['formId'])->toBe('contact-form');
|
|
expect($this->csrfProtection->validatedTokens[0]['token'])->toBe(str_repeat('b', 64));
|
|
});
|
|
|
|
it('validates CSRF token from headers for POST requests', function () {
|
|
// Create POST request with CSRF data in headers
|
|
$headers = new Headers([
|
|
'X-CSRF-Form-ID' => 'api-form',
|
|
'X-CSRF-Token' => str_repeat('c', 64),
|
|
'Content-Type' => 'application/json',
|
|
]);
|
|
|
|
$postRequest = new HttpRequest(
|
|
method: Method::POST,
|
|
headers: $headers,
|
|
path: '/api/submit',
|
|
parsedBody: new RequestBody(Method::POST, $headers, '{"data": "test"}', [])
|
|
);
|
|
|
|
$context = new MiddlewareContext($postRequest);
|
|
$stateManager = new RequestStateManager(new WeakMap(), $postRequest);
|
|
|
|
$next = new class () implements Next {
|
|
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
|
{
|
|
return $context->withResponse(new HttpResponse(Status::OK, new Headers(), 'success'));
|
|
}
|
|
};
|
|
|
|
$result = $this->middleware->__invoke($context, $next, $stateManager);
|
|
|
|
expect($result->hasResponse())->toBeTrue();
|
|
expect($this->csrfProtection->validatedTokens)->toHaveCount(1);
|
|
expect($this->csrfProtection->validatedTokens[0]['formId'])->toBe('api-form');
|
|
expect($this->csrfProtection->validatedTokens[0]['token'])->toBe(str_repeat('c', 64));
|
|
});
|
|
|
|
it('validates CSRF token for PUT requests', function () {
|
|
$headers = new Headers([
|
|
'Content-Type' => 'multipart/form-data',
|
|
]);
|
|
|
|
$requestBody = new RequestBody(Method::PUT, $headers, '', [
|
|
'_form_id' => 'update-form',
|
|
'_token' => str_repeat('d', 64),
|
|
]);
|
|
|
|
$putRequest = new HttpRequest(
|
|
method: Method::PUT,
|
|
headers: $headers,
|
|
path: '/update',
|
|
parsedBody: $requestBody
|
|
);
|
|
|
|
$context = new MiddlewareContext($putRequest);
|
|
$stateManager = new RequestStateManager(new WeakMap(), $putRequest);
|
|
|
|
$next = new class () implements Next {
|
|
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
|
{
|
|
return $context->withResponse(new HttpResponse(Status::OK, new Headers(), 'success'));
|
|
}
|
|
};
|
|
|
|
$result = $this->middleware->__invoke($context, $next, $stateManager);
|
|
|
|
expect($this->csrfProtection->validatedTokens)->toHaveCount(1);
|
|
expect($this->csrfProtection->validatedTokens[0]['formId'])->toBe('update-form');
|
|
});
|
|
|
|
it('validates CSRF token for DELETE requests', function () {
|
|
$headers = new Headers([
|
|
'Content-Type' => 'multipart/form-data',
|
|
]);
|
|
|
|
$requestBody = new RequestBody(Method::DELETE, $headers, '', [
|
|
'_form_id' => 'delete-form',
|
|
'_token' => str_repeat('e', 64),
|
|
]);
|
|
|
|
$deleteRequest = new HttpRequest(
|
|
method: Method::DELETE,
|
|
headers: $headers,
|
|
path: '/delete',
|
|
parsedBody: $requestBody
|
|
);
|
|
|
|
$context = new MiddlewareContext($deleteRequest);
|
|
$stateManager = new RequestStateManager(new WeakMap(), $deleteRequest);
|
|
|
|
$next = new class () implements Next {
|
|
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
|
{
|
|
return $context->withResponse(new HttpResponse(Status::OK, new Headers(), 'success'));
|
|
}
|
|
};
|
|
|
|
$result = $this->middleware->__invoke($context, $next, $stateManager);
|
|
|
|
expect($this->csrfProtection->validatedTokens)->toHaveCount(1);
|
|
expect($this->csrfProtection->validatedTokens[0]['formId'])->toBe('delete-form');
|
|
});
|
|
|
|
it('skips CSRF validation when session is not available', function () {
|
|
// Create container that throws exception when getting session
|
|
$failingContainer = new class () implements \App\Framework\DI\Container {
|
|
public \App\Framework\DI\MethodInvoker $invoker {
|
|
get => new \App\Framework\DI\MethodInvoker(new \App\Framework\DI\DependencyResolver(new \App\Framework\DI\DefaultContainer()));
|
|
}
|
|
|
|
public function get(string $class): object
|
|
{
|
|
throw new \RuntimeException("Service not found: $class");
|
|
}
|
|
|
|
public function has(string $class): bool
|
|
{
|
|
return false;
|
|
}
|
|
|
|
public function bind(string $abstract, callable|string|object $concrete): void
|
|
{
|
|
}
|
|
|
|
public function singleton(string $abstract, callable|string|object $concrete): void
|
|
{
|
|
}
|
|
|
|
public function instance(string $abstract, object $instance): void
|
|
{
|
|
}
|
|
|
|
public function forget(string $class): void
|
|
{
|
|
}
|
|
};
|
|
|
|
$middleware = new CsrfMiddleware($failingContainer);
|
|
|
|
$postRequest = new HttpRequest(
|
|
method: Method::POST,
|
|
path: '/test',
|
|
parsedBody: new RequestBody(Method::POST, new Headers(), '', [])
|
|
);
|
|
|
|
$context = new MiddlewareContext($postRequest);
|
|
$stateManager = new RequestStateManager(new WeakMap(), $postRequest);
|
|
|
|
$next = new class () implements Next {
|
|
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
|
{
|
|
return $context->withResponse(new HttpResponse(Status::OK, new Headers(), 'success'));
|
|
}
|
|
};
|
|
|
|
$result = $middleware->__invoke($context, $next, $stateManager);
|
|
|
|
// Should skip CSRF validation and proceed
|
|
expect($result->hasResponse())->toBeTrue();
|
|
expect($result->response->status)->toBe(Status::OK);
|
|
});
|
|
|
|
it('throws exception when form ID is missing', function () {
|
|
$headers = new Headers([
|
|
'Content-Type' => 'multipart/form-data',
|
|
]);
|
|
|
|
$requestBody = new RequestBody(Method::POST, $headers, '', [
|
|
'_token' => str_repeat('f', 64),
|
|
// Missing _form_id
|
|
]);
|
|
|
|
$postRequest = new HttpRequest(
|
|
method: Method::POST,
|
|
headers: $headers,
|
|
path: '/test',
|
|
parsedBody: $requestBody
|
|
);
|
|
|
|
$context = new MiddlewareContext($postRequest);
|
|
$stateManager = new RequestStateManager(new WeakMap(), $postRequest);
|
|
|
|
$next = new class () implements Next {
|
|
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
|
{
|
|
return $context;
|
|
}
|
|
};
|
|
|
|
$this->middleware->__invoke($context, $next, $stateManager);
|
|
})->throws(InvalidArgumentException::class, 'CSRF protection requires both form ID and token');
|
|
|
|
it('throws exception when token is missing', function () {
|
|
$headers = new Headers([
|
|
'Content-Type' => 'multipart/form-data',
|
|
]);
|
|
|
|
$requestBody = new RequestBody(Method::POST, $headers, '', [
|
|
'_form_id' => 'test-form',
|
|
// Missing _token
|
|
]);
|
|
|
|
$postRequest = new HttpRequest(
|
|
method: Method::POST,
|
|
headers: $headers,
|
|
path: '/test',
|
|
parsedBody: $requestBody
|
|
);
|
|
|
|
$context = new MiddlewareContext($postRequest);
|
|
$stateManager = new RequestStateManager(new WeakMap(), $postRequest);
|
|
|
|
$next = new class () implements Next {
|
|
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
|
{
|
|
return $context;
|
|
}
|
|
};
|
|
|
|
$this->middleware->__invoke($context, $next, $stateManager);
|
|
})->throws(InvalidArgumentException::class, 'CSRF protection requires both form ID and token');
|
|
|
|
it('throws exception when token validation fails', function () {
|
|
// Set CSRF protection to fail validation
|
|
$this->csrfProtection->shouldValidate = false;
|
|
|
|
$headers = new Headers([
|
|
'Content-Type' => 'multipart/form-data',
|
|
]);
|
|
|
|
$requestBody = new RequestBody(Method::POST, $headers, '', [
|
|
'_form_id' => 'test-form',
|
|
'_token' => str_repeat('f', 64),
|
|
]);
|
|
|
|
$postRequest = new HttpRequest(
|
|
method: Method::POST,
|
|
headers: $headers,
|
|
path: '/test',
|
|
parsedBody: $requestBody
|
|
);
|
|
|
|
$context = new MiddlewareContext($postRequest);
|
|
$stateManager = new RequestStateManager(new WeakMap(), $postRequest);
|
|
|
|
$next = new class () implements Next {
|
|
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
|
{
|
|
return $context;
|
|
}
|
|
};
|
|
|
|
$this->middleware->__invoke($context, $next, $stateManager);
|
|
})->throws(RuntimeException::class, 'CSRF token validation failed. This may indicate a security threat.');
|
|
|
|
it('throws exception for invalid token format', function () {
|
|
$headers = new Headers([
|
|
'Content-Type' => 'multipart/form-data',
|
|
]);
|
|
|
|
$requestBody = new RequestBody(Method::POST, $headers, '', [
|
|
'_form_id' => 'test-form',
|
|
'_token' => 'invalid-token', // Too short and not hex
|
|
]);
|
|
|
|
$postRequest = new HttpRequest(
|
|
method: Method::POST,
|
|
headers: $headers,
|
|
path: '/test',
|
|
parsedBody: $requestBody
|
|
);
|
|
|
|
$context = new MiddlewareContext($postRequest);
|
|
$stateManager = new RequestStateManager(new WeakMap(), $postRequest);
|
|
|
|
$next = new class () implements Next {
|
|
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
|
{
|
|
return $context;
|
|
}
|
|
};
|
|
|
|
$this->middleware->__invoke($context, $next, $stateManager);
|
|
})->throws(InvalidArgumentException::class, 'Invalid CSRF token format');
|