Merge pull request #235 from copenhagentruckwash/investigate-self-serve-path-projection-dos-vulnerability
Clamp self-serve path projection limits
This commit is contained in:
@@ -46,6 +46,11 @@ use objects\selfserve_config_versions_o;
|
||||
|
||||
class selfserve_studio_graph
|
||||
{
|
||||
private const DEFAULT_PATH_MAX_STATES = 2048;
|
||||
private const MAX_PATH_MAX_STATES = 2048;
|
||||
private const DEFAULT_PATH_SAMPLE_LIMIT = 200;
|
||||
private const MAX_PATH_SAMPLE_LIMIT = 200;
|
||||
|
||||
/** @var array<string,array<int,string>> */
|
||||
private array $columnCache = [];
|
||||
|
||||
@@ -678,7 +683,11 @@ class selfserve_studio_graph
|
||||
$vehicleTypeIds[] = null;
|
||||
}
|
||||
|
||||
$maxStates = $this->pathLimit($payload['max_states'] ?? null);
|
||||
$maxStates = $this->pathLimit(
|
||||
$payload['max_states'] ?? null,
|
||||
self::DEFAULT_PATH_MAX_STATES,
|
||||
self::MAX_PATH_MAX_STATES
|
||||
);
|
||||
$reg = trim((string)($payload['reg'] ?? $defaults['reg'] ?? 'TEST123'));
|
||||
if ($reg === '') {
|
||||
$reg = 'TEST123';
|
||||
@@ -694,14 +703,18 @@ class selfserve_studio_graph
|
||||
$stateCount = 0;
|
||||
$terminalPathCount = 0;
|
||||
$questionIds = [];
|
||||
$pathSampleLimit = $this->pathLimit($payload['path_sample_limit'] ?? null);
|
||||
$pathSampleLimit = $this->pathLimit(
|
||||
$payload['path_sample_limit'] ?? null,
|
||||
self::DEFAULT_PATH_SAMPLE_LIMIT,
|
||||
self::MAX_PATH_SAMPLE_LIMIT
|
||||
);
|
||||
$paths = [];
|
||||
$scenarioCount = max(1, count($vehicleTypeIds));
|
||||
$confirmationRows = $this->loadPathConfirmationRows($departmentId, $versionId, $laneId, $vehicleTypeId, $configSource);
|
||||
|
||||
foreach ($vehicleTypeIds as $scenarioIndex => $scenarioVehicleTypeId) {
|
||||
$remainingStates = $maxStates === null ? null : $maxStates - $stateCount;
|
||||
if ($remainingStates !== null && $remainingStates <= 0) {
|
||||
$remainingStates = $maxStates - $stateCount;
|
||||
if ($remainingStates <= 0) {
|
||||
$truncated = true;
|
||||
break;
|
||||
}
|
||||
@@ -759,7 +772,7 @@ class selfserve_studio_graph
|
||||
$projectionOptions = [
|
||||
'scope' => $scenarioScope,
|
||||
'max_states' => $remainingStates,
|
||||
'path_sample_limit' => $pathSampleLimit === null ? null : max(0, $pathSampleLimit - count($paths)),
|
||||
'path_sample_limit' => max(0, $pathSampleLimit - count($paths)),
|
||||
'progress_callback' => function (array $projection) use (
|
||||
$progressCallback,
|
||||
&$outcomes,
|
||||
@@ -787,7 +800,7 @@ class selfserve_studio_graph
|
||||
|
||||
$partialOutcomes = array_merge($outcomes, array_values((array)($projection['outcomes'] ?? [])));
|
||||
$partialPaths = array_merge($paths, array_values((array)($projection['paths'] ?? [])));
|
||||
if ($pathSampleLimit !== null && count($partialPaths) > $pathSampleLimit) {
|
||||
if (count($partialPaths) > $pathSampleLimit) {
|
||||
$partialPaths = array_slice($partialPaths, 0, $pathSampleLimit);
|
||||
}
|
||||
|
||||
@@ -839,12 +852,6 @@ class selfserve_studio_graph
|
||||
},
|
||||
'confirmation_rows' => $confirmationRows,
|
||||
];
|
||||
if ($remainingStates === null) {
|
||||
unset($projectionOptions['max_states']);
|
||||
}
|
||||
if ($pathSampleLimit === null) {
|
||||
unset($projectionOptions['path_sample_limit']);
|
||||
}
|
||||
$projection = $this->projectPathOutcomesFromSimulator($simulate, $projectionOptions);
|
||||
foreach ((array)($projection['outcomes'] ?? []) as $outcome) {
|
||||
if (is_array($outcome)) {
|
||||
@@ -855,7 +862,7 @@ class selfserve_studio_graph
|
||||
if (!is_array($path)) {
|
||||
continue;
|
||||
}
|
||||
if ($pathSampleLimit === null || count($paths) < $pathSampleLimit) {
|
||||
if (count($paths) < $pathSampleLimit) {
|
||||
$paths[] = $path;
|
||||
}
|
||||
}
|
||||
@@ -918,9 +925,18 @@ class selfserve_studio_graph
|
||||
*/
|
||||
public function projectPathOutcomesFromSimulator(callable $simulate, array $options = []): array
|
||||
{
|
||||
$maxStates = $this->pathLimit($options['max_states'] ?? null);
|
||||
$maxStates = $this->pathLimit(
|
||||
$options['max_states'] ?? null,
|
||||
self::DEFAULT_PATH_MAX_STATES,
|
||||
self::MAX_PATH_MAX_STATES
|
||||
);
|
||||
$sampleLimit = max(1, min(10, (int)($options['sample_limit'] ?? 5)));
|
||||
$pathSampleLimit = $this->pathLimit($options['path_sample_limit'] ?? null);
|
||||
$pathSampleLimit = $this->pathLimit(
|
||||
$options['path_sample_limit'] ?? null,
|
||||
self::DEFAULT_PATH_SAMPLE_LIMIT,
|
||||
self::MAX_PATH_SAMPLE_LIMIT,
|
||||
0
|
||||
);
|
||||
$progressCallback = is_callable($options['progress_callback'] ?? null) ? $options['progress_callback'] : null;
|
||||
$progressIntervalStates = max(1, (int)($options['progress_interval_states'] ?? 128));
|
||||
$scope = is_array($options['scope'] ?? null) ? (array)$options['scope'] : [];
|
||||
@@ -938,7 +954,7 @@ class selfserve_studio_graph
|
||||
$truncated = false;
|
||||
|
||||
while ($stack !== []) {
|
||||
if ($maxStates !== null && $stateCount >= $maxStates) {
|
||||
if ($stateCount >= $maxStates) {
|
||||
$truncated = true;
|
||||
break;
|
||||
}
|
||||
@@ -997,7 +1013,7 @@ class selfserve_studio_graph
|
||||
$terminalPathCount++;
|
||||
$chain = is_array($state['chain'] ?? null) ? (array)$state['chain'] : [];
|
||||
$this->addPathOutcomeGroup($groups, $simulation, $chain, $scope, $sampleLimit);
|
||||
if ($pathSampleLimit === null || count($paths) < $pathSampleLimit) {
|
||||
if (count($paths) < $pathSampleLimit) {
|
||||
$paths[] = $this->pathResultFromSimulation($simulation, $chain, $scope);
|
||||
}
|
||||
|
||||
@@ -4273,14 +4289,18 @@ class selfserve_studio_graph
|
||||
return null;
|
||||
}
|
||||
|
||||
private function pathLimit(mixed $value): ?int
|
||||
private function pathLimit(mixed $value, int $default, int $max, int $min = 1): int
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
return max($min, min($max, $default));
|
||||
}
|
||||
|
||||
$parsed = (int)$value;
|
||||
return $parsed > 0 ? $parsed : null;
|
||||
if ($parsed < $min) {
|
||||
return max($min, min($max, $default));
|
||||
}
|
||||
|
||||
return min($max, $parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18283,13 +18283,17 @@ components:
|
||||
max_states:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 2048
|
||||
default: 2048
|
||||
nullable: true
|
||||
description: Optional debug cap. Omit for complete path projection.
|
||||
description: Optional debug cap for explored states. Omitted and larger values are capped at 2048.
|
||||
path_sample_limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 200
|
||||
default: 200
|
||||
nullable: true
|
||||
description: Optional debug cap for returned path rows. Omit to return every terminal path row.
|
||||
description: Optional cap for returned path rows. Omitted and larger values are capped at 200.
|
||||
|
||||
SelfserveStudioPathOutcomesResponse:
|
||||
type: object
|
||||
|
||||
@@ -1321,7 +1321,7 @@ it('truncates path outcome projection when the state cap is reached', function (
|
||||
->and($projection['warnings'][0])->toContain('truncated at 2 explored state');
|
||||
});
|
||||
|
||||
it('returns complete terminal path results for wide question trees and reports progress', function (): void {
|
||||
it('applies default caps for wide question trees and reports progress', function (): void {
|
||||
$service = selfserve_studio_graph_without_constructor();
|
||||
$simulate = function (array $overrides): array {
|
||||
$answers = [];
|
||||
@@ -1380,17 +1380,18 @@ it('returns complete terminal path results for wide question trees and reports p
|
||||
},
|
||||
]);
|
||||
|
||||
expect($projection['truncated'])->toBeFalse()
|
||||
->and($projection['summary']['state_count'])->toBe(8191)
|
||||
expect($projection['truncated'])->toBeTrue()
|
||||
->and($projection['summary']['state_count'])->toBe(2048)
|
||||
->and($projection['summary']['question_count'])->toBe(12)
|
||||
->and($projection['summary']['terminal_path_count'])->toBe(4096)
|
||||
->and($projection['summary']['terminal_path_count'])->toBe(1023)
|
||||
->and($projection['summary']['outcome_count'])->toBe(2)
|
||||
->and($projection['summary']['path_sample_count'])->toBe(4096)
|
||||
->and($projection['summary']['path_sample_count'])->toBe(200)
|
||||
->and($projection['progress']['complete'])->toBeTrue()
|
||||
->and($projection['progress']['percent'])->toBe(100)
|
||||
->and($projection['paths'])->toHaveCount(4096)
|
||||
->and($projection['paths'])->toHaveCount(200)
|
||||
->and($projection['paths'][0]['answers'])->toHaveCount(12)
|
||||
->and($projection['paths'][0]['result'])->toBe('Allowed')
|
||||
->and($projection['warnings'][0])->toContain('truncated at 2048 explored state')
|
||||
->and($progressEvents)->not->toBeEmpty()
|
||||
->and($progressEvents[0]['terminal_path_count'])->toBeGreaterThan(0)
|
||||
->and($progressEvents[0]['path_sample_count'])->toBeGreaterThan(0);
|
||||
|
||||
Reference in New Issue
Block a user