Skip to content

Commit 5bf0309

Browse files
committed
ext/pgsql: escape table name, delimiter, null marker in pg_copy_from/to
The COPY query embedded table_name 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. Restrict the bare table_name argument to a plain identifier or schema.table (each side matching [A-Za-z_][A-Za-z0-9_]*) and route it through build_tablename, the same helper pg_insert/update/select/delete have used since bug #62978. Reject anything else with a clear warning so previously silent-fail cases (column lists, ONLY, quoted identifiers) point at the table_name argument instead of surfacing as a Postgres relation lookup error. Pass the delimiter and null marker through PQescapeLiteral. pg_copy_to keeps the parenthesised (query) source form documented in bug 73498; the input is now SQL-aware paren-balance checked (single, double, and dollar-quoted literals are tracked) and rejected if depth returns to 0 before end-of-string, so the user-supplied string cannot close the wrapper early and inject a trailing statement. Closes GH-21985
1 parent 8d0777e commit 5bf0309

8 files changed

Lines changed: 570 additions & 15 deletions

ext/pgsql/pgsql.c

Lines changed: 217 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,133 @@ static void pgsql_lob_free_obj(zend_object *obj)
273273

274274
/* Compatibility definitions */
275275

276+
static inline zend_result build_tablename(smart_str *querystr, PGconn *pg_link, const zend_string *table);
277+
278+
static bool pgsql_copy_table_name_is_simple(const char *s, size_t len)
279+
{
280+
if (len == 0) {
281+
return false;
282+
}
283+
size_t i = 0;
284+
if (!(isalpha((unsigned char) s[i]) || s[i] == '_')) {
285+
return false;
286+
}
287+
i++;
288+
while (i < len && (isalnum((unsigned char) s[i]) || s[i] == '_')) {
289+
i++;
290+
}
291+
if (i == len) {
292+
return true;
293+
}
294+
if (s[i] != '.') {
295+
return false;
296+
}
297+
i++;
298+
if (i >= len || !(isalpha((unsigned char) s[i]) || s[i] == '_')) {
299+
return false;
300+
}
301+
i++;
302+
while (i < len && (isalnum((unsigned char) s[i]) || s[i] == '_')) {
303+
i++;
304+
}
305+
return i == len;
306+
}
307+
308+
static bool pgsql_copy_query_form_balanced(const char *s, size_t len)
309+
{
310+
if (len < 2 || s[0] != '(' || s[len - 1] != ')') {
311+
return false;
312+
}
313+
int depth = 0;
314+
size_t i = 0;
315+
while (i < len) {
316+
char c = s[i];
317+
if (c == '\'' || c == '"') {
318+
char quote = c;
319+
i++;
320+
while (i < len) {
321+
if (s[i] == quote) {
322+
if (i + 1 < len && s[i + 1] == quote) {
323+
i += 2;
324+
continue;
325+
}
326+
i++;
327+
break;
328+
}
329+
i++;
330+
}
331+
continue;
332+
}
333+
if (c == '$') {
334+
size_t tag_start = i + 1;
335+
size_t p = tag_start;
336+
while (p < len && (isalnum((unsigned char) s[p]) || s[p] == '_')) {
337+
p++;
338+
}
339+
if (p < len && s[p] == '$') {
340+
size_t tag_len = p - tag_start;
341+
size_t scan = p + 1;
342+
bool closed = false;
343+
while (scan + tag_len + 1 < len) {
344+
if (s[scan] == '$' && s[scan + tag_len + 1] == '$'
345+
&& (tag_len == 0 || memcmp(s + scan + 1, s + tag_start, tag_len) == 0)) {
346+
scan = scan + tag_len + 2;
347+
closed = true;
348+
break;
349+
}
350+
scan++;
351+
}
352+
if (!closed) {
353+
return false;
354+
}
355+
i = scan;
356+
continue;
357+
}
358+
i++;
359+
continue;
360+
}
361+
if (c == '-' && i + 1 < len && s[i + 1] == '-') {
362+
i += 2;
363+
while (i < len && s[i] != '\n') {
364+
i++;
365+
}
366+
continue;
367+
}
368+
if (c == '/' && i + 1 < len && s[i + 1] == '*') {
369+
i += 2;
370+
int cdepth = 1;
371+
while (i + 1 < len && cdepth > 0) {
372+
if (s[i] == '/' && s[i + 1] == '*') {
373+
cdepth++;
374+
i += 2;
375+
} else if (s[i] == '*' && s[i + 1] == '/') {
376+
cdepth--;
377+
i += 2;
378+
} else {
379+
i++;
380+
}
381+
}
382+
if (cdepth != 0) {
383+
return false;
384+
}
385+
continue;
386+
}
387+
if (c == '(') {
388+
depth++;
389+
} else if (c == ')') {
390+
depth--;
391+
if (depth < 0) {
392+
return false;
393+
}
394+
if (depth == 0 && i != len - 1) {
395+
return false;
396+
}
397+
}
398+
i++;
399+
}
400+
return depth == 0;
401+
}
402+
276403
static zend_string *_php_pgsql_trim_message(const char *message)
277404
{
278405
size_t i = strlen(message);
@@ -3347,9 +3474,8 @@ PHP_FUNCTION(pg_copy_to)
33473474
pgsql_link_handle *link;
33483475
zend_string *table_name;
33493476
zend_string *pg_delimiter = NULL;
3350-
char *pg_null_as = "\\\\N";
3351-
size_t pg_null_as_len = 0;
3352-
char *query;
3477+
char *pg_null_as = "\\N";
3478+
size_t pg_null_as_len = sizeof("\\N") - 1;
33533479
PGconn *pgsql;
33543480
PGresult *pgsql_result;
33553481
ExecStatusType status;
@@ -3373,14 +3499,56 @@ PHP_FUNCTION(pg_copy_to)
33733499
zend_argument_value_error(3, "must be one character");
33743500
RETURN_THROWS();
33753501
}
3502+
smart_str querystr = {0};
3503+
smart_str_appends(&querystr, "COPY ");
3504+
if (ZSTR_LEN(table_name) > 0 && ZSTR_VAL(table_name)[0] == '(') {
3505+
if (!pgsql_copy_query_form_balanced(ZSTR_VAL(table_name), ZSTR_LEN(table_name))) {
3506+
php_error_docref(NULL, E_WARNING, "Invalid query source '%s': must be a single balanced parenthesised expression", ZSTR_VAL(table_name));
3507+
smart_str_free(&querystr);
3508+
RETURN_FALSE;
3509+
}
3510+
smart_str_appendc(&querystr, '(');
3511+
smart_str_append(&querystr, table_name);
3512+
smart_str_appendc(&querystr, ')');
3513+
} else {
3514+
if (!pgsql_copy_table_name_is_simple(ZSTR_VAL(table_name), ZSTR_LEN(table_name))) {
3515+
php_error_docref(NULL, E_WARNING, "Invalid table_name '%s': must be a plain identifier or schema.table", ZSTR_VAL(table_name));
3516+
smart_str_free(&querystr);
3517+
RETURN_FALSE;
3518+
}
3519+
if (build_tablename(&querystr, pgsql, table_name) == FAILURE) {
3520+
smart_str_free(&querystr);
3521+
RETURN_FALSE;
3522+
}
3523+
}
33763524

