Skip to content

Follow the server's page size and page count only within SQLite's limits - #8

Merged
mtrossbach merged 2 commits into
mainfrom
server-values
Nov 3, 2026
Merged

mtrossbach merged 2 commits into
mainfrom
server-values

Conversation

@reklov

@reklov reklov commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator

Closes the class of gap kupferdraht closed for its backend's responses (Step 95c): values from the other side that go unchecked into arithmetic or allocations.

The gap

The page size and page count of an Opened answer went straight into the page store. A page size of 0 divided by zero at the first read, a huge one sized every block, and a huge page count sized the bitmap of the local copy. The panic happened inside a VFS callback, which SQLite reaches through extern "C" and which rsqlite-vfs does not wrap in catch_unwind, so it ended the process instead of failing the SQLite call.

sqlite-remote-server validates these values, so only a foreign or compromised server could send them. That is the adversary the README already names for the relay case.

What changes

  • Opened answers (database.rs): the page size must be a power of two from 512 to 65536 and, on a resume, the one in use; the page count at most SQLITE_MAX_PAGE_COUNT (4294967294). Anything else fails the open or the resume with a protocol error before it sizes anything.
  • File copy header (local.rs): it has no checksum, so a header with a page size of 0 or above 65536, or a page count beyond the limit, is not trusted. The page size is only bounded there, because the copy's fit is decided against the open database when it is used.
  • Browser local mode (local_database.rs): the same bounds on the head the connection worker returns from IndexedDB.
  • The limits and the page-size rule live in pages.rs; one sentence in the README under How it works.

Tests

Four new unit tests: refused page sizes (0, 100, 3000, 2^17, u32::MAX), refused page counts beyond the limit, accepted limits (512, 65536, the largest page count on demand), and the damaged file header in three variants, with the undamaged one still read.

Verified locally: cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, the same on wasm32-unknown-unknown in browser/, cargo test --workspace, cargo doc with -D warnings.

Left as is: the reconnect loop retries a protocol error on resume until its deadline and then breaks the database as uncertain. Bounded, and a behaviour change there would need its own test.

🤖 Generated with Claude Code

The page size and page count of an Opened answer went unchecked into the page store: a page size of 0 divided by
zero at the first read, a huge one sized every block, and a huge page count sized the bitmap of the local copy. The
panic happened inside a VFS callback, which SQLite calls through extern "C", so it ended the process instead of
failing the SQLite call. The server validates these values, so only a foreign or compromised server could send
them; the same class kupferdraht closed for its backend's responses.

An Opened answer now fails the open or the resume with a protocol error unless the page size is a power of two from
512 to 65536, on a resume the one in use, and the page count is at most SQLITE_MAX_PAGE_COUNT. The header of the
file copy, which has no checksum, is not trusted with a page size of 0 or above 65536 or a page count beyond that
limit, and the browser's local mode checks the stored head the same way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@reklov
reklov requested a review from mtrossbach November 1, 2026 15:46
@reklov reklov mentioned this pull request Nov 1, 2026
A fetched block went into the page store as it came. The store slices blocks by the page size, so a shorter block
panicked at the first read inside a VFS callback, and a longer one would have been committed back as it is. The
protocol already says each block is exactly page_size bytes; the client now checks it for every block of a fetch and
of a catch-up fetch, and fails with a protocol error otherwise. The browser's local mode checks the blocks the
connection worker returns from IndexedDB the same way.

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

Copy link
Copy Markdown
Member

Thanks, this looks good. I pushed one commit on top (b35b72e) that closes a gap of the same class:

Block length. The blocks of a Pages answer went into the page store unchecked. The store slices them by the page size, so a block shorter than the page size panicked at the first read (pages.rs:212, inside the VFS callback, so the process ends), and a longer one would have been committed back as it is. The proto already says each block is exactly page_size bytes; Client::fetch and fetch_ranges now take the block size and fetch_frames refuses any other length with a protocol error. The browser's local mode checks the blocks from IndexedDB the same way.

Tests: blocks of 0, 100, page size ± 1 fail the open with preload, a short block fails the on-demand read with Broken::Refused, and blocks of the page size still read. Without the check, the tests panic exactly at pages.rs:212.

Not changed: with MAX_PAGE_COUNT, the local copy's bitmap can still reach about 512 MiB. That is bounded and no longer a crash, so I left it.

🤖 Generated with Claude Code

@mtrossbach
mtrossbach merged commit 24bc7fe into main Nov 3, 2026
15 checks passed
@mtrossbach
mtrossbach deleted the server-values branch November 3, 2026 15: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.

2 participants