Development

Whose Content Is Loaded: A Source Allowlist for Fetched Text

Antonio Evans10 min read

A gateway room: a single door with a posted list of approved domains and table names.
Key points

Key points

  1. 1Write the allowlist from the task the agent must complete, not from the open web: the specific hosts, path prefixes, tables and document collections it genuinely needs.
  2. 2Store the list as versioned configuration that the fetching service reads, and enforce it there, at the egress gateway and the query layer, before any bytes reach the model.
  3. 3A prompt instruction is a description of policy, not a control, so treat it as documentation and never as the boundary.
  4. 4Deny by default, re-check every redirect against the same list, and log each allow or block with a reason code so you can audit and tune the list.

Decide what the agent may read before you pick a model

The source allowlist is the list of places an agent is permitted to fetch text from. It has three shapes, and most teams need all three: web hosts with path prefixes, database tables and views with row filters, and document collections with metadata scopes. The work of deciding this list is small compared with the work of enforcing it, and the two must be kept separate. A team that debates the list for a week and then pastes it into a system prompt has done the deciding and skipped the enforcing.

Start from the task. If the agent answers billing questions, it needs the billing API host, the invoicing table, and the policy document collection. It does not need the public internet. Write that down as a table before you write any code.

Source typeIdentifierScope question to answerExample decision
Web hostHostname plus path prefixWhich paths, which query shapes, which methodsdocs.example.com/help/ GET only
DatabaseSchema, table, viewWhich columns and which rows (tenant, region)billing.invoices filtered by tenant claim
DocumentsCollection plus metadataWhich tags, which classification, which ownerpolicies where audience=customer
Object storeBucket plus key prefixWhich prefix, which file types, size caps3://kb-public/faq/ PDF and text only
Third-party APIBase URL plus endpoint listWhich endpoints, which credential scopeapi.vendor.com/v2/orders read scope

The rule of thumb that separates a list you can enforce from one you cannot is specificity. A hostname with no path is a wide grant, because every URL on that host becomes reachable, including paths the site owner adds later. A hostname plus a path prefix is a narrow grant. If you cannot name a path prefix, you probably cannot justify the host.

This mirrors the guidance on server-side request forgery, which distinguishes two cases: an application that can send requests only to identified and trusted applications, where an allowlist is available, and one that can send requests anywhere, where it is not (the OWASP SSRF prevention cheat sheet). Pick the first case whenever the task allows it. Almost every agent task does.

Why a prompt instruction is not a control

A system prompt that says "only fetch from docs.example.com" changes the model's behavior on average. It does not change what the process can do. The model is a probabilistic component, and OWASP is explicit that prompt injection happens when input alters a model's behavior in unintended ways, including content carried in from external sources like websites and files (LLM01: Prompt Injection). The same page notes that retrieval augmented generation and fine-tuning do not fully mitigate prompt injection, which means the defense has to live somewhere other than the model.

The practical consequence: if the fetch tool accepts a URL and the model supplies that URL, then the model is the authority on which URL gets fetched. Whatever the prompt says, the worst case is a URL that the model was talked into supplying by a document it read a minute earlier. Indirect injection is exactly this path, where an LLM accepts input from an external source and that content alters its behavior (OWASP LLM01).

OWASP's mitigation list puts the pieces in order. It recommends enforcing privilege control and least privilege by handling sensitive functions in code rather than giving them to the model, segregating and identifying external content, and conducting adversarial testing (LLM01). A gateway allowlist is the privilege control. The prompt is the documentation of intent.

There is a second reason to put the check in code: consistency. A model that follows the instruction 99 times out of 100 has 99 correct fetches and one that you will find in a log if you are lucky. A gateway that blocks unlisted destinations has zero unlisted fetches, because the request never leaves.

Injection tests and allowlists are complementary, not competing. An injection test suite tells you whether behavior changed under adversarial input. An allowlist shrinks the set of content that can change behavior in the first place. Run both, but do not use a green injection suite as evidence that you can skip the allowlist, because the suite tests the variants you thought of.

Build the list as configuration the fetch service reads

Put the allowlist in a file, a policy store, or a config service, and have the fetching code load it on startup and on change. Nobody should edit a Python list to grant access. Version the file, review it in a pull request, and keep a record of who added which entry and why.

JSON Schema is a reasonable way to express the shape of the policy so that bad entries fail at load time rather than at fetch time. The JSON Schema validation vocabulary defines keywords for asserting what a valid document must look like, including the required keyword for property presence (JSON Schema Validation). A policy with a typo in a hostname should be rejected before the service starts.

{
  "version": 7,
  "default": "deny",
  "web": [
    {"host": "docs.example.com", "path_prefix": "/help/", "methods": ["GET"]},
    {"host": "status.example.net", "path_prefix": "/api/v2/", "methods": ["GET"]}
  ],
  "database": [
    {"table": "billing.invoices", "columns": ["id", "amount", "status", "issued_at"],
     "row_filter": "tenant_id = :tenant"}
  ],
  "documents": [
    {"collection": "policies", "filter": {"audience": "customer"}, "max_bytes": 200000}
  ],
  "limits": {"per_request_bytes": 500000, "per_run_requests": 40}
}

