Validate index and constraint definitions at import and at use - #134
Open
roed-math wants to merge 2 commits into
Open
Validate index and constraint definitions at import and at use#134roed-math wants to merge 2 commits into
roed-math wants to merge 2 commits into
Conversation
This was referenced Aug 3, 2026
roed-math
force-pushed
the
security-metadata-validation
branch
from
August 3, 2026 07:45
c060649 to
0bc0585
Compare
roed-math
force-pushed
the
security-metadata-validation
branch
from
August 3, 2026 08:09
0bc0585 to
8bd2b5a
Compare
Author
|
The LMFDB test suite check on this PR failed on a devmirror outage, not on the change: The same commit is the base of #135, #136 and #137, whose Downstream runs started seven Lint is red for the unrelated ruff-default reason described at the top; #138 fixes it. |
roed-math
force-pushed
the
security-metadata-validation
branch
from
August 3, 2026 08:33
8bd2b5a to
a43947b
Compare
A column type cannot be bound as a value -- PostgreSQL has no placeholder for a type -- so create_table, add_column and header-driven table creation interpolate it into DDL as SQL text. The check guarding that interpolation used regexp.match() against a set of unanchored patterns, so any string beginning with a valid type passed validation with the rest still attached, and psycopg runs a parameterless statement with the simple query protocol, which executes every statement in it. Centralize the check in validate_column_type(), which matches the complete string with fullmatch(), restricts types to an ASCII character set that excludes NUL, control characters and lookalikes, and returns the spelling callers must emit. Every type-bearing DDL path now emits that returned spelling: _order_columns, _create_table, _create_table_from_header (and so reload/reload_all with adjust_schema=True), add_column, and the temporary table built when resorting ids. The character-type pattern was also malformed -- a bracket typo in the varchar length meant varchar(N) was only ever accepted as a prefix of varchar -- so the char family is now described deliberately, with an optional length and an optional collation. Collation names stay case-sensitive, since they are quoted identifiers: "C" is a collation and "c" is not. add_column validates before it mutates col_type, so a rejected type leaves the table object alone. InvalidColumnTypeError subclasses both ValueError and RuntimeError, the latter being what an invalid type raised before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
meta_indexes and meta_constraints hold what an index or constraint is rebuilt from, sometimes long after it was created. Between the two the rows can be edited with plain SQL, exported to a file, carried to another database and imported, or restored from the _hist tables, so "this row must once have passed through create_index" is not something a statement builder can rely on. It was relied on: the access method, column modifiers, storage-parameter names, check function and partial-index predicate were formatted into the statement as text, and a predicate is appended to CREATE INDEX, where a semicolon ends it. Add validate_index_definition and validate_constraint_definition, and apply them in both places -- when rows are imported by _reload_meta and _revert_meta, inside the transaction so a rejected file leaves the old metadata intact, and again in _create_index_statement and _create_constraint_statement when the definition becomes DDL. Column existence is checked only at the second point, against the relation being built, since a reload legitimately imports an index for a column the table is about to gain. Remove the interpolation the validators were the only defense for: access methods, operator classes, storage-parameter names and check functions are now quoted identifiers, and ASC/DESC/NULLS FIRST/NULLS LAST are fixed SQL constants selected by a validated key. Predicates stay raw SQL, but must remain predicates: no semicolons, comments, dollar quotes, control characters or unreasonable lengths. The index vocabulary (_operator_classes, _valid_storage_params) moves to base.py next to the validators, and is re-exported from table.py. _copy_from_meta, an import path into meta_* with no callers and no validation, is removed, and the assert guarding _meta_cols_types_jsonb_idx becomes a ValueError. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
roed-math
force-pushed
the
security-metadata-validation
branch
from
August 3, 2026 17:44
a43947b to
04be6d8
Compare
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.
Second of five PRs from the August 3 security audit.
Stacked on #133. The branch is cut from that one, so the diff here contains
its commit too; review #133 first and merge it first, and this diff shrinks to
its own single commit. (GitHub will not take a base branch that lives in the
fork, hence the
mainbase.)The problem
meta_indexesandmeta_constraintsrecord how to rebuild an index orconstraint.
create_indexandcreate_constraintvalidate their arguments, butnothing revalidated a row on the way back out, and
_create_index_statement/_create_constraint_statementformatted the stored access method, columnmodifiers, storage-parameter names, check function and partial-index predicate
into the statement as text.
Between creation and use those rows can be edited with plain SQL, exported to a
file and imported into another database, or restored from the
_histtables. Apredicate is appended to
CREATE INDEX ... WHERE, where a semicolon ends thestatement — so a poisoned
whereclauseruns whatever follows it. I confirmedthis on the unpatched tree against a disposable database:
restore_indexon arow whose predicate had a statement appended returned normally, and the appended
statement had run.
The fix
validate_index_definitionandvalidate_constraint_definitioninpsycodict.base, returning normalizedIndexDefinition/ConstraintDefinitiontuples, applied at both ends:
_reload_metaand_revert_meta, inside the transaction thatloaded the rows, so a rejected file or history version rolls back and leaves
the previous metadata untouched;
_create_index_statementand_create_constraint_statement,which is what protects against a row edited in place, a row written by an
older psycodict, or a future import path that forgets to check.
being built — a reload legitimately imports an index for a column the table is
about to gain, so checking that at import time would break it.
longer the only defense: access methods, operator classes, storage-parameter
names and CHECK functions are quoted identifiers, and
ASC,DESC,NULLS FIRST,NULLS LASTare fixedSQLconstants selected by a validatedkey. Storage-parameter values keep going through
Literal, and are nowrange- and type-checked.
parse them — but must remain predicates: no semicolon, comment, dollar-quoted
string, control character, or length over 4096.
_operator_classesand_valid_storage_paramsmove tobase.pynext to thevalidators and are re-exported from
table.pyfor anything importing them._copy_from_metais removed: an import path intometa_*with no validationand, as far as I can find, no callers in psycodict or LMFDB. Say the word if
you would rather keep it and have it validate instead.
assertin_meta_cols_types_jsonb_idxbecomes aValueError.Compatibility
CHECKconstraint's function must now be inPostgresTable._valid_check_functionsto be rebuilt, not only to becreated. A database with CHECK constraints whose functions are not registered
there will raise on restore, with a message saying so.
create_constraintadditionally refuses a name over PostgreSQL's 63-bytelimit: it never truncated its generated names, so an over-long one used to be
silently truncated by PostgreSQL and then fail to match its
meta_constraintsrow. The length is checked only when a name is beingcreated — psycodict builds the names it puts in DDL by appending
_tmpor_oldN, and holding those to 63 bytes would fail every reload of a tablewhose index name is near the limit (
create_indexgenerates names right upto it).
being identifiers: a column that exists is a column whatever it is called
(the LMFDB has one named
2adic_index), and it is quoted wherever it isused. A column name carrying SQL is refused when the DDL is built, because it
is not a column of the table.
vetoing —
C.UTF-8, ICU names likeund-x-icu,de_DE@euro— and collatedarrays. What it still refuses is a name that could end the quoted string
early.
meta_indexesstill stores modifiers exactly as the caller spelled them; thecanonical spellings are what the DDL is built from.
Tests
34 new cases in
tests/test_security.py:by
reload_indexes, and afterwards the marker does not exist, themeta_indexesrows are exactly what they were, and the built index is the onethat was there before;
modifier carrying SQL, modifier valid only for another method, mismatched
modifier/column lengths, unknown storage parameter, out-of-range and
wrong-typed storage values, malformed and empty column lists, a column name
carrying SQL);
meta_*with SQL, thenrestore_index/restore_constraint, including an unapproved CHECK function;meta_indexes_histfollowed byrevert_indexes;still survives export →
reload_indexes→restore_index, and predicatesthat are merely unusual (multi-line,
LIKE 'a%', nested parentheses) arestill accepted.
Full suite: 1006 passed, 36 skipped against PostgreSQL 18.
Review round (2026-08-03)
name is quoted, so
x-y,index with spaces,idx_éand a name containing asemicolon all round-trip through create → drop → restore; what is refused is
an empty name, a control character (a psycodict policy, so names stay
printable in its logs and exports) and a name over PostgreSQL's 63-byte
limit. The test that expected an SQL-looking index name to be rejected is
replaced by one asserting it is quoted as a single identifier, executes
nothing, and matches its catalog name exactly.
derived_identifier(base, suffix)returnsbase + suffixwhen it fits — always, for existing names —and otherwise cuts the base on a UTF-8 character boundary and inserts a short
SHA-256 digest before the suffix. Every
_tmp/_oldN/_depN/_pkeypath uses it, so no two paths can disagree about a derived name and none
relies on server-side truncation. Generated index and constraint names are
fitted (with the disambiguator's bytes reserved before the cut); explicit
names are refused rather than silently changed.
schema.functionis parsed intocomponents and emitted as
"schema"."function", never as one identifier.gin_pending_list_limithas no client-side ceiling (its maximum derivesfrom
MAX_KILOBYTESand is architecture-dependent); only the documented lowerbound is enforced, and the value still goes out as a
Literal.on,yes,t,1andPostgreSQL's other unambiguous spellings all become a Python bool, which is
what both the DDL and
meta_indexesget; floats are refused by type, since0.0 == Falsein Python.function, the constraint created and shown to be enforced, dropped and
restored, exported and reloaded, and restored onto a
_tmptable.Full suite: 1109 passed, 36 skipped. Docs build clean under
-W.🤖 Generated with Claude Code