Add --hide-internal-tables flag to hide __pgsqlite_* tables from client sqlite_master queries (#80) - #85
Merged
Merged
Conversation
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>
This was referenced Aug 5, 2026
Closed
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.
Closes #80.
Problem
pgsqlite's catalog queries already filter their own bookkeeping tables, but a
sqlite_master/sqlite_schemaquery 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 tocustomers:__pgsqlite_*tables__pgsqlite_schema,__pgsqlite_migrationsidx_enum_values_label,idx_comments_lookupsqlite_autoindex___pgsqlite_schema_1Note that a
name LIKE '__pgsqlite_%'test matches only 15 of those 37. The other 22 are caught viatbl_name, which every index row insqlite_mastercarries.Solution
A new
--hide-internal-tablesflag (envPGSQLITE_HIDE_INTERNAL_TABLES), default off — with no flag set, behaviour is unchanged.When on,
SqliteMasterFilterparses the client's SQL and replaces eachsqlite_master/sqlite_schemarelation with a filtered derived table: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 INlists andESCAPEclauses. Because the filtering happens inside the relation,count(*), joins, subqueries, CTEs,EXISTS, andSELECT sqlare all correct with no extra handling.substr(...)rather thanLIKE '__pgsqlite_%': inLIKE,_is a single-character wildcard, so theLIKEform also matches unrelated names such asabpgsqliteX.Scope
Hidden:
__pgsqlite_*tables and the index rows they own. Deliberately not hidden, and documented as such: the materializedpg_*/information_schema_*relations (filtering those is a broader policy call, since a user may legitimately name a tablepg_something) andPRAGMA table_list.Hiding is listing-only.
SELECT * FROM __pgsqlite_schemastill works when named explicitly, so a live deployment stays debuggable over the wire.Hooks — client queries only
src/query/executor.rs, inpreprocess_querysrc/query/extended.rs, top ofhandle_parse, ahead of the prepared-statement cache and outside theunified_processorcfg blockDeliberately 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, andcache/schema.rsall probesqlite_masterfor__pgsqlite_*names; filtering those would makeSELECT 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_masterat depth 2, so thedepth > 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_masterthe detector already permits at depth 1.Testing
main./temp.qualified, join, CTE,EXISTS,count(*),SELECT sql,sqlite_schemaspelling), no-op cases (flag off, no reference, unparseable,otherdb.qualified),DELETE/UPDATEpassthrough,INSERT ... SELECTrewrite, and a guard that theLIKEform is not generated.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)
CREATE VIEW v AS SELECT ... FROM sqlite_masteris 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.\dtreports "Did not find any tables" on a database that has one.information_schema.tableslists all 27 materializedpg_*/information_schema_*relations alongside user tables.cargo test --lib sql_injectionaborts onmaintoday:db_handler's own tests construct aDbHandler, which dereferencesCONFIG, which runsConfig::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 oncount(*), joins, subqueries, andSELECT sql.🤖 Generated with Claude Code