Three details matter more than the rest of the file. The default key is deny, so an entry that does not match anything is blocked. Every web entry has a path_prefix, so the grant is narrow. The database entry names columns rather than *, because column lists are the cheapest form of least privilege and they survive schema changes better than a blanket select.

Keep the policy small enough to read in one sitting. If your web section has more than twenty hosts, the task is probably too broad, or you are granting hosts you have not justified. The honest move is to cut it down.

Enforce at the egress gateway, not in the tool wrapper

The gateway is the process that actually opens the connection. It could be a dedicated service, an egress proxy, or the fetch client inside your agent runtime, as long as it is the single code path through which every outbound request passes. If two paths exist, the allowlist is enforced on one of them.

The validation order matters, and it is the part most implementations get wrong. Resolve, normalize, then check the resolved destination against the list, then connect to that exact resolved address. Checking the string the model supplied is not enough, because the resolved destination can differ from the text after DNS resolution, redirects, protocol handlers, and alternative address encodings. The OWASP guidance on request forgery prevention is blunt about this: URL denylists and hostname-only checks can be bypassed by alternate IP address formats, DNS resolution to private addresses, redirects, protocol handlers, and DNS rebinding, so effective validation restricts approved schemes, hosts, and ports while checking the resolved destination at connection time (Sweet Security on SSRF).

The same source notes that cloud environments make this worse, because a vulnerable workload may reach instance metadata services and obtain temporary credentials (Sweet Security). An agent that can be talked into fetching http://169.254.169.254/ is not a content problem, it is a credential problem.

A simple gateway check reads like this:

ALLOWED_SCHEMES = {"https"}
MAX_BYTES = 500_000

def fetch(url: str, policy: dict, tenant: str) -> bytes:
    parts = urlparse(url)
    if parts.scheme not in ALLOWED_SCHEMES:
        log_block(url, "scheme_not_allowed")
        raise Denied("scheme")
    host = parts.hostname or ""
    path = parts.path or "/"
    if not any(host == e["host"] and path.startswith(e["path_prefix"])
               for e in policy["web"]):
        log_block(url, "host_or_path_not_listed")
        raise Denied("allowlist")
    resolved = resolve_and_pin(host)  # single IP, no re-resolution on connect
    if is_private_or_link_local(resolved):
        log_block(url, "resolved_to_private_range")
        raise Denied("private_range")
    return get_pinned(resolved, path, query=parts.query,
                      max_bytes=MAX_BYTES, follow_redirects=False)

Two choices in that sketch are deliberate. follow_redirects=False means a redirect is not followed at all, so the client never silently changes destination. Instead, the redirect location is returned to the gateway, which runs the whole check again against the same policy. Second, resolve_and_pin returns one address and the connection is made to that address, so a DNS answer cannot change between the check and the connect.

If you need to follow redirects for usability, cap the chain (three hops is a common choice), re-check every hop, and log each hop with its verdict. Never let a hop through just because the first URL passed.

Apply the same discipline to tables and documents

The database path has its own version of the same mistake. An agent given one owner-level database credential has ambient authority over every row that credential can reach, and prompt injection exploits that authority. Running the agent as the end user, with the database filtering rows against a verified identity claim, is the pattern that holds under injection; the alternative relies on application code adding the right tenant filter to every query, which agent-generated SQL can route around (Powabase on row-level security for AI agents).

Two PostgreSQL statements make the difference between row-level security that filters rows and row-level security that is decoration: ENABLE ROW LEVEL SECURITY turns policies on for everyone except the table owner, and FORCE ROW LEVEL SECURITY applies policies to the table owner as well (Powabase). If the agent's role is a table owner or holds BYPASSRLS, the policies are skipped, so check both flags:

SELECT rolname, rolsuper, rolbypassrls
FROM pg_roles WHERE rolname = current_user;

Both flags must be false for the agent role. That check belongs in your startup validation, not in a wiki.

For documents, the allowlist entry is a collection plus a metadata filter, and the query layer adds that filter to every retrieval, whether the model asked for it or not. Chunk-level filtering is the only version that survives a model that constructs its own filters. If you use a managed retrieval service, confirm that the filter is applied in the query, not as a post-filter after retrieval, because post-filtering still reads the disallowed chunks.

The worked example that makes the volume difference concrete: suppose an agent run makes 40 fetch calls on average and 200 in the tail. With a 200 KB per-document cap and a 500 KB per-request cap, a run cannot bring in more than 20 MB of text, and the tail run is bounded by the same numbers. Without caps, one archived page of 12 MB can occupy the context and the bill. The caps are part of the allowlist, not an afterthought.

Test the gateway with recorded traffic, not just adversarial prompts

The allowlist needs its own tests, separate from the injection suite. Record real fetch traffic and replay it against the gateway with expected verdicts. Recording and replay as an HTTP technique means capturing requests, sending them to a target, and checking behavior, with the tooling supplying capture and replay while your assertions decide pass or fail (GoReplay on record and replay testing). The same article is careful about what a capture represents: it covers only the traffic observed during its collection window, so keep explicit tests for cases that did not occur.

