Skip to content

ext/pgsql: route pg_copy_from table_name through build_tablename#21985

Open
iliaal wants to merge 1 commit intophp:masterfrom
iliaal:fix/pgsql-copy-from-table-name-escape
Open

ext/pgsql: route pg_copy_from table_name through build_tablename#21985
iliaal wants to merge 1 commit intophp:masterfrom
iliaal:fix/pgsql-copy-from-table-name-escape

Conversation

@iliaal
Copy link
Copy Markdown
Contributor

@iliaal iliaal commented May 8, 2026

Add the table validation added in bug #62978 (which fixed pg_insert, pg_update, pg_select, pg_delete) but missed pg_copy_from. Not updating pg_copy_to since it allows (query) for the table name per ext/pgsql/tests/06_bug73498.phpt.

@devnexen
Copy link
Copy Markdown
Member

devnexen commented May 9, 2026

Thanks for starting the work, but as hinted. it is preferable you really go through. I ll put some comments.

@devnexen
Copy link
Copy Markdown
Member

devnexen commented May 9, 2026

I want to see $null_as parameter being challenged too, e.g.

pg_query($db, "DROP TABLE IF EXISTS table1");
  pg_query($db, "DROP TABLE IF EXISTS injected");
  pg_query($db, "CREATE TABLE table1 (v text)");

  $null_as = "X'; CREATE TABLE injected (v text); --";

  pg_copy_from($db, 'victim', ["row\n"], "\t", $null_as);
  
  $r = pg_query($db, "SELECT 1 FROM pg_tables WHERE tablename='injected'");
  var_dump(pg_num_rows($r));  => int(1)

@devnexen
Copy link
Copy Markdown
Member

devnexen commented May 9, 2026

Add the table validation added in bug #62978 (which fixed pg_insert, pg_update, pg_select, pg_delete) but missed pg_copy_from. Not updating pg_copy_to since it allows (query) for the table name per ext/pgsql/tests/06_bug73498.phpt.

pg_copy_to needs to go through the grinder too.

