security@ received a report about a regression in commit 2ebf25e. On 2026-09-12, I told the reporter that we handle defects in unreleased code as non-vulnerabilities and asked them to report it to pgsql-hackers. I didn't find that report, so I'm forwarding it here. From some searches of this thread, I also don't think this repeats any of the followup fixes already done. I'm attaching the reporter's materials. I'll file a v19 open item, since v19 is projected to reach a GA release before the 2026-11 back branch releases.
<reporter_message_body> I am reporting an RLS confidentiality bypass introduced by the recent jointree-rewrite self-join elimination change. An ordinary authenticated role can join an RLS-protected table directly to an existing default owner-executed, ID-only view of the same table. The direct RTE carries the attacker's RLS securityQuals, while the view-derived RTE is checked as the table-owning view owner and carries none. The new SJE implementation groups the RTEs by physical relation OID without comparing their security qualifiers or permission identities. It removes the lower relation ID and retains the later one; because a pulled-up view RTE is appended after the parent RTEs, the owner-context entry is deterministically retained in either SQL FROM order. Replanning produces one scan without the RLS predicate. I reproduced the leak on master 798bdcae89debabc59fa8afc6d690fec584db32f and REL_18_STABLE c0b3980507f459c09b5324b6536c9e1c31fad378. Exact PostgreSQL 18.6 is a negative control across the expanded matrix. The relevant REL_18_STABLE rewrite was committed after 18.6 and no inspected release tag contains it as of 2026-09-08, so I recommend pre-release P0 handling and High confidentiality severity if shipped. PostgreSQL 18.0 through 18.6 are not claimed as affected. The hardened reproducer grants no table-level SELECT and no SELECT on the policy column. It grants only the output columns plus SELECT on the existing views. With SJE off, the attacker receives one authorized row; with SJE on, it receives both the authorized and foreign-tenant rows. The same leak occurs through IN (SELECT ...) and through an invoker-view/owner-view pair with no direct table syntax when the attacker chooses the unsafe view order. Owner-view predicates that survive SJE bound the leaked row set. Removing the secret-column privilege still yields permission denied, so this is an RLS bypass rather than a column-ACL bypass. The attached package contains the detailed analysis, expanded standalone SQL reproducer, validation evidence, and a conservative guard that excludes RTEs carrying securityQuals from SJE. I applied that guard to the affected master, rebuilt it, and verified that every demonstrated entry path retains RLS. Please confirm receipt. I will keep the details private while the affected branches are triaged and corrected. Regards, Yonghwa Lee, Xint by Theori ___ Yonghwa Lee 이용화 Senior Security Engineer Xint [email protected] </reporter_message_body>
# 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`.
<<attachment: SJE-RLS-001-submit-ready-2026-09-07.zip>>
