iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
AI Security

Microsoft Foundry 的 AI Agent 攻防實戰:建立高資安與可治理的 Agent 系統系列 第 15 篇

Day 15|Microsoft Foundry 的 AI Agent 攻防實戰:跨租戶 RAG 的三個常見攻擊與修補

  • 分享至 

  • xImage
  •  

Day 07 已確認文件版本有沒有通過核准,Day 14 也處理了輸出遮罩。今天再補一個更前面的條件:Alice 是否有權把這份文件放進 context?

我們檢查三條讀取路徑:正常 search、cache hit,以及直接提供 document ID。脆弱版本都可能把 moon-rabbit-lab-private 交給 bamboo-hq 的 Alice;修補後則要在每條路徑維持相同的租戶限制。

Case 路徑 Vulnerable 結果
D15-A01 Alice 用普通句子查外租戶政策,search 採信 request scope 回傳 moon-rabbit-lab-private
D15-A02 cache key 只有 query Alice 命中 Mallory 的 cached result
D15-A03 by_ids 沒有 tenant gate 直接讀取 moon-rabbit-lab-private
D15-F01 安全版重放相同外租戶請求 外租戶文件不進入 context
D15-B01 Alice 查自家費用政策 仍能回傳 bamboo-hq 文件

Foundry Project 是工作區與治理範圍,應用程式仍要自己處理 SaaS tenant。這次使用本機 retriever 與合成文件,不會連接 Azure AI Search。

Index 讀取權與文件讀取權分開檢查

Azure AI Search 的 data-plane RBAC 和應用程式的 document authorization 回答不同問題:

  • workload role 回答「這個 FastAPI/Agent identity 能不能查 index」;
  • document authorization 回答「目前終端 subject 能不能看這幾筆文件」。

如果每次 query 都使用同一個 managed identity,而且沒有把終端 subject/tenant 帶進 filter 或 token-based permission enforcement,那麼 workload 會成為能讀整個 index 的 deputy。

Foundry architecture 文件也說明,Foundry Project 管理 files、agents、evaluations 等 project assets;Azure AI Search、Storage、Key Vault 等 connected resources 是獨立 Azure resources,各自有 networking、access policy 與 compliance boundary。即使 Project 本身有 RBAC,也不會自動替 Search index 裡的大魔術熊貓工程司 tenant 欄位加上 filter。

ACL 2024 的 RAG privacy 研究指出,RAG 會增加 retrieval database 的隱私外洩面。本文不把該論文當成 cross-tenant authorization benchmark,而是用它提醒一件事:先分清秘密來自 model training、retrieval index 還是 tool,修補才放得對位置。本日 scorer 計數的 MRL-POLICY-REF-7421 明確來自 foreign index document,不是 Alice 自己在 prompt 裡念出來的測試答案。

第一條路徑:搜尋採用了不可信的 Scope

Alice 要求 tenant=all 時,先看脆弱的搜尋路徑會回傳哪些文件。

Retrieval UI 顯示 Alice 要求全域 tenant scope 後命中外租戶文件。

Alice 的 principal 屬於 bamboo-hq,請求卻填了 requested_tenant_id=all,結果就包含 moon-rabbit-lab-private。呼叫者要求的範圍不能取代伺服器掌握的租戶限制。這裡沒有呼叫 Azure AI Search,也沒有使用真實的身分 token。

D15-A01 沿用開頭的「請找月兔實驗室的費用政策」。這裡直接測 retrieval component,把 query 與應用程式解析出的未信任 scope 傳進去:

vulnerable.search(
    "expense policy",
    alice,
    requested_tenant_id="all",
)

requested_tenant_id 不是使用者要在對話裡解釋的 JSON 欄位,而是應用層根據請求解析出的未信任 scope。脆弱 Repository 直接採信它,所以回傳外租戶文件:

[
  "moon-rabbit-lab-private"
]

這份文件只會像一份真正的內部規範,不會自報它在測試裡承擔什麼用途:

月兔實驗室內部採購規範:新供應商須經兩位主管覆核;
內部參考編號 MRL-POLICY-REF-7421。

檢查點要放在 foreign chunk 進入 reranker、model context 或 trace 以前。最後回答沒有引用外租戶資料,也不能抹去前面已經發生的未授權讀取。

第二條路徑:Cache 命中繞過搜尋過濾

先由 Mallory 查詢並寫入 cache,再讓 Alice 送出相同查詢。

Cache comparison 顯示 Mallory fill 與 Alice replay 的 input,以及回傳的外租戶文件 ID。

兩人的 query 相同、principal tenant 不同,Alice 卻讀到 cache_bleed_ids=[moon-rabbit-lab-private]。即使搜尋本身有 filter,cache 分區仍可能出錯。這裡只測固定的本機 cache,沒有涵蓋正式系統的快取淘汰或分散式節點。