iliaal added a commit to iliaal/php-src that referenced this pull request May 9, 2026
The COPY query embedded the table_name argument with a raw "%s" and the
delimiter and null marker inside literal E'..' wrappers, so caller-supplied
strings could break out and run side queries. Route bare table names
through build_tablename (the same helper pg_insert/update/select/delete
have used since bug #62978), and pass the delimiter and null marker
through PQescapeLiteral. pg_copy_to keeps the parenthesised (query)
source form documented in bug 73498, but wraps it in an extra paren pair
so a string like (SELECT 1); DROP TABLE x; -- becomes a syntax error
inside the outer parens instead of escaping out into a second statement.

Closes phpGH-21985
@iliaal iliaal force-pushed the fix/pgsql-copy-from-table-name-escape branch from 1f0a1fa to bc6f630 Compare May 9, 2026 13:10
@iliaal
Copy link
Copy Markdown
Contributor Author

iliaal commented May 9, 2026

Both null_as and the delimiter now go through PQescapeLiteral, dropping the E'...' wrapper. Bare table_name in pg_copy_to routes through build_tablename. The (query) form per bug 73498 still passes through, but wrapped in an extra paren pair so something like (SELECT 1); DROP TABLE x; becomes a syntax error inside the outer parens instead of escaping out into a second statement.

Comment thread ext/pgsql/pgsql.c Outdated
char *pg_null_as = "\\\\N";
size_t pg_null_as_len = 0;
char *query;
size_t pg_null_as_len = sizeof("\\\\N") - 1;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmmmm ... can you try a test that looks like this ?

--TEST--
  pg_copy_to() / pg_copy_from() default null marker is "\N"
  --EXTENSIONS--
  pgsql
  --SKIPIF--
  <?php include("inc/skipif.inc"); ?>
  --FILE--
  <?php
  include('inc/config.inc');
  $t = "pg_copy_default_null";

  $db = pg_connect($conn_str);
  pg_query($db, "DROP TABLE IF EXISTS {$t}");
  pg_query($db, "CREATE TABLE {$t} (id int, v text)");
  pg_query($db, "INSERT INTO {$t} VALUES (1, 'hello'), (2, NULL)");

  $rows = pg_copy_to($db, $t);
  var_dump($rows);

  pg_query($db, "DELETE FROM {$t}");
  var_dump(pg_copy_from($db, $t, $rows));
  var_dump(pg_fetch_all(pg_query($db, "SELECT v FROM {$t} ORDER BY id")));
  ?>
  --CLEAN--
  <?php
  include('inc/config.inc');
  $db = pg_connect($conn_str);
  pg_query($db, "DROP TABLE IF EXISTS pg_copy_default_null");
  ?>
  --EXPECT--
  array(2) {
    [0]=>
    string(8) "1        hello
  "
    [1]=>
    string(4) "2        \N
  "
  }
  bool(true)
  array(2) {
    [0]=>
    array(1) {
      ["v"]=>
      string(5) "hello"
    }
    [1]=>
    array(1) {
      ["v"]=>
      NULL
    }
  }

iliaal added a commit to iliaal/php-src that referenced this pull request May 9, 2026
The COPY query embedded the table_name argument with a raw "%s" and the
delimiter and null marker inside literal E'..' wrappers, so caller-supplied
strings could break out and run side queries. Route bare table names
through build_tablename (the same helper pg_insert/update/select/delete
have used since bug #62978), and pass the delimiter and null marker
through PQescapeLiteral. pg_copy_to keeps the parenthesised (query)
source form documented in bug 73498, but wraps it in an extra paren pair
so a string like (SELECT 1); DROP TABLE x; -- becomes a syntax error
inside the outer parens instead of escaping out into a second statement.

Closes phpGH-21985
@iliaal iliaal force-pushed the fix/pgsql-copy-from-table-name-escape branch from bc6f630 to 6fbe705 Compare May 9, 2026 15:26
@iliaal
Copy link
Copy Markdown
Contributor Author

iliaal commented May 9, 2026

Ah yes, good catch. Just \\N is enough here.

@devnexen
Copy link
Copy Markdown
Member

devnexen commented May 9, 2026

other things to think about (no rush, won t be committed today), what happens when query has already parens, your test need stricter output expectations ; i.e. pg_copy_from(): %s ... your new error messages need to be more specific, aka which parameter had failed.

Comment thread ext/pgsql/pgsql.c Outdated
if (!escaped_delimiter || !escaped_null_as) {
php_error_docref(NULL, E_WARNING, "Failed to escape COPY parameters");
if (escaped_delimiter) PQfreemem(escaped_delimiter);
if (escaped_null_as) PQfreemem(escaped_null_as);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: code style in php-src is new line and braces even with just 1 line block

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bah 😆 ok something to tweak

@iliaal
Copy link
Copy Markdown
Contributor Author

iliaal commented May 9, 2026

other things to think about (no rush, won t be committed today), what happens when query has already parens, your test need stricter output expectations ; i.e. pg_copy_from(): %s ... your new error messages need to be more specific, aka which parameter had failed.

If already has parens shouldn't be an issue would just be double parens, I tested locally but let me double check and add to test.

iliaal added a commit to iliaal/php-src that referenced this pull request May 9, 2026
The COPY query embedded the table_name argument with a raw "%s" and the
delimiter and null marker inside literal E'..' wrappers, so caller-supplied
strings could break out and run side queries. Route bare table names
through build_tablename (the same helper pg_insert/update/select/delete
have used since bug #62978), and pass the delimiter and null marker
through PQescapeLiteral. pg_copy_to keeps the parenthesised (query)
source form documented in bug 73498, but wraps it in an extra paren pair
so a string like (SELECT 1); DROP TABLE x; -- becomes a syntax error
inside the outer parens instead of escaping out into a second statement.

Closes phpGH-21985
@iliaal iliaal force-pushed the fix/pgsql-copy-from-table-name-escape branch from 6fbe705 to 7ff2c22 Compare May 9, 2026 16:21
Comment thread ext/pgsql/pgsql.c Outdated

char *escaped_delimiter = PQescapeLiteral(pgsql, ZSTR_VAL(pg_delimiter), 1);
if (!escaped_delimiter) {
php_error_docref(NULL, E_WARNING, "Failed to escape delimiter");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as mentioned, you have the specific information available, e.g. here pg_delimiter.

Copy link
Copy Markdown
Contributor Author

@iliaal iliaal May 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I didn't quite get the ask here, you want the error message to include the delimiter itself, seems of limited value to me, but can be added I suppose

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes just for clarification's sake.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or even better...
zend_string *msgbuf = _php_pgsql_trim_message(PQerrorMessage(pgsql));
php_error_docref...
zend_string_release(msgbuf);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied PQerrorMessage across all four errors and added params where needed, also for consistency promoted table escape error from notice to warning

@devnexen
Copy link
Copy Markdown
Member

devnexen commented May 9, 2026

You covered most of the points I ll have a fresh look in the following days. Cheers !

iliaal added a commit to iliaal/php-src that referenced this pull request May 9, 2026
The COPY query embedded the table_name argument with a raw "%s" and the
delimiter and null marker inside literal E'..' wrappers, so caller-supplied
strings could break out and run side queries. Route bare table names
through build_tablename (the same helper pg_insert/update/select/delete
have used since bug #62978), and pass the delimiter and null marker
through PQescapeLiteral. pg_copy_to keeps the parenthesised (query)
source form documented in bug 73498, but wraps it in an extra paren pair
so a string like (SELECT 1); DROP TABLE x; -- becomes a syntax error
inside the outer parens instead of escaping out into a second statement.

Closes phpGH-21985
@iliaal iliaal force-pushed the fix/pgsql-copy-from-table-name-escape branch from 7ff2c22 to a6dfbfb Compare May 9, 2026 17:13
The COPY query embedded the table_name argument with a raw "%s" and the
delimiter and null marker inside literal E'..' wrappers, so caller-supplied
strings could break out and run side queries. Route bare table names
through build_tablename (the same helper pg_insert/update/select/delete
have used since bug #62978), and pass the delimiter and null marker
through PQescapeLiteral. pg_copy_to keeps the parenthesised (query)
source form documented in bug 73498, but wraps it in an extra paren pair
so a string like (SELECT 1); DROP TABLE x; -- becomes a syntax error
inside the outer parens instead of escaping out into a second statement.

Closes phpGH-21985
@iliaal iliaal force-pushed the fix/pgsql-copy-from-table-name-escape branch from a6dfbfb to 29fce0c Compare May 9, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants