Un rol de Postgres responde una sola pregunta: ¿puede este usuario tocar este objeto? GRANT DELETE ON orders TO support_agent dice que el agente puede borrar filas de orders. No dice cuáles, y no distingue DELETE FROM orders WHERE id = 1 de DELETE FROM orders. La seguridad a nivel de fila (RLS) acota las filas a las que llega un rol, pero dentro de esas filas un DELETE sin WHERE las borra todas.
Una persona frente a psql cubre ese hueco con costumbre y con BEGIN. Con un agente de IA o un script que tiene credenciales reales, nadie lee la sentencia antes de que se ejecute. En julio de 2025, un agente de código de Replit borró una base de datos de producción durante un congelamiento de código (discusión en Hacker News). El agente tenía permiso. Lo que faltaba era una revisión de esa sentencia en particular.
Construimos vericto-proxy, un proxy que habla el protocolo de red de Postgres, parsea cada sentencia y decide si llega a la base. Este artículo trata de las dos cosas que tiene que hacer bien: leer una sentencia exactamente como la lee Postgres y responderle al cliente de una forma que el driver no pueda distinguir del servidor. Cada árbol, veredicto y traza de mensajes que sigue sale de una ejecución real; los comandos están en el apéndice.
Nuestra posición. Construimos Vericto, y este proxy es nuestro. Lo que sigue describe cómo funciona y también dónde se queda corto.
Parte 1: libpg_query, el parser de Postgres como biblioteca
libpg_query, del equipo de pganalyze, saca el parser del código fuente de Postgres y lo compila como una biblioteca C independiente. Le pasas un string SQL y devuelve el árbol sintáctico crudo en JSON o protobuf, el mismo árbol que arma la gramática del servidor. pganalyze mantiene también los bindings, entre ellos el crate de Rust pg_query. vericto-engine usa la versión 6.2 de ese crate, que trae el parser de PostgreSQL 17 (los árboles de abajo dicen "version": 170007).
Antes que nada importa una propiedad: es el árbol crudo. No consulta el catálogo ni resuelve nombres. El parser sabe que orders es una referencia a una relación, no si es una tabla o una vista.
Un DELETE con y sin WHERE
Los árboles de abajo están serializados desde el protobuf que devuelve el crate de Rust, sin las posiciones ni los campos con valor por defecto. DELETE FROM orders:
{
"stmts": [
{ "stmt": { "node": { "DeleteStmt": {
"relation": { "inh": true, "relname": "orders", "relpersistence": "p" }
} } } }
],
"version": 170007
}
DELETE FROM orders WHERE id = 1 tiene el mismo nodo más un where_clause:
"DeleteStmt": {
"relation": { "relname": "orders", … },
"where_clause": { "node": { "AExpr": {
"kind": 1,
"name": [ { "node": { "String": { "sval": "=" } } } ],
"lexpr": { "node": { "ColumnRef": { "fields": [ { "node": { "String": { "sval": "id" } } } ] } } },
"rexpr": { "node": { "AConst": { "val": { "Ival": { "ival": 1 } } } } }
} } }
}
El motor recorre este árbol y arma un registro plano por sentencia; las reglas se evalúan sobre ese registro. La regla del DELETE sin WHERE son tres líneas de Rust:
// vericto-engine, src/rules/evaluator.rs
"VERICTO-001" => find(stmts, |s| {
s.kind == StatementKind::Delete && s.where_presence == WherePresence::Absent
})
where_presence es Absent cuando el DeleteStmt no tiene where_clause, AlwaysTrue cuando el predicado es demostrablemente verdadero y Present en el resto de los casos. El recorrido también guarda el camino que siguió, y ese camino vuelve al cliente con el veredicto:
DELETE FROM orders → BLOCK VERICTO-001 DeleteStmt > WhereClause = NULL
DELETE FROM orders WHERE id = 1 → ALLOW
El formato no mueve el veredicto, porque no cambia el árbol. Estos tres strings producen el mismo DeleteStmt sin where_clause y el mismo BLOCK:
delete from ORDERS
/* WHERE id = 1 */ DELETE FROM orders
DELETE FROM orders -- WHERE id = 1
También vale al revés. SELECT 'DROP TABLE users' AS note contiene las palabras, pero el árbol es un SelectStmt cuyo único target es una constante de texto. No se dispara ninguna regla de DROP; el único hallazgo es VERICTO-050, un flag de severidad media por un SELECT sin LIMIT. Un regex que bloquee el segundo ejemplo y deje pasar el primero necesita un lexer de SQL, y a esa altura estás escribiendo un parser.
Un CTE que modifica datos es otro caso en el que la primera palabra engaña. WITH gone AS (DELETE FROM orders RETURNING id) SELECT count(*) FROM gone empieza con WITH y termina en un SELECT. El recorrido entra en el WithClause, encuentra el DeleteStmt y reporta WithClause > DeleteStmt > WhereClause = NULL.
OR 1=1
SELECT id, total FROM orders WHERE user_id = 1 OR 1=1 LIMIT 50 se parsea como un BoolExpr con boolop 2 (OR_EXPR) y dos argumentos:
"where_clause": { "node": { "BoolExpr": {
"boolop": 2,
"args": [
{ "node": { "AExpr": { "kind": 1, "name": [ { "node": { "String": { "sval": "=" } } } ],
"lexpr": { "node": { "ColumnRef": { "fields": [ { "node": { "String": { "sval": "user_id" } } } ] } } },
"rexpr": { "node": { "AConst": { "val": { "Ival": { "ival": 1 } } } } } } } },
{ "node": { "AExpr": { "kind": 1, "name": [ { "node": { "String": { "sval": "=" } } } ],
"lexpr": { "node": { "AConst": { "val": { "Ival": { "ival": 1 } } } } },
"rexpr": { "node": { "AConst": { "val": { "Ival": { "ival": 1 } } } } } } } }
]
} } }
El segundo argumento compara dos nodos AConst, así que su valor no depende de la fila y el motor puede evaluarlo: verdadero. Un OR con un argumento demostrablemente verdadero es verdadero para todas las filas, y la sentencia recibe BLOCK VERICTO-090 en SelectStmt > WhereClause > BoolExpr(OR) > always_true_branch.
Qué cuenta como «demostrablemente verdadero» es estrecho a propósito, y sigue la lógica de tres valores de SQL:
- Solo califican los predicados entre constantes.
name LIKE '%'eid IS NOT NULLparecen tautologías, pero descartan las filas en las que la columna es NULL, así que el motor no los trata como siempre verdaderos. NULL = NULLes desconocido, no verdadero.1 NOT IN (2, NULL)no es una tautología: con un NULL en la lista no coincide con ninguna fila.- La única forma con columnas que se acepta es una columna comparada consigo misma,
WHERE id = id. Es la manera habitual de quitar un WHERE sin dejar de escribirlo, yDELETE FROM orders WHERE id = idrecibe VERICTO-003.
Un falso positivo en una regla que bloquea es una caída del servicio, así que la regla solo afirma lo que el árbol demuestra.
Sentencias apiladas
SELECT 1; DROP TABLE users es un solo string, y el parser devuelve dos entradas RawStmt:
"stmts": [
{ "stmt": { "node": { "SelectStmt": { "target_list": [ … "AConst": { "val": { "Ival": { "ival": 1 } } } … ], … } } } },
{ "stmt": { "node": { "DropStmt": {
"remove_type": 42,
"objects": [ { "node": { "List": { "items": [ { "node": { "String": { "sval": "users" } } } ] } } } ],
"behavior": 1
} } } }
]
remove_type 42 es OBJECT_TABLE. Se recorre cada sentencia de la lista, y la decisión es la acción más fuerte de todas las violaciones: BLOCK VERICTO-010 en el DropStmt, con el flag del SELECT listado al lado.
Por qué la respuesta no cambia
El veredicto depende de tres cosas: el texto de la sentencia, las reglas y la política de enforcement. No entra nada más. No hay modelo ni muestreo, y no hay umbrales. Cuando coinciden varias reglas, la que se reporta sale de un orden fijo (acción, luego severidad, luego código de regla), así que las mismas reglas dan el mismo código en cada ejecución, sin importar en qué orden llegaron. Ejecuta DELETE FROM orders -- WHERE id = 1 mil veces y obtienes cada vez el mismo árbol, el mismo camino y el mismo código de regla.
Compara las alternativas con las mismas entradas. Un filtro de texto se deja engañar por el comentario -- WHERE id = 1 y por las palabras dentro de 'DROP TABLE users', y una revisión de la primera palabra lee el CTE como un SELECT. Un segundo LLM que revisa la query puede responder distinto en dos ejecuciones, y lee el mismo texto inyectado que confundió al primer modelo.
Parte 2: el protocolo de red
Los clientes de Postgres hablan el protocolo frontend/backend, versión 3. Después del paquete de inicio, cada mensaje es un byte de tipo, una longitud de cuatro bytes que se incluye a sí misma y un cuerpo (formatos de mensaje). Un proxy que lee SQL tiene que delimitar los mensajes de ambos sentidos con exactitud. Y tiene que hacer algo más difícil: cuando responde un mensaje en lugar de reenviarlo, la respuesta tiene que ser la que habría enviado el servidor.
Consulta simple y extendida: dónde está el SQL
En el protocolo de consulta simple, un mensaje Query lleva el texto SQL. El proxy lo lee, lo evalúa y lo reenvía o responde por el servidor. Estas son trazas reales a través del proxy:
client proxy server
│ Query "DELETE FROM orders" │ │
│ ─────────────────────────► │ BLOCK VERICTO-001 │
│ ◄───────────────────────── │ ErrorResponse 42501 │ (never sees it)
│ ◄───────────────────────── │ ReadyForQuery 'I' │
│ │ │
│ Query "DELETE … WHERE id=1"│ │
│ ─────────────────────────► │ ALLOW ─────────────────────► │
│ ◄──────────────────────────┼────── CommandComplete DELETE 1│
│ ◄──────────────────────────┼────── ReadyForQuery 'I' │
Un Query con varias sentencias se rechaza completo. SELECT 1; DROP TABLE users recibe un solo ErrorResponse, y el SELECT 1 tampoco se ejecuta. Coincide con la forma en que el servidor trata ese mensaje: varias sentencias en un Query se ejecutan como una sola transacción, salvo que el texto traiga su propio control de transacciones.
El protocolo de consulta extendida, el que usan la mayoría de los drivers para las queries con parámetros, divide el trabajo. Parse lleva el SQL con placeholders $1, Bind lleva los valores de los parámetros, Describe y Execute actúan sobre el resultado, y Sync cierra la secuencia. El SQL está solo en Parse, así que el proxy evalúa ahí. Bind, Describe, Execute y Sync no llevan SQL y pasan sin cambios. Una sentencia con nombre que se prepara una vez y se ejecuta muchas se revisa una vez, en su Parse.
Cuando se bloquea un Parse, el proxy tiene que dejar la máquina de estados del cliente donde la habría dejado el servidor. La documentación del protocolo describe lo que hace el servidor ante un error en una secuencia extendida: emite ErrorResponse, lee y descarta mensajes hasta llegar a un Sync, y luego emite ReadyForQuery. El proxy hace lo mismo:
client proxy
│ Parse "DELETE FROM orders" │ BLOCK VERICTO-001
│ Bind / Describe / Execute │ discarded
│ Sync ─────────────────────────────► │
│ ◄──────────────────────────────────── ErrorResponse 42501
│ ◄──────────────────────────────────── ReadyForQuery
Los valores de Bind son el punto ciego del proxy, por construcción. Esta secuencia pasó, porque la sentencia tiene WHERE:
-> Parse('DELETE FROM orders WHERE id > $1') Bind(['0']) Describe Execute Sync
<- ParseComplete, BindComplete, NoData, CommandComplete(DELETE 3), ReadyForQuery(I)
Borró las tres filas de la tabla de prueba. El árbol tiene id > $1, y $1 es un ParamRef sin valor.
Responder por el servidor sin mentir sobre la transacción
Cada ReadyForQuery termina con un byte de estado: I inactivo, T dentro de un bloque de transacción, E dentro de un bloque de transacción fallido (ReadyForQuery). Los clientes dependen de él: el prompt %x de psql lo muestra y PQtransactionStatus() de libpq lo devuelve. Cuando el proxy escribe un ReadyForQuery por su cuenta tiene que elegir ese byte, y el servidor no está ahí para decírselo.
Esto es lo que hacía el proxy hasta la 4.14.3, y lo que sigue haciendo con la configuración por defecto:
-> Query('BEGIN') <- CommandComplete(BEGIN), ReadyForQuery(T)
-> Query('DELETE FROM orders') <- ErrorResponse(42501 …), ReadyForQuery(I) ← written by the proxy
-> Query('SELECT count(*) FROM orders') <- RowDescription, DataRow, CommandComplete(SELECT 1), ReadyForQuery(T)
Durante una ida y vuelta, el cliente creyó que estaba inactivo mientras el servidor tenía una transacción abierta. La 4.15.0 lo corrigió. El relay que copia los bytes del servidor al cliente ahora también los delimita (después del inicio, cada mensaje del backend es una etiqueta más una longitud, así que delimitar desde el primer byte es exacto) y guarda el byte de estado de cada ReadyForQuery. Cada respuesta que escribe el proxy lleva el último estado que reportó el servidor:
-> Query('BEGIN') <- CommandComplete(BEGIN), ReadyForQuery(T)
-> Query('DELETE FROM orders') <- ErrorResponse(42501 …), ReadyForQuery(T)
-> Query('SELECT count(*) FROM orders') <- RowDescription, DataRow, CommandComplete(SELECT 1), ReadyForQuery(T)
La respuesta correcta es T, no E. El servidor nunca vio la sentencia bloqueada, así que su transacción no está abortada y la siguiente sentencia se ejecuta con normalidad. Si el proxy reportara E, un cliente correcto desharía trabajo que el servidor todavía conserva.
El seguimiento salió junto con las primeras sentencias que el proxy consume él mismo: con las reglas de política de negocio activadas (VERICTO_POLICY_RULES, en preview), SET vericto.ticket y SET vericto.tenant dan el contexto de la sesión para las políticas y nunca se reenvían. El proxy las responde con CommandComplete SET. En un bloque de transacción fallido, Postgres rechaza todo comando salvo el fin del bloque con el SQLSTATE 25P02. Si el proxy respondiera SET ahí, el cliente creería que la sesión está sana, así que en el estado E el proxy responde como lo haría el servidor y no toma el valor:
-> Query('BEGIN') <- CommandComplete(BEGIN), ReadyForQuery(T)
-> Query("SET vericto.ticket = 'OPS-1234'") <- CommandComplete(SET), ReadyForQuery(T) ← proxy
-> Query('SELECT 1/0') <- ErrorResponse(22012: division by zero), ReadyForQuery(E)
-> Query("SET vericto.tenant = 'acme'") <- ErrorResponse(25P02: current transaction is aborted, …), ReadyForQuery(E) ← proxy
-> Query('ROLLBACK') <- CommandComplete(ROLLBACK), ReadyForQuery(I)
Una salvedad, dicha sin vueltas: el seguimiento solo se activa cuando VERICTO_POLICY_RULES está encendido. Con el flag apagado, el proxy se mantiene idéntico byte a byte a la 4.14.3, así que en la configuración por defecto un bloqueo dentro de un bloque de transacción sigue reportando I.
Un bloqueo es un error SQL común
Una sentencia bloqueada vuelve como un ErrorResponse con SQLSTATE 42501 (insufficient_privilege). El código de la regla, el camino en el AST y una corrección sugerida van en el campo de mensaje, así que cualquier cliente que imprima el mensaje los muestra:
$ psql "host=proxy port=5433 …" -c "DELETE FROM orders"
ERROR: Vericto blocked this query [VERICTO-001] — AST node: DeleteStmt > WhereClause = NULL. Suggestion: DELETE FROM orders WHERE id = $1
Ningún driver, ORM ni pool de conexiones necesita cambios. Todos ya convierten un ErrorResponse en su excepción habitual con el SQLSTATE, la conexión sigue abierta y la llamada a la herramienta de un agente recibe un error que puede leer y corregir.
Terminación TLS y channel binding de SCRAM
Para leer el SQL, el proxy tiene que descifrarlo. Con PROXY_TLS_MODE=require termina el TLS del cliente con su propio certificado y abre otra sesión TLS hacia la base (UPSTREAM_SSLMODE). Los mensajes de autenticación se retransmiten sin cambios, así que scram-sha-256 funciona y el proxy nunca tiene una contraseña.
El channel binding de SCRAM (SCRAM-SHA-256-PLUS, tls-server-end-point) existe justamente para frustrar esta disposición. El cliente mezcla en su prueba SCRAM un hash del certificado del servidor que vio, y el servidor lo compara con su propio certificado (autenticación SASL). Detrás de un proxy que termina el TLS, el cliente vio el certificado del proxy, así que la verificación falla. Es la función haciendo su trabajo, y ningún proxy que lea SQL puede pasarla.
Lo que medimos con psql 17.11 contra el proxy:
| Tramos | channel_binding=require |
prefer (el valor por defecto de libpq) |
disable |
|---|---|---|---|
| cliente→proxy con TLS, proxy→base con TLS | FATAL: SCRAM channel binding check failed |
la misma falla | conecta |
| cliente→proxy con TLS, proxy→base sin TLS | channel binding is required, but server did not offer an authentication method that supports channel binding |
conecta | conecta |
La sorpresa es el resultado de prefer en la primera fila. Con TLS en los dos tramos, el servidor ofrece SCRAM-SHA-256-PLUS, libpq lo elige y la conexión falla en lugar de caer al método sin binding. A través de un proxy que termina el TLS, conéctate con:
sslmode=require channel_binding=disable
(channel_binding en la documentación de libpq.) El mismo razonamiento descarta la autenticación cert atada al cliente final: la base ve al proxy.
Las cancelaciones viajan por otra conexión
Cancelar una query no usa la conexión de la sesión. El cliente abre una nueva y envía un CancelRequest de 16 bytes con el ID de proceso y la clave secreta que recibió en BackendKeyData al iniciar. El proxy retransmite BackendKeyData sin cambios y está delante de una sola base, así que puede reenviar el paquete tal como llega. A través del proxy sin TLS, Ctrl-C en psql dio Cancel request sent y ERROR: canceling statement due to user request, igual que en una conexión directa.
Con PROXY_TLS_MODE=require no funcionó. psql 17.11 envió la cancelación sin TLS (el protocolo permite un SSLRequest antes de un CancelRequest, pero no lo exige), el proxy rechazó el paquete en texto plano como rechaza cualquier inicio sin TLS, y la query siguió hasta terminar. Lo encontramos mientras escribíamos este artículo. Si dependes de la cancelación, tenlo en cuenta al activar TLS de cliente en el proxy.
COPY
El proxy evalúa la sentencia COPY en sí. COPY … TO/FROM PROGRAM y COPY … FROM un archivo del servidor de base de datos tienen sus propias reglas. Las filas que siguen viajan en mensajes CopyData, que no son SQL, así que pasan sin evaluarse.
Lo que no hace
- Solo ve el SQL que pasa por él. La garantía vale cuando la base acepta conexiones solo desde el proxy, con
pg_hba.conf, reglas de red o ambas. Un cliente que llega directo al puerto 5432 no se revisa. - No evalúa los valores de Bind.
WHERE id > $1es un WHERE. Si$1vale0lo decide la aplicación. - Lee el árbol sintáctico crudo. No hay catálogo. Una llamada a una función es un nodo
FuncCall, y lo que la función hace por dentro no está en el árbol. Una regla se dispara solo sobre las formas de nodo que el recorrido registra, y por eso cada hueco que aparece se convierte en un test de regresión. - El SQL que no se puede parsear se reenvía por defecto. Si libpg_query rechaza una sentencia, el proxy la reenvía y la reporta como
VERICTO-PARSE-ERROR. En Postgres el parser es el del propio servidor, así que la mayoría son sentencias que el servidor también rechazaría. La excepción es la diferencia de versión:CREATE TABLE t (a int, b int GENERATED ALWAYS AS (a * 2) VIRTUAL)es válido en PostgreSQL 18, y el parser de PostgreSQL 17 que trae el crate se detiene enVIRTUAL. El modo fail-closed es un ajuste de la política del workspace; con él, estas sentencias se rechazan con 42501.
El proxy también habla el protocolo de MySQL. Ese tiene sus propios detalles y daría para otro artículo.
Lo que sigue
Medimos lo que esto cuesta en latencia; eso es el próximo artículo.
Es gratis para autoalojar con las reglas incluidas, sin cuenta: github.com/vericto/vericto-proxy. El proxy y el motor son source-available bajo la FSL-1.1-ALv2, y cada versión pasa a Apache-2.0 dos años después de publicarse.
Apéndice: cómo se obtuvieron los ejemplos
Árboles y veredictos. Un programa chico en Rust contra vericto-engine 4.5.0 (origin/main) y el crate pg_query 6.2.1:
let tree = pg_query::parse(&sql)?; // libpg_query, PostgreSQL 17.7 parser
let json = serde_json::to_value(&tree.protobuf)?; // prune locations and default values
let o = vericto_engine::evaluate(&sql, Dialect::Postgres,
&standard_rules(), &EnforcementPolicy::default());
println!("{:?} {:?} {:?}", o.decision, o.rule_code, o.ast_node_path);
Trazas del protocolo. vericto/vericto-proxy:4.15.3 delante de postgres:17, con un cliente pgwire v3 mínimo que imprime cada mensaje del backend y el estado de ReadyForQuery. La 4.16.0 solo cambia los bundles de políticas firmados; su CHANGELOG indica que el camino de cada sentencia no cambió.
docker network create pgw-net
docker run -d --name pgw-db --network pgw-net -e POSTGRES_HOST_AUTH_METHOD=trust postgres:17
docker run -d --name pgw-proxy --network pgw-net -p 127.0.0.1:15433:5433 \
-e UPSTREAM_HOST=pgw-db vericto/vericto-proxy:4.15.3
docker run -d --name pgw-proxy-on --network pgw-net -p 127.0.0.1:15434:5433 \
-e UPSTREAM_HOST=pgw-db -e VERICTO_POLICY_RULES=observe vericto/vericto-proxy:4.15.3
TLS y channel binding. postgres:17 con ssl=on, un certificado autofirmado y POSTGRES_PASSWORD (SCRAM), y el proxy con TLS en los dos tramos:
docker run -d --name pgw-proxy-tls --network pgw-net \
-e UPSTREAM_HOST=pgw-db-tls -e UPSTREAM_SSLMODE=require \
-e PROXY_TLS_MODE=require -e PROXY_TLS_CERT="$(cat proxy.crt)" -e PROXY_TLS_KEY="$(cat proxy.key)" \
vericto/vericto-proxy:4.15.3
for cb in require prefer disable; do
docker run --rm --network pgw-net -e PGPASSWORD=demo postgres:17 psql \
"host=pgw-proxy-tls port=5433 user=postgres dbname=postgres sslmode=require channel_binding=$cb" \
-Atc "select 'ok'"
done
Cancelación. psql -c "select count(*) from generate_series(1, 2000000000) g" en un contenedor y, a los cuatro segundos, docker kill -s INT sobre él.