Skip to content

fix: pin search_path on every eql_v2 function - #177

Merged
coderdan merged 4 commits into
mainfrom
dan/pin-search-path-and-pgcrypto-portability
May 5, 2026
Merged

fix: pin search_path on every eql_v2 function#177
coderdan merged 4 commits into
mainfrom
dan/pin-search-path-and-pgcrypto-portability

Conversation

@coderdan

@coderdan coderdan commented May 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Two related issues, one (mostly) shared fix:

  1. pgcrypto portability: a single public.encrypt(...) call in compare_ore_block_u64_8_256_term (src/ore_block_u64_8_256/functions.sql) breaks on any Postgres where pgcrypto isn't installed in public. Supabase puts it in extensions by default, so EQL's ORE comparators error with function public.encrypt(bytea, bytea, unknown) does not exist the first time a range query runs against a Supabase DB.
  2. Supabase lint 0011 (function_search_path_mutable): every eql_v2.* function inherits search_path from the caller, which the lint flags. This is also a privilege-escalation surface — a caller can shadow built-ins in a schema they own and steer function resolution to their code.

Before:

Screenshot 2026-05-05 at 12 48 56 pm

After:

Screenshot 2026-05-05 at 12 48 34 pm

Fix

  • Add SET search_path = pg_catalog, extensions, public to 143 PL/pgSQL CREATE FUNCTION eql_v2.* definitions.
  • Drop the lone public. prefix on the encrypt(...) call so resolution flows through the pinned search_path (finds pgcrypto in extensions on Supabase, in public on self-hosted).

