Skip to content

Add --hide-internal-tables flag to hide __pgsqlite_* tables from client sqlite_master queries (#80) - #85

Merged
erans merged 10 commits into
mainfrom
feat/hide-internal-tables
Aug 5, 2026
Merged

Add --hide-internal-tables flag to hide __pgsqlite_* tables from client sqlite_master queries (#80)#85
erans merged 10 commits into
mainfrom
feat/hide-internal-tables

Conversation

@erans

@erans erans commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Closes #80.

Problem

pgsqlite's catalog queries already filter their own bookkeeping tables, but a sqlite_master / sqlite_schema query written directly by a client is passed through verbatim. Schema browsers, ORMs, and \dt-style tooling therefore see pgsqlite's internals mixed in with the user's tables. On a fresh database with one user table, a client listing returns 37 internal rows next to customers:

Kind Count Examples
__pgsqlite_* tables 15 __pgsqlite_schema, __pgsqlite_migrations
Indexes without the prefix 8 idx_enum_values_label, idx_comments_lookup
Implicit unique indexes 14 sqlite_autoindex___pgsqlite_schema_1

Note that a name LIKE '__pgsqlite_%' test matches only 15 of those 37. The other 22 are caught via tbl_name, which every index row in sqlite_master carries.

Solution

A new --hide-internal-tables flag (env PGSQLITE_HIDE_INTERNAL_TABLES), default off — with no flag set, behaviour is unchanged.

When on, SqliteMasterFilter parses the client's SQL and replaces each sqlite_master / sqlite_schema relation with a filtered derived table:

FROM (SELECT * FROM sqlite_master
      WHERE substr(name, 1, 11) <> '__pgsqlite_'
        AND (tbl_name IS NULL OR substr(tbl_name, 1, 11) <> '__pgsqlite_')) AS sqlite_master

The client's own projections, predicates, and joins are never modified, so there is no predicate to splice and nothing to corrupt — the failure mode that sank the earlier rewriting attempt in #82 on large NOT IN lists and ESCAPE clauses. Because the filtering happens inside the relation, count(*), joins, subqueries, CTEs, EXISTS, and SELECT sql are all correct with no extra handling.

substr(...) rather than LIKE '__pgsqlite_%': in LIKE, _ is a single-character wildcard, so the LIKE form also matches unrelated names such as abpgsqliteX.

Scope

Hidden: __pgsqlite_* tables and the index rows they own. Deliberately not hidden, and documented as such: the materialized pg_* / information_schema_* relations (filtering those is a broader policy call, since a user may legitimately name a table pg_something) and PRAGMA table_list.

Hiding is listing-only. SELECT * FROM __pgsqlite_schema still works when named explicitly, so a live deployment stays debuggable over the wire.

Hooks — client queries only

Protocol Location
Simple src/query/executor.rs, in preprocess_query
Extended src/query/extended.rs, top of handle_parse, ahead of the prepared-statement cache and outside the unified_processor cfg block

Deliberately not at DbHandler::process_query, despite that being the single chokepoint all queries pass through. migration/runner.rs, metadata/enum_metadata.rs, rewriter/enum_rewriter.rs, cache/lazy_schema_loader.rs, and cache/schema.rs all probe sqlite_master for __pgsqlite_* names; filtering those would make SELECT 1 FROM sqlite_master WHERE name='__pgsqlite_metadata' return nothing and pgsqlite would re-run its migrations on every start.

Fail open throughout: a parse failure or unrecognized shape returns the query unchanged rather than erroring.

Notable fix included

The rewrite initially tripped pgsqlite's own SQL-injection detector. The added nesting level put sqlite_master at depth 2, so the depth > 1 "suspicious system table access" rule fired on benign listings over the extended protocol in text result format (psycopg2's default), logging HIGH-severity false positives into the security audit trail.

Fixed by exempting only pgsqlite's own generated wrapper from the depth increment. The exemption does not reset depth, so a client wrapping the generated form in their own subquery is still caught, and the recognized subquery is a fixed literal returning strictly fewer rows than the SELECT * FROM sqlite_master the detector already permits at depth 1.

Testing

  • 20 unit tests on the translator: rewrite shapes (bare, aliased, main./temp. qualified, join, CTE, EXISTS, count(*), SELECT sql, sqlite_schema spelling), no-op cases (flag off, no reference, unparseable, otherdb. qualified), DELETE/UPDATE passthrough, INSERT ... SELECT rewrite, and a guard that the LIKE form is not generated.
  • Wire-level integration tests in two separate binaries, one per flag state. The disabled binary asserts internal tables are still visible, proving the default is unchanged.
  • Detector regression tests asserting the generated form is accepted and client-written nesting is still rejected.
  • Full suite: 1281 passed, 0 failed.
  • Manual two-restart verification against a real database with psql: a fresh start applies 27 migrations; a later start of that same database with the flag on logs zero migration activity, confirming internal probes still see the real catalog.

Follow-ups (not in this PR)

  • The read-context restriction means CREATE VIEW v AS SELECT ... FROM sqlite_master is not rewritten, so a client can create a view over the unfiltered catalog once and query it thereafter. Consistent with the listing-only contract, but worth revisiting.
  • \dt reports "Did not find any tables" on a database that has one.
  • information_schema.tables lists all 27 materialized pg_* / information_schema_* relations alongside user tables.
  • cargo test --lib sql_injection aborts on main today: db_handler's own tests construct a DbHandler, which dereferences CONFIG, which runs Config::parse() against the test binary's argv.

Supersedes #81, #82, and #83. The reasoning for choosing relation substitution over #83's result-row filtering is in docs/superpowers/specs/2026-08-05-hide-internal-tables-design.md: row filtering sees only what the client projected and fails open silently on count(*), joins, subqueries, and SELECT sql.

🤖 Generated with Claude Code

erans and others added 10 commits August 5, 2026 13:55
Adds a --hide-internal-tables flag (default off) that filters pgsqlite's
bookkeeping tables out of client sqlite_master queries via relation
substitution, applied at the wire boundary only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pure query translator that rewrites client sqlite_master/sqlite_schema
references to filter out __pgsqlite_* internal tables via a substr-based
derived table, using sqlparser's VisitMut. Fails open on parse errors or
unrecognized shapes. Does not read config; call sites in Tasks 3/4 will
gate on config::hide_internal_tables().

Enables sqlparser's "visitor" feature to get VisitMut derives.
…ltering

Review found the raw `query.contains("__pgsqlite_")` guard disabled
filtering for any query where that literal appeared anywhere in the text,
including inside a comment (e.g. `... FROM sqlite_master -- __pgsqlite_`
returned the unfiltered catalog). The guard wasn't carrying its stated
weight: queries naming an internal table directly never reach translate()
in the first place, since they contain no sqlite_master/sqlite_schema
reference. Double-applying the rewrite is harmless (nested derived tables,
identical rows), so the guard is removed outright.

Flips leaves_explicit_internal_lookups_alone into
filters_listing_queries_that_name_an_internal_table (asserts the rewrite
now applies) and adds filters_despite_internal_prefix_in_a_comment to
cover the closed bypass.
Wires SqliteMasterFilter::translate into preprocess_query, gated on
config::hide_internal_tables(), so clients querying sqlite_master over
the simple protocol no longer see pgsqlite's __pgsqlite_* bookkeeping
tables when the flag is enabled. Default behavior (flag off) is
unchanged.

Also fixes tests/ssl_test.rs, which constructs Config via struct
literal and did not compile after Task 1 added the hide_internal_tables
field — a defect inherited from Task 1 that only surfaces once
integration tests are compiled (cargo test --lib never compiles tests/).
…n detector

The `--hide-internal-tables` rewrite wraps a client `sqlite_master` reference
in a derived table. That extra level of nesting made the SQL injection
detector see the relation at depth 2 instead of 1, so its `depth > 1`
"suspicious system table access" rule — meant to catch an attacker hiding
system-table access inside a subquery — fired on pgsqlite's own wrapper.

An ordinary schema listing over the extended protocol in text result format
(psycopg2's default) therefore logged a HIGH-severity SQL_INJECTION_ATTEMPT.
The rejection was swallowed at extended.rs:1690, so it failed open only by
accident, while costing the ultra-fast path and running the query twice.

The translator now exposes `is_generated_filter_subquery`, keyed on the exact
generated shape, and the detector does not increment nesting depth when
descending into a derived table that matches it. `depth > 1` is untouched for
client-written nesting: reproducing our subquery verbatim just yields the
filtered relation, which is what the flag exists to hand out.

Also in this wave:

- Restrict the rewrite to read contexts (`Statement::Query`, and the `source`
  of `Statement::Insert`). UPDATE/DELETE name their target with the same
  `TableFactor::Table`, so substituting there produced invalid SQL whose error
  text pasted `__pgsqlite_` in front of the very user the flag shields.
- Move the stray mid-file `use std::sync::atomic` in config.rs up with the
  other imports.
- Make `SqliteMasterFilter::needs_translation` private; it is an internal gate
  with no external caller.
- Cover the `main.`/`temp.` qualifier restriction, which had no test.
- Align the `--hide-internal-tables` README row with its block.

Co-Authored-By: Claude Opus 5 (1M context) <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.

Internal __pgsqlite_* tables leak into client sqlite_master queries

1 participant