A fail-open empty collection filter appears when an application resolves a user-supplied filter into a collection of identifiers and then applies the restriction only when that collection is non-empty. If resolution finds no identifiers, the Where clause disappears and the query returns every row. The syntax is valid, the request succeeds, and ordinary positive-path tests may pass. The failure is semantic: “a filter was requested but matched nothing” has been silently converted into “no filter was requested.”

This guide shows how to model that distinction explicitly in C#, how to keep UI labels separate from stable identifiers, how to align list and export paths, and how to verify the contract in a .NET 10 sandbox without production data.

Why an Empty Collection Filter Becomes Fail-Open

Direct answer: a guard such as if (ids.Any()) can remove the restriction precisely when a requested filter resolves to zero IDs. Preserve a separate filterRequested state and return an empty result when the filter was requested but its resolved allow list is empty.

The vulnerable shape is common because it appears defensive:

var query = accounts.AsQueryable();
if (allowedOwnerIds.Any())
{
    query = query.Where(account => allowedOwnerIds.Contains(account.OwnerId));
}
return query.ToList();

The code treats an empty collection as a reason not to add a predicate. That is correct only if an empty collection means the caller never requested a filter. It is wrong when the caller supplied a value, a resolver attempted to map that value to authorized identifiers, and no identifiers matched.

Those states have opposite meanings:

  • No filter requested: return the complete set that the caller is otherwise allowed to see.
  • Filter requested, no IDs resolved: return zero rows or reject the invalid filter.
  • Filter requested, IDs resolved: return only rows whose identifiers are members of that set.

The official Enumerable.Any documentation defines whether a sequence contains an element. It does not define the business meaning of an empty sequence. If the application stores only the final collection, the information needed to distinguish “not requested” from “requested but empty” has already been lost.

This bug is fail-open because the failure expands the result. It is not merely an inaccurate search. When the same query builder supports an export, report, scheduled task, or count endpoint, one missing predicate can affect every downstream representation. Pagination may hide the scale on screen while an export exposes the complete result set. A successful HTTP response also makes the defect harder to detect through generic error monitoring.

Model Filter Intent Separately from Resolution Results

A safer interface carries both the caller's intent and the resolved identifiers. The simplest representation is a Boolean plus a collection, although a dedicated discriminated state is even clearer in a larger codebase.

The following method is an excerpt; the verification section includes the complete executable fixture. The source must already be restricted to the caller’s authorized scope. Here, filterRequested represents an optional search filter, never permission to bypass mandatory authorization. An empty mandatory permission set must deny access or return zero rows. This IEnumerable example is not a drop-in database query implementation.

public static IReadOnlyList ApplyOwnerFilter(
    IEnumerable source,
    bool filterRequested,
    IReadOnlyCollection allowedOwnerIds)
{
    if (!filterRequested)
    {
        return source.ToList();
    }

    if (!allowedOwnerIds.Any())
    {
        return Array.Empty();
    }

    return source
        .Where(account => allowedOwnerIds.Contains(account.OwnerId))
        .ToList();
}

The branch order is intentional. An unrestricted path exists, but it must be selected explicitly. Once filterRequested is true, an empty resolved collection cannot fall through to that path. This creates a monotonic security property: adding a requested restriction can keep the result the same size or make it smaller, but it cannot make the result larger.

For a public API or a complex repository, consider replacing the Boolean with named states such as NotRequested, Resolved, and ResolvedEmpty. Named states reduce ambiguity around null, missing JSON properties, and empty arrays. They also make exhaustive tests easier because each state must be handled deliberately.

The same principle applies when multiple filters are combined. Suppose a country filter resolves to several accounts while an owner filter resolves to none. If both filters were requested and their semantics are conjunctive, the final result must be empty. Dropping the empty owner predicate and retaining only the country predicate is still a fail-open result, even though one restriction remains.

LINQ's Contains is a natural way to express membership. With EF Core, providers commonly translate a collection membership expression into a database query, but provider behavior and translation details can vary. Microsoft recommends testing important database behavior against the actual database system rather than assuming a test double behaves identically. A pure function test should lock the application state contract; a separate integration test should confirm provider translation and observed rows in an isolated test database.

