「空集合篩選退化成不篩選」發生在查詢條件先被解析成 ID 集合,再交給 LINQ、EF Core 或 repository 套用時。最危險的錯誤往往不是語法錯,而是「集合為空就跳過 Where」。使用者明明送出篩選條件,系統卻把解析不到任何 ID 解讀成未篩選,最後回傳全部資料。這類 fail-open 缺陷會同時影響列表、匯出與批次讀取,因此修正重點不是補一個 Any(),而是明確區分「沒有提出篩選」與「提出篩選但結果為空」。

空集合篩選為什麼會變成 Fail-Open

直接答案:只要程式用 if (ids.Any()) 決定是否套用限制,空集合就可能讓限制整段消失;安全設計必須另外保留 filterRequested,並在已要求篩選但解析結果為空時直接回傳空集合。

典型程式先取得某個條件對應的識別碼,再把識別碼放入查詢:

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

表面上,這段程式避免了空集合造成的查詢問題;實際上,它把兩種完全不同的意圖合併了:

  • 沒有篩選條件:使用者要看可存取範圍內的全部資料。
  • 有篩選條件,但找不到符合項目:正確答案應該是零筆。

當 allowedOwnerIds 為空時,上述程式直接略過 Where,因此第二種情況被錯誤改寫成第一種。Microsoft 的 Enumerable.Any 文件只保證空序列回傳 false;它不會替應用程式判斷 false 代表「未指定條件」或「指定條件但沒有結果」。這個語意必須由呼叫端明確保存。

這也是它比一般查詢錯誤更危險的原因:錯誤方向不是少回資料,而是擴大結果集合。若列表與匯出共用同一個查詢方法,畫面上的零筆預期可能變成完整資料集;若後續還有分頁、統計或檔案產生,問題會沿著同一條資料管線放大。

正確模型:把篩選意圖與解析結果分開

安全介面至少要傳遞兩個訊號:是否要求篩選,以及解析後允許的 ID 集合。不能只看集合是否有元素,因為空集合本身無法說明使用者是否提出條件。

以下方法是概念節錄,完整可執行版本見驗證章節。source 必須先限縮至呼叫者獲准存取的範圍;filterRequested 只代表選用搜尋條件,不能用來略過必要的授權檢查。對必要的權限集合,空集合必須拒絕存取或回傳零筆。此 IEnumerable 範例不是資料庫查詢的直接替代品。

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();
}

這個分支順序刻意把三種狀態寫清楚:

  1. 未提出篩選:走明確的未篩選路徑。
  2. 已提出篩選,但允許集合為空:立即回傳零筆。
  3. 已提出篩選,且集合非空:套用 membership predicate。

這種設計的價值在於可讀性與可測試性。審查者不必猜測 null、空陣列或缺少欄位各自代表什麼;測試也能分別覆蓋三種狀態。若系統使用 request DTO,最好直接建立具名型別,例如 FilterState.NotRequested、FilterState.Resolved、FilterState.ResolvedEmpty,而不是用多個可為 null 的欄位拼出隱含狀態。

在 EF Core 中,Contains 常被用來表示集合 membership,並由 provider 轉譯成對應查詢。不過 Microsoft 文件也提醒,不同 provider 的查詢行為可能不同;單純用記憶體集合通過測試,不等於資料庫端的 SQL、大小寫、參數化或空集合處理完全一致。因此,純函式測試負責驗證狀態契約,整合測試再驗證實際 provider 與資料庫行為。

前端顯示值與後端識別碼不能混用

空集合常常不是後端自然產生,而是前端送錯值後的下游結果。Autocomplete、下拉選單與搜尋框通常同時包含人類可讀標籤與穩定識別碼;如果畫面顯示 Owner Alpha,送出時卻把這個標籤塞入 ownerId,後端查不到對應資料,就會得到空集合。

正確資料模型應明確分離:

label: Owner Alpha
value: owner-a

送出前應驗證 value 是否來自目前允許的選項。若介面允許自由輸入,無法解析的文字不能被當成有效 ID,也不能因解析失敗而移除所有限制。安全預設應是拒絕該條件、回傳驗證錯誤,或在查詢語意允許時回傳零筆。

同樣原則也適用於其他欄位:

  • 使用者名稱與使用者 ID。
  • 國家顯示名稱與國家代碼。
  • 狀態翻譯文字與狀態 enum 值。
  • 標籤名稱與標籤主鍵。
  • 外部代碼與內部 surrogate key。

前端修正不能取代後端防線。即使 UI 永遠送出正確 ID,API 仍可能被舊版客戶端、批次程式或手動請求呼叫。後端必須把「要求篩選但無法解析」視為明確狀態,而不是依賴前端保證。

查詢、匯出與批次工作要共用同一個限制契約

許多系統的列表與匯出看似使用相同搜尋條件,實際上卻各自組裝查詢。列表可能已修正,匯出仍保留「空集合就不加 Where」;或分頁查詢正確,計數查詢仍回傳全表數量。修正時不能只看單一 controller,而要盤點完整查詢家族。

建議至少檢查以下路徑:

  • 列表資料查詢。
  • 總筆數與分頁計數。
  • CSV、Excel 或報表匯出。
  • 自動完成候選與簡易清單。
  • 背景排程與批次通知。
  • 管理後台與一般使用者端是否共用 repository。

最佳做法是讓這些入口共用一個不可繞過的限制函式,並讓回傳型別保留「已套用篩選」的資訊。若每個入口自行判斷 Any(),同一個缺陷就會以不同形式重複出現。

