fix: close serialization RCE, 5 lossy-payload defects, and 4 t.all() defects - #292
Open
hussainsultan wants to merge 2 commits into
Open
fix: close serialization RCE, 5 lossy-payload defects, and 4 t.all() defects#292hussainsultan wants to merge 2 commits into
hussainsultan wants to merge 2 commits into
Conversation
… defects
A serialized model is data that travels: the `xorq.from_tag_node` entry
point routes any `bsl`-tagged expression through this code automatically,
and git catalogs store resolver trees as editable YAML. None of these
paths validated what they were reading.
Arbitrary code execution (critical)
`("fn", module, qualname)` imported any module and handed the result to
`Call.resolve()`, which calls it — `("call", ("fn","builtins","eval"), ...)`
was a working RCE, and the import side effect fired at deserialize time.
Callables are now restricted to the ibis/xorq/operator roots, validated
on both sides so authors fail at write time rather than readers at load
time. The resolved object is re-checked because a qualname is a getattr
chain: `("fn","ibis.util","os.system")` starts in a trusted module and
walks out of it. Instrumenting the full suite shows real models emit
exactly two callables (`ifelse`, `_finish_searched_case`), so the
allowlist costs nothing; `trust_callable_module()` is the opt-in escape.
Silently wrong results
- thaw() read `("just", 0)` resolver nodes as dict entries and kept the
last, so `substr(0, 2)` came back as `substr(2)` and `isin([x, y])` as
`isin([y])`. Values under a `*_struct` key are now opaque.
- freeze() stringified every non-scalar constant; dates came back as
strings and Decimal/np.int64 as type errors. Constants now carry a
typed encoding, and freeze() raises instead of coercing.
- `aggregate(n=lambda t: t.a.max())` replayed the model's `n` when the
names collided. Bare references are recorded explicitly, so name
replay (which is what makes them fan-out safe) is no longer inferred.
- `bsl_version` was written and never read; a v1.0 payload and a
dimension with no readable expression both degraded to raw column
references. Both are refused now.
- `_rebind_to_backend` repointed every DatabaseTable at one backend
without checking, so joining two same-schema databases read every
column from whichever came first. Only tables sharing a physical
connection — the `from_ibis()`-mints-a-Backend-per-call case it exists
for — are rebound.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`t.all(x)` is "x over the whole filtered dataset, ignoring the group by".
Four code paths implemented it differently, and three of them silently
returned a sum of per-group values instead — which still looks like a
share, so nothing surfaced.
- IbisCalcScope.all only routed an exact Field reference to the real
totals table; anything else fell through to `x.sum().over(window())`.
So `t.all(m)` and `t.all(m * 1)` disagreed, and for a mean measure the
second summed the per-group means (120 where the answer is 40). Measure
references inside an expression are now rebound to the totals table, so
the expression is evaluated over the totals rather than re-aggregated.
- The post-aggregation chain (`aggregate().order_by().mutate()`) only has
the grouped rows, so its total can only be a window sum. That is exact
for SUM/COUNT and meaningless for a mean, median, distinct count or
ratio — where it disagreed with the identical calc-measure formula
(0.167 vs 0.5). Non-additive references are now refused with the
spelling that does work; additive ones are unchanged.
- Two integer operands truncated: `total / t.all(total)` came back 0
because xorq's DataFusion does integer division. Integral totals are
cast, matching why the calc path gives its virtual columns a float
schema.
- On a join_many model with only calc measures requested, the pre-agg
fallback returned `tbl.aggregate({})` — an aggregate with no columns —
dropping the group keys and every calc spec, and surfacing much later
as "Schema and number of arrays unequal" from arrow. It now aggregates
with its group keys, and refuses (naming the fix) when a calc builds
its reduction inline and so has no fan-out-safe base to compute from.
1266 existing tests pass; 11 added covering each spelling, with data
where the correct and sum-of-group-values answers cannot coincide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hussainsultan
marked this pull request as ready for review
August 12, 2026 16:37
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.
Fixes ten defects found by an implementation evaluation of this repo. Every one was reproduced with a working proof-of-concept before being fixed, and each has a regression test — none of these paths had coverage.
Full suite: 1266 passed, 1 skipped, 11 xfailed, 4 xpassed, 0 failed. 29 tests added.
1. Arbitrary code execution from a tagged model (critical)
("fn", module, qualname)imported any module and handed the result toCall.resolve(), which calls it.("call", ("fn","builtins","eval"), ...)was a working RCE, and the import side effect fired at deserialize time, before anything resolved.This is reachable, not theoretical: the
xorq.from_tag_nodeentry point inpyproject.tomlroutes anybsl-tagged expression through this code automatically, and git catalogs store the resolver trees as plain editable YAML. Anyone with write access to a catalog gets code execution in every reader's process. The v2.0 refactor removed pickle's format but kept pickle's capability.Callables are now restricted to the ibis/xorq/operator module roots, validated on both sides — an author fails when serializing rather than a reader when loading. The resolved object is checked too, because a qualname is a
getattrchain:("fn","ibis.util","os.system")and("fn","ibis.expr.api","builtins.eval")both start in a trusted module and walk out of it. Both are covered by tests.Sizing the allowlist was empirical — instrumenting the full suite shows real models emit exactly two callables ever,
ifelseand_finish_searched_case, so the restriction costs nothing.trust_callable_module()is the documented opt-in for anyone who needs more.2. Five silent-wrong serialization defects
Each reproduced end-to-end through the public
to_tagged/from_taggedAPI:thaw()collapsed multi-argument calls.("just", x)resolver nodes look exactly like the tuple-of-pairs encoding of a dict, so only the last survived:substr(0, 2)came back assubstr(2),isin([x, y])asisin([y]). Group-by results changed with no error. Values under a*_structkey are now opaque. The corruption was read-side only, so this repairs existing payloads with no format change.freeze()stringified every non-scalar constant. Dates came back as strings;Decimalandnp.int64came back as type errors from the query compiler. Constants now carry a typed encoding, andfreeze()raises rather than coercing.aggregate(n=lambda t: t.a.max())replayed the model'sn = a.sum()when the names collided (3 → 6). Bare references are now recorded explicitly, so name replay — which is what makes them fan-out safe — is no longer inferred from a name collision.bsl_versionwas written and never read, so a v1.0 payload (pickled expressions, a format no longer read at all) and a dimension with no readable expression both degraded to raw column references:amount = _.amount * 1.1came back asamount, with plausible numbers and no error. Both are refused now._rebind_to_backendrepointed everyDatabaseTableat one backend without checking. Joining two same-schema databases read every column from whichever came first — prod's data silently replaced by staging's. The fix hinges on an exact signal:from_ibis()mints a newBackendwrapper per call but reuses the caller's connection object, while a different database has a different one. Rebinding is now gated on connection identity, so duplicate wrappers still unify and distinct databases fail in the engine instead of merging. A near-duplicate copy of the rebind logic was folded into the shared primitive.3.
t.all()/ percent-of-totalt.all(x)means "x over the whole filtered dataset, ignoring the group by". Four code paths implemented it differently and three returned a sum of per-group values instead — which still looks like a share, so nothing surfaced.IbisCalcScope.allonly routed an exact Field reference to the real totals table, sot.all(m)andt.all(m * 1)disagreed; for a mean measure the second summed the per-group means. Measure references inside an expression are now rebound to the totals table.aggregate().order_by().mutate()) only has the grouped rows, so its total can only be a window sum — exact for SUM/COUNT, meaningless for a mean, median, distinct count or ratio, where it disagreed with the identical calc-measure formula (0.167 vs 0.5). Non-additive references are now refused, naming the spelling that works; additive ones are unchanged.total / t.all(total)returned 0 because xorq's DataFusion does integer division. Integral totals are cast, matching why the calc path gives its virtual columns a float schema.join_manymodel with only calc measures requested, the pre-agg fallback returnedtbl.aggregate({})— an aggregate with zero columns — dropping the group keys and every calc spec, surfacing much later asSchema and number of arrays unequalfrom arrow. It now aggregates with its group keys, and refuses (naming the fix) when a calc builds its reduction inline and so has no fan-out-safe base.Notes for review
join_many. Each replaces a silently wrong number, and each error message carries the working alternative.test_from_xorq_with_tagged_tablewas updated because it pinned the silent-degrade behavior.df.sort_values(k)[col][0]is label-based indexing and was reading an arbitrary row): the reported declaration-order dependence of percent-of-total, and a generalt.all()×join_manycrash. The underlying defects were real but narrower than first described. Tests now compare full result dicts, with data where the correct and sum-of-group-values answers cannot coincide.Left alone as out of scope: the
ops.pymonolith, the unenforced ruffselectset, andconvert.py's deadSemantic*Ophandlers.🤖 Generated with Claude Code