Skip to content

fix(lexical-graph): parameterize SQL filters and id lists in PGVector store - #11

Open
noel-improv wants to merge 5 commits into
mainfrom
fix/pgvector-filter-sql-injection
Open

noel-improv wants to merge 5 commits into
mainfrom
fix/pgvector-filter-sql-injection

Conversation

@noel-improv

Copy link
Copy Markdown
Owner

Summary

Metadata filter values, metadata key names, and id lists were string-interpolated into the SQL WHERE clauses of the PGVector store. A filter value or id containing a single quote could break out of its literal and inject arbitrary SQL. Because the get_embeddings, update_versioning, and delete_embeddings paths build raw IN (...) lists the same way, the blast radius includes UPDATE and DELETE, not just SELECT. This change binds all filter values, keys, and ids as psycopg2 parameters instead of inlining them.

The codebase already had the right pattern: the embedding vector and LIMIT in top_k, and every value in add_embeddings, were already bound with %s. This change applies the same approach to the remaining sinks.

What changed

parse_metadata_filters_recursive and filter_config_to_sql_filters now return (clause, params). Filter values become %s placeholders and the metadata key is bound into the JSON path operand (->>%s), so neither can break out of a literal.

top_k splices the filter params between the embedding and LIMIT placeholders, preserving order. get_embeddings, update_versioning, and delete_embeddings build IN (%s, ...) placeholder lists; update_versioning also binds its timestamp.

formatter_for_type now coerces a value for parameter binding (native numbers, format_datetime for timestamps, str for text) rather than quoting it into SQL. The LIKE wildcard wrapper drops its %% doubling, which is no longer needed once the value is a bound parameter.

Only internally-derived tokens stay interpolated: the type cast and the table/schema/index identifiers (sourced from config, not user input). The # nosec B608 annotations are kept for those identifier interpolations, with corrected justifications noting that values, keys, and ids are now bound.

Testing

New tests/unit/storage/vector/test_pg_vector_injection.py (17 tests) drives each sink against a mocked cursor and asserts the payload reaches cur.execute as a bound parameter, never as inline SQL. It covers the filter-value, filter-key, and three id-list sinks, a parametrized set of break-out payloads (tautology, stacked statement, comment terminator, null byte, unicode quote, backslash, newline), the clause-builder (clause, params) contract, and a positive path.

Before the fix the five injection tests fail, printing the actual injected SQL; after the fix all pass. tests/unit/storage (310 tests) and the related filter/metadata/graph suites (167 tests) pass unchanged. Bandit -ll reports no medium+ findings.

@github-actions

Copy link
Copy Markdown

Lexical Graph Coverage Report: The coverage is at 60.46% (target: 80%). Download the HTML report here.

@github-actions

Copy link
Copy Markdown

Lexical Graph Coverage Report: The coverage is at 60.46% (target: 80%). Download the HTML report here.

… store

Metadata filter values, metadata key names, and id lists were
string-interpolated into the WHERE clauses of the PGVector store. A filter
value or id containing a single quote could break out of its literal and
inject arbitrary SQL, widening to UPDATE and DELETE via the raw IN (...)
id-list builders.

Bind all filter values, keys, and ids as psycopg2 parameters, matching the
binding already used for the embedding vector and LIMIT:

- parse_metadata_filters_recursive / filter_config_to_sql_filters return
  (clause, params); values use %s placeholders and the metadata key is bound
  into the JSON path operand (->>%s).
- top_k splices the filter params between the embedding and LIMIT placeholders.
- get_embeddings, update_versioning, and delete_embeddings build IN (%s, ...)
  placeholder lists; update_versioning also binds the timestamp.
- formatter_for_type coerces the value for binding rather than quoting it.

Only the type cast and the table/schema/index identifiers (internal config)
remain interpolated, so the nosec B608 annotations are kept with accurate
justifications.

Adds tests that drive each sink against a mocked cursor and assert payloads
reach cur.execute as bound parameters, never as inline SQL.
@noel-improv
noel-improv force-pushed the fix/pgvector-filter-sql-injection branch from d2d19ad to 82dce0a Compare June 29, 2026 22:15
@github-actions

Copy link
Copy Markdown

Lexical Graph Coverage Report: The coverage is at 60.46% (target: 80%). Download the HTML report here.

An empty ids list produced `... IN ()`, which Postgres rejects as a syntax
error, in get_embeddings, update_versioning, and delete_embeddings. Return
early before opening a connection so an empty list is a no-op. Adds tests
asserting no query is emitted for an empty list.
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

Lexical Graph Coverage Report: The coverage is at 60.81% (target: 80%). Download the HTML report here.

Couples the integration test with this fix. It drives the real PGIndex sinks
against a live Postgres+pgvector engine, asserting injected filter values,
metadata keys, and id lists are inert while the pre-fix interpolation still
breaks out. A dedicated workflow runs it against a Postgres+pgvector service
container on release and on PRs touching the sink or the test. Skips when
PGVECTOR_TEST_DSN is unset.
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

Lexical Graph Coverage Report: The coverage is at 60.81% (target: 80%). Download the HTML report here.

The module docstring referenced its "parent fix PR", which is meaningless to a
reader on main. Describe what the test does without the process context.
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

Lexical Graph Coverage Report: The coverage is at 60.81% (target: 80%). Download the HTML report here.

…work

Address review feedback on awslabs#362:

- Remove the standalone pytest test and its GitHub Actions workflow; the
  toolkit runs integration tests manually on a SageMaker notebook, not in CI.
- Add lexical_graph_pgvector_safety.LexicalGraphPGVectorInjectionSafety,
  extending IntegrationTestBase and driving the real PGIndex via
  VectorStoreFactory instead of constructing one directly. It skips unless
  VECTOR_STORE is Postgres-backed, so it is safe in lexical.short for every
  env-type. Assertions are unchanged: injected filter values, metadata keys,
  and id lists stay inert, with a red-state proof of the pre-fix interpolation.
- Register it in lexical.short next to the Cypher-safety test, and document
  how to run it (a *-postgresql env-type) in the integration-tests README.
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

Lexical Graph Coverage Report: The coverage is at 60.81% (target: 80%). Download the HTML report here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant