Practical LLM Security · RAG and Knowledge Security · Part 1 of 5
Treat the ingestion pipeline as a security boundary: least-privilege connectors, isolated parsers, admission records, and a quarantine path for suspicious documents.
Where the knowledge base is really decided
Most RAG security discussions start at query time: filter the retrieved chunks, screen the prompt, check the answer. By then several irreversible decisions have already been made. A connector chose which documents to copy, a parser chose which text to extract, a chunker chose which sentences travel together, and an indexer recorded which principals may see each chunk. If any of those steps was wrong, query-time controls operate on corrupted inputs.
This chapter builds on the indirect prompt injection chapter, which explains why retrieved text must never carry authority. Here the question is narrower and more operational: how should an ingestion pipeline admit a document so that its content, provenance, and permissions are trustworthy enough to index? The running example is Northstar, a fictional retailer whose support assistant indexes a policy wiki, a shared drive of supplier contracts, and uploaded customer attachments.
A concrete failure: the invisible clause
A supplier uploads a revised returns agreement as a PDF. The visible page says refunds above 500 units require manager approval. The PDF also contains a text layer rendered in white at a tiny font size: an instruction claiming that approval is waived for this supplier and that the assistant should quote a different bank account for remittance. The shared-drive connector runs as a service account with read access to every folder, so it ingests the file within minutes. The parser extracts all text layers, including the invisible one. The chunk inherits the folder permission of the supplier-management team, and two days later a support agent asks the assistant about remittance details for that supplier.
Nothing in that sequence required a model jailbreak. The failure was admission: the system treated whatever the parser emitted as the document, and it treated the connector's broad read access as if it implied the document deserved to be indexed for that audience. Query-time injection screening may or may not catch the sentence. The ingestion pipeline had better evidence earlier, because it could compare what a person would see with what the parser extracted.
Architecture: an admission pipeline with explicit states
| Stage | Input | Decision recorded | Fails to |
|---|---|---|---|
| Connector fetch | Source object and its ACL | Source system, object id, version, connector identity | Retry, then alert |
| Type and size gate | Raw bytes | Detected type from signature, size, page count | Reject |
| Isolated parse | Allowed type only | Parser name and version, extracted text, layers | Quarantine |
| Consistency check | Extracted text plus rendered view | Hidden-text ratio, encoding anomalies, script mixing | Quarantine |
| Classification | Normalized text | Sensitivity label, owner, retention class | Hold for owner |
| Index commit | Chunks with ACL snapshot | Admission record id, content hash, ACL epoch | Roll back |
Each document moves through explicit states such as fetched, parsed, admitted, quarantined, and retired. Only admitted documents are visible to retrieval. The admission record is a small, append-only row that links the source version, the exact bytes hash, the parser version, the ACL snapshot, and the reviewer if one was involved. Later chapters rely on it: citation integrity needs the version and hash, and revocation needs the ACL epoch.
Design the connector identity deliberately. A single service account that can read everything is convenient, but it makes the connector a permission oracle: whatever it can read becomes a candidate for indexing. Prefer per-source connectors with read scopes limited to the collections the assistant is meant to serve, and record the source ACL alongside the content rather than inferring access from the connector's own rights. The ADK safety guidance makes the same distinction between an agent acting with its own identity and acting with a user's identity; ingestion is the agent-identity case and needs a narrow allow-list.
Implementation: the admission function
Illustrative python; adapt the persistence and identity adapters to your service.
from dataclasses import dataclass
import hashlib
ALLOWED = {"application/pdf", "text/markdown", "text/html"}
MAX_BYTES = 20 * 1024 * 1024
@dataclass(frozen=True)
class Admission:
state: str
reason: str
sha256: str
acl_epoch: int
def admit(raw: bytes, sniffed_type: str, parsed, rendered_text: str,
acl_snapshot, policy) -> Admission:
digest = hashlib.sha256(raw).hexdigest()
if sniffed_type not in ALLOWED or len(raw) > MAX_BYTES:
return Admission("rejected", "type_or_size", digest, acl_snapshot.epoch)
extracted = " ".join(layer.text for layer in parsed.layers)
hidden = policy.hidden_text_ratio(extracted, rendered_text)
if hidden > policy.max_hidden_ratio or parsed.has_active_content:
return Admission("quarantined", "hidden_or_active_content",
digest, acl_snapshot.epoch)
if not acl_snapshot.principals:
return Admission("held", "no_effective_audience", digest,
acl_snapshot.epoch)
return Admission("admitted", "ok", digest, acl_snapshot.epoch)The parsed object and the rendered text come from two independent paths. One path extracts text layers with the parser; the other rasterizes the page and runs OCR or uses the renderer's visible text. A large difference between them is a signal, not proof: scanned documents with poor OCR also disagree. That is why the outcome is quarantine for review rather than silent deletion. Run the parser itself in a sandbox with no network access, a memory and time limit, and no credentials. Document parsers are complex code that processes attacker-supplied bytes, and the OWASP file upload guidance applies directly: validate the type from the file signature rather than the extension or the declared content type, cap size, and store originals outside any web-served path.
Strip or neutralize active content during normalization: embedded scripts, external references, form actions, and link targets that would later be rendered in citations. Keep the original bytes for forensics under restricted access, and index only the normalized text.
Tradeoffs to decide explicitly
Quarantine adds latency to knowledge freshness. For a policy wiki edited by employees, a short automated check followed by immediate admission may be acceptable, with post-admission sampling. For supplier or customer uploads, a slower path with mandatory review of quarantined items is usually justified because the author is outside the organization. Write these choices per source, not globally.
Hidden-text detection has false positives on accessibility layers, alternative text, and right-to-left scripts. Tune thresholds on a labeled sample of your own corpus and keep the quarantine reason granular so reviewers can see why a document was held. Do not let reviewers approve by editing the extracted text in place; approval should admit a specific bytes hash, and a changed file must start a new admission.
Finally, decide what happens when the source ACL cannot be read. Failing open by inheriting the folder's broadest audience is the common bug. Hold the document until a definite ACL exists.
Worked case: replaying the supplier PDF
Replay the invisible-clause document through the admission pipeline. The type gate accepts the PDF. The parser extracts 1,140 words; the rendered view contains 980 visible words; the difference is concentrated in one block whose font color equals the background. The policy flags a hidden-text ratio above the configured threshold, so the document enters quarantine with reason hidden_or_active_content. The supplier-management owner reviews it, rejects the hidden block, and asks the supplier for a clean file. The clean upload has a different hash and goes through admission again.
Two further checks catch the remittance trick even if the hidden layer were visible. The classification step labels bank details as sensitive financial data, which routes the chunk to an index that the support assistant cannot query. And the action layer described in the refund ledger chapter never takes payment destinations from retrieved text in the first place.
Verification steps
Build a synthetic fixture corpus: a PDF with a white-on-white instruction, an HTML page with a script and a remote image, a file whose extension says PDF but whose signature is an archive, an oversized document, and a document whose source ACL is unreadable. Assert the exact admission state and reason for each. Then assert the absence of effects: quarantined content returns zero retrieval hits for every test principal.
Measure the pipeline in production terms: the number of documents per state, the age of the oldest quarantined item, and the share of admissions that recorded an ACL snapshot. A rising count of admitted documents without an ACL epoch is a configuration defect, not a statistic to tolerate. Re-run the fixture suite on every parser upgrade, because extraction behavior is exactly what changes between versions.
Practice and continue
Open the Secure retrieval: permissions, caches, and citations lab to change the assumptions with deterministic synthetic data and inspect each decision. This chapter belongs to the RAG and Knowledge Security group of Practical LLM Security, which builds on the 25-chapter abuse and fraud prevention series.
Sources and implementation scope
Primary references checked October 16, 2026. Framework details follow the linked documentation and, for ADK, the google-adk 2.12.0 package source; pin and test the version your application uses. The Northstar scenarios, policies, figures, and code are original illustrative designs, not measured production results or complete integrations.