3377-
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);
3525+
char *escaped_delimiter = PQescapeLiteral(pgsql, ZSTR_VAL(pg_delimiter), 1);
3526+
if (!escaped_delimiter) {
3527+
zend_string *msgbuf = _php_pgsql_trim_message(PQerrorMessage(pgsql));
3528+
php_error_docref(NULL, E_WARNING, "Failed to escape delimiter '%c': %s", *ZSTR_VAL(pg_delimiter), ZSTR_VAL(msgbuf));
3529+
zend_string_release(msgbuf);
3530+
smart_str_free(&querystr);
3531+
RETURN_FALSE;
3532+
}
3533+
char *escaped_null_as = PQescapeLiteral(pgsql, pg_null_as, pg_null_as_len);
3534+
if (!escaped_null_as) {
3535+
zend_string *msgbuf = _php_pgsql_trim_message(PQerrorMessage(pgsql));
3536+
php_error_docref(NULL, E_WARNING, "Failed to escape null_as '%s': %s", pg_null_as, ZSTR_VAL(msgbuf));
3537+
zend_string_release(msgbuf);
3538+
PQfreemem(escaped_delimiter);
3539+
smart_str_free(&querystr);
3540+
RETURN_FALSE;
3541+
}
3542+
smart_str_append_printf(&querystr, " TO STDOUT DELIMITER %s NULL AS %s", escaped_delimiter, escaped_null_as);
3543+
smart_str_0(&querystr);
3544+
PQfreemem(escaped_delimiter);
3545+
PQfreemem(escaped_null_as);
33783546

33793547
while ((pgsql_result = PQgetResult(pgsql))) {
33803548
PQclear(pgsql_result);
33813549
}
3382-
pgsql_result = PQexec(pgsql, query);
3383-
efree(query);
3550+
pgsql_result = PQexec(pgsql, ZSTR_VAL(querystr.s));
3551+
smart_str_free(&querystr);
33843552