Keep Display Labels and Stable Identifiers Separate

An empty resolved collection often begins at the UI boundary. Autocomplete controls, select lists, and search widgets usually carry two values:

label: Owner Alpha
value: owner-a

The label is for people. The value is for the contract. If the UI displays Owner Alpha but submits that label in an ownerId field, the backend may search for an identifier that does not exist. The resolver returns an empty collection, which then reaches the vulnerable Any() guard.

The client should submit the stable value, but the server must still validate it. Frontend correctness is not an authorization boundary. Older clients, batch integrations, manual requests, or future UI regressions can all send an invalid value. A backend resolver should treat an unknown submitted identifier or an unresolvable free-text label as an explicit failure state.

Depending on product requirements, that state can produce:

  • a validation response explaining that the selected value is invalid;
  • an empty result for a search endpoint;
  • an authorization denial when the collection represents access rights;
  • a structured warning with a correlation identifier, without logging sensitive values.

It must not remove the restriction and continue with an all-row query.

This separation applies beyond owner selectors. Country names and country codes, translated status labels and enum values, usernames and user IDs, tag names and tag keys, or external references and internal surrogate keys all have the same risk. Human-readable text is mutable and may not be unique; a stable identifier belongs in the request contract.

Use One Restriction Contract for Lists, Counts, Exports, and Jobs

Fixing the visible list endpoint is insufficient if related paths assemble predicates independently. A typical search family can include a data page, a total count, a CSV export, a compact lookup, an administrative view, and a scheduled report. If each path has its own Any() guard, each path can preserve or reintroduce the same semantic defect.

Inventory at least these consumers during a repair:

  • list and detail-search queries;
  • total counts used by pagination;
  • CSV, spreadsheet, or report exports;
  • autocomplete and compact lookup endpoints;
  • background jobs and notification batches;
  • administrator and ordinary-user routes that share a repository;
  • raw SQL implementations that bypass the LINQ path.

The preferred design centralizes the restriction in a function or query specification that callers cannot bypass accidentally. Count and data queries should use the same predicate object. Export should consume the same filtered query rather than reconstructing search parameters. If a raw SQL path is required, its tests should use the same state matrix as the EF or LINQ implementation.

The API contract must also define missing, null, empty, and invalid values. Serializers and model binders can normalize these representations differently. If an omitted property means “not requested” while an empty array means “requested with no selected values,” converting both to the same default collection destroys intent. If the business intentionally defines an empty array as unrestricted, document and test that decision rather than inheriting it from a convenient branch.

Verification and Reproduction in a .NET 10 Sandbox

The test program uses three fictional rows and performs no network access, database writes, or production calls. The build toolchain may perform an initial restore. It demonstrates both the vulnerable result and the fail-closed contract. The essential pass condition is that a requested filter with zero resolved identifiers returns zero rows; only the explicit no-filter state may return all fixture rows.

  1. Install .NET SDK 10.0.400. In a new empty test directory, save the three files listed below, then run the commands from that directory. Do not overwrite an existing project.
  2. Run the project in Release configuration and observe all assertions plus the process exit code.
  3. Compare the vulnerable Any() guard with the fail-closed implementation. Any nonzero result for the requested-empty case must fail the test.
dotnet --version
dotnet run --project empty_filter_fail_closed_20261007.csproj --configuration Release

This scheduler environment executed the reproduction with SDK 10.0.400. All five checks passed. The vulnerable implementation returned all three fixture rows when the restriction collection was empty. The safe implementation returned zero rows when filtering was requested with zero resolved IDs, returned all three rows only when filtering was explicitly not requested, returned IDs 1 and 3 for a non-empty allow list, and returned zero rows when a submitted display label could not be resolved. The final line was SUMMARY 5/5 checks passed on .NET 10.0.

This in-memory fixture verifies the state machine, not database-provider equivalence. For an EF Core application, add a provider integration test using a local or disposable test database. Assert the returned identities and, when useful, inspect generated SQL in a safe test environment. Do not point an unattended reproduction at production, real accounts, or customer data.

Complete reproduction files and expected output

Prerequisites: .NET SDK 10.0.400, a new writable test directory, and the normal toolchain facilities for initial restore. Save all three files in the same directory and run the commands above from there.

