Skip to content

fix: enforce SOQL literal safety at the query sink - #165

Merged
scolladon merged 1 commit into
mainfrom
fix/enforce-soql-literal-safety-at-the-sink
Aug 25, 2026
Merged

scolladon merged 1 commit into
mainfrom
fix/enforce-soql-literal-safety-at-the-sink

Conversation

@scolladon

Copy link
Copy Markdown
Owner

Explain your changes


One regex was the only thing keeping a hostile name out of Tooling API query text:
APEX_CLASS_NAME_PATTERN in src/service/configReader.ts. Its comment said so, and said so
correctly — the grammar admits no quote, backslash or whitespace, so nothing that could alter a
WHERE clause could reach the org.

Two properties made that a liability rather than a defence:

  1. The guard sits four modules from the sink. ConfigReader is a service; the queries are
    built in src/adapter/org/. The adapters trust the grammar silently — nothing in them says so,
    and nothing in them checks. Widening the regex for a product reason would silently widen two
    injection sinks, and no test would fail.
  2. Neither downstream builder is safe on its own. jsforce's soql-builder escapes quotes and
    leaves backslashes raw (lib/soql-builder.js:14), so Foo\ closes its own literal and the
    literal runs on into the rest of the clause. @salesforce/apex-node is worse — its
    apexClassIdQueryForTestSuiteMember builds ... WHERE Name = '${shortName}' ... with no
    escaping at all. That second sink is reached only via getApexClassIds / buildSuite, which
    ApexTestRunner never calls, so there is no live exposure — and nothing prevents a future
    caller from creating one.

A third-party sink cannot be fixed from here, and a comment is not a control. This PR moves the
control to where the query is built.

src/adapter/org/soqlLiteral.ts becomes the single module that knows how a value becomes query
text, split by ownership of the escaping contract:

  • Where this layer writes the SOQL itself, it escapes. escapeSoqlLiteral doubles backslashes
    before escaping quotes — the order is load-bearing, since the reverse lets a payload's own
    trailing backslash escape the backslash just added in front of the closing quote.
    toSoqlLiteralList wraps it for IN clauses. ApexTestSuiteRepository's private copy of that
    escaper is deleted in favour of the shared one, so the next hand-built query has a correct
    primitive to reuse instead of inventing a second.
  • Where this layer hands a value to a builder whose escaping it does not own, it refuses.
    assertSoqlLiteralSafe throws on a quote or a backslash at the three .find({ Name }) sinks in
    ApexClassRepository.

The refusal is a plain unlocalised Error that deliberately does not quote the rejected value:
this is an invariant breach, not a user mistake — the audience is whoever widened the grammar, and
echoing attacker-shaped text into CLI output would make the guard its own output sink. It matches
the plain-Error precedent of PollTimeoutError and the pollOptions constructor checks in the
same file.

ConfigReader's grammar comment is retargeted accordingly: it now documents a usability guard
(reject a typo at the CLI boundary rather than as a puzzling zero-row org result) and points at
soqlLiteral.ts for the security property, instead of carrying that property alone.

Options rejected, and why

  • Pre-double backslashes before handing values to jsforce — one line, and it composes with
    jsforce's quote escaping to give exactly correct escaping. Rejected: it silently depends on
    jsforce escaping quotes and nothing else, so a future jsforce that fixes its backslash handling
    would double-escape every name. A trap for the next reader.
  • Hand-build every query with our own escaper, dropping .find() — removes the dependency on a
    third-party escaping contract entirely. Rejected: rewrites three well-tested read paths, with
    their projection and paging semantics, to fix a latent issue. Risk out of proportion to the
    finding.
  • Leave the grammar as the sole guard and strengthen its comment — zero diff, and keeps a
    security property in prose where a product-motivated edit silently revokes it.

Deliberately out of scope: EntityDefinition.DeveloperName reads stay unguarded. Those names
come from the ANTLR Apex lexer, over source the org already compiled, so a guard there would assert
against the lexer rather than against an input.

No behaviour change for any valid input — every name the CLI accepts today reaches the same
query it reached before.

The rest of the security sweep (no findings)

The whole user-input surface was swept, not just SOQL. Everything below was already hardened and is
untouched by this PR:

Area Finding
Path traversal via -r/--report-dir Clean — string-level resolve and realpath both checked against cwd
HTML report script injection Clean — </> escaped as \uXXXX, </script neutralised in the inlined bundle
ReDoS via --skip-patterns Clean — RE2JS, linear-time by construction
Command injection Clean — no child_process import anywhere in src/
Prototype pollution via the config file Clean — zod strips unknown keys; __ is rejected in names
npm audit --omit=dev Clean — 0 vulnerabilities

Does this close any currently open issues?


No open issue — found while reviewing the WHERE Name = '${shortName}' note in
src/service/configReader.ts.

  • Unit tests added to cover the fix.
  • NUT tests added to cover the fix.
  • E2E tests added to cover the fix.

