Skip to content

fix(security): keep caller-named fields off internal columns and operators - #1056

Open
abnegate wants to merge 11 commits into
fix/injection-hardeningfrom
fix/injection-hardening-follow-ups
Open

abnegate wants to merge 11 commits into
fix/injection-hardeningfrom
fix/injection-hardening-follow-ups

Conversation

@abnegate

@abnegate abnegate commented Nov 5, 2026

Copy link
Copy Markdown
Member

Follow-ups from #1054 that don't depend on a new utopia-php/query release. Stacked on fix/injection-hardening.

Changes

  • Storage columns stay out of unvalidated writes. With validation skipped, a document key that filters to _uid, _id or another storage column could overwrite it on create, update or upsert. SQL and Memory write paths now drop those keys, like Mongo's withoutStorageKeys(). The Structure validator filters a key before its reserved check, so _u!id is refused too. Memory refuses increaseDocumentAttribute() on a storage column.
  • One DDL statement per prepare. DDL no longer joins statements with ;, so connections no longer need multi-statement execution. The README recommends turning it off (PDO::MYSQL_ATTR_MULTI_STATEMENTS => false).
    • Postgres loses implicit multi-statement atomicity for DDL. createCollection now drops both tables if an internal step fails. A duplicate declared index keeps them.
    • Relationship and attribute DDL converge after a partial failure: a statement refused as already applied is skipped, the rest still run, then the refusal is rethrown.
  • Allowlist regexes use D, so a trailing newline no longer matches $.
  • Mongo maxTimeMS is scoped per event. find, count and sum read the timeout set for their own event, falling back to the global one.
  • Pool clears a borrowed connection's stale per-event timeouts before applying its own.

Notes

  • SQL::executeUpsertBatch() gained an $extractions parameter. Subclasses that override it need updating.
  • Out of scope here, waiting for query 0.7.3: removing the database-side raw-condition enclosing, adopting LIKE ... ESCAPE, and containsAll() literal escaping (its own PR, since it changes behaviour).

Every fix has a regression test.

🤖 Generated with Claude Code

abnegate and others added 10 commits November 5, 2026 17:43
…alidation

filter() keeps underscores, so with validation skipped a document key such
as `_uid` or `_u!id` reached the column the storage keeps for the document's
own id and rewrote it on create or update, `_id` chose the sequence, and a
bulk update with `_permissions` replaced the document's permissions. An
upsert also let `_ten!ant` move a new row to another tenant, since the
filtered key overwrote the tenant the write hooks set.

Every SQL and Memory write now takes the document's attributes through
writableAttributes(), which drops a key whose filtered name is a reserved
storage column before operators are extracted, matching how Mongo drops
storage keys rather than refusing the write.

An upsert splits each change's writable attributes into operators and
updates once and hands the result to executeUpsertBatch, rather than
extracting them up to three times per change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collection, relationship and analyze DDL joined several statements with
';' into one prepare, which only ran on MySQL and MariaDB because
multi-statement execution was left enabled on every connection, and on
PostgreSQL because emulated prepares send a simple query. Keeping
multi-statement execution on widens what an injected fragment can do.

executeStatements() now runs each statement through its own prepare, so
transforms, events and the profiler still see every statement, and a
later statement's error fails the call without MariaDB having to walk
its rowsets. No transaction is opened, since createCollection may run
inside a caller's transaction. The MySQL-family e2e connections turn
multi-statement execution off so any regression fails there.

PostgreSQL ran a multi-statement simple query as one implicit
transaction, so DDL loses that atomicity here. createCollection marks a
collection created once its table exists and discards both tables on
any failure in its internal phase (permissions table, internal indexes);
only a duplicate declared index keeps them. deleteCollection converges
on retry: when the main table is already gone it reports NotFound after
still dropping the permissions table. Relationship create and delete
converge through the executor added in a following commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without PCRE_DOLLAR_ENDONLY, `$` also matches before a final "\n", so a
join alias, aggregate alias, joined column, UUIDv7 sequence, SQL hook
identifier, collation or integer string carrying one passed its
allowlist and reached the query. The D modifier anchors `$` to the end
of the subject, as Rounding and ObjectPath already do.

BigInt::integralValue and the Postgres hex-WKB check get it too: "5.0\n"
is not a whole decimal and "ab\n" is not hex. Parsers whose input
cannot carry a trailing newline (collapsed or trimmed whitespace, first
error line) or where D would loosen a deny-list are left unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
setTimeout() and clearTimeout() ignored their event, so a timeout meant for
one event (as Pool::syncTimeouts() forwards each one) bounded every find,
count and sum, and clearing any event dropped the global timeout too. The
adapter now keeps per-event state through the Timeout trait like the SQL
adapters and Pool, find/count/sum read DocumentFind/DocumentCount/DocumentSum,
and a non-positive timeout clears that event as Pool does. count() also
loses option assignments that getTransactionOptions() always overwrote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With DDL run one statement per prepare, PostgreSQL no longer applies a
two-way relationship's columns atomically. The database recovers from a
partial create by catching DuplicateException and from a partial delete
by catching NotFoundException, but the SQL adapters threw the raw
PDOException, so neither recovery ever ran, and a retry stopped at the
first column a previous attempt had already handled.

Relationship create and delete now map engine errors and carry on past a
statement the schema already reflects, then report that refusal, so a
retry applies the rest and the database writes or removes the metadata.
deleteCollection shares the same path. Inside a transaction the first
refusal is thrown at once, as PostgreSQL rejects the rest of an aborted
transaction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pooled connection keeps the per-event timeouts its last holder set, and
an adapter prefers a per-event entry over the global one. The global-only
shortcut set the global timeout without clearing them, so a handle asking
for 1000ms everywhere ran finds under another request's 50ms. Each sync
now clears every event the pool holds no entry for, which also avoids a
full clear and its extra SET round trip on MariaDB when only the global
timeout is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The adapter drops a key whose filtered name is a reserved column, but
the validator checked the raw key, so `_u!id` or `_ten.ant` passed
validation and, on schemaless adapters, was accepted and then silently
dropped on write. Filtering through the same static helper the adapter
uses keeps the two checks in lockstep; keys Validator\Key accepts never
start with an underscore, so legitimate attributes are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
increaseDocumentAttribute filtered the attribute name itself, so an
increment of a reserved column such as `_tenant` or `_ten.ant` rewrote
the document's own tenant, sequence or timestamps. It now resolves the
column through writableColumn() like the SQL and Mongo adapters, so the
increment is refused with NotFound before the document is touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The adapters now send one statement per call, so a connection with
multi-statements enabled (the MySQL default) only widens what an
injected semicolon can do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A converging DDL run takes a refused statement as one an earlier failed
attempt already applied, runs the rest, then rethrows so the caller knows
the schema held part of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review: 2

  • main
  • 0.69.x

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

The pool now clears each per-event timeout a borrowed connection kept, not
only the global one, so the read adapter sees one clear per event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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