global.json:

{"sdk":{"version":"10.0.400","rollForward":"disable"}}

empty_filter_fail_closed_20261007.csproj:



  
    Exe
    net10.0
    enable
    enable
  


Program.cs:

using System;
using System.Collections.Generic;
using System.Linq;

internal static class Program
{
    private static void Main()
    {
        var accounts = new[]
        {
            new Account(1, "owner-a"),
            new Account(2, "owner-b"),
            new Account(3, "owner-a"),
        };

        var emptyOwners = Array.Empty();
        var selectedOwners = new[] { "owner-a" };

        var vulnerable = FilterLab.VulnerableFilter(accounts, emptyOwners);
        AssertEx.Equal(3, vulnerable.Count, "vulnerable empty restriction returns every row");

        var filteredEmpty = FilterLab.FailClosedFilter(accounts, filterRequested: true, emptyOwners);
        AssertEx.Equal(0, filteredEmpty.Count, "requested filter with zero resolved owners returns zero rows");

        var unfiltered = FilterLab.FailClosedFilter(accounts, filterRequested: false, emptyOwners);
        AssertEx.Equal(3, unfiltered.Count, "no filter request remains an explicit all-rows path");

        var filtered = FilterLab.FailClosedFilter(accounts, filterRequested: true, selectedOwners);
        AssertEx.SequenceEqual(new[] { 1, 3 }, filtered.Select(account => account.Id), "non-empty allow list returns only matching rows");

        var labelToOwnerId = new Dictionary(StringComparer.OrdinalIgnoreCase)
        {
            ["Owner Alpha"] = "owner-a",
        };
        var submittedLabel = "Unknown Owner";
        var resolvedOwners = labelToOwnerId.TryGetValue(submittedLabel, out var resolvedOwnerId)
            ? new[] { resolvedOwnerId }
            : Array.Empty();
        var unresolvedSelection = FilterLab.FailClosedFilter(accounts, filterRequested: true, resolvedOwners);
        AssertEx.Equal(0, unresolvedSelection.Count, "unresolved display label cannot widen the result set");

        Console.WriteLine("SUMMARY 5/5 checks passed on .NET 10.0");
    }
}

internal sealed record Account(int Id, string OwnerId);

internal static class FilterLab
{
    public static IReadOnlyList VulnerableFilter(
        IEnumerable source,
        IReadOnlyCollection allowedOwnerIds)
    {
        var query = source;
        if (allowedOwnerIds.Any())
        {
            query = query.Where(account => allowedOwnerIds.Contains(account.OwnerId));
        }

        return query.ToList();
    }

    public static IReadOnlyList FailClosedFilter(
        IEnumerable source,
        bool filterRequested,
        IReadOnlyCollection allowedOwnerIds)
    {
        if (!filterRequested)
        {
            return source.ToList();
        }

        if (!allowedOwnerIds.Any())
        {
            return Array.Empty();
        }

        return source.Where(account => allowedOwnerIds.Contains(account.OwnerId)).ToList();
    }
}

internal static class AssertEx
{
    public static void Equal(T expected, T actual, string name)
        where T : IEquatable
    {
        if (!expected.Equals(actual))
        {
            throw new InvalidOperationException($"FAIL {name}: expected {expected}, got {actual}");
        }

        Console.WriteLine($"PASS {name}: {actual}");
    }

    public static void SequenceEqual(IEnumerable expected, IEnumerable actual, string name)
    {
        if (!expected.SequenceEqual(actual))
        {
            throw new InvalidOperationException(
                $"FAIL {name}: expected [{string.Join(",", expected)}], got [{string.Join(",", actual)}]");
        }

        Console.WriteLine($"PASS {name}: [{string.Join(",", actual)}]");
    }
}
PASS vulnerable empty restriction returns every row: 3
PASS requested filter with zero resolved owners returns zero rows: 0
PASS no filter request remains an explicit all-rows path: 3
PASS non-empty allow list returns only matching rows: [1,3]
PASS unresolved display label cannot widen the result set: 0
SUMMARY 5/5 checks passed on .NET 10.0

