fix(scripts): actually escape LIKE pattern literals + scope count endpoint
Codex review of #36 + #37 surfaced that my prior `escape="\\"` only declared the escape character — the pattern literals themselves still contained unescaped `_` and `%`, so `parent_path="foo_bar"` continued to match `fooXbar/...`, `foo2bar/...`, etc. My earlier ESCAPE-clause assertions were tautological: they verified the SQL rendered the ESCAPE keyword without ever checking that the pattern was actually escaped. The tests passed; the leak persisted. Fix in three layers: 1. Real escape: `backend/src/backend/scripts.py` gains `_escape_like_pattern(value)` that escapes `\` → `\\`, `%` → `\%`, `_` → `\_` (in that order — the escape char MUST be escaped first). `_build_list_scripts_descendant_prefix` now returns the escaped prefix. `list_workspace_directories` and `delete_workspace_directory` also escape their server-built prefixes. `count_scripts` escapes the user subtree prefix. 2. Same bug elsewhere: `backend/src/backend/resources.py:404` had the identical `DataResources.resource_name.like(f"%{keyword}%")` pattern; a search for "100%" would match everything. Now escaped too. 3. Count endpoint scope: `count_scripts` was workspace-wide and skipped the StorageObjects JOIN. Now INNER JOINs StorageObjects (drops orphans whose current_object_id is dangling) and filters by `workspace/{user_id}/` subtree so the result matches what `list_scripts(parent_path="")` would return. Multi-member workspaces no longer over-report, and orphan rows no longer inflate the count. Frontend: `DashboardRoute` is not keyed by workspace/user (only ScriptsPage is), so without a workspace_id dep the previous workspace's count persisted across navigation. useEffect now depends on `currentWorkspace?.workspace_id`; `loadScriptCount` clears the count to null at the start of the fetch so the dashboard doesn't flash a stale number. Tests — backend/tests/test_list_scripts_parent_path.py - Rewritten with three layers of coverage: * Pure helper tests for `_escape_like_pattern` (7 cases including backslash-escape-first ordering). * SQL-contract tests asserting the COMPILED PATTERN contains the escaped form (lowercased to neutralise SQLAlchemy keyword casing). * BEHAVIORAL tests on SQLite in-memory with the same LIKE semantics — proves the fix actually prevents the wildcard leak. Includes a negative test (without escape, siblings DO match) so the fixture is verified to exercise the bug. Tests — backend/tests/test_count_scripts.py - Updated to assert the JOIN + user-scope filter. New test verifies two different users in the same workspace get different subtrees. Verified: - pytest backend/tests: 65 passed (43 baseline + 12 list_scripts + 4 count + 6 helper/SQLite behavioral) - pnpm typecheck: clean - Raw SQL on MySQL (live DB) confirms `LIKE 'workspace/.../foo\_bar/%%' ESCAPE '\\'`.
This commit is contained in:
@@ -400,8 +400,12 @@ async def list_resources(
|
||||
)
|
||||
statement = statement.where(DataResources.visibility == visibility)
|
||||
if keyword:
|
||||
# Escape LIKE metacharacters so a search like "100%" or "my_file"
|
||||
# doesn't act as a wildcard. The outer "%...%" wildcards stay raw.
|
||||
from backend.scripts import _escape_like_pattern # local import: avoid cycle
|
||||
escaped = _escape_like_pattern(keyword.strip())
|
||||
statement = statement.where(
|
||||
DataResources.resource_name.like(f"%{keyword.strip()}%")
|
||||
DataResources.resource_name.like(f"%{escaped}%", escape="\\")
|
||||
)
|
||||
rows = (await session.execute(statement)).all()
|
||||
return {
|
||||
|
||||
@@ -115,10 +115,25 @@ def user_relative_path(context: RequestContext, child_path: str = "") -> str:
|
||||
return f"{base}/{normalized}" if normalized else base
|
||||
|
||||
|
||||
def _escape_like_pattern(value: str) -> str:
|
||||
"""Escape SQL LIKE metacharacters so user-supplied folder names that
|
||||
contain ``_`` or ``%`` do not act as wildcards.
|
||||
|
||||
Must be paired with ``escape="\\\\"`` on the LIKE clause so MySQL
|
||||
recognizes the doubled backslash as a single literal backslash escape.
|
||||
The trailing ``%`` / ``%/%`` SQL wildcards are NOT escaped — they are
|
||||
added by the caller and are meant to be wildcards.
|
||||
"""
|
||||
# Order matters: escape the escape char FIRST, otherwise the next two
|
||||
# replacements would double-escape our newly inserted backslashes.
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _build_list_scripts_descendant_prefix(
|
||||
context: RequestContext, parent_path: str
|
||||
) -> str:
|
||||
"""Return the materialized-path prefix for direct children of ``parent_path``.
|
||||
"""Return the escaped materialized-path prefix for direct children of
|
||||
``parent_path``.
|
||||
|
||||
The endpoint appends ``LIKE '<prefix>/%' AND NOT LIKE '<prefix>/%/%'``
|
||||
against ``storage_objects.relative_path`` so only scripts whose parent
|
||||
@@ -127,6 +142,10 @@ def _build_list_scripts_descendant_prefix(
|
||||
|
||||
Empty ``parent_path`` produces the user-scoped root prefix — i.e. the
|
||||
endpoint returns root-level scripts only, not the full workspace.
|
||||
|
||||
The prefix is run through ``_escape_like_pattern`` so folder names
|
||||
containing ``_`` / ``%`` do not act as wildcards. The trailing ``/``
|
||||
is appended AFTER escaping so it remains a literal slash.
|
||||
"""
|
||||
normalized_parent = normalize_user_path(parent_path)
|
||||
scoped_prefix = user_relative_path(context)
|
||||
@@ -134,7 +153,7 @@ def _build_list_scripts_descendant_prefix(
|
||||
target_prefix = f"{scoped_prefix}/{normalized_parent}"
|
||||
else:
|
||||
target_prefix = scoped_prefix
|
||||
return f"{target_prefix}/"
|
||||
return f"{_escape_like_pattern(target_prefix)}/"
|
||||
|
||||
|
||||
def safe_script_name(value: str, script_type: str) -> str:
|
||||
@@ -769,7 +788,7 @@ async def list_workspace_directories(
|
||||
scoped_prefix = user_relative_path(context)
|
||||
parent = normalize_user_path(parent_path)
|
||||
target_prefix = f"{scoped_prefix}/{parent}" if parent else scoped_prefix
|
||||
descendant_prefix = f"{target_prefix}/"
|
||||
descendant_prefix = f"{_escape_like_pattern(target_prefix)}/"
|
||||
|
||||
rows = (
|
||||
await session.execute(
|
||||
@@ -805,7 +824,8 @@ async def list_workspace_directories(
|
||||
|
||||
for directory in directories.values():
|
||||
# directory['path'] is already workspace-relative and includes the parent segment.
|
||||
child_prefix = f"{scoped_prefix}/{directory['path']}/"
|
||||
# Escape defensively in case the DB has folder names containing `_` or `%`.
|
||||
child_prefix = f"{_escape_like_pattern(scoped_prefix)}/{_escape_like_pattern(directory['path'])}/"
|
||||
has_children = await session.scalar(
|
||||
select(StorageObjects.storage_object_id).where(
|
||||
StorageObjects.workspace_id == context.workspace.workspace_id,
|
||||
@@ -1007,7 +1027,7 @@ async def delete_workspace_directory(
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "directory not found")
|
||||
target_ulid = target_dir_row.storage_object_id
|
||||
|
||||
child_prefix = f"{target_relative}/"
|
||||
child_prefix = f"{_escape_like_pattern(target_relative)}/"
|
||||
descendants = (
|
||||
(
|
||||
await session.execute(
|
||||
@@ -1117,17 +1137,29 @@ async def list_scripts(
|
||||
# 工作区内 active 脚本总数。DashboardRoute 等不需要列表但需要计数的场景使用,
|
||||
# 避免被 listScripts 的懒加载语义污染。该路由必须在 /scripts/{script_id} 之前声明
|
||||
# ——FastAPI 按声明顺序匹配,否则 `count` 会被当作 script_id 命中 get_script。
|
||||
#
|
||||
# 范围与 list_scripts(parent_path="") 对齐:INNER JOIN 到 StorageObjects 以排除
|
||||
# 孤儿脚本(其 current_object_id 没有 joinable row),按用户子树
|
||||
# (workspace/{user_id}/) 过滤。否则:
|
||||
# - 含孤儿 → 数字虚高
|
||||
# - workspace 范围 → 多成员工作区里 dashboard 会显示用户看不见的脚本
|
||||
@router.get("/api/v1/scripts/count")
|
||||
async def count_scripts(
|
||||
context: RequestContext = Depends(request_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
user_subtree_prefix = f"{_escape_like_pattern(user_relative_path(context))}/%"
|
||||
total = await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(Scripts)
|
||||
.join(
|
||||
StorageObjects,
|
||||
StorageObjects.storage_object_id == Scripts.current_object_id,
|
||||
)
|
||||
.where(
|
||||
Scripts.workspace_id == context.workspace.workspace_id,
|
||||
Scripts.status == "active",
|
||||
StorageObjects.relative_path.like(user_subtree_prefix, escape="\\"),
|
||||
)
|
||||
)
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user