Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 144 additions & 14 deletions ext/pgsql/pgsql.c
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,60 @@ static void pgsql_lob_free_obj(zend_object *obj)

/* Compatibility definitions */

static inline zend_result build_tablename(smart_str *querystr, PGconn *pg_link, const zend_string *table);

static bool pgsql_copy_table_name_is_simple(const char *s, size_t len)
{
if (len == 0) {
return false;
}
size_t i = 0;
if (!(isalpha((unsigned char) s[i]) || s[i] == '_')) {
return false;
}
i++;
while (i < len && (isalnum((unsigned char) s[i]) || s[i] == '_')) {
i++;
}
if (i == len) {
return true;
}
if (s[i] != '.') {
return false;
}
i++;
if (i >= len || !(isalpha((unsigned char) s[i]) || s[i] == '_')) {
return false;
}
i++;
while (i < len && (isalnum((unsigned char) s[i]) || s[i] == '_')) {
i++;
}
return i == len;
}

static bool pgsql_copy_query_form_balanced(const char *s, size_t len)
{
if (len < 2 || s[0] != '(' || s[len - 1] != ')') {
return false;
}
int depth = 0;
for (size_t i = 0; i < len; i++) {
if (s[i] == '(') {
depth++;
} else if (s[i] == ')') {
depth--;
if (depth < 0) {
return false;
}
if (depth == 0 && i != len - 1) {
return false;
}
}
}
return depth == 0;
}

static zend_string *_php_pgsql_trim_message(const char *message)
{
size_t i = strlen(message);
Expand Down Expand Up @@ -3347,9 +3401,8 @@ PHP_FUNCTION(pg_copy_to)
pgsql_link_handle *link;
zend_string *table_name;
zend_string *pg_delimiter = NULL;
char *pg_null_as = "\\\\N";
size_t pg_null_as_len = 0;
char *query;
char *pg_null_as = "\\N";
size_t pg_null_as_len = sizeof("\\N") - 1;
PGconn *pgsql;
PGresult *pgsql_result;
ExecStatusType status;
Expand All @@ -3373,14 +3426,56 @@ PHP_FUNCTION(pg_copy_to)
zend_argument_value_error(3, "must be one character");
RETURN_THROWS();
}
smart_str querystr = {0};
smart_str_appends(&querystr, "COPY ");
if (ZSTR_LEN(table_name) > 0 && ZSTR_VAL(table_name)[0] == '(') {
if (!pgsql_copy_query_form_balanced(ZSTR_VAL(table_name), ZSTR_LEN(table_name))) {
php_error_docref(NULL, E_WARNING, "Invalid query source '%s': must be a single balanced parenthesised expression", ZSTR_VAL(table_name));
smart_str_free(&querystr);
RETURN_FALSE;
}
smart_str_appendc(&querystr, '(');
smart_str_append(&querystr, table_name);
smart_str_appendc(&querystr, ')');
} else {
if (!pgsql_copy_table_name_is_simple(ZSTR_VAL(table_name), ZSTR_LEN(table_name))) {
php_error_docref(NULL, E_WARNING, "Invalid table_name '%s': must be a plain identifier or schema.table", ZSTR_VAL(table_name));
smart_str_free(&querystr);
RETURN_FALSE;
}
if (build_tablename(&querystr, pgsql, table_name) == FAILURE) {
smart_str_free(&querystr);
RETURN_FALSE;
}
}

spprintf(&query, 0, "COPY %s TO STDOUT DELIMITER E'%c' NULL AS E'%s'", ZSTR_VAL(table_name), *ZSTR_VAL(pg_delimiter), pg_null_as);
char *escaped_delimiter = PQescapeLiteral(pgsql, ZSTR_VAL(pg_delimiter), 1);
if (!escaped_delimiter) {
zend_string *msgbuf = _php_pgsql_trim_message(PQerrorMessage(pgsql));
php_error_docref(NULL, E_WARNING, "Failed to escape delimiter '%c': %s", *ZSTR_VAL(pg_delimiter), ZSTR_VAL(msgbuf));
zend_string_release(msgbuf);
smart_str_free(&querystr);
RETURN_FALSE;
}
char *escaped_null_as = PQescapeLiteral(pgsql, pg_null_as, pg_null_as_len);
if (!escaped_null_as) {
zend_string *msgbuf = _php_pgsql_trim_message(PQerrorMessage(pgsql));
php_error_docref(NULL, E_WARNING, "Failed to escape null_as '%s': %s", pg_null_as, ZSTR_VAL(msgbuf));
zend_string_release(msgbuf);
PQfreemem(escaped_delimiter);
smart_str_free(&querystr);
RETURN_FALSE;
}
smart_str_append_printf(&querystr, " TO STDOUT DELIMITER %s NULL AS %s", escaped_delimiter, escaped_null_as);
smart_str_0(&querystr);
PQfreemem(escaped_delimiter);
PQfreemem(escaped_null_as);