33853553
if (pgsql_result) {
33863554
status = PQresultStatus(pgsql_result);
@@ -3462,9 +3630,8 @@ PHP_FUNCTION(pg_copy_from)
34623630
zval *value;
34633631
zend_string *table_name;
34643632
zend_string *pg_delimiter = NULL;
3465-
char *pg_null_as = "\\\\N";
3466-
size_t pg_null_as_len;
3467-
char *query;
3633+
char *pg_null_as = "\\N";
3634+
size_t pg_null_as_len = sizeof("\\N") - 1;
34683635
PGconn *pgsql;
34693636
PGresult *pgsql_result;
34703637
ExecStatusType status;
@@ -3488,14 +3655,46 @@ PHP_FUNCTION(pg_copy_from)
34883655
zend_argument_value_error(4, "must be one character");
34893656
RETURN_THROWS();
34903657
}
3658+
smart_str querystr = {0};
3659+
smart_str_appends(&querystr, "COPY ");
3660+
if (!pgsql_copy_table_name_is_simple(ZSTR_VAL(table_name), ZSTR_LEN(table_name))) {
3661+
php_error_docref(NULL, E_WARNING, "Invalid table_name '%s': must be a plain identifier or schema.table", ZSTR_VAL(table_name));
3662+
smart_str_free(&querystr);
3663+
RETURN_FALSE;
3664+
}
3665+
if (build_tablename(&querystr, pgsql, table_name) == FAILURE) {
3666+
smart_str_free(&querystr);
3667+
RETURN_FALSE;
3668+
}
3669+
3670+
char *escaped_delimiter = PQescapeLiteral(pgsql, ZSTR_VAL(pg_delimiter), 1);
3671+
if (!escaped_delimiter) {
3672+
zend_string *msgbuf = _php_pgsql_trim_message(PQerrorMessage(pgsql));
3673+
php_error_docref(NULL, E_WARNING, "Failed to escape delimiter '%c': %s", *ZSTR_VAL(pg_delimiter), ZSTR_VAL(msgbuf));
3674+
zend_string_release(msgbuf);
3675+
smart_str_free(&querystr);
3676+
RETURN_FALSE;
3677+
}
3678+
char *escaped_null_as = PQescapeLiteral(pgsql, pg_null_as, pg_null_as_len);
3679+
if (!escaped_null_as) {
3680+
zend_string *msgbuf = _php_pgsql_trim_message(PQerrorMessage(pgsql));
3681+
php_error_docref(NULL, E_WARNING, "Failed to escape null_as '%s': %s", pg_null_as, ZSTR_VAL(msgbuf));
3682+
zend_string_release(msgbuf);
3683+
PQfreemem(escaped_delimiter);
3684+
smart_str_free(&querystr);
3685+
RETURN_FALSE;
3686+
}
3687+
smart_str_append_printf(&querystr, " FROM STDIN DELIMITER %s NULL AS %s", escaped_delimiter, escaped_null_as);
3688+
smart_str_0(&querystr);
3689+
PQfreemem(escaped_delimiter);
3690+
PQfreemem(escaped_null_as);
34913691

3492-
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);
34933692
while ((pgsql_result = PQgetResult(pgsql))) {
34943693
PQclear(pgsql_result);
34953694
}
3496-
pgsql_result = PQexec(pgsql, query);
3695+
pgsql_result = PQexec(pgsql, ZSTR_VAL(querystr.s));
34973696

3498-
efree(query);
3697+
smart_str_free(&querystr);
34993698

