Merge pull request #235 from copenhagentruckwash/investigate-self-serve-path-projection-dos-vulnerability

Clamp self-serve path projection limits
This commit is contained in:
Jeppe B
2026-06-01 23:27:33 +02:00
committed by GitHub
3 changed files with 53 additions and 28 deletions
@@ -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);
}
/**
+6 -2
View File
@@ -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);