Prevent access to other sessions' empty temp tables

Commit ce146621 ensures that ERROR is raised if a session tries to read
pages of another session's temp table.  But there is a corner case where
the other session's temp table is empty -- in this case the INSERT
command bypasses our checks and executes without any errors.

Such behavior is inconsistent and erroneous: it leaves an invalid buffer
in the temp buffers pool.  Since the buffer was created for another
session's temp table, we get an error "no such file or directory" when
trying to flush it.

This commit fixes it by adding a RELATION_IS_OTHER_TEMP check in the
relation-extension path.

Backpatch to 16, because it is the first release after 31966b151e, which
introduced a separate local relation extension function
ExtendBufferedRelLocal(), which lacks of RELATION_IS_OTHER_TEMP() check.
As this fix introduces more checks to 013_temp_obj_multisession.pl, backpatch
the whole test script to 16.

Discussion: https://postgr.es/m/CAJDiXgiX2XZBHDNo%2BzBbvku%2BtchrUurvPRaN1_40mEQ1_sG90g%40mail.gmail.com
Author: Daniil Davydov <3danissimo@gmail.com>
Reviewed-by: Jim Jones <jim.jones@uni-muenster.de>
Reviewed-by: Imran Zaheer <imran.zhir@gmail.com>
Reviewed-by: ZizhuanLiu X-MAN <44973863@qq.com>
Backpatch-through: 16
This commit is contained in:
Alexander Korotkov 2026-07-03 15:53:03 +03:00
parent f9afdb6d1a
commit cc3fe7e2a7
2 changed files with 288 additions and 0 deletions

View file

@ -1805,9 +1805,23 @@ ExtendBufferedRelCommon(BufferManagerRelation bmr,
extend_by);
if (bmr.relpersistence == RELPERSISTENCE_TEMP)
{
/*
* Reject attempts to extend non-local temporary relations; we have no
* ability to transfer about-to-be-created local buffers into the
* owning session's local buffers. This is the canonical place for
* the check, covering any attempt to extend a non-local temporary
* relation.
*/
if (bmr.rel && RELATION_IS_OTHER_TEMP(bmr.rel))
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot access temporary tables of other sessions")));
first_block = ExtendBufferedRelLocal(bmr, fork, flags,
extend_by, extend_upto,
buffers, &extend_by);
}
else
first_block = ExtendBufferedRelShared(bmr, fork, strategy, flags,
extend_by, extend_upto,

View file

@ -0,0 +1,274 @@
# Copyright (c) 2026, PostgreSQL Global Development Group
# Tests that one session cannot read or modify data in another session's
# temporary table. Each session keeps its temp data in its own local
# buffer pool, and a different backend has no visibility into those
# buffers, so any command that needs to look at the data must be
# rejected.
#
# DROP TABLE is intentionally allowed: it does not touch the table's
# contents, and autovacuum relies on this to clean up orphaned temp
# relations left behind by a crashed backend.
#
# A regression caught here typically means a new buffer-access entry
# point bypasses the RELATION_IS_OTHER_TEMP() check. See
# ReadBuffer_common(), StartReadBuffersImpl(), and read_stream_begin_impl()
# for the existing checks. When adding a new command or buffer-access
# path, also add a corresponding case below.
use strict;
use warnings;
use PostgreSQL::Test::Cluster;
use PostgreSQL::Test::Utils;
use PostgreSQL::Test::BackgroundPsql;
use Test::More;
my $node = PostgreSQL::Test::Cluster->new('temp_lock');
$node->init;
$node->append_conf('postgresql.conf', 'log_lock_waits=true');
$node->start;
# Owner session. Created via background_psql so it stays alive while
# the second session probes its temp objects.
my $psql1 = $node->background_psql('postgres');
# Initially create the table without an index, so read paths go straight
# through the read-stream / buffer-manager entry points without being
# masked by an index scan that would hit ReadBuffer_common from nbtree.
$psql1->query_safe(q(CREATE TEMP TABLE foo AS SELECT 42 AS val;));
# Also create an empty table, so read path go straight through the
# extend-relation entry point.
$psql1->query_safe(q(CREATE TEMP TABLE empty_foo (val INT);));
# Resolve the owner's temp schema so the probing session can refer to
# the table by a fully-qualified name.
my $tempschema = $node->safe_psql(
'postgres',
q{
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE relname = 'foo' AND relpersistence = 't';
}
);
chomp $tempschema;
ok($tempschema =~ /^pg_temp_\d+$/, "got temp schema: $tempschema");
my ($stdout, $stderr);
# DML and SELECT have to read the table's data and therefore go through
# the buffer manager. With no index on the table, the planner cannot
# use index access, so SELECT/UPDATE/DELETE/MERGE/COPY all run through
# the read-stream path and are caught by read_stream_begin_impl().
$node->psql(
'postgres',
"SELECT val FROM $tempschema.foo;",
stderr => \$stderr);
like(
$stderr,
qr/cannot access temporary tables of other sessions/,
'SELECT (seqscan via read_stream)');
# INSERT into empty table goes through hio.c which calls RelationAddBlocks() to
# extend the table; that hits the check before new pages are created for the
# table.
$node->psql(
'postgres',
"INSERT INTO $tempschema.empty_foo VALUES (42);",
stderr => \$stderr);
like(
$stderr,
qr/cannot access temporary tables of other sessions/,
'INSERT (caught via hio.c)');
# INSERT goes through hio.c which calls ReadBufferExtended() to find a
# page with free space; that hits the existing check before any data
# is written.
$node->psql(
'postgres',
"INSERT INTO $tempschema.foo VALUES (73);",
stderr => \$stderr);
like(
$stderr,
qr/cannot access temporary tables of other sessions/,
'INSERT (caught via hio.c)');
$node->psql(
'postgres',
"UPDATE $tempschema.foo SET val = NULL;",
stderr => \$stderr);
like($stderr, qr/cannot access temporary tables of other sessions/, 'UPDATE');
$node->psql('postgres', "DELETE FROM $tempschema.foo;", stderr => \$stderr);
like($stderr, qr/cannot access temporary tables of other sessions/, 'DELETE');
$node->psql(
'postgres',
"MERGE INTO $tempschema.foo USING (VALUES (42)) AS s(val) "
. "ON foo.val = s.val WHEN MATCHED THEN DELETE;",
stderr => \$stderr);
like($stderr, qr/cannot access temporary tables of other sessions/, 'MERGE');
$node->psql('postgres', "COPY $tempschema.foo TO STDOUT;",
stderr => \$stderr);
like($stderr, qr/cannot access temporary tables of other sessions/, 'COPY');
# DDL and maintenance commands have their own command-specific checks
# (older than the buffer-manager check above), so they fail with
# command-specific error messages. Verifying them here documents the
# expected behaviour and guards against accidental removal of those
# checks.
$node->psql('postgres', "TRUNCATE TABLE $tempschema.foo;",
stderr => \$stderr);
like($stderr, qr/cannot truncate temporary tables of other sessions/,
'TRUNCATE');
$node->psql(
'postgres',
"ALTER TABLE $tempschema.foo ALTER COLUMN val TYPE bigint;",
stderr => \$stderr);
like($stderr, qr/cannot alter temporary tables of other sessions/,
'ALTER TABLE');
# VACUUM silently skips other sessions' temp tables (vacuum_rel() returns
# without warning to avoid noise during database-wide VACUUM). Verify
# that no error is reported, and that no buffer-access path is hit.
$node->psql('postgres', "VACUUM $tempschema.foo;", stderr => \$stderr);
is($stderr, '', 'VACUUM is silently skipped');
$node->psql('postgres', "CLUSTER $tempschema.foo;", stderr => \$stderr);
like($stderr,
qr/cannot cluster temporary tables of other sessions/,
'CLUSTER');
# Now create an index to exercise the index-scan path. nbtree calls
# ReadBuffer (which is ReadBufferExtended -> ReadBuffer_common), so
# this exercises a different chain of buffer-manager entry points.
$psql1->query_safe(q(CREATE INDEX ON foo(val);));
$node->psql(
'postgres',
"SET enable_seqscan = off; SELECT val FROM $tempschema.foo WHERE val = 42;",
stderr => \$stderr);
like(
$stderr,
qr/cannot access temporary tables of other sessions/,
'index scan (ReadBuffer_common via nbtree)');
# ALTER INDEX goes through the same CheckAlterTableIsSafe() path as
# ALTER TABLE, so it produces the same error.
$node->psql(
'postgres',
"ALTER INDEX $tempschema.foo_val_idx SET (fillfactor = 50);",
stderr => \$stderr);
like($stderr, qr/cannot alter temporary tables of other sessions/,
'ALTER INDEX');
# A function created by the owner in its own pg_temp using its own
# row type can be observed via the catalog by a separate session.
# ALTER FUNCTION and DROP FUNCTION on it must work as catalog
# operations -- they don't read the underlying table -- which
# documents the boundary between catalog and data access for temp
# objects.
$psql1->query_safe(
q[CREATE FUNCTION pg_temp.foo_id(r foo) RETURNS int LANGUAGE SQL ]
. q[AS 'SELECT r.val';]);
$node->psql(
'postgres',
"ALTER FUNCTION $tempschema.foo_id($tempschema.foo) "
. "SET search_path = pg_catalog;",
stderr => \$stderr);
is($stderr, '', 'ALTER FUNCTION on function over other session\'s row type');
$node->psql(
'postgres',
"DROP FUNCTION $tempschema.foo_id($tempschema.foo);",
stderr => \$stderr);
is($stderr, '', 'DROP FUNCTION on function over other session\'s row type');
# DROP TABLE on another session's temp table is intentionally permitted.
# DROP doesn't touch the table's contents, and autovacuum relies on this
# to remove temp relations orphaned by a crashed backend. Verify that
# the bare DROP succeeds without error.
$node->psql('postgres', "DROP TABLE $tempschema.foo;", stderr => \$stderr);
is($stderr, '', 'DROP TABLE is allowed');
# Cross-session CREATE FUNCTION scenario. The owner creates a fresh
# temp table foo2 in its pg_temp namespace, and a separate session
# then creates a function whose argument type is that row type.
# PostgreSQL allows this and emits a NOTICE: the function is moved
# into the creator's pg_temp namespace with an auto-dependency on
# the borrowed type, so it disappears together with the session that
# created it.
$psql1->query_safe(q(CREATE TEMP TABLE foo2 AS SELECT 42 AS val;));
$node->psql(
'postgres',
"CREATE FUNCTION public.cross_session_func(r $tempschema.foo2) "
. "RETURNS int LANGUAGE SQL AS 'SELECT 1';",
stderr => \$stderr);
# A bare DROP TABLE on foo2 now fails because cross_session_func
# depends on its row type. This is normal SQL dependency behaviour
# and documents that DROP itself is not blocked by buffer-manager
# checks -- we get a catalog-level error instead.
$node->psql('postgres', "DROP TABLE $tempschema.foo2;", stderr => \$stderr);
like(
$stderr,
qr/cannot drop table .*\.foo2 because other objects depend on it/,
'DROP TABLE blocked by cross-session dependency');
my $foo2_oid = $node->safe_psql('postgres',
"SELECT oid FROM pg_class WHERE relname='foo2';");
# Cross-session LOCK TABLE scenario. Ensure that LockRelationOid is working
# properly for other temp tables since this mechanism is also used by
# autovacuum during orphaned tables cleanup.
my $psql2 = $node->background_psql('postgres');
$psql2->query_safe(
qq{
BEGIN;
LOCK TABLE $tempschema.foo2 IN ACCESS SHARE MODE;
});
# When the owner session ends, its temp objects are dropped via the
# normal session-exit cleanup, which cascades through
# DEPENDENCY_NORMAL and also removes the cross-session function that
# depended on the temp row type. This is the same mechanism
# autovacuum relies on to clean up temp relations left behind by a
# crashed backend.
# Access share lock on the foo2 will block session-exit cleanup, because an
# owner will try to acquire deletion lock all its temp objects via
# findDependentObjects.
my $log_offset = -s $node->logfile;
$psql1->quit;
# Check whether session-exit cleanup is blocked.
$node->wait_for_log(qr/waiting for AccessExclusiveLock on relation $foo2_oid/,
$log_offset);
# Release lock on foo2 and allow session-exit cleanup to finish.
$psql2->query_safe(q(COMMIT;));
$psql2->quit;
# After releasing the lock, the owner can finally acquire
# AccessExclusiveLock on foo2 and finish session-exit cleanup. Verify
# directly that both foo2 (the locked temp table) and cross_session_func
# (which depended on its row type) have been dropped. Both being gone
# confirms the owner's cleanup got past the blocked findDependentObjects()
# call and completed normally.
$node->poll_query_until('postgres',
"SELECT NOT EXISTS (SELECT 1 FROM pg_class WHERE oid = $foo2_oid)")
or die "foo2 was not cleaned up after owner session exit";
is( $node->safe_psql(
'postgres',
"SELECT count(*) FROM pg_proc WHERE proname = 'cross_session_func'"),
'0',
'cross_session_func cleaned up by cascade from foo2');
done_testing();