From ff9e5823164714e14cba5e70099c051bc2571e70 Mon Sep 17 00:00:00 2001
From: Satya Narlapuram <satyanaralapuram@gmail.com>
Date: Thu, 16 Apr 2026 16:43:34 +0000
Subject: [PATCH] Fix COPY FROM ON_ERROR SET_NULL OOB read with partial column
 list

domain_with_constraint[] was allocated with list_length(attnumlist)
elements and indexed sequentially via foreach_current_index(), but
copyfromparse.c accesses it via attnum - 1 (physical attribute index).
With a partial column list targeting high-numbered columns, this caused
an out-of-bounds read that bypassed domain NOT NULL checks, silently
inserting NULL into NOT NULL domain columns.

Fix by allocating with num_phys_attrs and indexing by attnum - 1,
consistent with all other per-column arrays in BeginCopyFrom().
---
 src/backend/commands/copyfrom.c     |  8 ++------
 src/test/regress/expected/copy2.out | 23 +++++++++++++++++++++++
 src/test/regress/sql/copy2.sql      | 19 +++++++++++++++++++
 3 files changed, 44 insertions(+), 6 deletions(-)

diff --git a/src/backend/commands/copyfrom.c b/src/backend/commands/copyfrom.c
index 64ac3063..0087585b 100644
--- a/src/backend/commands/copyfrom.c
+++ b/src/backend/commands/copyfrom.c
@@ -1636,8 +1636,6 @@ BeginCopyFrom(ParseState *pstate,
 
 	if (cstate->opts.on_error == COPY_ON_ERROR_SET_NULL)
 	{
-		int			attr_count = list_length(cstate->attnumlist);
-
 		/*
 		 * When data type conversion fails and ON_ERROR is SET_NULL, we need
 		 * ensure that the input column allow null values.  ExecConstraints()
@@ -1646,15 +1644,13 @@ BeginCopyFrom(ParseState *pstate,
 		 * check must be performed during the initial string-to-datum
 		 * conversion (see CopyFromTextLikeOneRow()).
 		 */
-		cstate->domain_with_constraint = palloc0_array(bool, attr_count);
+		cstate->domain_with_constraint = palloc0_array(bool, num_phys_attrs);
 
 		foreach_int(attno, cstate->attnumlist)
 		{
-			int			i = foreach_current_index(attno);
-
 			Form_pg_attribute att = TupleDescAttr(tupDesc, attno - 1);
 
-			cstate->domain_with_constraint[i] = DomainHasConstraints(att->atttypid, NULL);
+			cstate->domain_with_constraint[attno - 1] = DomainHasConstraints(att->atttypid, NULL);
 		}
 	}
 
diff --git a/src/test/regress/expected/copy2.out b/src/test/regress/expected/copy2.out
index 7600e523..2d6bfdd8 100644
--- a/src/test/regress/expected/copy2.out
+++ b/src/test/regress/expected/copy2.out
@@ -839,6 +839,28 @@ SELECT * FROM t_on_error_null ORDER BY a;
  13 |   14 | NULL
 (3 rows)
 
+-- Test on_error set_null with partial column list and domain NOT NULL.
+-- Bug: domain_with_constraint[] was indexed by physical attnum, but
+-- allocated with only as many elements as the COPY column list.
+-- With a partial column list targeting a high-numbered column, this
+-- caused an out-of-bounds read that bypassed the domain NOT NULL check.
+CREATE TABLE t_on_error_null_partial (
+    c1 text, c2 text, c3 text, c4 text, c5 text,
+    c6 text, c7 text, c8 text, c9 text,
+    c10 d_int_not_null   -- attnum 10, domain NOT NULL
+);
+-- Partial column list: attnumlist = {1, 10}.  Should fail because
+-- the domain on c10 does not allow null, and 'bad' is not an integer.
+COPY t_on_error_null_partial(c1, c10) FROM STDIN WITH (on_error set_null); -- fail
+ERROR:  domain d_int_not_null does not allow null values
+DETAIL:  ON_ERROR SET_NULL cannot be applied because column "c10" (domain d_int_not_null) does not accept null values.
+CONTEXT:  COPY t_on_error_null_partial, line 1, column c10: "bad"
+SELECT count(*) AS "expect_0" FROM t_on_error_null_partial;
+ expect_0 
+----------
+        0
+(1 row)
+
 \pset null ''
 -- tests for on_error option with log_verbosity and null constraint via domain
 CREATE DOMAIN dcheck_ign_err2 varchar(15) NOT NULL;
@@ -900,6 +922,7 @@ DROP VIEW instead_of_insert_tbl_view_2;
 DROP FUNCTION fun_instead_of_insert_tbl();
 DROP TABLE check_ign_err;
 DROP TABLE t_on_error_null;
+DROP TABLE t_on_error_null_partial;
 DROP DOMAIN d_int_not_null;
 DROP DOMAIN d_int_positive_maybe_null;
 DROP TABLE check_ign_err2;
diff --git a/src/test/regress/sql/copy2.sql b/src/test/regress/sql/copy2.sql
index e0810109..287c8fb8 100644
--- a/src/test/regress/sql/copy2.sql
+++ b/src/test/regress/sql/copy2.sql
@@ -580,6 +580,24 @@ COPY t_on_error_null FROM STDIN WITH (on_error set_null, log_verbosity verbose);
 
 SELECT * FROM t_on_error_null ORDER BY a;
 
+-- Test on_error set_null with partial column list and domain NOT NULL.
+-- Bug: domain_with_constraint[] was indexed by physical attnum, but
+-- allocated with only as many elements as the COPY column list.
+-- With a partial column list targeting a high-numbered column, this
+-- caused an out-of-bounds read that bypassed the domain NOT NULL check.
+CREATE TABLE t_on_error_null_partial (
+    c1 text, c2 text, c3 text, c4 text, c5 text,
+    c6 text, c7 text, c8 text, c9 text,
+    c10 d_int_not_null   -- attnum 10, domain NOT NULL
+);
+-- Partial column list: attnumlist = {1, 10}.  Should fail because
+-- the domain on c10 does not allow null, and 'bad' is not an integer.
+COPY t_on_error_null_partial(c1, c10) FROM STDIN WITH (on_error set_null); -- fail
+hello	bad
+\.
+
+SELECT count(*) AS "expect_0" FROM t_on_error_null_partial;
+
 \pset null ''
 
 -- tests for on_error option with log_verbosity and null constraint via domain
@@ -652,6 +670,7 @@ DROP VIEW instead_of_insert_tbl_view_2;
 DROP FUNCTION fun_instead_of_insert_tbl();
 DROP TABLE check_ign_err;
 DROP TABLE t_on_error_null;
+DROP TABLE t_on_error_null_partial;
 DROP DOMAIN d_int_not_null;
 DROP DOMAIN d_int_positive_maybe_null;
 DROP TABLE check_ign_err2;
-- 
2.43.0

