fix(budgets): escape the member search filter before it reaches ILIKE - #411
aivong-openhands wants to merge 3 commits into
Conversation
get_org_members_paginated interpolated email_filter straight into
`User.email.ilike(f'%{email_filter}%')`, so an ILIKE metacharacter typed into
the members search box kept its wildcard meaning: searching for "%" returned
every member of the organization rather than the addresses containing a literal
percent sign.
Escape backslash, percent and underscore and pass escape='\' so the pattern
matches the term literally. The budget page already did this through
_escape_ilike; this is the path that did not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||||||||||||||
aivong-openhands
left a comment
There was a problem hiding this comment.
Note: submitted as a COMMENT review rather than REQUEST_CHANGES because GitHub does not allow requesting changes on a pull request authored by the same account. The verdict below is nonetheless needs rework — please treat the CRITICAL ISSUES as blocking.
Code Review
🟡 Acceptable — the escaping itself is correct, minimal, and matches the existing _escape_ilike helper's behaviour exactly. The problem is that it was applied to one of the two functions that need it, and it was inlined instead of reusing the helper that already exists for this.
[CRITICAL ISSUES]
-
[
storage/org_member_store.py, L216] Same Defect, Untouched, Twelve Lines Up:get_org_members_count— in the same class, in the same file, 40 lines above the fixed line — still doesUser.email.ilike(f'%{email_filter}%')with no escaping. It is not a dead path:server/routes/orgs.py:1054exposes it asGET /orgs/{org_id}/members/count, andfrontend/src/routes/manage-organization-members.tsx:54-62callsuseOrganizationMembersanduseOrganizationMembersCountwith the samedebouncedEmailFilter.So after this PR, typing
%into the members search box returns zero rows from the fixed listing endpoint while the unfixed count endpoint reports the whole organization. The user-visible result is a table showing "no results" next to a total of 47. The PR trades one wrong answer for an internally contradictory pair, which is arguably harder to diagnose. Both call sites must be fixed together.
[IMPROVEMENT OPPORTUNITIES]
-
[
storage/org_member_store.py, L252-257] Duplicated Logic:_escape_ilikealready exists atserver/services/org_budget_service.py:382and is character-for-character what this inlines. The PR description even cites it as prior art — then reimplements it instead of calling it. Escaping rules that exist in two places drift; the next person to handle[in aLIKEpattern will fix one. Hoist_escape_ilikesomewhere shared (utils/, orstorage/) and have all three call sites — budget search, member listing, member count — use it. -
[
storage/org_member_store.py, L250-251] Comment States the Requirement, Not the Reason: "A metacharacter typed into the members search box must match itself rather than widen the filter to the org" restates what the.replace()chain plainly does. The non-obvious part worth recording is theescape='\\'argument — that SQLAlchemy needs it explicitly or the backslashes are passed through as literal characters rather than escapes. Everything else is inferable from the code.
[TESTING GAPS]
-
No Test for
get_org_members_count:tests/unit/test_org_member_store.py:838hastest_get_org_members_count_with_email_filter, but it only exercises a literal term. Nothing pins the metacharacter behaviour on the count path, which is precisely why the gap above is invisible to CI — all checks are green on a PR that leaves the two endpoints disagreeing. -
Credit where due on the listing test:
test_member_search_never_matches_beyond_the_literal_termis a genuinely good test. It seeds three real members against a real session, runs the real query, and asserts on the result set rather than on a mock call. It fails on the old code and passes on the new. That is exactly the right shape — please give the count path the same treatment. -
Coverage of the escape set is thin: the test searches for
%only._(matches any single character) and\(the escape character itself, and the reason for the.replace('\\', '\\\\')ordering) are handled by the code but unpinned by any test. The backslash case in particular is where a naive reordering of those three.replace()calls silently breaks, and nothing would catch it. Parametrising over['%', '_', '\\', '%_\\']costs one decorator. -
[PR description] Evidence:
How to Testlists onlypytestinvocations. Per this repo's bar, test output alone is not evidence. Since the described symptom is user-facing and trivially reproducible — type%into the members search — a screenshot of the members page before and after, or acurlagainstGET /orgs/{org_id}/members/financial?email=%25with the response body, would settle it. That would also have surfaced the count/listing disagreement immediately, since the UI renders both numbers side by side. Please also include the agent conversation URL, given the description notes this was agent-generated.
[SECURITY NOTE]
For the record, since "escaping" and "SQL" appear together: this was never an injection risk. SQLAlchemy parameterises the pattern, so the term cannot escape the string literal — the bug is a wildcard-semantics bug confined to matching behaviour. Worth stating plainly so nobody back-ports this as a security fix or reaches for the wrong severity. The real consequence is an information-disclosure-shaped one: an admin searching for a specific member got handed the entire org roster, which is a correctness and UX failure rather than a boundary failure.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟢 LOW
Eight lines in one query builder, no schema change, no API contract change, no dependency change, all CI green. The escaping is provably correct and the behaviour change is strictly narrowing — any search term that worked literally before still works identically, and only terms containing %, _, or \ change meaning, which is the entire point. Worst case if the fix is wrong is a search that under-matches, which is visible and harmless. The count-endpoint gap raises the confusion cost but not the risk: it is already broken on main, and this PR does not make it worse in isolation, only inconsistent with its neighbour.
VERDICT:
❌ Needs rework — the fix is right, the scope is not. Ship the count path with it.
KEY INSIGHT:
Two functions in the same class build the same ILIKE pattern from the same user input and are called with the same term by the same page; fixing one and not the other converts a consistently wrong answer into two mutually contradictory ones.
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:
- Add a
.agents/skills/custom-codereview-guide.mdfile to your branch (or edit it if one already exists) with the/codereviewtrigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.- Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
- When your PR is merged, the guideline file goes through normal code review by repository maintainers.
Resolve with AI? Install the iterate skill in your agent and run
/iterateto automatically drive this PR through CI, review, and QA until it's merge-ready.Was this review helpful? React with 👍 or 👎 to give feedback.
This review was generated by an AI agent (OpenHands) on behalf of the requesting user.
get_org_members_count built the same unescaped ILIKE pattern as get_org_members_paginated, and the members page calls both with the same search term. Fixing only the listing left the table empty beside a total of 47. Hoist the escaping into utils/sql.escape_ilike so the budget search, the member listing and the member count share one copy, and parametrise the store test over %, _, backslash and a combination on both query paths. Co-authored-by: openhands <openhands@all-hands.dev>
|
Review addressed in 34a1e0d. Taking the points in order: Critical — Duplicated logic. Comment states the requirement, not the reason. The call site is back to Testing gaps. Evidence. The description now shows the count/listing pair in all three states rather than only a pytest summary line. Security note. Agreed and now stated in the description: SQLAlchemy parameterises the pattern, so this was never injection. It is wildcard semantics. One thing I deliberately did not do: 92 passed, 9 skipped across the member store, financial service and budget service suites; pre-commit clean. This comment was created by an AI agent (OpenHands) on behalf of the requesting user. |
Mutation review of the tests in this PRI hand-wrote 14 mutants against the lines this PR touches and ran them over the Controls — the PR's central claims are pinned
All three die, so the claims in the description hold up. What makes them die is Survivors
M1 — the third call site is the one nothing testsThis PR hoists That is the regression risk the refactor introduces. The helper now has three
@pytest.mark.asyncio
@pytest.mark.parametrize(
'users_search,expected',
[
('%', set()),
('_', {'bob_smith@example.com'}),
('\\', {'dave\\ops@example.com'}),
('alice', {'alice@example.com'}),
],
)
async def test_budget_user_search_treats_metacharacters_literally(
async_session_maker, budget_org, users_search, expected
):
"""The budget page search box must narrow the roster, not widen it."""
emails = ['alice@example.com', 'bob_smith@example.com', 'dave\\ops@example.com']
async with async_session_maker() as session:
role = Role(name='member', rank=1)
session.add(role)
await session.flush()
for i, email in enumerate(emails):
user_id = uuid4()
session.add_all(
[
User(id=user_id, current_org_id=budget_org.id, email=email),
OrgMember(
org_id=budget_org.id,
user_id=user_id,
role_id=role.id,
llm_api_key=f'test-key-{i}',
status='active',
),
]
)
session.add(OrgBudgetSettings(org_id=budget_org.id, enabled=True))
await session.commit()
async with async_session_maker() as session:
settings = (
await session.execute(
select(OrgBudgetSettings).where(
OrgBudgetSettings.org_id == budget_org.id
)
)
).scalar_one()
rows, total = await OrgBudgetService(session)._build_user_budget_rows(
budget_org.id,
settings,
None,
users_page=1,
users_per_page=50,
users_search=users_search,
users_status=None,
)
assert {row['user_email'] for row in rows} == expected
assert total == len(expected)Verified both ways: 4 passed on this branch unmodified, and M8 — the count path's case-insensitivity is unassertedChanging This is the same count/listing divergence the PR was written to close, reached One more parametrise case on the test you already added covers it, since that ('alice', {'alice@example.com'}),
('ALICE', {'alice@example.com'}),Verified both ways: 6 passed on this branch unmodified, M2 / M3 / M7 — I think these are equivalent mutantsDropping and both forms agree on real rows ( Worth flagging only because the M6 — defended outside the code under testRelaxing the listing's It cannot be reached through the API: all three routes in Not a test gap
This comment was generated by an AI assistant on behalf of the user. |
…itivity The mutation review on this PR found two live mutants against lines it touches. Deleting the escape_ilike call from the budget search (escaped = search_value) left the suite green, so the third call site of the helper the PR introduces had no coverage. Making get_org_members_count case-sensitive while the listing stayed on ilike also survived, which is the same count/listing divergence this PR closes reached from the other side. Add test_budget_user_search_treats_metacharacters_literally against _build_user_budget_rows, and an 'ALICE' case to the store test that already asserts both query paths. Also correct the escape_ilike docstring: on Postgres ESCAPE '\\' is the default for LIKE/ILIKE, so passing it documents intent rather than being a correctness requirement, as the review pointed out. Co-authored-by: openhands <openhands@all-hands.dev>
|
Thanks — the mutation review was useful, and both survivors you flagged were real gaps. Addressed in a9e7ec8. M1 — budget search untested. You were right that this was the regression risk the refactor introduced: hoisting M8 — count path case-insensitivity. Added M2 / M3 / M7 — agreed, equivalent. M6 — leaving as is. Your reasoning holds: the Full run after the change: On the two "not a test gap" notes: This comment was written by an AI agent (OpenHands). |
HUMAN:
AGENT:
Why
A metacharacter typed into the members search box widened the filter instead of narrowing it. Both
OrgMemberStore.get_org_members_countandOrgMemberStore.get_org_members_paginatedinterpolatedemail_filterstraight intoUser.email.ilike(f'%{email_filter}%'), so searching for%matched every member of the organization and_matched any address at all. The members page (frontend/src/routes/manage-organization-members.tsx) feeds the same debounced term to both, so both have to be fixed together — escaping only the listing swaps one wrong answer for an empty table beside a total of 47.The budget page already escaped its own search term through a private
_escape_ilikeinorg_budget_service.py. Rather than copy it a third time, this hoists it toutils/sql.escape_ilikeand has all three call sites — budget search, member listing, member count — share one copy.Summary
utils/sql.escape_ilike, moved fromserver/services/org_budget_service.py, and use it from the budget search.get_org_members_countandget_org_members_paginatedand pass an explicitescape='\'.%against a seeded organization.test_member_email_filter_treats_metacharacters_literally, parametrised over%,_,\,%_\, a literal term and a differently-cased literal term, asserting the count and listing paths agree on real rows in a real database.test_budget_user_search_treats_metacharacters_literallyagainst_build_user_budget_rows, so the third caller of the shared helper is covered too.Issue Number
N/A
How to Test
Expect
97 passed, 9 skipped.Video/Screenshots
No UI change, so the evidence is the pair of numbers the members page renders side by side. Seeding an org with 47 members (
user0@example.com…user46@example.com) and calling the count and listing stores with the same search term, as the page does:On
main— the bug. Searching for%returns the whole org instead of nothing:At the previous head of this PR — the gap the review caught. Only the listing was escaped, so the page would have shown an empty table next to a total of 47:
With this push. Both endpoints agree, and literal searches are unaffected:
Mutation checks on the new tests: reverting either
.ilike()call to the unescaped form fails 3 of the parametrised cases, and reordering the.replace()chain inescape_ilikeso the backslash is handled last fails 2 of them. The ordering is therefore pinned by a test, not just described in a comment.A mutation review on this PR found two further live mutants, both now killed. Dropping the
escape_ilikecall from the budget search (escaped = search_value) left the suite green, because the helper this PR hoists gained a third caller with no coverage; the new budget test fails 3 of its 4 cases under that mutant. Makingget_org_members_countcase-sensitive while the listing stayed onilikealso survived — the same count/listing divergence this PR closes, reached from the other side — and the addedALICEcase is the only failure under it.Type
Notes
One of a set of draft PRs, each carrying a single defect the Quint model for org budgets surfaced, together with the reproduction test that was already committed but skipped.
To be explicit, since "escaping" and "SQL" appear together: this is not an injection fix. SQLAlchemy parameterises the pattern, so the term never escapes the string literal. Nor is the explicit
escape='\\'load-bearing: it is already Postgres's default forLIKE/ILIKE, so it documents intent at the call site rather than changing behaviour. The defect is wildcard semantics — an admin searching for one member was handed the whole roster.Not touched here:
server/services/org_conversation_service.py:291builds an unescapedILIKEpattern the same way for the conversation search. It is the same class of bug but a different page, so it belongs in its own PR rather than widening this one.🤖 Generated with Claude Code
Enterprise server image for this PR: