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>>

Reply via email to