Skip to content

fix(W-23695429): escape single quotes in SmartSQL IN-clause record IDs - #2992

Merged
wmathurin merged 2 commits into
forcedotcom:devfrom
wmathurin:W-23695429-smartsql-single-quote-escaping
Aug 15, 2026
Merged

fix(W-23695429): escape single quotes in SmartSQL IN-clause record IDs#2992
wmathurin merged 2 commits into
forcedotcom:devfrom
wmathurin:W-23695429-smartsql-single-quote-escaping

Conversation

@wmathurin

Copy link
Copy Markdown
Contributor

Summary

  • Escapes single quotes in the ID lists passed to deleteRecordsFromLocalStore in SyncTarget.kt
  • The IDs are joined into a SmartSQL IN ('id1','id2',...) clause; without escaping a single quote in an ID could break the query
  • Salesforce record IDs are 15/18-char alphanumeric so actual risk is zero, but this satisfies scanner rule BUG-03 (SQL injection, P3) filed as W-23695429

Test plan

  • Build: ./gradlew :libs:MobileSync:build — confirmed passing
  • Run tests: ./gradlew :libs:MobileSync:connectedAndroidTest
  • Verify sync-down + clean-ghosts still work end-to-end in MobileSyncExplorer sample app

Escape single quotes in the ID lists passed to deleteRecordsFromLocalStore
in SyncTarget.kt to satisfy scanner rule BUG-03 (SQL injection). The risk
is academic since SmartStore is a per-user encrypted database and IDs come
from the server, but sanitizing values before interpolating into SQL is
correct hygiene.

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce 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.

Reviewed at HEAD 1c5c37c. This escapes single quotes (' -> '') in each record ID before they are joined into the SmartSQL IN (...) literal in SyncTarget.deleteRecordsFromLocalStore. That is the correct SQLite/SmartSQL escaping, and it matches the already-approved-and-merged iOS twin (PR 4131, same W-23695429). Approving.

What I verified

  • Correctness: String.replace("'", "''") replaces every literal occurrence; SmartSqlHelper only rewrites {soup:path} tokens, so quoted string literals (and the doubled quotes) pass through to SQLite unchanged. There is no bind-parameter API on the SmartSQL path, so escaping is the correct and only remediation here.
  • Callers: all three callers of deleteRecordsFromLocalStore (SyncDownTarget.cleanGhosts, ParentChildrenSyncDownTarget.cleanGhosts, BriefcaseSyncDownTarget) flow through the single escaped path.
  • Cross-platform parity: same escaping semantics as the approved iOS change.
  • Backward compatibility: internal method-body change only; no public API, signature, string, dependency, permission, or build change.
  • CI: the two red checks look environmental rather than a regression from this change. The unit-tests-pr (MobileSync) failures are dominated by REQUEST_LIMIT_EXCEEDED (shared test-org API quota) with NullPointerException / "Creates failed" cascades downstream, concentrated in sync-up tests that this sync-down change does not touch; the same failures appear on unrelated PRs in the same window, and the dev nightly (~9h later, after quota reset) is green (251/251 on API 31/32/33/34/36). The ui-tests-pr failures are the known AuthFlowTester Custom-Tab / creds-section flake. Recommend re-running both jobs on a fresh quota window before merge.

One follow-up worth filing (out of scope for this surgical fix): a sibling local-store SmartSQL path with the same trust boundary is still unescaped on both platforms -- see the inline note.

This review was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

…tIds in getQueryForChildren

- Add ParentChildrenSyncTargetHelperTest: @smallTest verifying that parent IDs
  containing single quotes are doubled (001'Test -> 001''Test) in the SmartSQL
  IN-clause built by getQueryForChildren.
- Fix getQueryForChildren in ParentChildrenSyncTargetHelper to escape single
  quotes in parentIds (parallel to the existing fix in deleteRecordsFromLocalStore).
- Add SyncTargetSingleQuoteTest: @LargeTest exercising deleteRecordsFromLocalStore
  end-to-end — upserts a record with ID "001'Quote" and one with "001Normal",
  calls deleteRecordsFromLocalStore for the quote ID, asserts only "001Normal" remains.
@JohnsonEricAtSalesforce

Copy link
Copy Markdown
Contributor

Thanks for the quick turnaround, Wolf — appreciate you folding both notes into 9124c40. The getQueryForChildren escaping and the two regression tests close the loop nicely on the Android side.

One thing to keep on the radar: the iOS twin (PR 4131) merged without the parent-children escaping, so that sibling path is still unescaped on iOS. Might be worth a small follow-up there to keep the two platforms in parity.

This review was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

@wmathurin
wmathurin merged commit 23abf30 into forcedotcom:dev Aug 15, 2026
6 checks passed
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.

2 participants