soqlLiteral.test.ts covers the escaper (including the backslash-then-quote ordering, via a payload
whose trailing backslash would defeat the reverse order) and both refusal branches, and
apexClassRepository.test.ts asserts the sink refuses a hostile name without issuing the find.
No NUT/E2E tier: the guard is unreachable through the CLI by construction — ConfigReader rejects
both characters first — so a command-level test could only assert the pre-existing upstream
rejection, not this guard.

Any particular element that can be tested locally


No new or changed parameters, and no user-visible behaviour change. The refusal is reachable only by
calling the repository directly:

await new ApexClassRepository(connection).readCandidates("Mutation' OR Name != '")
// throws; no find() is issued

Through the CLI, -c "Mutation' OR Name != '" still fails the same way it does on main, with
ConfigReader's error.invalidClassName.

Any other comments


  • The @salesforce/apex-node sink stays unescaped upstream and out of reach. If a future change
    ever needs buildSuite or getApexClassIds, the names it passes must be escaped or refused
    before the call — that helper cannot be made safe from here.
  • DESIGN.md gains a SOQL Literal Safety section under the class-name grammar, and the grammar
    section's security rationale is rewritten to point at it.
  • An ADR is drafted locally (124 — Query-text integrity belongs to the adapter that builds the query, status proposed) and awaits your ratification; docs/ is excluded from the repo, so it
    is not in this diff.
  • Full gate green locally: 2136 unit tests at the 100% coverage threshold, 56 NUT, Biome, tsc,
    knip, commitlint, npm pack --dry-run.

@github-actions

Copy link
Copy Markdown

Performance Comparison (same runner)

Stable

Benchmark Base PR Ratio Change
antlr-lex-small 9963 10661 0.93 +6.5%
antlr-parse-small 176 181 0.97 +2.8%
antlr-lex-medium 2142 2305 0.93 +7.1%
antlr-parse-medium 34 35 0.97 +2.9%
antlr-lex-large 643 694 0.93 +7.3%
antlr-parse-large 13 13 1.00 +0%
pipeline-small-compute-mutations 165 166 0.99 +0.6%
pipeline-small-type-discovery 224 224 1.00 +0%
pipeline-medium-compute-mutations 40 41 0.98 +2.4%
pipeline-medium-type-discovery 51 52 0.98 +1.9%
pipeline-large-compute-mutations 14 14 1.00 +0%
pipeline-large-type-discovery 18 17 1.06 -5.9%
pipeline-apply-all-mutations 5 5 1.00 +0%
antlr-lex-small (mean) 0.1004ms 0.0938ms 0.93 -6.6%
antlr-parse-small (mean) 5.667ms 5.5248ms 0.97 -2.5%
antlr-lex-medium (mean) 0.4669ms 0.4338ms 0.93 -7.1%
antlr-parse-medium (mean) 29.0044ms 28.3645ms 0.98 -2.2%
antlr-lex-large (mean) 1.5559ms 1.4412ms 0.93 -7.4%
antlr-parse-large (mean) 75.6244ms 74.1821ms 0.98 -1.9%
pipeline-small-compute-mutations (mean) 6.0592ms 6.0356ms 1.00 -0.4%
pipeline-small-type-discovery (mean) 4.4721ms 4.4615ms 1.00 -0.2%
pipeline-medium-compute-mutations (mean) 24.7507ms 24.3049ms 0.98 -1.8%
pipeline-medium-type-discovery (mean) 19.5486ms 19.2798ms 0.99 -1.4%
pipeline-large-compute-mutations (mean) 71.2987ms 72.9905ms 1.02 +2.4%
pipeline-large-type-discovery (mean) 56.9762ms 57.9828ms 1.02 +1.8%
pipeline-apply-all-mutations (mean) 219.228ms 216.7155ms 0.99 -1.1%

@scolladon
scolladon force-pushed the fix/enforce-soql-literal-safety-at-the-sink branch from c8b396d to e7f5eda Compare August 25, 2026 13:23
@github-actions

Copy link
Copy Markdown

Preview build for this pull request:

sf plugins install https://pkg.pr.new/apex-mutation-testing@e7f5eda

@scolladon
scolladon merged commit e362be6 into main Aug 25, 2026
22 checks passed
@scolladon
scolladon deleted the fix/enforce-soql-literal-safety-at-the-sink branch August 25, 2026 13:34
@github-actions

Copy link
Copy Markdown

Shipped in release v1.9.1.
Version v1.9.1 will be assigned to the latest npm channel soon
Install it using either v1.9.1 or the latest-rc npm channel

$ sf plugins install apex-mutation-testing@latest-rc
# Or
$ sf plugins install apex-mutation-testing@v1.9.1

💡 Enjoying apex-mutation-testing?
Your contribution helps us provide fast support 🚀 and high quality features 🔥
Become a sponsor 💙
Happy zombies detection!

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