while ((pgsql_result = PQgetResult(pgsql))) {
PQclear(pgsql_result);
}
pgsql_result = PQexec(pgsql, query);
efree(query);
pgsql_result = PQexec(pgsql, ZSTR_VAL(querystr.s));
smart_str_free(&querystr);

if (pgsql_result) {
status = PQresultStatus(pgsql_result);
Expand Down Expand Up @@ -3462,9 +3557,8 @@ PHP_FUNCTION(pg_copy_from)
zval *value;
zend_string *table_name;
zend_string *pg_delimiter = NULL;
char *pg_null_as = "\\\\N";
size_t pg_null_as_len;
char *query;
char *pg_null_as = "\\N";
size_t pg_null_as_len = sizeof("\\N") - 1;
PGconn *pgsql;
PGresult *pgsql_result;
ExecStatusType status;
Expand All @@ -3488,14 +3582,46 @@ PHP_FUNCTION(pg_copy_from)
zend_argument_value_error(4, "must be one character");
RETURN_THROWS();
}
smart_str querystr = {0};
smart_str_appends(&querystr, "COPY ");
if (!pgsql_copy_table_name_is_simple(ZSTR_VAL(table_name), ZSTR_LEN(table_name))) {
php_error_docref(NULL, E_WARNING, "Invalid table_name '%s': must be a plain identifier or schema.table", ZSTR_VAL(table_name));
smart_str_free(&querystr);
RETURN_FALSE;
}
if (build_tablename(&querystr, pgsql, table_name) == FAILURE) {
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.

ditto

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.

Same gate applied here.

smart_str_free(&querystr);
RETURN_FALSE;
}

char *escaped_delimiter = PQescapeLiteral(pgsql, ZSTR_VAL(pg_delimiter), 1);
if (!escaped_delimiter) {
zend_string *msgbuf = _php_pgsql_trim_message(PQerrorMessage(pgsql));
php_error_docref(NULL, E_WARNING, "Failed to escape delimiter '%c': %s", *ZSTR_VAL(pg_delimiter), ZSTR_VAL(msgbuf));
zend_string_release(msgbuf);
smart_str_free(&querystr);
RETURN_FALSE;
}
char *escaped_null_as = PQescapeLiteral(pgsql, pg_null_as, pg_null_as_len);
if (!escaped_null_as) {
zend_string *msgbuf = _php_pgsql_trim_message(PQerrorMessage(pgsql));
php_error_docref(NULL, E_WARNING, "Failed to escape null_as '%s': %s", pg_null_as, ZSTR_VAL(msgbuf));
zend_string_release(msgbuf);
PQfreemem(escaped_delimiter);
smart_str_free(&querystr);
RETURN_FALSE;
}
smart_str_append_printf(&querystr, " FROM STDIN DELIMITER %s NULL AS %s", escaped_delimiter, escaped_null_as);
smart_str_0(&querystr);
PQfreemem(escaped_delimiter);
PQfreemem(escaped_null_as);

spprintf(&query, 0, "COPY %s FROM STDIN DELIMITER E'%c' NULL AS E'%s'", ZSTR_VAL(table_name), *ZSTR_VAL(pg_delimiter), pg_null_as);
while ((pgsql_result = PQgetResult(pgsql))) {
PQclear(pgsql_result);
}
pgsql_result = PQexec(pgsql, query);
pgsql_result = PQexec(pgsql, ZSTR_VAL(querystr.s));

efree(query);
smart_str_free(&querystr);