That limitation is the reason to hand-write the adversarial cases. A capture from a normal week contains no fetch to http://169.254.169.254/, no redirect to an unlisted host, no hostname that resolves to a private range. Write those by hand and add them to the replay set. Replay tooling can also generate mocks from real dependency responses, which keeps the test fast and offline (Mokapi on recording and replaying API traffic).

For the model side of the harness, a deterministic mock server removes the cost and variance of real calls. A mock LLM server that behaves as a real HTTP server, streams in provider formats, routes fixtures by substring, regex or predicate, and injects tool calls lets you test the full agent loop without burning budget or fighting non-deterministic output (CopilotKit on LLMock). Point that mock at your gateway with fixture tool calls whose arguments include unlisted hosts and private-range IPs, and assert that the gateway blocks them before the connection.

Two testing practices carry over from general CI hygiene. Keep the test suite fast, since fast feedback is what makes frequent runs practical (GitLab on continuous integration best practices). And treat failures as process signals rather than blame signals, because a block that surprises you is a gap in the policy, and the fix is a reviewable change to the policy file (GitLab).

The test matrix to keep in CI:

CaseExpected verdictWhy it exists
Listed host, listed path prefixallowProves the happy path works
Listed host, unlisted pathblockCatches path prefix drift
Unlisted hostblockThe base case
Listed host that redirects to unlisted hostblock at hop 2Redirect re-check
Hostname resolving to private rangeblockDNS-based bypass
http:// scheme for an https-only entryblockScheme restriction
Response larger than per-request captruncate plus logVolume bound
Unlisted table in a SQL tool callblockQuery-layer parity

Operate the list: logging, review and change control

Every decision the gateway makes should produce a log line with the URL or table, the verdict, the reason code, and the policy version. Without the policy version, you cannot tell whether a block was correct at the time or caused by an edit. Without the reason code, you cannot tell a missing entry from a malformed one.

Make policy review part of the normal change process. A new host entry is a new trust decision, so the pull request should state which task needs it, which path prefix is required, and when it can be removed. Entries added for a one-off debugging session are the ones that persist for years.

OWASP's broader guidance points the same way. It recommends restricting the tools an LLM can access, applying least privilege to data, APIs and tools, and logging and monitoring suspicious prompt patterns (Mend on the OWASP Top 10 for LLM Applications). The allowlist is where least privilege for data becomes a fact rather than a diagram. The project itself has grown into a wider GenAI security initiative with published Top 10 material you can align internal reviews to (the OWASP project page).

A short cadence keeps the list honest. Once a month, list the entries that were never matched by an allowed request in that period, and ask whether each one is still needed. That review takes minutes and shrinks the surface more reliably than any audit.

If you are building this kind of agent and want the fetching layer to be a managed component rather than a service you patch, a specialist can build it with you at Quanta.

FAQ

FAQ

What is a source allowlist for a fetched-text agent?

It is the explicit set of hosts, path prefixes, database tables and document collections an agent may fetch from, stored as configuration and enforced by the service that performs the fetch. It is narrower than a blocklist and only useful if it is enforced in code. The prompt can describe it, but the gateway makes it true.

Why not just tell the model in the system prompt which sites it may use?

Because a prompt changes behavior on average, not capability. Prompt injection can alter a model's behavior through content it reads from external sources, and OWASP notes that retrieval and fine-tuning do not fully mitigate this (LLM01). If the model supplies the URL and the tool fetches it, the model is the authority, whatever the prompt says.

What is the minimum check before a fetch leaves the gateway?

Allow only the schemes and hosts on the list, match the path prefix, resolve the host, and reject private, loopback, link-local and metadata addresses, then connect to the address you checked. Checking only the string the model supplied is not enough, because resolution, redirects and alternate address formats can change the effective destination (Sweet Security). Do not follow redirects automatically; re-check each hop instead.

How should the allowlist handle redirects?

Turn off automatic following, take the redirect location, and run the full check on it, including resolution, before any connection. Cap the number of hops and log each one with its verdict. A first URL that passes does not make the second one safe.

Does a passing injection test mean the allowlist is unnecessary?

No. Injection tests cover the adversarial inputs you thought of, and they measure behavior change rather than network reachability. The allowlist reduces what reaches the model in the first place, which shrinks the input space those tests have to cover. Run both, and test the gateway separately with recorded traffic plus hand-written private-range and redirect cases.

Sources

Sources

  1. Server-Side Request Forgery Prevention Cheat Sheet
  2. LLM01:2025 Prompt Injection - OWASP Gen AI Security Project
  3. A Vocabulary for Structural Validation of JSON
  4. SSRF Attack: Understanding Server-Side Request Forgery
  5. Row-Level Security for AI Agents: Run as User or Not?
  6. Record and Replay Testing for HTTP APIs with GoReplay
  7. Record and Replay Real API Traffic for Testing and Offline ...
  8. CopilotKit
  9. Continuous integration best practices
  10. OWASP Top 10 for LLM Applications: Risks & Mitigations
  11. OWASP Top 10 for Large Language Model Applications
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles