Skip to content

fix: escape glob metacharacters in SCAN match patterns - #702

Merged
vishal-bala merged 4 commits into
mainfrom
fix/escape-glob-in-scan-patterns
Sep 10, 2026
Merged

vishal-bala merged 4 commits into
mainfrom
fix/escape-glob-in-scan-patterns

Conversation

@vishal-bala

@vishal-bala vishal-bala commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

What's wrong

BaseCache.clear() built its SCAN MATCH pattern by interpolating the cache name directly. Per the SCAN docs that pattern is glob-style, and the name is caller-supplied and unvalidated — so a name containing *, ?, [ or \ produced a pattern matching keys the cache does not own, and clear() then deleted them.

Reproduced on Redis 8.4.5:

redis-cli -n 9 MSET 'cache[ab]:x' 1 'cachea:y' 2 'cacheb:z' 3
redis-cli -n 9 --scan --pattern 'cache[ab]:*'     # -> cachea:y, cacheb:z
redis-cli -n 9 --scan --pattern 'cache\[ab]:*'    # -> cache[ab]:x

So SemanticCache(name="cache[ab]").clear() wipes two unrelated caches and leaves its own entries intact. It is silent — no error, and the caller believes their cache was cleared.

This is data loss on a destructive path, not a matching quirk: low likelihood (unusual names) but high consequence, with a blast radius covering other keys in the same keyspace. In a multi-tenant naming scheme such as EmbeddingsCache(name=f"cache:{tenant_id}") a tenant registering x[ab] destroys other tenants' entries.

Grepping match= across the repo found the same shape in three more places, two of them also destructive:

Site Consequence when unescaped
BaseCache.clear / aclear deletes other caches' keys
SemanticRouter._route_pattern deletes other routes' keys via delete_route_references
build_scan_match_patterns rewrites other indices' keys on the executor's RENAME / DUMP-RESTORE-DEL path
MigrationPlanner._sample_keys and the async twin wrong key sample, so a wrong migration plan

The fix

A match_pattern(*segments) builder in redisvl/utils/utils.py escapes each literal segment and appends the trailing glob. All four sites now build patterns through it; build_scan_match_patterns is itself a chokepoint, so fixing it covers executor, async_executor, validation and async_validation at once.

Exposing a builder rather than a bare escape() is deliberate. Escaping at the call site is a step the next contributor can forget, and no lint rule can express the invariant here: ruff and flake8 are unconfigured, mypy is not strict, and patterns are consumed several call sites away from where they are built. A builder makes the omission unrepresentable — there is no other way to make a pattern.

The escape set is \, *, ?, [. ], ^ and - are deliberately absent: they are only meaningful inside a [...] class, which can never open once [ is escaped. Confirmed empirically — cache\[ab]:* and cache\[ab\]:* return identical results.

Escaping rather than rejecting such names. Rejecting at construction would be a breaking change that orphans keys: a deployment whose cache is already named cache[ab] could no longer construct the object to clean up after itself. If maintainers prefer validation, it wants a deprecation cycle rather than a straight raise.

Testing

Two questions, separated by what can actually answer each:

  • Does RedisVL emit the right pattern? An exact-string assertion, no matcher needed — tests/unit/test_scan_pattern_escaping.py covers all four sites hermetically, including aclear and both planners.
  • Does Redis then interpret it as intended? Only a real server can say. tests/integration/test_embedcache.py adds the cache regression (three caches colliding under an unescaped glob; clear one, assert the others survive), and tests/integration/test_migration_v1.py adds the migration and router prefix case.

No glob matcher is reimplemented anywhere, so nothing can drift from Redis. Every new test was checked to fail without the fix.

Verified: mypy clean (119 files), 1421 unit tests pass, integration green across embedcache and migration.

Deliberately out of scope

Four pre-existing issues surfaced while working in this code. None is caused or worsened by this change, and each wants its own PR:

  • BaseCache.clear() has a containment bug escaping cannot fix. _get_prefix() is f"{name}:", so a cache named foo clearing foo:* deletes the entries of a cache named foo:bar — ordinary names, no metacharacters, same blast radius. SearchIndex.clear() already does this correctly by deleting the doc ids FT.SEARCH returns; SemanticCache should route through it, and EmbeddingsCache (which has no index) should verify each scanned key's prefix segment before deleting.
  • clear() / aclear() never advance a Mapping cursor on Redis Cluster, so they re-scan page 1 forever. Note cursor = cursor_int is not the fix — a dict cannot be passed back as a SCAN cursor; per-node iteration via scan_iter is. Already addressed on a separate branch.
  • async_planner appends the key separator where the other three sites do not, so it samples a narrower key set than the index covers — FT.CREATE PREFIX is a literal string-prefix match (verified: PREFIX 1 zzglobgl[ab] indexes only its own keys), which makes the sync planner the correct one. Flagged in a comment here rather than changed, since fixing it is a behaviour change. The natural follow-up consolidates all three pattern builders and drops build_scan_match_patterns' unused key_separator parameter.
  • delete_route_references splits with a hardcoded ":" rather than key_separator, and raises after drop_keys has already deleted.

Note

High Risk
Changes destructive SCAN-based clear, route deletion, and migration key enumeration; the fix prevents cross-tenant/keyspace deletes but any remaining pattern bugs would still cause data loss.

Overview
Fixes a silent data-loss bug where caller-supplied cache names, route names, or index prefixes containing Redis glob metacharacters (*, ?, [, \) were interpolated into SCAN MATCH patterns unescaped, so destructive operations could target the wrong keys (e.g. clear() on cache[ab] deleting cachea/cacheb while leaving its own entries).

Introduces match_pattern(*segments) in redisvl/utils/utils.py to escape literal segments and append *, and routes all pattern construction through it: BaseCache.clear/aclear, SemanticRouter._route_pattern, build_scan_match_patterns, and sync/async migration key sampling. Unit and integration tests assert emitted patterns and live Redis behavior; the async planner only renames a local variable and documents a pre-existing separator sampling divergence.

Docs: installation.md adds cache TTL refresh (EXPIRE on hits) ACL guidance and notes that ACL key patterns use the same glob rules as SCAN; the Voyage contextualized notebook cell gets # NBVAL_SKIP for CI.

Reviewed by Cursor Bugbot for commit 0886e9a. Bugbot is set up for automated code reviews on this repo. Configure here.

SCAN/KEYS MATCH patterns are glob-style, but several call sites
interpolated a caller-supplied literal straight into one. A cache name,
index prefix or route name containing *, ?, [ or \ therefore produced a
pattern matching keys the caller did not own -- and on the destructive
paths those keys were then deleted or rewritten.

Verified on Redis 8.4.5: SemanticCache(name="cache[ab]").clear() deletes
every entry under cachea: and cacheb: while leaving its own keys intact,
silently and with no error. The same shape reaches delete_route_references
and the migration executor's RENAME/DUMP-RESTORE-DEL path.

Patterns are now built with match_pattern(), which escapes each literal
segment and appends the trailing glob. Exposing a builder rather than a
bare escaper is deliberate: escaping stops being a step a future call site
can forget, which matters because no lint rule can express the invariant
(ruff and flake8 are unconfigured, mypy is not strict, and patterns are
consumed several call sites away from where they are built).

Covered sites: BaseCache.clear/aclear, SemanticRouter._route_pattern,
build_scan_match_patterns (and through it executor, async_executor,
validation, async_validation), and both planners' key sampling.

Escaping rather than rejecting such names, because rejecting is a breaking
change that orphans keys: a deployment whose cache is already named
"cache[ab]" could no longer construct the object to clean up after itself.

Tests split by what can actually answer each question. That RedisVL emits
the right pattern is an exact-string assertion, needing no matcher. That
Redis then interprets it as intended is asked of a real server: the
existing cache regression in test_embedcache.py, and a new migration and
router prefix case in test_migration_v1.py. No glob matcher is reimplemented
anywhere, so nothing can drift from Redis.

Two pre-existing issues in the same neighbourhood are deliberately left
alone: BaseCache.clear/aclear never advance a Mapping cursor on Redis
Cluster (fixed separately by replacing the hand-rolled loop with
scan_iter), and async_planner appends the key separator where the other
three sites do not, so it samples a narrower key set than the index covers.
Two things that surprise anyone assembling a least-privilege credential,
both measured on Redis 8.4.5 rather than inferred.

A cache read is not read-only: with a TTL configured, every hit refreshes
the matched entries' TTL, so the read path issues EXPIRE, which is in
@Write and not @READ. A lookup-only credential therefore fails on a cache
hit rather than on the write that populated the entry. Granting the command
is only half of it -- ACL DRYRUN shows +expire under a read-only key
pattern such as %R~llmcache:* is denied on the key, and that is the shape
the Key permissions table presents as sufficient for querying. Placed in
"Roles built from @READ and @Write", where a reader assembling exactly that
role will be.

ACL key patterns are also glob-style, matched by the same engine as SCAN
MATCH, so they carry the same metacharacter trap: ~cache[ab]:* grants
cachea:1 and cacheb:1 and denies the literal cache[ab]:1.
@vishal-bala vishal-bala added the auto:patch Increment the patch version when merged label Aug 24, 2026
@vishal-bala
vishal-bala marked this pull request as ready for review August 25, 2026 14:04

@limjoobin limjoobin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, this PR introduces a fix to escape glob-style patterns in caller-supplied names such that they are no longer interpolated in their raw form into SCAN MATCH patterns.

Resolves against #703, which replaced the hand-rolled SCAN cursor loops
with scan_iter to fix the cluster hang and cross-slot DEL. Both sides are
kept: main's scan_iter iteration, with the caller-supplied prefix escaped
through match_pattern rather than interpolated.

- BaseCache.clear/aclear: main's scan_iter batching, match_pattern(prefix)
- MigrationPlanner._sample_keys and the async twin: main's scan_iter (and
  its aclosing wrapper), with the local renamed to scan_match so it no
  longer shadows the imported match_pattern helper
- test_scan_pattern_escaping: the recording clients now capture scan_iter
  instead of scan, matching what the code under test calls

Audited every match= site after the merge: all nine are either built by
match_pattern or come from build_scan_match_patterns, which escapes. None
of the four build_scan_match_patterns consumers imports match_pattern, so
their loop variable of that name shadows nothing.
04_vectorizers.ipynb cell 19 constructs a VoyageAIVectorizer for
voyage-context-4 and calls embed_many, which reaches the live API and
times out at nbval's 60s limit. The cell arrived unguarded in #692 and
fails on main; this branch only surfaced it by merging main in.

The notebook already marks eight other live-API cells with # NBVAL_SKIP,
so this follows the established convention: notebook validation must not
call external providers, and the API-keyed coverage for these models lives
in the integration job (tests/integration/test_vectorizers.py exercises
voyage-context-4 behind --run-api-tests).

Guards only the failing cell. The basic VoyageAI cell above it passes on
the same key and is left alone.
@vishal-bala vishal-bala added the auto:release Create a release when this PR is merged label Sep 10, 2026
@vishal-bala
vishal-bala merged commit 3aeebe8 into main Sep 10, 2026
58 checks passed
@vishal-bala
vishal-bala deleted the fix/escape-glob-in-scan-patterns branch September 10, 2026 10:49
@applied-ai-release-bot

Copy link
Copy Markdown

🚀 PR was released in v0.27.2 🚀

@applied-ai-release-bot applied-ai-release-bot Bot added the released This issue/pull request has been released. label Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto:patch Increment the patch version when merged auto:release Create a release when this PR is merged released This issue/pull request has been released.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants