fix: enforce SOQL literal safety at the query sink - #165
Merged
Merged
Conversation
Performance Comparison (same runner)Stable
|
scolladon
force-pushed
the
fix/enforce-soql-literal-safety-at-the-sink
branch
from
August 25, 2026 13:23
c8b396d to
e7f5eda
Compare
|
Preview build for this pull request: sf plugins install https://pkg.pr.new/apex-mutation-testing@e7f5eda |
|
Shipped in release $ sf plugins install apex-mutation-testing@latest-rc
# Or
$ sf plugins install apex-mutation-testing@v1.9.1💡 Enjoying apex-mutation-testing? |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Explain your changes
One regex was the only thing keeping a hostile name out of Tooling API query text:
APEX_CLASS_NAME_PATTERNinsrc/service/configReader.ts. Its comment said so, and said socorrectly — the grammar admits no quote, backslash or whitespace, so nothing that could alter a
WHEREclause could reach the org.Two properties made that a liability rather than a defence:
ConfigReaderis a service; the queries arebuilt 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.
soql-builderescapes quotes andleaves backslashes raw (
lib/soql-builder.js:14), soFoo\closes its own literal and theliteral runs on into the rest of the clause.
@salesforce/apex-nodeis worse — itsapexClassIdQueryForTestSuiteMemberbuilds... WHERE Name = '${shortName}' ...with noescaping at all. That second sink is reached only via
getApexClassIds/buildSuite, whichApexTestRunnernever calls, so there is no live exposure — and nothing prevents a futurecaller 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.tsbecomes the single module that knows how a value becomes querytext, split by ownership of the escaping contract:
escapeSoqlLiteraldoubles backslashesbefore 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.
toSoqlLiteralListwraps it forINclauses.ApexTestSuiteRepository's private copy of thatescaper 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.
assertSoqlLiteralSafethrows on a quote or a backslash at the three.find({ Name })sinks inApexClassRepository.The refusal is a plain unlocalised
Errorthat 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-
Errorprecedent ofPollTimeoutErrorand thepollOptionsconstructor checks in thesame 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.tsfor the security property, instead of carrying that property alone.Options rejected, and why
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.
.find()— removes the dependency on athird-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.
security property in prose where a product-motivated edit silently revokes it.
Deliberately out of scope:
EntityDefinition.DeveloperNamereads stay unguarded. Those namescome 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:
-r/--report-dirresolveandrealpathboth checked against cwd</>escaped as\uXXXX,</scriptneutralised in the inlined bundle--skip-patternschild_processimport anywhere insrc/__is rejected in namesnpm audit --omit=devDoes this close any currently open issues?
No open issue — found while reviewing the
WHERE Name = '${shortName}'note insrc/service/configReader.ts.soqlLiteral.test.tscovers the escaper (including the backslash-then-quote ordering, via a payloadwhose trailing backslash would defeat the reverse order) and both refusal branches, and
apexClassRepository.test.tsasserts the sink refuses a hostile name without issuing the find.No NUT/E2E tier: the guard is unreachable through the CLI by construction —
ConfigReaderrejectsboth 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:
Through the CLI,
-c "Mutation' OR Name != '"still fails the same way it does onmain, withConfigReader'serror.invalidClassName.Any other comments
@salesforce/apex-nodesink stays unescaped upstream and out of reach. If a future changeever needs
buildSuiteorgetApexClassIds, the names it passes must be escaped or refusedbefore the call — that helper cannot be made safe from here.
DESIGN.mdgains a SOQL Literal Safety section under the class-name grammar, and the grammarsection's security rationale is rewritten to point at it.
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 itis not in this diff.
tsc,knip, commitlint,
npm pack --dry-run.