Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable AppFramework dispatcher to enforce integer ranges #41578

Merged
merged 3 commits into from
Nov 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/dashboard/lib/Controller/DashboardApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ public function getWidgetItems(array $sinceIds = [], int $limit = 7, array $widg
* Get the items for the widgets
*
* @param array<string, string> $sinceIds Array indexed by widget Ids, contains date/id from which we want the new items
* @param int $limit Limit number of result items per widget
* @param int $limit Limit number of result items per widget, not more than 30 are allowed
* @psalm-param int<1, 30> $limit
* @param string[] $widgets Limit results to specific widgets
* @return DataResponse<Http::STATUS_OK, array<string, DashboardWidgetItems>, array{}>
*
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@
{
"name": "limit",
"in": "query",
"description": "Limit number of result items per widget",
"description": "Limit number of result items per widget, not more than 30 are allowed",
"schema": {
"type": "integer",
"format": "int64",
Expand Down
22 changes: 21 additions & 1 deletion lib/private/AppFramework/Http/Dispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
use OCP\Diagnostics\IEventLogger;
use OCP\IConfig;
use OCP\IRequest;
use OutOfRangeException;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;

Expand Down Expand Up @@ -197,7 +198,7 @@ public function dispatch(Controller $controller, string $methodName): array {
private function executeController(Controller $controller, string $methodName): Response {
$arguments = [];

// valid types that will be casted
// valid types that will be cast
$types = ['int', 'integer', 'bool', 'boolean', 'float', 'double'];

foreach ($this->reflector->getParameters() as $param => $default) {
Expand All @@ -219,6 +220,7 @@ private function executeController(Controller $controller, string $methodName):
$value = false;
} elseif ($value !== null && \in_array($type, $types, true)) {
settype($value, $type);
$this->ensureParameterValueSatisfiesRange($param, $value);
} elseif ($value === null && $type !== null && $this->appContainer->has($type)) {
$value = $this->appContainer->get($type);
}
Expand Down Expand Up @@ -250,4 +252,22 @@ private function executeController(Controller $controller, string $methodName):

return $response;
}

/**
* @psalm-param mixed $value
* @throws OutOfRangeException
*/
private function ensureParameterValueSatisfiesRange(string $param, $value): void {
$rangeInfo = $this->reflector->getRange($param);
if ($rangeInfo) {
if ($value < $rangeInfo['min'] || $value > $rangeInfo['max']) {
throw new OutOfRangeException(sprintf(
'Parameter %s must be between %d and %d',
$param,
$rangeInfo['min'],
$rangeInfo['max'],
));
}
}
}
}
33 changes: 27 additions & 6 deletions lib/private/AppFramework/Utility/ControllerMethodReflector.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class ControllerMethodReflector implements IControllerMethodReflector {
public $annotations = [];
private $types = [];
private $parameters = [];
private array $ranges = [];

/**
* @param object $object an object or classname
Expand All @@ -54,26 +55,38 @@ public function reflect($object, string $method) {
if ($docs !== false) {
// extract everything prefixed by @ and first letter uppercase
preg_match_all('/^\h+\*\h+@(?P<annotation>[A-Z]\w+)((?P<parameter>.*))?$/m', $docs, $matches);
foreach ($matches['annotation'] as $key => $annontation) {
$annontation = strtolower($annontation);
foreach ($matches['annotation'] as $key => $annotation) {
$annotation = strtolower($annotation);
$annotationValue = $matches['parameter'][$key];
if (isset($annotationValue[0]) && $annotationValue[0] === '(' && $annotationValue[\strlen($annotationValue) - 1] === ')') {
$cutString = substr($annotationValue, 1, -1);
$cutString = str_replace(' ', '', $cutString);
$splittedArray = explode(',', $cutString);
foreach ($splittedArray as $annotationValues) {
$splitArray = explode(',', $cutString);
foreach ($splitArray as $annotationValues) {
[$key, $value] = explode('=', $annotationValues);
$this->annotations[$annontation][$key] = $value;
$this->annotations[$annotation][$key] = $value;
}
continue;
}

$this->annotations[$annontation] = [$annotationValue];
$this->annotations[$annotation] = [$annotationValue];
}

// extract type parameter information
preg_match_all('/@param\h+(?P<type>\w+)\h+\$(?P<var>\w+)/', $docs, $matches);
$this->types = array_combine($matches['var'], $matches['type']);
preg_match_all('/@psalm-param\h+(?P<type>\w+)<(?P<rangeMin>(-?\d+|min)),\h*(?P<rangeMax>(-?\d+|max))>\h+\$(?P<var>\w+)/', $docs, $matches);
provokateurin marked this conversation as resolved.
Show resolved Hide resolved
foreach ($matches['var'] as $index => $varName) {
if ($matches['type'][$index] !== 'int') {
// only int ranges are possible at the moment
// @see https://psalm.dev/docs/annotating_code/type_syntax/scalar_types
continue;
}
$this->ranges[$varName] = [
'min' => $matches['rangeMin'][$index] === 'min' ? PHP_INT_MIN : (int)$matches['rangeMin'][$index],
'max' => $matches['rangeMax'][$index] === 'max' ? PHP_INT_MAX : (int)$matches['rangeMax'][$index],
];
}
}

foreach ($reflection->getParameters() as $param) {
Expand Down Expand Up @@ -106,6 +119,14 @@ public function getType(string $parameter) {
return null;
}

public function getRange(string $parameter): ?array {
if (array_key_exists($parameter, $this->ranges)) {
return $this->ranges[$parameter];
}

return null;
}

/**
* @return array the arguments of the method with key => default value
*/
Expand Down
47 changes: 47 additions & 0 deletions tests/lib/AppFramework/Http/DispatcherTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -522,4 +522,51 @@ public function testResponsePrimarilyTransformedByParameterFormat() {

$this->assertEquals('{"text":[3,true,4,1]}', $response[3]);
}


public function rangeDataProvider(): array {
return [
[PHP_INT_MIN, PHP_INT_MAX, 42, false],
[0, 12, -5, true],
[-12, 0, 5, true],
[7, 14, 5, true],
[7, 14, 10, false],
[-14, -7, -10, false],
];
}

/**
* @dataProvider rangeDataProvider
*/
public function testEnsureParameterValueSatisfiesRange(int $min, int $max, int $input, bool $throw): void {
$this->reflector = $this->createMock(ControllerMethodReflector::class);
$this->reflector->expects($this->any())
->method('getRange')
->willReturn([
'min' => $min,
'max' => $max,
]);

$this->dispatcher = new Dispatcher(
$this->http,
$this->middlewareDispatcher,
$this->reflector,
$this->request,
$this->config,
\OC::$server->getDatabaseConnection(),
$this->logger,
$this->eventLogger,
$this->container,
);

if ($throw) {
$this->expectException(\OutOfRangeException::class);
}

$this->invokePrivate($this->dispatcher, 'ensureParameterValueSatisfiesRange', ['myArgument', $input]);
if (!$throw) {
// do not mark this test risky
$this->assertTrue(true);
}
}
}
21 changes: 21 additions & 0 deletions tests/lib/AppFramework/Utility/ControllerMethodReflectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ public function test2() {

public function test3() {
}

/**
* @psalm-param int<-4, 42> $rangedOne
* @psalm-param int<min, max> $rangedTwo
* @return void
*/
public function test4(int $rangedOne, int $rangedTwo) {
}
}

class EndController extends MiddleController {
Expand Down Expand Up @@ -234,4 +242,17 @@ public function testInheritanceOverrideNoDocblock() {

$this->assertFalse($reader->hasAnnotation('Annotation'));
}

public function testRangeDetection() {
$reader = new ControllerMethodReflector();
$reader->reflect('Test\AppFramework\Utility\EndController', 'test4');

$rangeInfo1 = $reader->getRange('rangedOne');
$this->assertSame(-4, $rangeInfo1['min']);
$this->assertSame(42, $rangeInfo1['max']);

$rangeInfo2 = $reader->getRange('rangedTwo');
$this->assertSame(PHP_INT_MIN, $rangeInfo2['min']);
$this->assertSame(PHP_INT_MAX, $rangeInfo2['max']);
}
}
Loading