Pass requires all six output lines and process exit code 0. The vulnerable result is an intentional negative control, not a failed test. If the safe requested-empty case returns any rows, its assertion throws and the process must exit nonzero.

Build a Regression Matrix Around Result-Set Monotonicity

A positive-path test with one valid identifier cannot detect this defect. The minimum useful matrix includes no filter, a matching filter, a zero-match filter, an invalid display value, multiple identifiers, and duplicate identifiers.

Scenario Filter requested Resolved IDs Expected result
No filter false empty Explicitly allowed complete fixture
Valid filter true non-empty Matching rows only
Zero-match filter true empty Zero rows
Unresolvable label true empty Validation failure or zero rows
Several IDs true non-empty Union of matching IDs
Duplicate IDs true non-empty No duplicated source rows

For combined filters, test intersections. A requested filter that resolves empty should dominate an AND-based query and produce an empty result. Also test the count and export against the same expected identities, not merely against an HTTP status. A list returning zero while a count returns the full table is a contract failure even if both calls complete successfully.

Use test names that state the invariant, such as RequestedFilter_WithNoResolvedIds_ReturnsEmpty and UnknownDisplayLabel_CannotWidenResults. Clear names make failures actionable in code review and continuous integration.

A useful property-based statement is: for a fixed caller scope, applying an additional requested restriction must never increase the set of returned identities. That property can catch accidental predicate removal across many generated cases. It does not replace scenario tests, but it expresses the core fail-closed guarantee more precisely than checking only row counts.

Review Query Builders for Other Fail-Open Shapes

The empty-collection guard is one member of a broader family. Reviewers should flag patterns where an invalid or missing restriction becomes a permissive default:

  • if (ids.Any()) query = query.Where(...) without separate intent state;
  • an empty IN list rewritten as WHERE 1 = 1;
  • an empty permission set interpreted as unrestricted access;
  • an invalid enum silently ignored;
  • an omitted date range converted into all historical records;
  • a search parameter declared in a DTO but never consumed by the query builder;
  • an EF condition implemented while the raw SQL path ignores it;
  • an export rebuilding filters differently from the list endpoint.

For each search parameter, trace the complete path: request DTO, model binding, normalization, resolver, query-condition object, LINQ or EF predicate, raw SQL alternative, count query, and export. A field can exist in documentation and request models while having no effect on the final query. Tests should prove observable restriction, not merely that a property was populated.

Logging also needs restraint. A warning can record that a requested filter resolved empty, the filter category, and a correlation identifier. It should not print complete identifiers, request bodies, or returned data. The objective is to make the branch observable without creating a second data-exposure channel.

Code Review Checklist

Use this checklist when reviewing C#, LINQ, or EF Core query code:

  • Intent preserved: Can the code distinguish no requested filter from a requested filter with zero resolutions?
  • Fail-closed empty state: Does an empty required allow list return zero rather than skip the predicate?
  • Stable values: Does the client submit an identifier rather than a display label?
  • Server validation: Can an invalid or unknown value widen results?
  • Shared implementation: Do list, count, export, and batch paths consume the same restriction?
  • Intersection behavior: Does one empty requested condition keep a multi-filter AND query empty?
  • Negative tests: Are nonexistent IDs, invalid labels, and empty allow lists asserted?
  • Provider coverage: Is critical translation tested against the real provider in isolation?
  • Safe observability: Are logs useful without containing sensitive values?
  • Explicit unrestricted path: Is returning all rows a named, intentional state rather than a fallthrough?

The key review question is simple: when this collection is empty, does the next branch return nothing or remove a restriction? Asking it consistently catches defects that static type checks and successful requests cannot.

Conclusion: Define Empty-Set Semantics Before Writing the Query

An empty collection is not a complete business state. In one context it means no matches; in an authorization context it usually means no permission; only an explicit no-filter state should mean unrestricted within the caller's existing scope. If those meanings are compressed into one collection, a convenient Any() guard can turn a zero-match search into an all-row response.

The durable fix is to model filter intent, resolution, and unrestricted access as separate states; centralize the predicate across lists, counts, exports, and jobs; and lock the contract with negative tests. In .NET, Any, Where, and Contains are reliable tools once the application supplies the right semantics. They cannot recover intent that the request pipeline discarded.

Official references