diff --git a/app/Http/RequestHandlers/AddChildToFamilyAction.php b/app/Http/RequestHandlers/AddChildToFamilyAction.php index 564a0045327..d6b42a4bac4 100644 --- a/app/Http/RequestHandlers/AddChildToFamilyAction.php +++ b/app/Http/RequestHandlers/AddChildToFamilyAction.php @@ -23,6 +23,7 @@ use Fisharebest\Webtrees\Registry; use Fisharebest\Webtrees\Services\GedcomEditService; use Fisharebest\Webtrees\Tree; +use Fisharebest\Webtrees\Validator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -78,7 +79,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $family->createFact('1 CHIL @' . $child->xref() . '@', false); $base_url = $request->getAttribute('base_url'); - $url = str_starts_with($params['url'], $base_url) ? $params['url'] : $child->url(); + $url = Validator::parsedBody($request)->localUrl($base_url)->string('url') ?? $child->url(); return redirect($url); } diff --git a/app/Http/RequestHandlers/AddChildToIndividualAction.php b/app/Http/RequestHandlers/AddChildToIndividualAction.php index 48e0c77b9aa..c2b4a33e1f4 100644 --- a/app/Http/RequestHandlers/AddChildToIndividualAction.php +++ b/app/Http/RequestHandlers/AddChildToIndividualAction.php @@ -23,6 +23,7 @@ use Fisharebest\Webtrees\Registry; use Fisharebest\Webtrees\Services\GedcomEditService; use Fisharebest\Webtrees\Tree; +use Fisharebest\Webtrees\Validator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -86,7 +87,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $child->createFact('1 FAMC @' . $family->xref() . '@', false); $base_url = $request->getAttribute('base_url'); - $url = str_starts_with($params['url'], $base_url) ? $params['url'] : $child->url(); + $url = Validator::parsedBody($request)->localUrl($base_url)->string('url') ?? $child->url(); return redirect($url); } diff --git a/app/Http/RequestHandlers/AddParentToIndividualAction.php b/app/Http/RequestHandlers/AddParentToIndividualAction.php index 0b7ecdd0fb4..8d19d88c761 100644 --- a/app/Http/RequestHandlers/AddParentToIndividualAction.php +++ b/app/Http/RequestHandlers/AddParentToIndividualAction.php @@ -23,6 +23,7 @@ use Fisharebest\Webtrees\Registry; use Fisharebest\Webtrees\Services\GedcomEditService; use Fisharebest\Webtrees\Tree; +use Fisharebest\Webtrees\Validator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -86,7 +87,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $parent->createFact('1 FAMS @' . $family->xref() . '@', false); $base_url = $request->getAttribute('base_url'); - $url = str_starts_with($params['url'], $base_url) ? $params['url'] : $parent->url(); + $url = Validator::parsedBody($request)->localUrl($base_url)->string('url') ?? $parent->url(); return redirect($url); } diff --git a/app/Http/RequestHandlers/AddSpouseToFamilyAction.php b/app/Http/RequestHandlers/AddSpouseToFamilyAction.php index 80755cb653d..79e9e4e0999 100644 --- a/app/Http/RequestHandlers/AddSpouseToFamilyAction.php +++ b/app/Http/RequestHandlers/AddSpouseToFamilyAction.php @@ -23,6 +23,7 @@ use Fisharebest\Webtrees\Registry; use Fisharebest\Webtrees\Services\GedcomEditService; use Fisharebest\Webtrees\Tree; +use Fisharebest\Webtrees\Validator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -95,7 +96,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $family->createFact('1 ' . $link . ' @' . $spouse->xref() . '@', false); $base_url = $request->getAttribute('base_url'); - $url = str_starts_with($params['url'], $base_url) ? $params['url'] : $spouse->url(); + $url = Validator::parsedBody($request)->localUrl($base_url)->string('url') ?? $spouse->url(); return redirect($url); } diff --git a/app/Http/RequestHandlers/AddSpouseToIndividualAction.php b/app/Http/RequestHandlers/AddSpouseToIndividualAction.php index e9e2547f1e1..05565649540 100644 --- a/app/Http/RequestHandlers/AddSpouseToIndividualAction.php +++ b/app/Http/RequestHandlers/AddSpouseToIndividualAction.php @@ -23,6 +23,7 @@ use Fisharebest\Webtrees\Registry; use Fisharebest\Webtrees\Services\GedcomEditService; use Fisharebest\Webtrees\Tree; +use Fisharebest\Webtrees\Validator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -89,7 +90,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $spouse->createFact('1 FAMS @' . $family->xref() . '@', false); $base_url = $request->getAttribute('base_url'); - $url = str_starts_with($params['url'], $base_url) ? $params['url'] : $spouse->url(); + $url = Validator::parsedBody($request)->localUrl($base_url)->string('url') ?? $spouse->url(); return redirect($url); } diff --git a/app/Http/RequestHandlers/AddUnlinkedAction.php b/app/Http/RequestHandlers/AddUnlinkedAction.php index 81e7949cc84..51e7437ad2c 100644 --- a/app/Http/RequestHandlers/AddUnlinkedAction.php +++ b/app/Http/RequestHandlers/AddUnlinkedAction.php @@ -21,6 +21,7 @@ use Fisharebest\Webtrees\Services\GedcomEditService; use Fisharebest\Webtrees\Tree; +use Fisharebest\Webtrees\Validator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -66,7 +67,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $individual = $tree->createIndividual("0 @@ INDI\n" . $gedcom); $base_url = $request->getAttribute('base_url'); - $url = str_starts_with($params['url'], $base_url) ? $params['url'] : $individual->url(); + $url = Validator::parsedBody($request)->localUrl($base_url)->string('url') ?? $individual->url(); return redirect($url); } diff --git a/app/Http/RequestHandlers/ContactAction.php b/app/Http/RequestHandlers/ContactAction.php index 5a88657c03e..a60019f13b3 100644 --- a/app/Http/RequestHandlers/ContactAction.php +++ b/app/Http/RequestHandlers/ContactAction.php @@ -30,6 +30,7 @@ use Fisharebest\Webtrees\Services\MessageService; use Fisharebest\Webtrees\Services\UserService; use Fisharebest\Webtrees\Tree; +use Fisharebest\Webtrees\Validator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -87,13 +88,13 @@ public function handle(ServerRequestInterface $request): ResponseInterface $tree = $request->getAttribute('tree'); assert($tree instanceof Tree); - $params = (array) $request->getParsedBody(); - $body = $params['body']; - $from_email = $params['from_email']; - $from_name = $params['from_name']; - $subject = $params['subject']; - $to = $params['to']; - $url = $params['url']; + $base_url = $request->getAttribute('base_url'); + $body = Validator::parsedBody($request)->string('body') ?? ''; + $from_email = Validator::parsedBody($request)->string('from_email') ?? ''; + $from_name = Validator::parsedBody($request)->string('from_name') ?? ''; + $subject = Validator::parsedBody($request)->string('subject') ?? ''; + $to = Validator::parsedBody($request)->string('to') ?? ''; + $url = Validator::parsedBody($request)->localUrl($base_url)->string('url') ?? ''; $ip = $request->getAttribute('client-ip'); $to_user = $this->user_service->findByUserName($to); @@ -117,8 +118,6 @@ public function handle(ServerRequestInterface $request): ResponseInterface $errors = true; } - $base_url = $request->getAttribute('base_url'); - if (preg_match('/(?!' . preg_quote($base_url, '/') . ')(((?:ftp|http|https):\/\/)[a-zA-Z0-9.-]+)/', $subject . $body, $match)) { FlashMessages::addMessage(I18N::translate('You are not allowed to send messages that contain external links.') . ' ' . /* I18N: e.g. ‘You should delete the “https://” from “https://www.example.com” and try again.’ */ I18N::translate('You should delete the “%1$s” from “%2$s” and try again.', $match[2], $match[1]), 'danger'); diff --git a/app/Http/RequestHandlers/EditFactAction.php b/app/Http/RequestHandlers/EditFactAction.php index b31713a4ef3..8bb0967ed5d 100644 --- a/app/Http/RequestHandlers/EditFactAction.php +++ b/app/Http/RequestHandlers/EditFactAction.php @@ -26,6 +26,7 @@ use Fisharebest\Webtrees\Services\GedcomEditService; use Fisharebest\Webtrees\Services\ModuleService; use Fisharebest\Webtrees\Tree; +use Fisharebest\Webtrees\Validator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -115,7 +116,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface } $base_url = $request->getAttribute('base_url'); - $url = str_starts_with($params['url'], $base_url) ? $params['url'] : $record->url(); + $url = Validator::parsedBody($request)->localUrl($base_url)->string('url') ?? $record->url(); return redirect($url); } diff --git a/app/Http/RequestHandlers/EditRawFactAction.php b/app/Http/RequestHandlers/EditRawFactAction.php index 7f86ff8c527..6b5b67f0c57 100644 --- a/app/Http/RequestHandlers/EditRawFactAction.php +++ b/app/Http/RequestHandlers/EditRawFactAction.php @@ -22,6 +22,7 @@ use Fisharebest\Webtrees\Auth; use Fisharebest\Webtrees\Registry; use Fisharebest\Webtrees\Tree; +use Fisharebest\Webtrees\Validator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -73,7 +74,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface } $base_url = $request->getAttribute('base_url'); - $url = str_starts_with($params['url'], $base_url) ? $params['url'] : $record->url(); + $url = Validator::parsedBody($request)->localUrl($base_url)->string('url') ?? $record->url(); return redirect($url); } diff --git a/app/Http/RequestHandlers/EmptyClipboard.php b/app/Http/RequestHandlers/EmptyClipboard.php index 4f50ae1c44b..b7115ffd0a0 100644 --- a/app/Http/RequestHandlers/EmptyClipboard.php +++ b/app/Http/RequestHandlers/EmptyClipboard.php @@ -19,16 +19,12 @@ namespace Fisharebest\Webtrees\Http\RequestHandlers; -use Fisharebest\Webtrees\Auth; -use Fisharebest\Webtrees\Registry; use Fisharebest\Webtrees\Services\ClipboardService; -use Fisharebest\Webtrees\Tree; +use Fisharebest\Webtrees\Validator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; -use function assert; -use function is_string; use function redirect; /** @@ -57,12 +53,11 @@ public function __construct(ClipboardService $clipboard_service) */ public function handle(ServerRequestInterface $request): ResponseInterface { - $params = (array) $request->getParsedBody(); - $this->clipboard_service->emptyClipboard(); - $base_url = $request->getAttribute('base_url'); - $url = str_starts_with($params['url'], $base_url) ? $params['url'] : $request->getHeaderLine('Referer'); + $base_url = $request->getAttribute('base_url'); + $default_url = $request->getHeaderLine('Referer'); + $url = Validator::parsedBody($request)->localUrl($base_url)->string('url') ?? $default_url; return redirect($url); } diff --git a/app/Http/RequestHandlers/LoginAction.php b/app/Http/RequestHandlers/LoginAction.php index a3a95412109..ab8574882bb 100644 --- a/app/Http/RequestHandlers/LoginAction.php +++ b/app/Http/RequestHandlers/LoginAction.php @@ -30,6 +30,7 @@ use Fisharebest\Webtrees\Services\UserService; use Fisharebest\Webtrees\Session; use Fisharebest\Webtrees\Tree; +use Fisharebest\Webtrees\Validator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -66,13 +67,12 @@ public function __construct(UpgradeService $upgrade_service, UserService $user_s */ public function handle(ServerRequestInterface $request): ResponseInterface { - $tree = $request->getAttribute('tree'); - - $params = (array) $request->getParsedBody(); - - $username = $params['username']; - $password = $params['password']; - $url = $params['url']; + $tree = $request->getAttribute('tree'); + $base_url = $request->getAttribute('base_url'); + $default_url = route(HomePage::class); + $username = Validator::parsedBody($request)->string('username') ?? ''; + $password = Validator::parsedBody($request)->string('password') ?? ''; + $url = Validator::parsedBody($request)->localUrl($base_url)->string('url') ?? $default_url; try { $this->doLogin($username, $password); @@ -82,9 +82,6 @@ public function handle(ServerRequestInterface $request): ResponseInterface } // Redirect to the target URL - $base_url = $request->getAttribute('base_url'); - $url = str_starts_with($url, $base_url) ? $url : route(HomePage::class); - return redirect($url); } catch (Exception $ex) { // Failed to log in. diff --git a/app/Http/RequestHandlers/MessageAction.php b/app/Http/RequestHandlers/MessageAction.php index 6954125aaf9..56c35d37730 100644 --- a/app/Http/RequestHandlers/MessageAction.php +++ b/app/Http/RequestHandlers/MessageAction.php @@ -27,6 +27,7 @@ use Fisharebest\Webtrees\Services\MessageService; use Fisharebest\Webtrees\Services\UserService; use Fisharebest\Webtrees\Tree; +use Fisharebest\Webtrees\Validator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -69,14 +70,16 @@ public function handle(ServerRequestInterface $request): ResponseInterface $tree = $request->getAttribute('tree'); assert($tree instanceof Tree); - $user = $request->getAttribute('user'); - $params = (array) $request->getParsedBody(); - $body = $params['body']; - $subject = $params['subject']; - $to = $params['to']; - $url = $params['url']; - $to_user = $this->user_service->findByUserName($to); - $ip = $request->getAttribute('client-ip'); + $user = $request->getAttribute('user'); + $params = (array) $request->getParsedBody(); + $body = $params['body']; + $subject = $params['subject']; + $to = $params['to']; + $to_user = $this->user_service->findByUserName($to); + $ip = $request->getAttribute('client-ip'); + $base_url = $request->getAttribute('base_url'); + $default_url = route(TreePage::class, ['tree' => $tree->name()]); + $url = Validator::parsedBody($request)->localUrl($base_url)->string('url') ?? $default_url; if ($to_user === null || $to_user->getPreference(UserInterface::PREF_CONTACT_METHOD) === 'none') { throw new HttpAccessDeniedException('Invalid contact user id'); @@ -95,9 +98,6 @@ public function handle(ServerRequestInterface $request): ResponseInterface if ($this->message_service->deliverMessage($user, $to_user, $subject, $body, $url, $ip)) { FlashMessages::addMessage(I18N::translate('The message was successfully sent to %s.', e($to_user->realName())), 'success'); - $base_url = $request->getAttribute('base_url'); - $url = str_starts_with($url, $base_url) ? $url : route(TreePage::class, ['tree' => $tree->name()]); - return redirect($url); } diff --git a/app/Validator.php b/app/Validator.php new file mode 100644 index 00000000000..57bd57a0979 --- /dev/null +++ b/app/Validator.php @@ -0,0 +1,177 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees; + +use Closure; +use LogicException; +use Psr\Http\Message\ServerRequestInterface; + +use function array_reduce; +use function ctype_digit; +use function is_array; +use function is_int; +use function is_string; +use function parse_url; +use function str_starts_with; + +/** + * Validate a parameter from an HTTP request + */ +class Validator +{ + /** @var array */ + private array $parameters; + + /** @var array */ + private array $rules = []; + + /** + * @param array $parameters + */ + public function __construct(array $parameters) + { + $this->parameters = $parameters; + } + + /** + * @param ServerRequestInterface $request + * + * @return self + */ + public static function parsedBody(ServerRequestInterface $request): self + { + return new self((array) $request->getParsedBody()); + } + + /** + * @param ServerRequestInterface $request + * + * @return self + */ + public static function queryParams(ServerRequestInterface $request): self + { + return new self($request->getQueryParams()); + } + + /** + * @param int $minimum + * @param int $maximum + * + * @return self + */ + public function isBetween(int $minimum, int $maximum): self + { + $this->rules[] = static function ($value) use ($minimum, $maximum): ?int { + if (is_int($value)) { + if ($value < $minimum || $value > $maximum) { + return null; + } + + return $value; + } + + throw new LogicException('Validator::isBetween() can only be used for integers'); + }; + + return $this; + } + + public function localUrl(string $base_url): self + { + $this->rules[] = static function ($value) use ($base_url): ?string { + if (is_string($value)) { + $value_info = parse_url($value); + $base_url_info = parse_url($base_url); + + if (!is_array($base_url_info)) { + throw new LogicException(__METHOD__ . ' needs a valid URL'); + } + + if (is_array($value_info)) { + $scheme_ok = ($value_info['scheme'] ?? 'http') === ($base_url_info['scheme'] ?? 'http'); + $host_ok = ($value_info['host'] ?? '') === ($base_url_info['host'] ?? ''); + $port_ok = ($value_info['port'] ?? '') === ($base_url_info['port'] ?? ''); + $user_ok = ($value_info['user'] ?? '') === ($base_url_info['user'] ?? ''); + $path_ok = str_starts_with($value_info['path'] ?? '/', $base_url_info['path'] ?? '/'); + + if ($scheme_ok && $host_ok && $port_ok && $user_ok && $path_ok) { + return $value; + } + } + + return null; + } + + throw new LogicException(__METHOD__ . ' can only be used for strings'); + }; + + return $this; + } + + /** + * @param string $parameter + * + * @return array + */ + public function array(string $parameter): array + { + $value = $this->parameters[$parameter] ?? null; + + if (!is_array($value)) { + $value = []; + } + + return array_reduce($this->rules, static fn($rule) => $rule($value), $value); + } + + /** + * @param string $parameter + * + * @return int|null + */ + public function integer(string $parameter): ?int + { + $value = $this->parameters[$parameter] ?? null; + + if (is_string($value) && ctype_digit($value)) { + $value = (int) $value; + } else { + $value = null; + } + + return array_reduce($this->rules, static fn($rule) => $rule($value), $value); + } + + /** + * @param string $parameter + * + * @return string|null + */ + public function string(string $parameter): ?string + { + $value = $this->parameters[$parameter] ?? null; + + if (!is_string($value)) { + $value = null; + } + + return array_reduce($this->rules, static fn($value, $rule) => $rule($value), $value); + } +}