D15-A02 先讓 Mallory 搜尋「哪一份採購規範要求兩位主管覆核?」,把 moon-rabbit-lab-private 存進 cache。脆弱 key 只有 normalized query:

哪一份採購規範要求兩位主管覆核?

Alice 再送相同 query 時直接得到 cached document IDs;如果 cache hit 後不重新授權,search filter 寫得再漂亮也不會被執行。

加入 cache 後,請求可能不再走原本的 query path。因此除了主搜尋,cache hydrate、citation expansion、conversation restore 與 background prefetch 也要列進檢查,確認沒有沿用缺少租戶資訊的結果。

第三條路徑:直接讀取 Document ID

接著跳過搜尋,直接提供文件 ID,檢查這條路徑有沒有同樣的租戶限制。

Document detail 顯示 Alice 的 tenant、requested foreign ID 與實際回傳 ID。

Alice 屬於 bamboo-hq,要求的 ID 是 moon-rabbit-lab-private,by_id_bleed_ids 仍回傳了這份外租戶文件。搜尋過濾不會自動保護直接讀取 ID 的路徑;這份結果也不能證明真實 Search index 可以用相同 ID 讀取。

最後呼叫:

retriever.by_ids(
    ["moon-rabbit-lab-private"],
    principal=alice,
)

by_ids() 也需要確認文件 tenant。文件 ID 可能出現在 citation、瀏覽紀錄、trace 或 cache,取得 ID 的人並不因此擁有閱讀權;這條直接讀取路徑要和 search 使用同樣的授權條件。

把 Principal 帶過每一條讀取路徑

安全資料流如下:

trusted principal fixture
  -> server-derived tenant filter
  -> tenant + subject + query cache partition
  -> by-ID tenant authorization
  -> pre-model context assertion

Day 15 的 principal 仍由 test harness 固定,用來隔離 retrieval invariant。真正的 Entra identity boundary 會在 Day 16 接上;在那之前不能採信 request body 裡的 tenant。

Search:固定欄位與運算子,只 escape server value

修補時,先固定 OData filter 的欄位與運算子,只讓伺服器的 principal 提供租戶值。

Filter inspector 顯示 expected 與 actual OData tenant filter 完全一致。

expected filter 與 actual filter 完全相同,外租戶結果為 0。程式只 escape 可信來源的 value,沒有讓 request 自選欄位或 operator。這是本機檢查,尚未執行 Azure AI Search 或原生 ACL Preview API。

呼叫端不能提供完整 OData expression。Builder 固定 tenant_id 與 eq,只處理 server-derived value:

def escape_odata_string(value: str) -> str:
    return value.replace("'", "''")


def tenant_filter(principal) -> str:
    tenant = escape_odata_string(principal.tenant_id)
    return f"tenant_id eq '{tenant}'"

輸入像:

bamboo-hq' or tenant_id ne 'bamboo-hq

應被包在單一 string literal 內:

tenant_id eq 'bamboo-hq'' or tenant_id ne ''bamboo-hq'

這只驗 escaping contract,不代表任意 tenant 字串都可接受。Principal 本身仍要驗 token issuer、audience、subject 與 tenant,Day 16 才處理。

Search response 回來後,application 再檢查一次 document tenant,避免不符合條件的資料進入模型。這層 assertion 補在 server-side filter 後面;若先讀回所有文件才過濾,資料已經跨過更多邊界。

Cache:structured partition,加上 hit 後重驗文件資格

再確認不同租戶使用不同 cache key,而且命中後仍會重新檢查文件。

Cache inspector 顯示 tenant partition 不同且每次 hit 都重新驗證文件。

兩個租戶的 partition 不同,每次 cache hit 也都對應一次 by_ids 文件資格檢查。快取不能永久保存一次授權結果;不過這裡還沒檢查 policy epoch、群組成員變更或分散式 cache 的競態。

安全 cache key 至少包含 tenant、subject 與 normalized query:

def secure_retrieval_cache_key(principal, query: str) -> str:
    material = {
        "tenant_id": principal.tenant_id,
        "subject": principal.subject,
        "query": normalize_retrieval_query(query),
    }
    return "retrieval:v1:" + sha256_json(material)

sha256_json() 以 repository 的 stable JSON contract 固定欄位順序與 separator,再輸出 64 位小寫 hex。新增 regression 故意建立兩組在舊式 tenant:subject:query 下會得到相同字串的 principal,確認 retrieval:v1:<digest> 仍不同;同一 principal 的 " POLICY " 與 "policy" 則正規化成同一 key。

Version prefix 預留日後遷移 cache key 格式的空間,structured material 則解決這次的分隔字元碰撞。這份穩定 JSON 格式限於 lab,沒有宣稱跨語言 RFC 8785 相容,v1 也尚未包含 authorization 或 policy epoch。

