Security¶
Threat model¶
| Trusted (authored by the developer or operator) | Untrusted |
|---|---|
static t-string text, migration files, reset_query, the DSN and ConnectOptions, the checker's database (plan-time evaluation) |
interpolated values, {x:ident} values, and the types of those values (str and tuple subclasses) |
server bytes when sslmode is weaker than verify-full (a MITM can impersonate the server) |
|
session state that application SQL can change (SET client_encoding, SET ROLE, temp tables) and that outlives a pool checkout |
Invariant: no untrusted input can change the parse tree of a statement, or the element and bound structure of an array or range parameter.
Parameters and SQL text¶
- Values are always bound as server-side parameters, never interpolated into SQL text.
queryrejects every dynamic construct. Inquery_unchecked, nested templates also bind their values, and:identis quoted and length-checked (:ident). There is no public raw-SQL escape. :identvalues are untrusted, and so are their types. The compiler normalises a name once, withstr.encode, and every check and the quoting then run on those bytes, so astrortuplesubclass that overrides__contains__,__len__,encode,replaceor__iter__has no effect. Identifiers are not restricted to enum members: an application that takes a column name from a user MUST allowlist it.- Compile limits are
TypeErrors raised before any IO: a NUL in the SQL text, and more than 65535 parameters after flattening (Compile limits). - Hand-built templates. The only way to put attacker-controlled text into the static part of a query is
Template(...)with a non-literal string (ort"…" + Template(user_str)). The runtime compiles whateverTemplateit is given, and has no provenance check. The checker'stemplate-constructionrule (Rules) rejects that construction in every file under the include roots, and also a non-literal format spec in any t-string literal there. - The rule is best-effort.
getattrandevalcan still build a template, so can code outside the include roots, and so can generated code. Thetemplate-constructionrule can be configured or suppressed with a native rowx ignore comment; doing so hides the finding and does not add a runtime provenance check. See Rules. - A runtime provenance check and an
assert_sql_safeescape hatch were both rejected. - Migration files are trusted code.
Migratorandrowx check --scratchrun their statements over the simple query protocol, because a file holds many statements. This is internal, and it adds no public raw-SQL escape. Only the table name is built into SQL by rowx, and it is parsed into quoted identifiers (History table); so are database names inrowx databaseand--scratch. - Internal SQL names
pg_catalogobjects explicitly (pg_catalog.now()in the history DDL,pg_catalog.pg_advisory_unlock_all()inRESET_QUERY, and so on). Operators are qualified asOPERATOR(pg_catalog.…)too, because operators resolve throughsearch_pathlike functions. A role or DSNsearch_pathcannot make rowx's own statements resolve to a shadowing object. :identis accepted only in code position. The compiler tracks quotes and comments and rejects any other placement (:ident).- A
Templateis recognised by exact type. Compilation accepts aTemplate, as its argument and as a nested value, only whentype(x) is Template, never throughisinstance. A nested value that is not exactly aTemplateis bound as a parameter, so an unsupported object fails in the encoder withEncodeError.
Arrays, ranges and session encoding¶
- Arrays and ranges are always sent in binary format. There is no text literal for an element or a range bound
(Binary arrays and ranges). Scalar parameter formats are unchanged. A scalar
has no literal grammar, so a text-format scalar can corrupt only itself.
json.dumpsstays ASCII-only. - Session encoding watch. tokio-postgres forces
client_encoding=UTF8at startup but ignores a laterSET client_encodingrun by application SQL. Whenclient_encodingleavesUTF8, orstandard_conforming_stringsturnsoff, the connection is tainted, every later call raisesInterfaceError, and the pool discards it (Session encoding watch). - The taint is sticky for the life of the connection.
UTF8andUNICODE(ASCII case-insensitive) both count as UTF-8.standard_conforming_strings = offset at role or database level taints the connection at connect. - This is a backstop against data drift. Binary arrays and ranges are what stop element injection. A statement
already queued behind the
SETcan corrupt data but not change the structure of an array. DateStyleandIntervalStyleare not watched, because every decoder rowx uses is binary.
Connect options¶
- Option keys and values are validated (Option validation): a
server_settingskey MUST match the GUC grammar, anextrakey MUST match^[a-z_]+$, and a NUL or other C0 control character in any value is anInterfaceErrorthat names the key and never the value. - Neither diagnostics nor exceptions may include a password. Redaction is by secret value, not by DSN syntax:
every occurrence of the password, and of the value of any
extrakey containingpassword(such assslpassword), is redacted in any exception message,repr, wrapped cause'srepr()and checker diagnostic. Redaction replaces the decoded value and every raw spelling of it in the DSN text, and also masks URL userinfo andpassword=tokens syntactically, so a DSN that fails to parse is still covered. Empty secrets are skipped.ConnectOptions.__repr__showsextravalues as'<redacted>'. Parameter values are redacted from Rust debug logs. Checker redaction is string processing over the URL the command already holds. It does not load a database backend to do it.
TLS and authentication¶
sslmodedefaults toprefer, which is encrypted but not verified, as in libpq, sqlx and asyncpg. Underpreferordisablea MITM can impersonate the server.- Production recommendation:
sslmode=verify-full&channel_binding=require.channel_binding=requiremakes the client refuse any authentication method that cannot be bound to the TLS channel.requirewithsslmode=disableis a parse-time error. The server certificate must be one for which tokio-postgres-rustls can computetls-server-end-point(Connection and DSN). verify-fullandrequirenever fall back to plaintext when the server answersNto SSLRequest, and a cancel request follows the connection's TLS mode.- Non-goals:
require_auth(tokio-postgres has no support), client certificates (#304), and changing thesslmodedefault.
Server bytes¶
- The wire decoders parse bytes that a MITM controls under any
sslmodeweaker thanverify-full. They MUST returnDecodeErrorand never panic, because a panic surfaces in Python asPanicException, aBaseException. crates/rowx-postgres/src/wire/deniesclippy::indexing_slicingandclippy::arithmetic_side_effects, so slices usegetand arithmetic is checked. The lints coverwire/only. The PyObject decoders inpackages/rowx-postgres/src/codec/and the describe and explain parsing are covered by the panic-boundary test and the fuzz targets.- Every
wire/decoder, array decoding for each element family, the EXPLAIN plan JSON reader and the DSN parser have a fuzz target, run with-rss_limit_mb. Encoders havedecode(encode(x)) == xtargets. - A malformed value of every type surfaces as
DecodeError(the panic-boundary test). - The checker's own connection uses the same TLS settings, so its server bytes are untrusted below
verify-fulleven though its database contents are trusted. - A parameter or message of 2³¹ bytes or more raises
EncodeErrororInterfaceErrorand leaves the connection usable.
Pool reset¶
- The default
RESET_QUERYleaves a pooled connection with none of the previous checkout's session state: advisory locks, cursors,LISTEN, everySET, the role and session authorization, and temp tables (Release). - The batch starts with
RESET SESSION AUTHORIZATION(whichRESET ALLdoes not cover) and thenRESET ALL, and its function ispg_catalog-qualified. - When rowx believes no transaction is open, an idle guard runs with the reset. A custom reset query is a separate
round trip after the guard or
ROLLBACK.reset_query=Nonewith no transaction flag sends no guard. A reset inside an open transaction fails and the connection is discarded. - A temp table matters because
pg_tempis searched first for relations. DEALLOCATE ALLis not part of the reset, because rowx's protocol-level statements are cached per connection. SQL-levelPREPAREis unsupported across checkouts.reset_query=Noneturns the reset off, and is unsafe for multi-tenant pools.
Checker¶
- The checker's guarantees and limits are in
Interface and checker session, including plan-time evaluation of
functions and locking. With
--scratchthe checker executes migration files in a database it creates. Use a dedicated role on a CI or disposable database. - No SQL text reaches the checker's EXPLAIN, whether the user's or a view definition. The checker prepares the
statement and runs
EXPLAIN … EXECUTE "<name>"(NULL, …)over its server-side name (Algorithm). - A view definition takes the same path. It is prepared under a pinned
search_pathand explained by name. - Multi-statement SQL is still rejected by Parse.
EXPLAIN … EXECUTEevaluates its arguments, so a statement with a domain parameter is explained through its base types. No domain constraint is evaluated.- The plan pass accepts exactly one result set, and its JSON must have a
Plankey. - Server-supplied text (message, detail, hint) is sanitised before it reaches a terminal. Text output strips Unicode
categories Cc (except
\nand\t), Zl, Zp and Cf, and surrogate-escaped C1 bytes (U+DC80 through U+DC9F). U+200C (ZWNJ), U+200D (ZWJ) and U+00AD (soft hyphen) are kept (Output). - The checker never imports or executes user code, and the
template-constructionrule reads source only.