Skip to content

fix: trust-boundary hardening gaps from the cross-cutting security sweep (#102) - #149

Merged
Xof merged 2 commits into
mainfrom
fix/102-trust-boundary-hardening
Aug 5, 2026
Merged

fix: trust-boundary hardening gaps from the cross-cutting security sweep (#102)#149
Xof merged 2 commits into
mainfrom
fix/102-trust-boundary-hardening

Conversation

@Xof

@Xof Xof commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Closes #102.

The 2026-07-29 review's cross-cutting security sweep found five places where a value read across the on-disk trust boundary was acted on before it was bounded. SECURITY-SWEEP-1 (Argon2 cost parameters) landed in #128; this closes the other four, plus the gaps an adversarial review found in those fixes.

The unifying defect is treating a valid XXH3 page checksum as if it authenticated the bytes. It does not: XXH3 is non-cryptographic and publicly recomputable, so an attacker with byte-level control over the file re-stamps it for free. Three of the four hazards also bypass the poison model outright, because an allocator abort and a stack overflow are not Rust errors and cannot be intercepted by a Result-based contract.

What is fixed

Finding Hazard Fix
SWEEP-2 Overflow::read sized a Vec from a disk-controlled u64handle_alloc_error, process abort, from a plain Chisel::read Bound total_length against next_page_id * OVERFLOW_PAYLOAD
SWEEP-3 freemap_depth copied from the superblock unvalidated → scan_node stack overflow (SIGSEGV) and cow_descend O(depth²) file growth on the ordinary commit path Gate at open_existing; depth check at the tree's own entry points
SWEEP-4 Both files created 0666 & ~umask; predictable sidecar path followed a planted symlink and truncated the target 0600 on both; sidecar created O_EXCL | O_NOFOLLOW
SWEEP-5 Cross-crate &[Identifier]&[Handle] transmute guarded by const assertions that cannot detect removal of repr(transparent) Replace with a safe collect — the guard was unclosable

Second commit: what the adversarial review caught

The review (Fable 5, against the stated threat model) confirmed all five original tests are non-vacuous, then found two fixes incomplete in ways that left the original hazard reachable through a different door:

  • SWEEP-2 had a second door. Overflow::collect_chain_pages reads the same total_length and derives max_pages from it with no ceiling — reached from delete/update rather than read. Forged u64::MAX + a self-referential next_page grows an unbounded Vec until the allocator aborts. Verified by removing the guard and watching the new test run past 90s instead of failing.
  • SWEEP-4 had a second direction. O_NOFOLLOW stops a planted symlink, but a planted regular file is not a symlink: the open succeeds, and mode(0600) applies only when the open creates the file. The planter keeps ownership and their 0666, and Chisel writes spilled pages — uncommitted user values — into a file they can read. Measured at 0666 before the fix. The same class of hole existed on the main database file, where a planted empty file is a legitimate create target under the fix: refuse to create a database over an existing sub-page file #127 rules.

Plus: mark_free_growing evaluated capacity() (which loops depth times) before its depth check, so a forged u32::MAX spun ~4.3e9 multiplies before failing closed; the over-deep depth was reported as CorruptSuperblock with an empty defect list, which is both undiagnosable and the wrong recoverability class; and handle.rs still documented the transmute the first commit deleted.

Verification

  • 712 tests pass (709 + 3 new), clippy and fmt clean.
  • Every new test verified non-vacuous by reverting its guard: the two permission tests fail with mode 666, the overflow test runs unbounded past 90s.
  • ARCHITECTURE.md gains a "File permissions and the sidecar path" section — these were user-visible contract changes shipped with no documentation.

Xof added 2 commits August 4, 2026 08:04
…weep

The 2026-07-29 review's cross-cutting security sweep found five places where
a value read across the on-disk trust boundary was acted on before it was
bounded. SECURITY-SWEEP-1 (Argon2 cost parameters) landed in PR #128; this
closes the other four.

The unifying defect is treating a valid XXH3 page checksum as if it
authenticated the bytes. It does not: XXH3 is non-cryptographic and publicly
recomputable, so an attacker with byte-level control over the file re-stamps
it for free. Three of the four hazards below also bypass the poison model
outright, because an allocator abort and a stack overflow are not Rust errors
and cannot be intercepted by a Result-based contract.

SECURITY-SWEEP-2 (overflow.rs): Overflow::read sized a Vec directly from the
disk-controlled u64 at bytes 16..24. The preceding guards reject only a page
that is not Overflow-typed, which is exactly the case a crafted file avoids;
a forged u64::MAX therefore reached handle_alloc_error and aborted the process
from a plain Chisel::read. Bound it against next_page_id * OVERFLOW_PAYLOAD.
The ceiling is the allocator high-water mark rather than the file length so a
read-your-own-writes of a large value inside the writing transaction still
works.

SECURITY-SWEEP-3 (transaction/recovery.rs, freemap_tree.rs): freemap_depth was
copied out of the superblock into FreeMapTree::from_roots unvalidated, while
the handle table and membership index both cap theirs. A forged depth drives
scan_node's per-level recursion into a stack overflow and cow_descend into an
O(depth^2) loop that materializes a page per absent child on the ordinary
commit path. Gate it at open_existing, and add the depth check to the tree's
own entry points as defense in depth, matching what the other two radixes do.
The freemap's comment claimed capacity() saturation made this fail closed;
saturation only prevents arithmetic overflow, it rejects nothing.

SECURITY-SWEEP-4 (page_io.rs, spillway.rs): both files were created at
0666 & ~umask. The spillway is the sharper case — its path is derived from the
database path, it is created lazily mid-transaction under cache pressure, and
it is opened with truncate(true) on the assumption that pre-existing content is
garbage from a crashed run. That assumption fails for a symlink planted by
another local user, whose target would then be truncated to zero. Create both
at 0600 and open the sidecar O_NOFOLLOW. Encryption does not help here: the
hazard is the truncate, not the contents.

SECURITY-SWEEP-5 (handle.rs, bench): the bench adapter reinterpreted
&[Identifier] as &[Handle] on the strength of both being repr(transparent),
guarded by const assertions that cannot detect removal of repr(transparent) —
a repr(Rust) struct Handle(u64) has the same size and align. Rust offers no
stable way to assert the attribute, so the guard was unclosable. Replace the
transmute with a safe collect: it bought one Vec per delete_many against an
operation that performs three fsyncs.

Five tests cover the hazards, each forging bytes and re-stamping the checksum
so the file passes validation exactly as an attacker's would. The freemap one
points its root at a never-allocated page, so the only way to get a typed
error rather than a read failure is for the guard to run before the first
cache.get — an unguarded traversal at that depth is a stack overflow, not a
failure a test harness could report.

Closes #102.
The trust-boundary fixes in the parent commit were reviewed adversarially
against their own threat model — attacker controls the file bytes and can
re-stamp the XXH3 checksum for free. Two of them were incomplete in ways
that left the original hazard reachable, just through a different door.

SECURITY-SWEEP-2, second door. `Overflow::read` got a ceiling on the
disk-controlled `total_length`; `Overflow::collect_chain_pages` reads the same
u64 at bytes 16..24 and derives `max_pages` from it with no ceiling at all.
That path is reached from `Chisel::delete` and `Chisel::update` rather than
`Chisel::read`. A forged u64::MAX yields max_pages ~2.26e15, so a chain whose
`next_page` points at itself pushes into an unbounded Vec until the allocator
aborts — the same poison-model bypass, entered from a different public method.
Apply the identical ceiling. Verified by removing the guard and watching the
new test run past 90 seconds instead of failing.

SECURITY-SWEEP-4, second direction. O_NOFOLLOW closes "planted symlink gets
the victim's file truncated" but not "planted regular file gets adopted": a
plain file is not a symlink, so the open succeeds, and `mode(0600)` applies
only when the open CREATES the file. The planter keeps ownership and their
0666, and the engine then writes spilled pages — uncommitted user values —
into a file they can read. Measured at 0666 before the fix.

Create the sidecar with O_EXCL instead, and unlink a pre-existing entry only
after confirming it is a plain file this uid owns with one link. That keeps
the documented crash-debris behaviour for the case it was written for and
fails closed for the case it was not; a re-plant between the unlink and the
retry loses to O_EXCL. The same class of hole existed on the main database
file, where a planted EMPTY file is a legitimate create target under the
PR #127 rules, so it was adopted with its permissive mode intact; tighten any
zero-length file the create path adopts.

Also from the review:

  * `mark_free_growing` — the manager-facing entry point — evaluates
    `capacity()` in its `while` condition before `depth < MAX_DEPTH`, and
    `capacity()` loops `depth` times. At a forged u32::MAX that is ~4.3e9
    saturating multiplies (tens of seconds) before the guard inside
    `mark_free` fires. It failed closed, but not promptly, and not the way
    the sibling entry points promise. Depth-check first.

  * The over-deep freemap depth was reported as `CorruptSuperblock` with an
    empty defect list: no diagnosis, and the wrong recoverability class —
    that variant is documented as reopen-recoverable via slot selection, but
    every sibling slot carries the same rejected value, so a reopen fails
    identically forever. Give it a typed `InvalidFreemapDepth { stored, max }`
    carrying the offending value, matching what the `page_size` check twenty
    lines above already does.

  * `handle.rs` still documented the bench transmute that the parent commit
    deleted, including in the two const-assert messages, which contradicted
    the corrected comment four lines above them.

  * The permission test asserted `mode == 0o600` exactly, which fails under a
    umask that masks owner bits on a file that is if anything more restrictive
    than required. Assert the property (no group/world bits) instead.

ARCHITECTURE.md gains a section for the permission and sidecar contracts,
which were user-visible behaviour changes introduced with no documentation.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🚦 Bench results: PR vs main

⚠️ 11 regression(s) detected across 6 scenario/mode pair(s)

Scenario Mode Δ throughput Worst Δ
mutation-log redb-strict -0.9% p99 +61.3% ⚠️
document-store sqlite-strict -9.2% p95 +19.6% ⚠️
document-store chisel-mem -7.8% p95 +16.1% ⚠️
mutation-log sqlite-strict -1.3% p99 +11.1% ⚠️
mutation-log chisel-strict -2.9% p99 +10.3% ⚠️
ycsb-b redb-strict -1.1% p50 +5.1% ⚠️
document-store chisel-strict -3.6%
document-store redb-strict -0.6%
mutation-log chisel-mem -1.8%
ycsb-a chisel-mem -2.0%
ycsb-a chisel-strict -1.0%
ycsb-a redb-strict -0.1%
ycsb-a sqlite-strict -0.2%
ycsb-b chisel-mem -4.0%
ycsb-b chisel-strict -0.0%
ycsb-b sqlite-strict -1.7%
Per-scenario detail (4 metrics × cells)

document-store

Mode Throughput p50 p95 p99
chisel-mem 32500 ops/s → 29956 ops/s (-7.8%) ⚠️ 5.6 µs → 5.8 µs (+3.2%) 69.0 µs → 80.1 µs (+16.1%) ⚠️ 307.5 µs → 356.0 µs (+15.8%) ⚠️
chisel-strict 4789 ops/s → 4618 ops/s (-3.6%) 14.1 µs → 14.2 µs (+1.2%) 744.9 µs → 775.5 µs (+4.1%) 1.38 ms → 1.50 ms (+8.8%)
redb-strict 8306 ops/s → 8256 ops/s (-0.6%) 12.1 µs → 12.2 µs (+0.5%) 412.7 µs → 421.3 µs (+2.1%) 983.3 µs → 993.2 µs (+1.0%)
sqlite-strict 8058 ops/s → 7314 ops/s (-9.2%) ⚠️ 20.4 µs → 20.7 µs (+1.9%) 321.0 µs → 383.9 µs (+19.6%) ⚠️ 1.22 ms → 1.37 ms (+13.0%) ⚠️

mutation-log

Mode Throughput p50 p95 p99
chisel-mem 53887 ops/s → 52924 ops/s (-1.8%) 23.3 µs → 23.6 µs (+1.2%) 26.5 µs → 27.0 µs (+2.0%) 35.4 µs → 36.0 µs (+1.7%)
chisel-strict 3432 ops/s → 3333 ops/s (-2.9%) 275.5 µs → 278.6 µs (+1.1%) 569.2 µs → 589.9 µs (+3.6%) 696.6 µs → 768.7 µs (+10.3%) ⚠️
redb-strict 3777 ops/s → 3744 ops/s (-0.9%) 134.1 µs → 140.0 µs (+4.4%) 196.9 µs → 235.7 µs (+19.7%) ⚠️ 369.7 µs → 596.4 µs (+61.3%) ⚠️
sqlite-strict 9319 ops/s → 9196 ops/s (-1.3%) 89.8 µs → 93.8 µs (+4.5%) 248.9 µs → 256.1 µs (+2.9%) 308.1 µs → 342.3 µs (+11.1%) ⚠️

ycsb-a

Mode Throughput p50 p95 p99
chisel-mem 39081 ops/s → 38290 ops/s (-2.0%) 41.8 µs → 42.6 µs (+2.0%) 58.2 µs → 59.7 µs (+2.6%) 79.5 µs → 82.9 µs (+4.4%)
chisel-strict 4145 ops/s → 4105 ops/s (-1.0%) 249.7 µs → 248.3 µs (-0.6%) 661.4 µs → 657.4 µs (-0.6%) 775.4 µs → 788.6 µs (+1.7%)
redb-strict 5607 ops/s → 5601 ops/s (-0.1%) 116.4 µs → 116.6 µs (+0.2%) 178.8 µs → 188.3 µs (+5.4%) 254.7 µs → 265.6 µs (+4.2%)
sqlite-strict 128412 ops/s → 128194 ops/s (-0.2%) 7.7 µs → 7.7 µs (-0.0%) 10.7 µs → 10.7 µs (-0.1%) 12.8 µs → 12.6 µs (-1.6%)

ycsb-b

Mode Throughput p50 p95 p99
chisel-mem 258680 ops/s → 248357 ops/s (-4.0%) 1.8 µs → 1.8 µs (+0.0%) 42.1 µs → 44.8 µs (+6.6%) 47.0 µs → 50.4 µs (+7.1%)
chisel-strict 36347 ops/s → 36343 ops/s (-0.0%) 3.9 µs → 4.0 µs (+0.5%) 253.6 µs → 253.5 µs (-0.0%) 573.1 µs → 565.3 µs (-1.4%)
redb-strict 57031 ops/s → 56401 ops/s (-1.1%) 3.2 µs → 3.3 µs (+5.1%) ⚠️ 118.9 µs → 118.6 µs (-0.2%) 172.3 µs → 174.1 µs (+1.1%)
sqlite-strict 157089 ops/s → 154415 ops/s (-1.7%) 6.6 µs → 6.7 µs (+0.8%) 8.9 µs → 9.0 µs (+2.0%) 11.0 µs → 11.2 µs (+2.0%)
Generated by chisel-bench-diff at 2026-08-04T20:50:50Z. Compares PR HEAD against main. Never blocks merge — signal, not gate. Thresholds: throughput 5%, p50 5%, p95 10%, p99 10%.

@Xof
Xof merged commit e3401e7 into main Aug 5, 2026
10 checks passed
@Xof
Xof deleted the fix/102-trust-boundary-hardening branch August 5, 2026 03:46
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.

Trust-boundary hardening gaps found by the cross-cutting security sweep

1 participant