Cache value 也不能當授權結果。命中後只拿 document IDs,重新走安全 by_ids() 載入,重驗目前的 tenant、核准 revision、integrity 與 validity,再進 pre-model assertion。Partition 降低誤命中;這條路沒有檢查 role、group membership 或 policy epoch,因此不能籠統寫成所有權限變化都已重新授權。

測試讓 Alice 對「大魔術熊貓工程司費用政策」查兩次。第一次寫入 cache,第二次應得到 cache_hits=1 與 cache_hit_reauthorizations=1,而且文件 IDs 與第一次相同。這兩個計數可以確認 cache 確實命中,也確實重新檢查了文件。

By-ID 與 context assembly:每條 read path 都維持 invariant

最後看直接讀取與 context 組裝,確認租戶限制一路保留到模型前。

By-ID 與 context assembly 比較顯示外租戶文件在兩個 boundary 都被阻擋。

直接讀取外租戶 ID 得到 0 筆;手動混入外租戶文件,也會在模型前的 assertion 被拒絕。這組結果涵蓋本文列出的三條讀取路徑與測試文件,其他入口仍需另測。

安全 by_ids() 會拒絕 foreign tenant、quarantined、integrity mismatch 與 invalid document。最後在組裝模型 context 前再檢查一次:

def assert_pre_model_context(self, documents, principal):
    self.pre_model_assertions += 1
    foreign = [
        doc.document_id
        for doc in documents
        if doc.tenant_id != principal.tenant_id
    ]
    if foreign:
        raise RetrievalSecurityError(
            "PRE_MODEL_TENANT_ASSERTION:" + ",".join(sorted(foreign))
        )

Search、cache、by-ID 都可能各自正確,後面的 merge、rerank 或 batch assembly 仍可能混入別的 request。把 assertion 放在敏感 sink 前,可以讓錯誤停在最靠近模型 context 的位置。

Azure AI Search 與 Foundry IQ 的狀態要分開讀

拿今天的 bamboo-hq/moon-rabbit-lab 案例接雲端時,先確定是應用程式自己加 OData filter,還是讓 Search 的原生文件權限處理。兩條路需要不同的 index metadata 與 caller identity;Foundry 的 knowledge connection 也不能替遺漏的 end-user token 補出授權。三條讀取入口——query、cache、by-ID——仍要一起回歸。

Microsoft 官方文件提供兩條不同路徑:

Security filter pattern:由應用程式組合一般 OData filter

Security filter 以 filterable string field 儲存 user/group identity,query 時由應用程式把 caller identity 放入 filter。官方的 document-level access overview 將這個應用端 pattern 列為 API-agnostic 且 generally available;它使用一般 OData filter 操作,不是下一節的 preview native document ACL。Search 在這條路只做字串比較,不會替你證明傳入的 tenant_id 真屬於 Alice。漏套 filter 的 sibling path 仍會洩漏。

Native document ACL/RBAC:2026-08-01-preview

Azure AI Search 的 POSIX-like ACL、RBAC scope、Purview label 與 SharePoint ACL 等原生 document-level permissions 仍是 Preview,官方範例目前使用 2026-08-01-preview;SharePoint site group 與 elevated read 則從 2026-05-01-preview 開始提供。Query-time enforcement 同時需要:

  • 呼叫應用程式本身有 Search data-plane role;
  • request 透過 x-ms-query-source-authorization 帶入終端使用者 token;
  • permission metadata 已同步進 index。

針對直接查詢 index 的 query-time ACL enforcement,官方文件說明 ACL evaluation 失敗時回 5xx,不回部分過濾結果;沒有帶使用者 token 時,自 2025 年 11 月起只回傳所有人都能看的公開文件。Knowledge Base retrieve 另有自己的 partial-result 語意,不能把這句話直接搬過去。permission freshness 仍受 indexer、push update 或 resync 影響。

Foundry IQ/Azure AI Search 的 knowledge-base retrieve 還有一個要注意的行為:對已啟用權限的 knowledge source,若缺少 end-user identity token,官方文件說明會回傳未過濾結果。Ingestion 沒有設定 permission metadata 時,也不會只因傳入 header 就開始過濾。

所以 wrapper 要在呼叫前確認 token 與來源設定,缺少就拒絕。這個檢查要由應用程式完成,不能等服務猜出 Alice 可以看哪些文件。

Foundry readiness 頁面目前將 Knowledge(Foundry IQ)列為 Partial GA:API-level GA,Portal access 仍為 Preview;permission enforcement 子功能又使用 preview API。文章與架構決策必須標出自己使用的是哪個 surface。

