Skip to content
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
14 changes: 14 additions & 0 deletions docs/Server/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ LDAP Server Configuration
* [ServerOptions:setMaxSearchSize](#setmaxsearchsize)
* [ServerOptions:setMaxSearchTimeLimit](#setmaxsearchtimelimit)
* [ServerOptions:setMaxSearchPageSize](#setmaxsearchpagesize)
* [ServerOptions:setMaxSearchLookthrough](#setmaxsearchlookthrough)
* [SASL Options](#sasl-options)
* [ServerOptions:setSaslMechanisms](#setsaslmechanisms)

Expand Down Expand Up @@ -589,6 +590,19 @@ The maximum number of entries the server will return per page in a paged search.

**Default**: `1000`

------------------
#### setMaxSearchLookthrough

Maximum number of entries the server will examine while evaluating a search before returning an `ADMIN_LIMIT_EXCEEDED`
result code. Unlike the size limit (entries returned), this caps entries inspected, so it bounds an unindexed filter that
scans many entries to return few. Raise it above the largest legitimate subtree a client may scan, or set `0` to disable.

**Default**: `5000`

> It applies only to filters evaluated in PHP (array/JSON backends, and SQL backends when the filter cannot be pushed to the
> index); indexed equality and prefix filters are bounded by the database and are not counted. Paged searches are subject to
> this limit cumulatively across all pages.

## Monitoring

Opt-in observability. See [Server Monitoring](Monitoring.md) for the full guide, the `cn=monitor` attributes, and the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public function build(
string $base,
bool $subtree,
?SqlFilterResult $filterResult,
?int $sizeLimit,
?int $sqlLimit,
array $sortKeys,
): SqlQuery {
$query = match (true) {
Expand All @@ -52,8 +52,8 @@ public function build(
);
}

if ($sizeLimit !== null) {
$query = $query->appending(' LIMIT ' . $sizeLimit);
if ($sqlLimit !== null) {
$query = $query->appending(' LIMIT ' . $sqlLimit);
}

return $query;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,12 @@ public function list(StorageListOptions $options): EntryStream
$filterResult = $this->translator->translate($options->filter);
$isPreFiltered = $filterResult !== null && $filterResult->isExact;

$sqlLimit = $isPreFiltered && $options->sizeLimit > 0
? $options->sizeLimit
: null;
// Exact is bound by sizeLimit. Inexact is PHP re-evaluated: cap candidate transfer at lookthrough+1.
$sqlLimit = match (true) {
$isPreFiltered && $options->sizeLimit > 0 => $options->sizeLimit,
!$isPreFiltered && $options->lookthroughLimit > 0 => $options->lookthroughLimit + 1,
default => null,
};

$query = $this->queryBuilder->build(
$options->baseDn->normalize()->toString(),
Expand Down
11 changes: 11 additions & 0 deletions src/FreeDSx/Ldap/Server/Backend/Storage/SearchStreamBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,23 @@ private function injectHasSubordinates(Entry $entry): Entry
/**
* @param Generator<Entry> $generator
* @return Generator<Entry>
* @throws OperationException
*/
private function wrapWithFilterEvaluation(
Generator $generator,
FilterInterface $filter,
): Generator {
$lookthrough = $this->limits->maxSearchLookthrough;
$examined = 0;

foreach ($generator as $entry) {
if ($lookthrough > 0 && ++$examined > $lookthrough) {
throw new OperationException(
'Administrative limit exceeded.',
ResultCode::ADMIN_LIMIT_EXCEEDED,
);
}

if ($this->filterEvaluator->evaluate($entry, $filter)) {
yield $entry;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public function __construct(
public readonly int $timeLimit = 0,
public readonly int $sizeLimit = 0,
public readonly array $sortKeys = [],
public readonly int $lookthroughLimit = 0,
) {}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ public function search(
sortKeys: $sortingControl instanceof SortingControl
? $sortingControl->getSortKeys()
: [],
lookthroughLimit: $this->limits->maxSearchLookthrough,
);

try {
Expand Down
1 change: 1 addition & 0 deletions src/FreeDSx/Ldap/Server/SearchLimits.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@ public function __construct(
public int $maxSearchSize = 0,
public int $maxSearchTimeLimit = 0,
public int $maxSearchPageSize = 0,
public int $maxSearchLookthrough = 0,
) {}
}
18 changes: 18 additions & 0 deletions src/FreeDSx/Ldap/ServerOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ final class ServerOptions

private int $maxSearchPageSize = 1000;

private int $maxSearchLookthrough = 5000;

private ?Closure $onServerReady = null;

private ?ConfigReloaderInterface $configReloader = null;
Expand Down Expand Up @@ -869,12 +871,28 @@ public function setMaxSearchPageSize(int $maxSearchPageSize): self
return $this;
}

/**
* Maximum entries examined per search before adminLimitExceeded (default 5000). Guards unindexed scans. Zero disables.
*/
public function getMaxSearchLookthrough(): int
{
return $this->maxSearchLookthrough;
}

public function setMaxSearchLookthrough(int $maxSearchLookthrough): self
{
$this->maxSearchLookthrough = $maxSearchLookthrough;

return $this;
}

public function makeSearchLimits(): SearchLimits
{
return new SearchLimits(
maxSearchSize: $this->maxSearchSize,
maxSearchTimeLimit: $this->maxSearchTimeLimit,
maxSearchPageSize: $this->maxSearchPageSize,
maxSearchLookthrough: $this->maxSearchLookthrough,
);
}

Expand Down
2 changes: 1 addition & 1 deletion tests/integration/LdapServerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ public function testWhoAmIWhenNotAuthenticated(): void
public function testItCanHandlingPaging(): void
{
$this->stopServer();
$this->createServerProcess('tcp', ['--entries=5000']);
$this->createServerProcess('tcp', ['--entries=5000', '--max-search-lookthrough=0']);
$this->authenticate();

$allEntries = [];
Expand Down
23 changes: 23 additions & 0 deletions tests/integration/Storage/LdapBackendStorageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,29 @@ public function testSortControlPlacesMissingAttributeFirstWhenDescending(): void
);
}

public function testInexactSearchTripsLookthroughLimit(): void
{
$this->stopServer();
$this->createServerProcess(
'tcp',
[
...static::storageExtraArgs(),
'--seed-entries=10',
'--max-search-lookthrough=3',
],
);
$this->authenticateUser();

$this->expectException(OperationException::class);
$this->expectExceptionCode(ResultCode::ADMIN_LIMIT_EXCEEDED);

$this->ldapClient()->search(
Operations::search(Filters::endsWith('cn', 'zzz'))
->base('dc=foo,dc=bar')
->useSubtreeScope(),
);
}

/**
* Hook for subclasses to route the shared server through a different backend.
*
Expand Down
8 changes: 8 additions & 0 deletions tests/support/LdapBackendStorageCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ protected function configure(): void
null,
InputOption::VALUE_NONE,
'Enable the cn=monitor entry',
)
->addOption(
'max-search-lookthrough',
null,
InputOption::VALUE_REQUIRED,
'Maximum entries examined per search before adminLimitExceeded (0 = no limit)',
'0',
);
}

Expand Down Expand Up @@ -195,6 +202,7 @@ protected function execute(
->setSocketAcceptTimeout(0.1)
->setSchemaValidationMode($validationMode)
->setMonitorEnabled((bool) $input->getOption('monitor'))
->setMaxSearchLookthrough((int) $this->getStringOption($input, 'max-search-lookthrough'))
->setOnServerReady(fn() => fwrite(STDOUT, 'server starting...' . PHP_EOL));

if ($input->getOption('allow-relax')) {
Expand Down
8 changes: 8 additions & 0 deletions tests/support/LdapServerCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ protected function configure(): void
'Number of extra entries to seed (used to test paging)',
'0',
)
->addOption(
'max-search-lookthrough',
null,
InputOption::VALUE_REQUIRED,
'Maximum entries examined per search before adminLimitExceeded (0 = no limit)',
'5000',
)
->addOption(
'sasl',
null,
Expand Down Expand Up @@ -161,6 +168,7 @@ protected function execute(
->setUseSsl($useSsl)
->setAllowAnonymous($allowAnonymous)
->setSocketAcceptTimeout(0.1)
->setMaxSearchLookthrough((int) $this->getStringOption($input, 'max-search-lookthrough'))
->setOnServerReady(fn() => fwrite(STDOUT, 'server starting...' . PHP_EOL));

if ($reloadFlagFile !== '') {
Expand Down
56 changes: 56 additions & 0 deletions tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
use FreeDSx\Ldap\Server\Backend\Storage\Exception\StorageIoException;
use FreeDSx\Ldap\Server\Backend\Storage\StorageListOptions;
use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend;
use FreeDSx\Ldap\Server\SearchLimits;
use FreeDSx\Ldap\Control\ControlBag;
use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand;
use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand;
Expand Down Expand Up @@ -282,6 +283,61 @@ public function test_search_matches_mixed_case_attribute_via_lowercase_filter():
);
}

public function test_search_inexact_filter_trips_lookthrough_limit(): void
{
$storage = SqliteStorage::forPcntl(':memory:');
$backend = new WritableStorageBackend(
$storage,
new SearchLimits(maxSearchLookthrough: 2),
);
$backend->add(
new AddCommand(new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example'))),
$this->systemContext(),
);
foreach (['Ann', 'Bob', 'Cyd'] as $cn) {
$backend->add(
new AddCommand(new Entry(new Dn("cn={$cn},dc=example,dc=com"), new Attribute('cn', $cn))),
$this->context(),
);
}

self::expectException(OperationException::class);
self::expectExceptionCode(ResultCode::ADMIN_LIMIT_EXCEEDED);

$request = (new SearchRequest(Filters::endsWith('cn', 'x')))
->base('dc=example,dc=com')
->useSubtreeScope();
iterator_to_array($backend->search($request)->entries);
}

public function test_search_exact_filter_is_not_subject_to_lookthrough(): void
{
$storage = SqliteStorage::forPcntl(':memory:');
$backend = new WritableStorageBackend(
$storage,
new SearchLimits(maxSearchLookthrough: 1),
);
$backend->add(
new AddCommand(new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example'))),
$this->systemContext(),
);
foreach (['Ann', 'Bob', 'Cyd'] as $cn) {
$backend->add(
new AddCommand(new Entry(new Dn("cn={$cn},dc=example,dc=com"), new Attribute('cn', $cn))),
$this->context(),
);
}

$request = (new SearchRequest(Filters::equal('cn', 'Ann')))
->base('dc=example,dc=com')
->useSubtreeScope();

self::assertCount(
1,
iterator_to_array($backend->search($request)->entries),
);
}

public function test_atomic_rolls_back_on_exception(): void
{
$threw = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,39 @@ public function test_search_converts_time_limit_exception_to_operation_exception
iterator_to_array($subject->search($request)->entries);
}

public function test_search_trips_lookthrough_limit_when_examined_exceeds_cap(): void
{
$subject = new WritableStorageBackend(
new InMemoryStorage([$this->base, $this->alice, $this->bob]),
new SearchLimits(maxSearchLookthrough: 2),
);

self::expectException(OperationException::class);
self::expectExceptionCode(ResultCode::ADMIN_LIMIT_EXCEEDED);

$request = (new SearchRequest(new PresentFilter('objectClass')))
->base('dc=example,dc=com')
->useSubtreeScope();
iterator_to_array($subject->search($request)->entries);
}

public function test_search_does_not_trip_lookthrough_limit_within_cap(): void
{
$subject = new WritableStorageBackend(
new InMemoryStorage([$this->base, $this->alice, $this->bob]),
new SearchLimits(maxSearchLookthrough: 100),
);

$request = (new SearchRequest(new PresentFilter('objectClass')))
->base('dc=example,dc=com')
->useSubtreeScope();

self::assertCount(
3,
iterator_to_array($subject->search($request)->entries),
);
}

public function test_add_converts_storage_io_exception_to_unavailable_operation_exception(): void
{
$ioException = new StorageIoException('Unable to publish the storage update.');
Expand Down
22 changes: 21 additions & 1 deletion tests/unit/ServerOptionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -724,18 +724,38 @@ public function test_it_can_set_max_search_page_size(): void
);
}

public function test_max_search_lookthrough_defaults_to_5000(): void
{
self::assertSame(
5000,
$this->subject->getMaxSearchLookthrough(),
);
}

public function test_it_can_set_max_search_lookthrough(): void
{
$this->subject->setMaxSearchLookthrough(5000);

self::assertSame(
5000,
$this->subject->getMaxSearchLookthrough(),
);
}

public function test_make_search_limits_reflects_current_options(): void
{
$this->subject
->setMaxSearchSize(500)
->setMaxSearchTimeLimit(60)
->setMaxSearchPageSize(250);
->setMaxSearchPageSize(250)
->setMaxSearchLookthrough(5000);

self::assertEquals(
new SearchLimits(
maxSearchSize: 500,
maxSearchTimeLimit: 60,
maxSearchPageSize: 250,
maxSearchLookthrough: 5000,
),
$this->subject->makeSearchLimits(),
);
Expand Down
Loading