35003699
if (pgsql_result) {
35013700
status = PQresultStatus(pgsql_result);
@@ -5574,7 +5773,9 @@ static inline zend_result build_tablename(smart_str *querystr, PGconn *pg_link,
55745773
} else {
55755774
char *escaped = PQescapeIdentifier(pg_link, ZSTR_VAL(table), len);
55765775
if (escaped == NULL) {
5577-
php_error_docref(NULL, E_NOTICE, "Failed to escape table name '%s'", ZSTR_VAL(table));
5776+
zend_string *msgbuf = _php_pgsql_trim_message(PQerrorMessage(pg_link));
5777+
php_error_docref(NULL, E_WARNING, "Failed to escape table name '%s': %s", ZSTR_VAL(table), ZSTR_VAL(msgbuf));
5778+
zend_string_release(msgbuf);
55785779
return FAILURE;
55795780
}
55805781
smart_str_appends(querystr, escaped);
@@ -5590,7 +5791,9 @@ static inline zend_result build_tablename(smart_str *querystr, PGconn *pg_link,
55905791
} else {
55915792
char *escaped = PQescapeIdentifier(pg_link, after_dot, len);
55925793
if (escaped == NULL) {
5593-
php_error_docref(NULL, E_NOTICE, "Failed to escape table name '%s'", ZSTR_VAL(table));
5794+
zend_string *msgbuf = _php_pgsql_trim_message(PQerrorMessage(pg_link));
5795+
php_error_docref(NULL, E_WARNING, "Failed to escape table name '%s': %s", ZSTR_VAL(table), ZSTR_VAL(msgbuf));
5796+
zend_string_release(msgbuf);
55945797
return FAILURE;
55955798
}
55965799
smart_str_appendc(querystr, '.');

ext/pgsql/tests/ghsa-hrwm-9436-5mv3.phpt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ bool(false)
4242
Notice: pg_insert(): String value escaping failed for PostgreSQL 'text' (bar) in %s on line %d
4343
bool(false)
4444

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

4848
Notice: pg_insert(): Failed to escape field 'ABC%s';' in %s on line %d
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
--TEST--
2+
pg_copy_to() / pg_copy_from() default null marker round-trip
3+
--EXTENSIONS--
4+
pgsql
5+
--SKIPIF--
6+
<?php include("inc/skipif.inc"); ?>
7+
--FILE--
8+
<?php
9+
10+
include('inc/config.inc');
11+
12+
$db = pg_connect($conn_str);
13+
pg_query($db, 'DROP TABLE IF EXISTS pg_copy_default_null');
14+
pg_query($db, 'CREATE TABLE pg_copy_default_null (id int, v text)');
15+
pg_query($db, "INSERT INTO pg_copy_default_null VALUES (1, 'hello'), (2, NULL)");
16+
17+
$rows = pg_copy_to($db, 'pg_copy_default_null');
18+
var_dump($rows);
19+
20+
pg_query($db, 'DELETE FROM pg_copy_default_null');
21+
var_dump(pg_copy_from($db, 'pg_copy_default_null', $rows));
22+
var_dump(pg_fetch_all(pg_query($db, 'SELECT v FROM pg_copy_default_null ORDER BY id')));
23+
24+
?>
25+
--CLEAN--
26+
<?php
27+
include('inc/config.inc');
28+
$db = pg_connect($conn_str);
29+
pg_query($db, 'DROP TABLE IF EXISTS pg_copy_default_null');
30+
?>
31+
--EXPECT--
32+
array(2) {
33+
[0]=>
34+
string(8) "1 hello
35+
"
36+
[1]=>
37+
string(5) "2 \N
38+
"
39+
}
40+
bool(true)
41+
array(2) {
42+
[0]=>
43+
array(1) {
44+
["v"]=>
45+
string(5) "hello"
46+
}
47+
[1]=>
48+
array(1) {
49+
["v"]=>
50+
NULL
51+
}
52+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
--TEST--
2+
pg_copy_from() escapes the null_as argument
3+
--EXTENSIONS--
4+
pgsql
5+
--SKIPIF--
6+
<?php include("inc/skipif.inc"); ?>
7+
--FILE--
8+
<?php
9+
10+
include('inc/config.inc');
11+
12+
$db = pg_connect($conn_str);
13+
pg_query($db, 'DROP TABLE IF EXISTS pg_copy_null_as_target');
14+
pg_query($db, 'DROP TABLE IF EXISTS pg_copy_null_as_injected');
15+
pg_query($db, 'CREATE TABLE pg_copy_null_as_target (v text)');
16+
17+
$evil = "X'; CREATE TABLE pg_copy_null_as_injected (v text); --";
18+
var_dump(pg_copy_from($db, 'pg_copy_null_as_target', ["row\n"], "\t", $evil));
19+
20+
$r = pg_query($db, "SELECT 1 FROM pg_tables WHERE tablename = 'pg_copy_null_as_injected'");
21+
var_dump(pg_num_rows($r));
22+
23+
$r = pg_query($db, 'SELECT v FROM pg_copy_null_as_target ORDER BY v');
24+
var_dump(pg_fetch_all($r));
25+
26+
?>
27+
--CLEAN--
28+
<?php
29+
include('inc/config.inc');
30+
$db = pg_connect($conn_str);
31+
pg_query($db, 'DROP TABLE IF EXISTS pg_copy_null_as_target');
32+
pg_query($db, 'DROP TABLE IF EXISTS pg_copy_null_as_injected');
33+
?>
34+
--EXPECT--
35+
bool(true)
36+
int(0)
37+
array(1) {
38+
[0]=>
39+
array(1) {
40+
["v"]=>
41+
string(3) "row"
42+
}
43+
}

0 commit comments

Comments
 (0)