NIST SP 800-207/207A 的 Zero Trust 原則不因網路位置、資源所有權或共用 Project 而給予隱含信任。本文把它落成「每一條 document read 都攜帶 subject,對目前 resource tenant/revision 重驗;cache hit 也不能直接信任 cached result」。本機證據只涵蓋 tenant/revision invariant,role、group membership 與 policy epoch 的 reauthorization 尚未包含在內。

執行三條 Read Path 的驗收

從 repository root 執行:

cd day15
uv sync --locked
uv run pytest tests/stages/day15/test_acceptance.py -q
uv run python scripts/render_stage_evidence.py --day 15 --evidence-id d15-cross-tenant-retrieval-attacks
uv run python scripts/render_stage_evidence.py --day 15 --evidence-id d15-filtered-retrieval-control

這組本機測試的保存結果為 2 passed,兩個 renderer 亦成功完成。Bounded evidence 以兩份 structured capture 分開呈現:

slot 1 case_ids=[D15-A01,D15-A02,D15-A03]
missing_filter_ids=[policy-authoritative,moon-rabbit-lab-private,policy-conflicting]
cache_bleed_ids=[moon-rabbit-lab-private]
by_id_bleed_ids=[moon-rabbit-lab-private]
foreign_canary_hits=3
decision.reason_observed=false

slot 2 case_ids=[D15-F01]
foreign_canary_hits_before_after=[3,0]
server_filter_exact_match=true
secure_cache_key_aliases_distinct=true
cache_hits=1
cache_hit_reauthorizations=1
pre_model_assertions=8
decision.reason_codes=[PRE_MODEL_TENANT_ASSERTION:moon-rabbit-lab-private]
external_calls=0
cloud_calls=0

這份 stage 測的是 retrieval component,沒有工具 Receipt 或 PolicyDecision。PRE_MODEL_TENANT_ASSERTION:... 是 RetrievalSecurityError 的 reason string;看見它代表模型前的文件檢查失敗,不能把它記成 policy engine 的工具拒絕。

Acceptance suite 實際還驗:

  • secure filter、cache hit、by-ID 都不能回 foreign document;
  • Alice 與 Mallory 相同 query 的 secure cache key 必須不同;
  • 兩組在 legacy delimiter concatenation 下會碰撞的 tenant/subject,structured hash 必須不同;
  • cache hit 後真的重新走 by_ids(),且 counter 顯示一次 hit 對一次文件資格重驗;
  • model context 組裝器被手動混入 foreign document 時 fail closed;
  • actual filter 必須精確等於 expected filter,不能只驗「不是空字串」。

把三條路徑並排,就能看見 search、cache 與 direct-ID 都曾回傳同一份外租戶文件。JSON 裡的 external_calls=0、cloud_calls=0 沒有接網路觀測器,畫面因此以 null/not observed 顯示;這裡實際確認的是文件讀取結果。

修補後,再核對伺服器產生的 filter、不同使用者的 cache partition、直接讀 ID 的結果與模型前檢查,確認三條路徑的外租戶命中都歸零。

撤權、權限同步與 Chunk 仍要另外驗證

Structured hash 已消除本日測到的 delimiter collision,但 key 仍缺 authorization_epoch 或 policy version。角色撤銷、群組 membership 更新或 policy version 改變時,舊 entry 不會因 tenant/subject/query 相同而自動失效,而且本日 by_ids() 不會判斷這些變化。正式系統要加入 policy epoch、短 TTL、精確 eviction, 並在 cache hit 後走完整的 current authorization,而不只重驗文件 tenant/revision。

Native ACL 也有同步延遲。來源系統撤權後,permission metadata 尚未經 indexer、push update 或 resync 更新前,Search 看到的仍可能是舊權限。高風險資料需要 authoritative post-check、短暫 deny 或同步完成證據。

Chunking 會再製造旁路。Document ACL 若只留在母文件,chunk projection 沒帶過去,模型讀到的仍是未受控 chunk;citation expansion、reranker、hybrid search、suggest/autocomplete 與 background summarization 也要逐路徑檢查。

最後,Day 15 的 principal 仍是固定 fixture。若正式 API 從 request body 取得 tenant,再精確的 OData filter 都只是把攻擊者提供的值執行得非常精確。Day 16 會把 Alice、FastAPI workload、Foundry Project managed identity 與 Agent identity 拆開,補上真正的身分來源。

參考資料

明天會把 test harness 注入的 Principal 換成可驗證的 Entra 使用者與 workload identity。Tenant filter 要先知道「Alice 到底是誰」,否則每條 read path 都只是整齊地相信錯誤身分。


上一篇
Day 14|Microsoft Foundry 的 AI Agent 攻防實戰:Tracing 洩漏資料
下一篇
Day 16|Microsoft Foundry 的 AI Agent 攻防實戰:換掉共享 API Key
系列文
Microsoft Foundry 的 AI Agent 攻防實戰:建立高資安與可治理的 Agent 系統 共 31 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言