The schema list is intentionally broad rather than parameterised:

  • pg_catalog first defends against shadowing of built-ins.
  • extensions next so pgcrypto resolves there on Supabase before any user-writable public can shadow it. Schema is harmless when absent (vanilla PG).
  • public last for self-hosted Postgres (pgcrypto's default location) and EQL's own cross-schema objects (eql_v2_encrypted, eql_v2_configuration).

The original commit had public before extensions; reordered in f05c909 after CodeRabbit flagged the search-path-hijacking surface that ordering created. The pgcrypto.encrypt(...) qualifier CodeRabbit suggested isn't portable — pgcrypto's functions live in whatever schema CREATE EXTENSION placed them, and there's no schema literally named pgcrypto.

Same commit also added missing @param Doxygen tags on the four > and >= jsonb cross-type overloads (also CodeRabbit feedback).

Known gap: 37 LANGUAGE SQL functions remain unpinned

Pinning every function via SET initially broke containment queries — eql_v2.jsonb_contains and eql_v2.jsonb_contained_by fell back to seq scans instead of using the GIN index on jsonb_array(column). PostgreSQL refuses to inline a LANGUAGE SQL function that carries a SET clause, and EQL's containment helpers are explicitly designed to be inlined so the planner sees the underlying @> / <@ operators and can match the GIN index.

So commit bc7976d reverted SET on all 37 LANGUAGE SQL functions. PL/pgSQL functions keep theirs — they're never inlinable anyway, so the lint hardening is a free win there.

Practical impact:

  • ✅ Problem 1 (pgcrypto portability) is fully solved. The function carrying the encrypt call is PL/pgSQL and retains its SET clause.
  • ⚠️ Problem 2 (lint 0011) is partially cleared. 143 of 180 functions are now pinned; the 37 LANGUAGE SQL ones still trip the lint. They're already attack-resistant in practice — every internal call is eql_v2.* schema-qualified, and the only unqualified pieces are pg_catalog operators on jsonb (which are implicitly resolved against pg_catalog first).

Closing the remaining lint warnings cleanly would require swapping operator references in those bodies to OPERATOR(pg_catalog.@>) form, which makes pinning safe to add without losing inlining. That's a larger change and out of scope for unblocking the original Supabase pgcrypto-portability fix.

Residual hijacking surface on self-hosted

Even with extensions before public, vanilla self-hosted PG installs pgcrypto into public, so public still has to be in the search_path to resolve it. The proper structural fix is to move eql_v2_encrypted and eql_v2_configuration into eql_v2 so public can be dropped from the search_path entirely. Tracked in #180 — out of scope here.

Follow-ups

Verification

  • tasks/build.sh rebuilds cleanly. release/cipherstash-encrypt.sql and release/cipherstash-encrypt-supabase.sql regenerate with 143 SET search_path = pg_catalog, extensions, public clauses and zero public.encrypt references.
  • All 33 SQLx test targets pass locally (0 failures, 0 panics) post-reorder.
  • Splinter (splinter@55db5b1f) findings unchanged after the reorder: 40 function_search_path_mutable (the LANGUAGE SQL helpers above) + 1 extension_in_public (pgcrypto). No new findings introduced.
  • Local doc validator (required-tags.sh) reports Errors: 0 (3 pre-existing @param warnings, not introduced by this PR).
  • Spot-checked all four function-definition styles in the diff:
    • Single-line CREATE FUNCTION ... AS $$ (e.g. bytea_eq)
    • Multi-line attribute block RETURNS x \n IMMUTABLE STRICT \n AS $$
    • SQL-standard BEGIN ATOMIC body (e.g. text_to_ore_block_u64_8_256_term, check_encrypted)
    • Inline body RETURNS x AS $$ ... collapsing onto one line

CI timing

PG17 job ran ~33 min vs main's recent ~28 min baseline (April 23) and ~22 min (April 9). Within main's own run-to-run variance, but at the upper end. Plausibly a small per-call overhead from search_path save/restore in tight test loops; could also be runner noise.

Summary by CodeRabbit

  • Chores
    • Explicitly set database schema search path in many PL/pgSQL functions to make runtime name resolution consistent across comparison, encryption, operators, sorting, and config-related routines. No public APIs, signatures, return types, or user-visible behavior changed.

Two related issues, one fix:

1. Lint: every `eql_v2.*` function inherits search_path from the caller,
   which Supabase's `function_search_path_mutable` lint flags (rule 0011).
   This is a privilege-escalation surface — a caller can shadow built-ins
   in a schema they own and steer function resolution to their code.

2. Portability: a single hard-coded `public.encrypt(...)` call in
   `compare_ore_block_u64_8_256_term` breaks on any Postgres where
   pgcrypto isn't installed in `public`. Supabase puts it in
   `extensions` by default, so EQL's ORE comparators error with
   "function public.encrypt(...) does not exist" the first time a
   range query runs.

Fix: add `SET search_path = pg_catalog, public, extensions` to every
`CREATE FUNCTION eql_v2.*` (180 functions). With that pin in place,
internal calls don't need schema qualifiers — pg_catalog catches
built-ins, public catches EQL's own objects + self-hosted pgcrypto,
extensions catches Supabase-style pgcrypto. Drop the lone `public.`
prefix on the encrypt() call so resolution flows through the pinned
search_path.

The schema list is intentionally broad rather than parameterised:
- `pg_catalog` first defends against shadowing of built-ins
- `public` covers self-hosted Postgres (pgcrypto default) and EQL's
  own cross-schema objects (eql_v2_encrypted, configuration table)
- `extensions` covers Supabase, harmless when absent

No `SECURITY DEFINER` functions exist in EQL, but pinning still
matters for `INVOKER` functions that call other extensions' code.
@coderabbitai

coderabbitai Bot commented May 4, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR consistently adds SET search_path = pg_catalog, extensions, public (and a few minor formatting edits) inside PL/pgSQL function bodies across the codebase; function signatures, return types, and core logic are unchanged except one unqualified encrypt(...) reference in an ORE function now resolves under the declared search_path.

Changes

Search-path hardening across eql_v2 functions

Layer / File(s) Summary
Runtime environment (added directives)
src/... (many files, see list)
Inserted SET search_path = pg_catalog, extensions, public at the start of numerous PL/pgSQL function bodies across modules (blake3, hmac_256, bloom_filter, jsonb, ste_vec, ore_*, operators, sort, config, encrypted, encryptindex, common, etc.).
Core functions affected
src/operators/compare.sql, src/operators/sort.sql, src/ste_vec/functions.sql, src/encrypted/hash.sql, src/ore_block_u64_8_256/functions.sql
Key comparison, sorting, ste_vec, hashing, and ORE comparison functions now execute under the explicit search_path; compare/sort flows and logic unchanged.
Name-resolution adjustment
src/ore_block_u64_8_256/functions.sql
compare_ore_block_u64_8_256_term switched a call from public.encrypt(...) to an unqualified encrypt(...), so it resolves using the new search_path.
Operator wrappers & overloads
src/operators/*.sql
All operator implementation functions and overload wrappers (e.g., =, <, >, <=, >=, <>, ->, ->>, ~~) now set the search_path; delegation and return semantics unchanged.
Configuration & constraints
src/config/*, src/encrypted/constraints.sql
Config validation and migration functions and encrypted-field validators set search_path at function start; signatures unchanged.
Formatting-only edits
src/operators/@>.sql, src/operators/<@.sql, src/ore_block_u64_8_256/operators.sql, src/operators/~~.sql
Minor DDL formatting changes (splitting RETURNS ... AS $$) with no behavior change.
Tests / Docs
(none)
No tests or documentation changes in this diff.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • freshtonic

Poem

🐰
I hopped through schemas, nose held high,
Declared my path so names won't lie.
Now functions know just where to go,
No mystery in lookup flow.
Hooray — our queries bounce and fly!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately reflects the main change: adding pinned search_path to eql_v2 functions across 143 definitions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dan/pin-search-path-and-pgcrypto-portability

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

coderdan added 2 commits May 4, 2026 15:51
The required-tags doc validator looks back 50 lines from each CREATE
FUNCTION for `@return`. Adding the SET search_path line to every
function shifted the doc comments of follow-up `>` and `>=` overloads
just outside that 50-line window, so they no longer inherited the
first overload's `@return` tag.

Add explicit `@return Boolean ...` lines to the four overloads that
were silently relying on inheritance. (The remaining @PARAM warnings
are pre-existing and don't fail the build.)
PostgreSQL refuses to inline LANGUAGE SQL functions that carry a SET
clause. Several EQL functions (eql_v2.jsonb_contains,
eql_v2.jsonb_contained_by, the chain through eql_v2.jsonb_array, etc.)
are explicitly designed to be inlined so the planner sees the
underlying @> / <@ operators and can use the GIN index on
jsonb_array(column). The previous commit broke that — CI's containment
tests fell back to seq scans.

Strip the SET clause from all 37 LANGUAGE SQL function definitions
(plus version.template). PL/pgSQL functions keep their SET — they're
never inlinable anyway, so the lint hardening is a free win there.

Trade-off: the SQL functions remain flagged by Supabase lint 0011
(function_search_path_mutable). Their bodies are already attack-resistant
because every internal call is fully schema-qualified (eql_v2.*) and
the only unqualified references are pg_catalog operators on jsonb. A
later pass could swap operator references to OPERATOR(pg_catalog.@>)
form to get full lint coverage without losing inlining, but that's a
larger change and out of scope for unblocking the original Supabase
pgcrypto-portability fix.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (6)
src/encryptindex/functions.sql (1)

221-224: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unsafe dynamic SQL interpolation for table name.

format(... FROM %s ...) injects table_name verbatim. This allows SQL injection and malformed identifier execution. Use identifier-safe quoting (or regclass) for relation names.

Proposed fix
-	EXECUTE format(
-        'SELECT COUNT(%I) FROM %s t WHERE %I->>%L = (SELECT id::TEXT FROM eql_v2_configuration WHERE state = %L)',
-        column_name, table_name, column_name, 'v', 'active'
-    )
+	EXECUTE format(
+        'SELECT COUNT(%1$I) FROM %2$I t WHERE %1$I->>%3$L = (SELECT id::TEXT FROM eql_v2_configuration WHERE state = %4$L)',
+        column_name, table_name, 'v', 'active'
+    )
 	INTO result;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/encryptindex/functions.sql` around lines 221 - 224, The dynamic SQL uses
format(... FROM %s ...) with table_name injected verbatim, which is vulnerable
to SQL injection; change the EXECUTE to use identifier-safe quoting (e.g.
format(... FROM %I t ...)) or explicitly convert the table name to a
regclass/quoted identifier (e.g. to_regclass or quote_ident) before formatting
so table_name and column_name are passed as identifiers, not raw strings; update
the EXECUTE call that references column_name and table_name accordingly.
src/ore_cllw_u64_8/compare.sql (1)

54-56: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Wrong operand checked before computing b_term

Line 54 checks eql_v2.has_ore_cllw_u64_8(a) again, so b_term extraction is gated by the wrong value. This can return wrong ordering or raise unexpectedly when only one side has an ORE term. Use b in that condition.

Suggested fix
-    IF eql_v2.has_ore_cllw_u64_8(a) THEN
+    IF eql_v2.has_ore_cllw_u64_8(b) THEN
       b_term := eql_v2.ore_cllw_u64_8(b);
     END IF;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ore_cllw_u64_8/compare.sql` around lines 54 - 56, The condition gating
extraction of b_term is using the wrong operand; change the IF that now calls
eql_v2.has_ore_cllw_u64_8(a) before computing b_term to instead call
eql_v2.has_ore_cllw_u64_8(b), so that b_term := eql_v2.ore_cllw_u64_8(b) is only
executed when b actually has the ORE term (refer to eql_v2.has_ore_cllw_u64_8,
eql_v2.ore_cllw_u64_8, b_term, a, b).
src/ore_cllw_var_8/compare.sql (1)

54-55: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Use b in the second index-presence check.

This branch still checks has_ore_cllw_var_8(a), so b_term is skipped when only b has the ORE index and ore_cllw_var_8(b) is called when only a has it. That makes the comparator return the wrong ordering or raise on valid inputs.

Suggested fix
-    IF eql_v2.has_ore_cllw_var_8(a) THEN
+    IF eql_v2.has_ore_cllw_var_8(b) THEN
       b_term := eql_v2.ore_cllw_var_8(b);
     END IF;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ore_cllw_var_8/compare.sql` around lines 54 - 55, The second
presence-check incorrectly calls eql_v2.has_ore_cllw_var_8(a) instead of
checking b, causing b_term to be skipped when only b has the ORE index; change
that branch to check eql_v2.has_ore_cllw_var_8(b) and then call
eql_v2.ore_cllw_var_8(b) to assign b_term (refer to the IF condition calling
eql_v2.has_ore_cllw_var_8 and the assignment to b_term using
eql_v2.ore_cllw_var_8, and the variables a and b).
src/ore_block_u64_8_256/compare.sql (1)

49-50: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Use b in the second index-presence check.

This guard repeats has_ore_block_u64_8_256(a). If only b has the index, b_term stays NULL and the function can return equality; if only a has it, ore_block_u64_8_256(b) is called and raises instead of sorting the missing term correctly.

Suggested fix
-    IF eql_v2.has_ore_block_u64_8_256(a) THEN
+    IF eql_v2.has_ore_block_u64_8_256(b) THEN
       b_term := eql_v2.ore_block_u64_8_256(b);
     END IF;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ore_block_u64_8_256/compare.sql` around lines 49 - 50, The second
index-presence guard mistakenly checks eql_v2.has_ore_block_u64_8_256(a) again;
change it to check eql_v2.has_ore_block_u64_8_256(b) so b_term is only assigned
via eql_v2.ore_block_u64_8_256(b) when b actually has the index (prevents NULL
b_term or unintended exceptions when b lacks the index while a has it).
src/operators/>.sql (1)

17-24: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add missing function attributes and documentation parameters to all > operator functions.

Four functions lack IMMUTABLE STRICT PARALLEL SAFE attributes present on corresponding operators in src/operators/=.sql. This inconsistency affects query optimization and NULL handling semantics. Additionally, the two overloaded function definitions at lines 65-72 and 87-94 are missing required @param documentation tags, violating the coding guideline requiring parameter documentation.

Proposed fix
 CREATE FUNCTION eql_v2.gt(a eql_v2_encrypted, b eql_v2_encrypted)
 RETURNS boolean
+  IMMUTABLE STRICT PARALLEL SAFE
   SET search_path = pg_catalog, public, extensions
 AS $$
@@
 CREATE FUNCTION eql_v2.">"(a eql_v2_encrypted, b eql_v2_encrypted)
 RETURNS boolean
+  IMMUTABLE STRICT PARALLEL SAFE
   SET search_path = pg_catalog, public, extensions
 AS $$
@@
 --! `@brief` > operator for encrypted value and JSONB
+--! `@param` a eql_v2_encrypted Left operand (encrypted value)
+--! `@param` b jsonb Right operand (will be cast to eql_v2_encrypted)
 --! `@return` Boolean True if a > b
 --! `@see` eql_v2.">"(eql_v2_encrypted, eql_v2_encrypted)
 CREATE FUNCTION eql_v2.">"(a eql_v2_encrypted, b jsonb)
 RETURNS boolean
+  IMMUTABLE STRICT PARALLEL SAFE
   SET search_path = pg_catalog, public, extensions
 AS $$
@@
 --! `@brief` > operator for JSONB and encrypted value
+--! `@param` a jsonb Left operand (will be cast to eql_v2_encrypted)
+--! `@param` b eql_v2_encrypted Right operand (encrypted value)
 --! `@return` Boolean True if a > b
 --! `@see` eql_v2.">"(eql_v2_encrypted, eql_v2_encrypted)
 CREATE FUNCTION eql_v2.">"(a jsonb, b eql_v2_encrypted)
 RETURNS boolean
+  IMMUTABLE STRICT PARALLEL SAFE
   SET search_path = pg_catalog, public, extensions
 AS $$
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/operators/`>.sql around lines 17 - 24, The eql_v2.gt function(s) are
missing the required function attributes and some overloads lack `@param` docs;
update every eql_v2.gt(...) CREATE FUNCTION to include the attributes IMMUTABLE
STRICT PARALLEL SAFE (e.g., add them after the RETURNS clause so the signature
reads RETURNS boolean IMMUTABLE STRICT PARALLEL SAFE AS $$ ... $$ LANGUAGE
plpgsql;) and add documentation comment blocks for the overloaded eql_v2.gt
definitions in this file that include `@param` tags for both parameters (use the
exact parameter names a and b) to match the coding guideline.
src/operators/compare.sql (1)

47-62: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Pre-existing: STRICT makes the NULL-handling block in the body unreachable

STRICT causes PostgreSQL to short-circuit and return NULL automatically when either argument is NULL, so lines 52–62 are never reached. As a result:

  • eql_v2.compare(NULL, NULL)NULL, not 0
  • eql_v2.compare(NULL, val)NULL, not -1

This pre-dates this PR, but it is worth resolving because btree operator classes that rely on eql_v2.compare may behave inconsistently for NULL inputs. Drop STRICT to allow the body's NULL logic to execute, or remove the NULL guards entirely if NULL inputs should genuinely return NULL.

🐛 Proposed fix — drop STRICT to activate the existing NULL logic
 CREATE FUNCTION eql_v2.compare(a eql_v2_encrypted, b eql_v2_encrypted)
   RETURNS integer
-  IMMUTABLE STRICT PARALLEL SAFE
+  IMMUTABLE PARALLEL SAFE
   SET search_path = pg_catalog, public, extensions
 AS $$
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/operators/compare.sql` around lines 47 - 62, The function declaration
includes the STRICT attribute which causes PostgreSQL to return NULL before the
body runs, making the NULL-handling block in eql_v2.compare unreachable; remove
the STRICT token from the function signature in src/operators/compare.sql so the
procedure's internal NULL checks (the IF a IS NULL / IF b IS NULL branches)
execute and return 0/−1/1 as intended.
🧹 Nitpick comments (1)
src/blake3/functions.sql (1)

66-74: ⚡ Quick win

Refactor trivial wrapper functions to use LANGUAGE SQL

Both eql_v2.has_blake3 overloads are single-expression functions without procedural logic and should be converted to SQL language per coding guidelines. Convert LANGUAGE plpgsql blocks to SELECT statements with LANGUAGE SQL.

Applies to: lines 66-74 and 86-94

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/blake3/functions.sql` around lines 66 - 74, Convert both
eql_v2.has_blake3 PL/pgSQL single-statement functions to SQL-language functions:
replace the PL/pgSQL block with a single SELECT expression that returns the
boolean (e.g. "SELECT val ->> 'b3' IS NOT NULL;"), keep the same signature,
attributes (IMMUTABLE, STRICT, PARALLEL SAFE, SET search_path ...) and change
LANGUAGE plpgsql to LANGUAGE SQL for both eql_v2.has_blake3 overloads so they
are simple SQL functions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/operators/`>.sql:
- Around line 62-65: Add missing Doxygen `@param` tags for the `>` overloads:
update the comment block above the function declaration `CREATE FUNCTION
eql_v2.">"(a eql_v2_encrypted, b jsonb)` to include `@param a` and `@param b`
descriptions (type/context and purpose), and do the same for the other overload
declared around the 84-87 block (the alternate `>` overload for eql_v2_encrypted
parameters) so both blocks contain `@brief`, `@param` for each parameter, and
`@return`.

In `@src/operators/`>=.sql:
- Around line 61-63: The docs for the two ">=" operator overloads are missing
`@param` entries; update the Doxygen comment blocks for the overloads labeled
">="(eql_v2_encrypted, eql_v2_encrypted) and the second overload referenced
around the same file section to include `@param` a and `@param` b (describe
types/roles: left encrypted value and right encrypted/JSONB value) along with
the existing `@brief` and `@return` so the comments satisfy the project's required
tags.

In `@src/operators/sort.sql`:
- Line 483: The sort_compare function currently concatenates the filter
parameter into a dynamic SQL string and passes it into order_by_compare which
executes it (two-layer dynamic SQL injection via the filter variable); change
sort_compare to stop concatenating raw filter text — either validate/whitelist
the filter expression (only allow specific column names/operators), or build the
dynamic SQL using proper formatting functions (e.g., use format('%I',
column_name) for identifiers and pass literal values as parameters) and pass
parameters into EXECUTE USING instead of string interpolation; update
order_by_compare to accept a safely constructed query or parameter list rather
than raw SQL, and consider tightening privileges by documenting SECURITY
INVOKER/DEFINER intent and REVOKE EXECUTE on function eql_v2.sort_compare(text,
text, text, text, text) FROM PUBLIC if callers must be restricted.

In `@src/ore_block_u64_8_256/functions.sql`:
- Around line 144-145: The function sets "SET search_path = pg_catalog, public,
extensions" but calls the crypto function unqualified as encrypt(...), which
allows search_path hijacking; update every unqualified call to encrypt(bytea,
bytea, text) to fully qualified pgcrypto.encrypt(...) (e.g., replace
encrypt(...) with pgcrypto.encrypt(...)) or alternatively remove "public" from
the search_path; ensure you change both occurrences of the unqualified encrypt()
calls in this SQL function so the pgcrypto implementation is invoked directly.

---

Outside diff comments:
In `@src/encryptindex/functions.sql`:
- Around line 221-224: The dynamic SQL uses format(... FROM %s ...) with
table_name injected verbatim, which is vulnerable to SQL injection; change the
EXECUTE to use identifier-safe quoting (e.g. format(... FROM %I t ...)) or
explicitly convert the table name to a regclass/quoted identifier (e.g.
to_regclass or quote_ident) before formatting so table_name and column_name are
passed as identifiers, not raw strings; update the EXECUTE call that references
column_name and table_name accordingly.

In `@src/operators/`>.sql:
- Around line 17-24: The eql_v2.gt function(s) are missing the required function
attributes and some overloads lack `@param` docs; update every eql_v2.gt(...)
CREATE FUNCTION to include the attributes IMMUTABLE STRICT PARALLEL SAFE (e.g.,
add them after the RETURNS clause so the signature reads RETURNS boolean
IMMUTABLE STRICT PARALLEL SAFE AS $$ ... $$ LANGUAGE plpgsql;) and add
documentation comment blocks for the overloaded eql_v2.gt definitions in this
file that include `@param` tags for both parameters (use the exact parameter names
a and b) to match the coding guideline.

In `@src/operators/compare.sql`:
- Around line 47-62: The function declaration includes the STRICT attribute
which causes PostgreSQL to return NULL before the body runs, making the
NULL-handling block in eql_v2.compare unreachable; remove the STRICT token from
the function signature in src/operators/compare.sql so the procedure's internal
NULL checks (the IF a IS NULL / IF b IS NULL branches) execute and return 0/−1/1
as intended.

In `@src/ore_block_u64_8_256/compare.sql`:
- Around line 49-50: The second index-presence guard mistakenly checks
eql_v2.has_ore_block_u64_8_256(a) again; change it to check
eql_v2.has_ore_block_u64_8_256(b) so b_term is only assigned via
eql_v2.ore_block_u64_8_256(b) when b actually has the index (prevents NULL
b_term or unintended exceptions when b lacks the index while a has it).

In `@src/ore_cllw_u64_8/compare.sql`:
- Around line 54-56: The condition gating extraction of b_term is using the
wrong operand; change the IF that now calls eql_v2.has_ore_cllw_u64_8(a) before
computing b_term to instead call eql_v2.has_ore_cllw_u64_8(b), so that b_term :=
eql_v2.ore_cllw_u64_8(b) is only executed when b actually has the ORE term
(refer to eql_v2.has_ore_cllw_u64_8, eql_v2.ore_cllw_u64_8, b_term, a, b).

In `@src/ore_cllw_var_8/compare.sql`:
- Around line 54-55: The second presence-check incorrectly calls
eql_v2.has_ore_cllw_var_8(a) instead of checking b, causing b_term to be skipped
when only b has the ORE index; change that branch to check
eql_v2.has_ore_cllw_var_8(b) and then call eql_v2.ore_cllw_var_8(b) to assign
b_term (refer to the IF condition calling eql_v2.has_ore_cllw_var_8 and the
assignment to b_term using eql_v2.ore_cllw_var_8, and the variables a and b).

---

Nitpick comments:
In `@src/blake3/functions.sql`:
- Around line 66-74: Convert both eql_v2.has_blake3 PL/pgSQL single-statement
functions to SQL-language functions: replace the PL/pgSQL block with a single
SELECT expression that returns the boolean (e.g. "SELECT val ->> 'b3' IS NOT
NULL;"), keep the same signature, attributes (IMMUTABLE, STRICT, PARALLEL SAFE,
SET search_path ...) and change LANGUAGE plpgsql to LANGUAGE SQL for both
eql_v2.has_blake3 overloads so they are simple SQL functions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 05c1e88d-b2c0-49c0-8bdb-68c3d83310e3

📥 Commits

Reviewing files that changed from the base of the PR and between 7fcdcb6 and bc7976d.

📒 Files selected for processing (37)
  • src/blake3/compare.sql
  • src/blake3/functions.sql
  • src/bloom_filter/functions.sql
  • src/common.sql
  • src/config/constraints.sql
  • src/config/functions.sql
  • src/config/functions_private.sql
  • src/encrypted/aggregates.sql
  • src/encrypted/constraints.sql
  • src/encrypted/functions.sql
  • src/encrypted/hash.sql
  • src/encryptindex/functions.sql
  • src/hmac_256/compare.sql
  • src/hmac_256/functions.sql
  • src/jsonb/functions.sql
  • src/operators/->.sql
  • src/operators/->>.sql
  • src/operators/<.sql
  • src/operators/<=.sql
  • src/operators/<>.sql
  • src/operators/<@.sql
  • src/operators/=.sql
  • src/operators/>.sql
  • src/operators/>=.sql
  • src/operators/@>.sql
  • src/operators/compare.sql
  • src/operators/order_by.sql
  • src/operators/sort.sql
  • src/operators/~~.sql
  • src/ore_block_u64_8_256/compare.sql
  • src/ore_block_u64_8_256/functions.sql
  • src/ore_block_u64_8_256/operators.sql
  • src/ore_cllw_u64_8/compare.sql
  • src/ore_cllw_u64_8/functions.sql
  • src/ore_cllw_var_8/compare.sql
  • src/ore_cllw_var_8/functions.sql
  • src/ste_vec/functions.sql

Comment thread src/operators/&gt;.sql
Comment thread src/operators/&gt;=.sql
Comment thread src/operators/sort.sql Outdated
Comment thread src/ore_block_u64_8_256/functions.sql Outdated
Addresses the search-path-hijacking concern flagged by CodeRabbit on the
earlier change in this PR.

Before: SET search_path = pg_catalog, public, extensions
After:  SET search_path = pg_catalog, extensions, public

The previous order let any role with CREATE on `public` shadow pgcrypto's
encrypt(bytea, bytea, text) by planting a same-signature function in
`public`, which would then resolve before the real `encrypt()` in
`extensions` on Supabase deployments. None of the affected functions are
SECURITY DEFINER, so the blast radius is the calling session, but it's
defence-in-depth worth fixing.

With the new order:
  - pg_catalog still resolves built-ins first (and is implicit anyway).
  - extensions resolves pgcrypto on Supabase before public can shadow it.
  - public still resolves pgcrypto on self-hosted (where extensions is
    typically absent) and EQL's own `eql_v2_encrypted` /
    `eql_v2_configuration` cross-schema objects.

Also fills in @PARAM tags on the four `>` and `>=` jsonb-cross-type
overloads that CodeRabbit flagged as missing required Doxygen entries.

143 SET clauses reordered across 34 files; 8 new @PARAM lines on the
operator overloads.
@coderdan

coderdan commented May 5, 2026

Copy link
Copy Markdown
Contributor Author

Pushed f05c909 addressing the CodeRabbit feedback that's in scope here:

  1. Search-path hijacking on encrypt(...) — reordered all 143 SET search_path clauses from pg_catalog, public, extensions to pg_catalog, extensions, public. On Supabase this resolves pgcrypto in extensions before any user-writable public can shadow it. On self-hosted vanilla Postgres extensions is absent so resolution falls through to public exactly as before — no behavioural change. eql_v2_encrypted / eql_v2_configuration continue to resolve via public. Considered (and rejected) the suggested pgcrypto.encrypt(...) qualifier — pgcrypto's functions live in whatever schema CREATE EXTENSION placed them, and there's no portable schema name (public on self-hosted, extensions on Supabase, none called pgcrypto).

  2. Missing @param tags on > / >= jsonb cross-type overloads — added.

Full local test pass: 33 sqlx test targets, 0 failures. Splinter findings unchanged at 41 (40 function_search_path_mutable on the LANGUAGE SQL helpers retained for inlinability + 1 extension_in_public for pgcrypto).

Out of scope, tracked separately:

  • SQL injection via filter parameter in sort_compare (Major) — preexisting on main, this PR didn't touch the concatenation. Filed: #TBD
  • Move eql_v2_encrypted / eql_v2_configuration from public into eql_v2 so public can be dropped from search_path entirely (the proper structural fix). Filed: #TBD

@coderdan

coderdan commented May 5, 2026

Copy link
Copy Markdown
Contributor Author

Tracking issues opened for the out-of-scope CodeRabbit findings: #180 (move types into eql_v2 — the proper fix for search-path hijacking on self-hosted), #181 (sort_compare filter SQL injection).

@coderdan
coderdan merged commit afa0be6 into main May 5, 2026
5 checks passed
@coderdan
coderdan deleted the dan/pin-search-path-and-pgcrypto-portability branch May 5, 2026 07:45
coderdan added a commit that referenced this pull request May 5, 2026
- Fix @file Doxygen tag (cross_type_opfamily.sql → cross_type_operator_class.sql).
  Leftover from the original filename.

- Convert the three opfamily support functions from LANGUAGE plpgsql + SET
  search_path to LANGUAGE sql + schema-qualified bodies (no SET).

  These functions are called by the planner on every index comparison or
  hash probe — inlinability matters here in a way it doesn't for the
  user-facing operator wrappers in src/operators/{=,<,...}.sql. PG refuses
  to inline a LANGUAGE SQL function that carries a SET clause (per #177's
  investigation), so the usual EQL convention of plpgsql + SET would force
  a full function call per index op.

  Schema-qualifying every reference inside the bodies (eql_v2.compare,
  public.eql_v2_encrypted) gives us inlining AND keeps name resolution
  deterministic regardless of caller search_path. Trade-off: pins the
  references to current schema locations — eql_v2_encrypted lives in
  `public` today; will need a touch when #180 (move types into eql_v2)
  lands. A short comment in the file documents this rationale so the
  divergence from the surrounding plpgsql convention is intentional and
  reviewable.

  EXPLAIN cost on the bare-jsonb equality is unchanged (9.18 — same
  Index Scan + Index Cond as before). Tests still pass.
@coderabbitai coderabbitai Bot mentioned this pull request May 20, 2026
4 tasks
tobyhede pushed a commit that referenced this pull request Jun 20, 2026
…ypto-portability

fix: pin search_path on every eql_v2 function
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.

2 participants