此外,不要把「呼叫者沒帶條件」與「呼叫者帶了空陣列」任意正規化成相同 JSON。API contract 應定義缺少欄位、null、空陣列與無法解析值的差異。若業務需求真的要把空陣列視為全部,也必須把這個選擇寫入規格與測試,而不是讓它由偶然的分支行為決定。

驗證與重現:用 .NET 10 沙箱證明結果集合不會被放大

這個測試以三筆虛構資料重現脆弱分支與修正分支,不連接正式資料庫、不使用真實帳號,也不執行寫入。核心通過條件是:已要求篩選且解析集合為空時,結果必須是零筆;只有明確的未篩選路徑可以回傳全部測試資料。

  1. 確認已安裝 .NET SDK 10.0.400。在新的空白測試目錄依下方名稱儲存三個檔案,再於該目錄執行指令;不要覆寫既有專案。
  2. 執行 Release 組態的具名沙箱專案,觀察五個 assertion 與程序退出碼。
  3. 將脆弱方法中的 if (allowedOwnerIds.Any()) 與安全方法比較;若空集合案例回傳非零筆,測試必須失敗。
dotnet --version
dotnet run --project empty_filter_fail_closed_20261007.csproj --configuration Release

本次排程環境已實際執行,SDK 版本為 10.0.400。結果為五項檢查全部通過:脆弱版本在空限制下回傳三筆;安全版本在「已要求篩選、零個已解析 ID」時回傳零筆;未要求篩選時明確回傳三筆;非空 allow list 只回傳指定資料;無法解析的顯示標籤不會擴大集合。最終摘要為 SUMMARY 5/5 checks passed on .NET 10.0。

純記憶體測試的目的是驗證應用程式狀態機,不是假裝取代資料庫整合測試。若實際專案使用 EF Core,還應在隔離的本機或容器資料庫加入 provider 測試,確認產生的查詢、參數與結果集合符合預期。不要以正式資料庫作為排程驗證目標,也不要在測試中放入客戶資料。

完整重現檔案與預期輸出

前置條件:.NET SDK 10.0.400、獨立且可寫入的測試目錄,以及初次還原所需的工具鏈。測試程式本身只操作記憶體;建置工具可能需要還原。把以下檔案放在同一目錄後,執行上方指令。

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

通過條件是六行標記皆出現且程序結束碼為 0。脆弱版本回傳三筆是刻意驗證的負向對照,不代表測試失敗;安全版本若在已要求空限制時回傳非零筆,斷言會拋出例外,程序應以非零碼結束。

回歸測試矩陣:至少覆蓋正向、零命中與無條件

單一「有資料」測試抓不到 fail-open。最小測試矩陣應同時包含:

測試情境 filterRequested 解析集合 預期結果
未要求篩選 false 空 明確允許的完整測試集合
要求篩選且有命中 true 非空 僅符合 ID 的資料
要求篩選但零命中 true 空 零筆
顯示值無法解析 true 空 驗證錯誤或零筆
多個 ID 部分命中 true 非空 僅命中的聯集
重複 ID true 非空 不應重複資料列

若查詢同時包含多個條件,還要測試交集語意。例如國家條件有結果、擁有者條件零命中時,最終答案仍應為零,而不是只保留國家條件。每加入一個 SearchParameter 欄位,都要確認 DTO、解析器、LINQ/EF 條件、raw SQL 路徑、計數與匯出是否同步支援;「欄位存在但查詢建構器沒有讀取」也是另一種有條件等於無條件。

測試命名應直接描述安全不變量,例如 RequestedFilter_WithNoResolvedIds_ReturnsEmpty。這比 TestSearch2 更能在 code review 與失敗報告中揭示風險。對匯出路徑,可另外斷言輸出列數與列表查詢相同,避免兩條管線逐漸漂移。

程式碼審查清單

在 C#、LINQ 或 EF Core 查詢看到集合條件時,可依序檢查:

  • 意圖是否獨立保存:是否能分辨未要求篩選與解析後空集合?
  • 空集合是否 fail-closed:已要求限制時,空集合會回零筆還是跳過條件?
  • 識別碼是否穩定:前端送的是 value 還是顯示 label?
  • 所有入口是否一致:列表、count、export、batch 是否共用同一限制?
  • 多條件是否維持交集:任一必要限制零命中時,結果是否仍為零?
  • 測試是否包含負向案例:不存在 ID、無法解析文字與空 allow list 是否都有 assertion?
  • provider 是否被驗證:關鍵資料查詢是否在隔離的實際 provider 上跑過?
  • 紀錄是否安全:警告訊息只記錄條件類型與 request correlation,不輸出敏感識別碼或完整資料?

特別留意下列程式形狀:if (ids.Any()) query = query.Where(...)、空陣列被改寫成 WHERE 1=1、權限集合為空時視為 unrestricted,以及日期範圍缺失時自動查詢所有歷史資料。它們共同的問題是把「限制無結果」解讀成「沒有任何限制」。

結論:先定義空集合的業務語意,再寫查詢

空集合不是單純的技術細節,而是查詢契約的一部分。對一般搜尋,它可能代表零命中;對存取控制,它通常必須代表沒有權限;對未指定條件,才可能代表不限制。只有先把這三者寫成可觀察狀態,LINQ 與 EF Core 的 Any、Where、Contains 才能被正確使用。

最可靠的修正不是在每個 controller 補例外分支,而是建立統一的 fail-closed 過濾介面,讓「有提出限制但解析結果為空」永遠不能擴大結果集合。再以正向、零命中、未篩選、無法解析與匯出一致性測試鎖住契約,就能把一次偶發查詢錯誤轉化為可持續檢查的安全設計。

官方文件