if (pgsql_result) {
status = PQresultStatus(pgsql_result);
Expand Down Expand Up @@ -5574,7 +5700,9 @@ static inline zend_result build_tablename(smart_str *querystr, PGconn *pg_link,
} else {
char *escaped = PQescapeIdentifier(pg_link, ZSTR_VAL(table), len);
if (escaped == NULL) {
php_error_docref(NULL, E_NOTICE, "Failed to escape table name '%s'", ZSTR_VAL(table));
zend_string *msgbuf = _php_pgsql_trim_message(PQerrorMessage(pg_link));
php_error_docref(NULL, E_WARNING, "Failed to escape table name '%s': %s", ZSTR_VAL(table), ZSTR_VAL(msgbuf));
zend_string_release(msgbuf);
return FAILURE;
}
smart_str_appends(querystr, escaped);
Expand All @@ -5590,7 +5718,9 @@ static inline zend_result build_tablename(smart_str *querystr, PGconn *pg_link,
} else {
char *escaped = PQescapeIdentifier(pg_link, after_dot, len);
if (escaped == NULL) {
php_error_docref(NULL, E_NOTICE, "Failed to escape table name '%s'", ZSTR_VAL(table));
zend_string *msgbuf = _php_pgsql_trim_message(PQerrorMessage(pg_link));
php_error_docref(NULL, E_WARNING, "Failed to escape table name '%s': %s", ZSTR_VAL(table), ZSTR_VAL(msgbuf));
zend_string_release(msgbuf);
return FAILURE;
}
smart_str_appendc(querystr, '.');
Expand Down
2 changes: 1 addition & 1 deletion ext/pgsql/tests/ghsa-hrwm-9436-5mv3.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ bool(false)
Notice: pg_insert(): String value escaping failed for PostgreSQL 'text' (bar) in %s on line %d
bool(false)

Notice: pg_insert(): Failed to escape table name 'ABC%s';' in %s on line %d
Warning: pg_insert(): Failed to escape table name 'ABC%s';': %s in %s on line %d
bool(false)

Notice: pg_insert(): Failed to escape field 'ABC%s';' in %s on line %d
Expand Down
52 changes: 52 additions & 0 deletions ext/pgsql/tests/pg_copy_default_null_marker.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
--TEST--
pg_copy_to() / pg_copy_from() default null marker round-trip
--EXTENSIONS--
pgsql
--SKIPIF--
<?php include("inc/skipif.inc"); ?>
--FILE--
<?php

include('inc/config.inc');

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

$rows = pg_copy_to($db, 'pg_copy_default_null');
var_dump($rows);

pg_query($db, 'DELETE FROM pg_copy_default_null');
var_dump(pg_copy_from($db, 'pg_copy_default_null', $rows));
var_dump(pg_fetch_all(pg_query($db, 'SELECT v FROM pg_copy_default_null 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(5) "2 \N
"
}
bool(true)
array(2) {
[0]=>
array(1) {
["v"]=>
string(5) "hello"
}
[1]=>
array(1) {
["v"]=>
NULL
}
}
43 changes: 43 additions & 0 deletions ext/pgsql/tests/pg_copy_from_null_as_escape.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
--TEST--
pg_copy_from() escapes the null_as argument
--EXTENSIONS--
pgsql
--SKIPIF--
<?php include("inc/skipif.inc"); ?>
--FILE--
<?php

include('inc/config.inc');

$db = pg_connect($conn_str);
pg_query($db, 'DROP TABLE IF EXISTS pg_copy_null_as_target');
pg_query($db, 'DROP TABLE IF EXISTS pg_copy_null_as_injected');
pg_query($db, 'CREATE TABLE pg_copy_null_as_target (v text)');

$evil = "X'; CREATE TABLE pg_copy_null_as_injected (v text); --";
var_dump(pg_copy_from($db, 'pg_copy_null_as_target', ["row\n"], "\t", $evil));

$r = pg_query($db, "SELECT 1 FROM pg_tables WHERE tablename = 'pg_copy_null_as_injected'");
var_dump(pg_num_rows($r));

$r = pg_query($db, 'SELECT v FROM pg_copy_null_as_target ORDER BY v');
var_dump(pg_fetch_all($r));

?>
--CLEAN--
<?php
include('inc/config.inc');
$db = pg_connect($conn_str);
pg_query($db, 'DROP TABLE IF EXISTS pg_copy_null_as_target');
pg_query($db, 'DROP TABLE IF EXISTS pg_copy_null_as_injected');
?>
--EXPECT--
bool(true)
int(0)
array(1) {
[0]=>
array(1) {
["v"]=>
string(3) "row"
}
}
36 changes: 36 additions & 0 deletions ext/pgsql/tests/pg_copy_from_table_name_escape.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
--TEST--
pg_copy_from() escapes the table name argument
--EXTENSIONS--
pgsql
--SKIPIF--
<?php include("inc/skipif.inc"); ?>
--FILE--
<?php

include('inc/config.inc');

$db = pg_connect($conn_str);
pg_query($db, 'DROP TABLE IF EXISTS pg_copy_from_target');
pg_query($db, 'CREATE TABLE pg_copy_from_target (v text)');
pg_query($db, 'DROP TABLE IF EXISTS pg_copy_from_other');
pg_query($db, 'CREATE TABLE pg_copy_from_other (v text)');

$evil = "pg_copy_from_other FROM STDIN --";
var_dump(pg_copy_from($db, $evil, ["redirected\n"]));

$rows = pg_fetch_all(pg_query($db, 'SELECT v FROM pg_copy_from_other')) ?: [];
var_dump($rows);

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.

can you add a case that tries to close the wrapper early, like '(SELECT 1)); DROP TABLE pg_copy_to_qsource; --', plus the pg_tables check?

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.

Added the wrapper-close test case plus a paren-balance check on the (query) input: must start with (, end with ), and depth must reach 0 only at the final character. The original (SELECT 1); DROP...; -- case also hits the new gate, so its expected output flipped from a Postgres syntax error to the validation warning.

?>
--CLEAN--
<?php
include('inc/config.inc');
$db = pg_connect($conn_str);
pg_query($db, 'DROP TABLE IF EXISTS pg_copy_from_target');
pg_query($db, 'DROP TABLE IF EXISTS pg_copy_from_other');
?>
--EXPECTF--
Warning: pg_copy_from(): Invalid table_name 'pg_copy_from_other FROM STDIN --': must be a plain identifier or schema.table in %s on line %d
bool(false)
array(0) {
}
Loading
Loading