# SJE-RLS-001: self-join elimination drops RLS policy across owner-view and invoker permission contexts ## Summary A newly rewritten self-join elimination (SJE) optimization can merge two range-table entries for the same physical table even when they have different permission and row-security contexts. In the minimal query, one entry is a direct reference evaluated as a low-privilege attacker and carries an RLS `securityQuals` expression. The other originates from an ordinary owner-executed, ID-only view (`security_invoker=false`, the default) and carries no RLS qualifier because the view owner owns the table. SJE groups the entries by physical relation OID, eliminates the attacker-context entry, and rewrites its Vars to the owner-context entry. Replanning then produces a single scan without the RLS predicate. This is deterministic for a direct-table/owner-view pair in either SQL FROM order: the pulled-up view RTE is appended after the parent query's RTEs, and SJE always keeps the higher relation ID. The exposed rows are bounded by any surviving owner-view predicates, and exposed columns remain bounded by ordinary table/column ACLs. Nevertheless, an ID-only reporting view can be composed with separately granted table columns to read those columns across tenants. The reproducer also confirms an `IN (SELECT ...)` entry path and a two-view variant with no direct table syntax. This is an RLS confidentiality bypass, not an ACL bypass. ## Security classification - Release status as of 2026-09-08: **not present in a released PostgreSQL tag** - Recommended handling: **pre-release P0**; block affected branch releases until fixed - Severity if shipped: **High confidentiality impact**, not Critical - Suggested CVSS 3.1 if shipped: **6.5** (`AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N`) - CWE: CWE-862 (Missing Authorization) / CWE-200 (Exposure of Sensitive Information) No write-policy or availability impact was demonstrated. ## Affected commits Confirmed dynamically: - master `798bdcae89debabc59fa8afc6d690fec584db32f` - `REL_18_STABLE` `c0b3980507f459c09b5324b6536c9e1c31fad378` Negative control: - exact PostgreSQL 18.6 `724edf9bde9d356724ad384a2e196edc3c9f80f7` is not affected and retains the RLS filter. The `REL_18_STABLE` jointree-rewrite commit was made on 2026-08-28, after the 18.6 release commit dated 2026-08-11, and it is not an ancestor of that release. Although self-join elimination already exists in PostgreSQL 18, the older implementation preserved the RLS predicate in the complete expanded test matrix. Therefore PostgreSQL 18.0 through 18.6 must not be listed as affected. The jointree-rewrite SJE change was introduced to the inspected branches by: ```text master: 2ebf25e7d70a8fce31ace78d723fa9271ab8af72 REL_16_STABLE: 986870baa06bc70245ee731d83486d4ed529c223 REL_17_STABLE: 13466d1f78394a8ec2d6d0b1c9d8bf5ac3acb99 REL_18_STABLE: 9f25197bf27c4c4a02d754842bc4055d83be735b REL_19_STABLE: 0ab90a5c94188ab0a2113e36c73f093f741129c1 ``` All listed branches should be audited. The commit presence alone should not be interpreted as a released vulnerable version. ## Threat model and prerequisites The attacker is an ordinary authenticated SQL role with: - `USAGE` on the application schema; - column-level `SELECT` on the RLS-protected table columns returned by the query (`id` and `secret` in the reproducer), but no table-level `SELECT` and no privilege on the policy column `tenant`; - `SELECT` on an existing, ordinary owner-executed view that exposes the table's unique key; and - the ability to issue an arbitrary `SELECT` combining that table and view. The table owner created both the RLS policy and the view, a common application pattern. The attacker does not need table ownership, view creation, policy modification, `BYPASSRLS`, superuser, `CREATEROLE`, or operating-system access. This does not bypass column ACLs: removing the attacker's `secret` column privilege produces `permission denied`. It bypasses row authorization for columns the attacker is otherwise allowed to read. A second confirmed form uses no direct table syntax. A `security_invoker=true` view exposes the already-authorized `id` and `secret` columns, while an owner-executed view exposes only `id`. Putting the invoker view first and owner view second makes the owner-context RTE the retained entry and leaks the same foreign row. The underlying column privileges required by the invoker view are still enforced. ## Reproduction Run `reproducer.sql` as a PostgreSQL superuser on a fresh database built from an affected commit: ```sh psql -X -v ON_ERROR_STOP=1 -f reproducer.sql postgres ``` The script creates a table containing one attacker row and one foreign-tenant row. It grants the attacker only the two output columns, creates a default owner-executed ID view, and runs the same query with SJE off and on. It then exercises reverse FROM order, a filtered owner view, an `IN (SELECT ...)` form, a two-view form, and invoker/barrier negative controls. Expected security metadata: ```text table_select | id_select | tenant_select | secret_select | rls_active -------------+-----------+---------------+---------------+----------- f | t | f | t | t ``` Observed on master and `REL_18_STABLE`: ```text enable_self_join_elimination = off id | secret ----+--------- 1 | allowed enable_self_join_elimination = on id | secret ----+----------- 1 | allowed 2 | forbidden ``` The affected `EXPLAIN (VERBOSE, COSTS OFF)` contains a single scan without `tenant = CURRENT_USER`: ```text Seq Scan on sje_rls_probe.secrets t Output: t.id, t.secret ``` On exact 18.6, the result remains one row and the plan retains the RLS filter. Additional confirmed results on the affected master build are: ```text direct table + owner view, either FROM order, SJE on: {1,2} direct table WHERE id IN (owner view), SJE off/on: {1} / {1,2} invoker view then owner view, SJE off/on: {1} / {1,2} owner view then invoker view, SJE on: {1} (fail-closed) ``` An owner view filtered by a non-key predicate exposed only the foreign row selected by that predicate. Its plan retained the view predicate but omitted the RLS predicate. Thus view quals can bound the leaked row set; they are not the security condition being dropped. A unique-key predicate prevented SJE in one negative control, so the report does not claim every filtered view is exploitable. ## Root cause 1. View rewriting sets `checkAsUser` to the view owner for a default owner-executed view (`rewriteHandler.c`, `ApplyRetrieveRule()` in the inspected tree). 2. RLS expansion uses that permission identity. The direct attacker RTE receives the `tenant = CURRENT_USER` expression in `securityQuals`; the owner-context view RTE does not. 3. The new SJE candidate collector accepts ordinary relations without checking `securityQuals` or permission identity. 4. Candidates are sorted and grouped only by relation OID. 5. Within a group, the lower relation ID is the removal candidate and the later relation ID is retained. A base RTE pulled up from an owner view is appended at the end, making the unsafe choice deterministic for the direct-table form regardless of FROM order. 6. SJE removes one RTE from the jointree and rewrites its Vars to the retained RTE. When planner state is recomputed, the removed direct or invoker RTE's RLS qualifier is no longer present. Its `RTEPermissionInfo` is not discarded, so ordinary ACL checks remain effective. The relevant candidate check currently resembles: ```c if (rte->rtekind == RTE_RELATION && rte->relkind == RELKIND_RELATION && rte->tablesample == NULL && ...) relids = bms_add_member(relids, varno); ``` Grouping then compares only `root->simple_rte_array[i]->relid` (the physical table OID). Neither `securityQuals` nor the permission identity participates in candidate equivalence. The older 18.6 implementation transferred the removed relation's `baserestrictinfo` to the retained relation, which preserved the RLS predicate in this test. The jointree rewrite invalidates and rebuilds planner state, exposing the loss. ## Controls and mitigations The following independent negative controls prevent the minimal leak when applied to all relevant views: - define the view with `security_invoker=true`; or - define the view with `security_barrier=true`. These can mitigate known application views but do not repair the optimizer invariant globally. `ALTER TABLE ... FORCE ROW LEVEL SECURITY` can make a non-superuser, non-`BYPASSRLS` table owner subject to policy and thereby remove the differing owner context. It is application-dependent and is not a substitute for fixing SJE; superusers and `BYPASSRLS` roles remain outside that protection. `SET enable_self_join_elimination = off` is useful for diagnosis, but it is a `USERSET` option. A hostile database role can turn it back on, so it is not an enforceable security mitigation unless query execution is otherwise constrained. ## Recommended fix The safest immediate fix is to exclude any RTE with `securityQuals != NIL` from SJE. The attached `proposed-fix.patch` implements this conservative guard. It was applied to the affected master source, rebuilt, and dynamically verified to block the direct, reversed, `IN`, filtered-view, and two-view variants while retaining RLS. A more permissive future implementation would need to prove that both RTEs have equivalent permission identities and semantically equivalent security qualifiers, and then preserve those qualifiers correctly during jointree rewriting. Comparing relation OIDs alone is insufficient. Add regression coverage for: 1. a direct attacker RTE joined to a default owner-executed view of the same RLS table; 2. column-only grants, including no grant on the policy column; 3. both possible kept/removed RTE orderings; 4. sublink-to-semijoin conversion using `IN (SELECT ...)`; 5. owner/invoker view pairs in both FROM orders; 6. owner views with surviving non-key quals; 7. prepared statements and role changes; 8. `security_invoker` and `security_barrier` controls; and 9. a non-RLS self-join proving the optimization still fires. ## Independent validation The original reproducer was independently rerun on fresh master, `REL_18_STABLE`, and exact 18.6 builds. A second validation round exercised the expanded variants on the affected master, reran the complete matrix on exact 18.6, and rebuilt master with the proposed guard. The leak appeared only on the unpatched post-change build. Full results are in `evidence/expanded-validation.md`.