fix(storage): route the last two id sanitisers through the single source + scope every record-id fetch to the brain - #63
Conversation
| if result and isinstance(result, list) and len(result) > 0: | ||
| return _row_to_neuron(result[0]) | ||
| rows = await self._query( | ||
| f"SELECT * FROM neuron:{sid} WHERE brain_id = $brain_id", |
There was a problem hiding this comment.
You flagged the two batch methods; the same bare-select read-by-id class was also open in get_neuron, get_neuron_state, get_synapse and get_fiber, so I scoped all of them here rather than leave a half-guarantee — a caller could otherwise still read a foreign brain's record via the single-record path. brain_id is a bound param from _get_brain_id() (→ _safe_brain_id, fail-closed), and the record stays pinned in FROM, so it's still a direct fetch, not a scan. Routing through _query also gives the single getters the reconnect-on-dropped-transport retry they lacked. (device was already brain-keyed in its record id.)
| things = ", ".join(f"neuron:{s}" for s in safe[i : i + 1000]) | ||
| rows = await self._query(f"{projection} FROM {things}") | ||
| rows = await self._query( | ||
| f"{projection} FROM {things} WHERE brain_id = $brain_id", |
There was a problem hiding this comment.
The ids in FROM already pass _to_surreal_id + an isalnum()/_ filter, so the WHERE only adds the brain check on records already pinned by id — no change to the 2.7.2 dashboard-perf profile (still a pinned-record fetch, not a FROM neuron scan).
| sid = _record_part(row["id"]) | ||
| src = _sanitize_id(str(row.get("source_id", ""))) | ||
| tgt = _sanitize_id(str(row.get("target_id", ""))) | ||
| src = _to_surreal_id(str(row.get("source_id", ""))) |
There was a problem hiding this comment.
Second follow-up: the old _sanitize_id was dash-only, so it both duplicated the fold and under-folded. _to_surreal_id here also quietly corrects endpoints — neuron records are stored folded, so a legacy source_id/target_id with a . (or any non-- char) previously produced a neuron:{id} endpoint that didn't match the stored record. uuid4 / hash ids are unaffected.
… every record-id fetch, incl. concurrent batch fetches (SECURITY: cross-brain IDOR, adapted for 2.10.5 concurrency)
Single-source sanitisation:
* migrations._sanitize_id (dash-only .replace) and an inline .replace("-","_")
in tool_events were the last two id sanitisers bypassing the _ids.py choke
point. Both now call _to_surreal_id (full [A-Za-z0-9_] fold), which also fixes
the latent dotted-id -> non-matching-edge mismatch in migrations.
Brain-scoped record fetches (cross-brain IDOR):
* Every fetch-by-record-id did a bare select with no brain filter, so a caller
who knows/guesses another brain's record id could read it (confirmed path:
server/routes/hub.py switches brain from body.brain_id validating only the
char format, app.py calls find_neurons_by_ids with client ids). All now add
WHERE brain_id = $brain_id (bound param; brain_id from _get_brain_id ->
_safe_brain_id, fail-closed): get_neuron, get_neuron_state, get_synapse,
get_fiber, find_neurons_by_ids. They stay direct record fetches (ids pinned in
FROM, not a table scan), so the dashboard-perf property is preserved.
* get_neurons_batch (v2.10.5 concurrent: semaphore + gather) keeps its
concurrency — the bare conn.select inside _fetch_one becomes a scoped
self._query, with brain_id resolved ONCE before the semaphore/gather so an
unsafe brain context fails closed before a single fetch goes out.
* get_synapses_batch (NEW in v2.10.5, had the same unscoped concurrent-fetch
pattern, absent from the original fc88973) gets the identical adaptation.
Tests: brain-scope query shape + bound param for all single-record methods and
both concurrent batch methods (asserted over conn.query.call_args_list, N calls);
fail-closed brain-id for both batch methods; single-source routing for migrations
(dotted-id fold) and tool_events; dashboard-perf assertion updated to expect
WHERE brain_id while still forbidding a FROM-neuron table scan.
(adapted from commit fc88973; extends scope to get_synapses_batch, new in v2.10.5, which had the same unscoped-fetch pattern)
(cherry picked from commit 8bca0c7)
fc88973 to
9627828
Compare
|
Updated this branch — same fix, extended to the concurrent batch paths that were added after this PR was opened. Why it changedThe original commit ( Added on top of the original:
Unchanged from the original: single-sourcing Heads-up on the red
|
| Check | Result |
|---|---|
ruff check src/ tests/ |
All checks passed! |
ruff format --check src/ tests/ |
702 files already formatted |
mypy src/ --ignore-missing-imports |
Success: no issues found in 369 source files |
pytest -m "not stress" -n auto |
6547 passed, 84 skipped, 1 xfailed |
Baseline on unmodified main is 6535 passed / 84 skipped / 1 xfailed → +12 tests, no regressions.
Hi Toni!
The two follow-ups you tracked on the id-sanitiser consolidation — thank you again for that review. Both are small and neither is reachable with today's inputs, but they each nick a guarantee, so here they are closed together.
1. Two sanitisers still bypassed the single choke point
_ids._to_surreal_idis meant to be the only place a caller-supplied id is folded to[A-Za-z0-9_]. Two copies still lived outside it:migrations._sanitize_id— a dash-only.replace("-", "_"). Replaced with_to_surreal_id. Besides restoring the single-source guarantee, this fixes a latent mismatch: neuron records are stored folded (_to_surreal_id), so a legacy v7 endpoint id containing anything other than-(e.g. a.) used to migrate to aneuron:{id}edge endpoint that did not match the stored neuron. For uuid4 / content-hash ids (the only ids in practice) the output is unchanged.tool_events.insert_tool_events— an inlineevent_id.replace("-", "_")on a server-generated uuid4. Routed through_to_surreal_id; output is identical for a uuid4, but the id no longer has a private folding path.2. Record-id fetches were not brain-scoped
Every fetch-by-record-id did a bare
select(neuron:{sid},synapse:{sid}, …) with noWHERE brain_id, so a caller in brain A could read a record owned by brain B just by knowing (or guessing) its id — the same gap the store-layer guard closed forconsolidation.py. You flaggedget_neurons_batch/find_neurons_by_ids; the same class was open inget_neuron,get_neuron_state,get_synapseandget_fiber, so I closed the whole class here rather than leave a half-guarantee. All six now addWHERE brain_id = $brain_id(parameterised;brain_idfrom_get_brain_id()→_safe_brain_id, fail-closed). (devicefetch was already brain-keyed in its record id.)They stay direct record fetches — the ids are still pinned in
FROM neuron:{sid}, …, so this is not a table scan and the 2.7.2 dashboard-perf property is preserved; theWHEREonly rejects a cross-brain hit on records already pinned by id. The single-record getters also gain the reconnect-on-dropped-transport retry (they route through_querynow instead of a bareselect).Type of Change
Testing
tests/unit/test_id_consolidation_and_brain_scope.py— brain-scope query shape + bound param for all six methods; fail-closed brain-id path; single-source routing for migrations (dotted-id fold) and tool_events.test_dashboard_perf_queries.py::test_fetches_records_directly_with_omitto pin the scoped-but-still-direct fetch (was assertingWHERE not in sql).main): 6,090 passed, 33 skipped in ~29 s (-n0), clean exit.ruff+mypyclean.surrealdb:v3.2.0: for all six methods the owning brain reads the record and a foreign brain getsNone.Checklist
CHANGELOG.md— happy to add a Security/Fixed line if you'd like.Callers audited (why the scope is a no-op for legitimate use)
All ids reaching these methods are derived from the current brain's own graph/fibers:
get_neuron/get_neuron_state(anchor ids from this brain's fibers), the batch methods (retrieval_context, activation, consolidation, dashboard_api, compression, query_expansion),find_neurons_by_ids(the graph-view route). None cross a brain.Robert