Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ When any of the three request functions (`http_get`, `http_post`, `http_delete`)

Once a response is received, it gets stored in the `_http_response` table. By monitoring this table, you can keep track of response statuses and messages.

A request whose headers contain a carriage return or line feed is not sent. It gets an `ERROR` response naming the header, since libcurl terminates headers with CRLF and an embedded one would let the header smuggle extra headers or a body into the request.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We can reword it in terms of correctness rather than security by removing the word "smuggle" and saying that this leads to creation of malformed requests.


> [!IMPORTANT]
> Inserting directly into `net.http_request_queue` won't wake the worker, you must use the request functions. Rows inserted directly are only processed the next time the worker wakes up.
> We do it this way to avoid polling the `net.http_request_queue` table, which would pollute `pg_stat_statements` and cause unnecessary activity from the worker.
Expand Down
30 changes: 27 additions & 3 deletions src/core.c
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,21 @@ static size_t body_cb(void *contents, size_t size, size_t nmemb, void *userp) {
return realsize;
}

static struct curl_slist *pg_text_array_to_slist(ArrayType *array, struct curl_slist *headers) {
// A header with a CR or LF in it can inject extra headers or a body into the request, since libcurl
// ends every header with CRLF. Such requests are not sent. The message only names the header, the
// value may hold credentials.
static char *crlf_header_rejection(const char *hdr) {
size_t bad = strcspn(hdr, "\r\n");
if (hdr[bad] == '\0') return NULL;

size_t name_len = strcspn(hdr, ":");
if (name_len > bad) name_len = bad;

return psprintf("header \"%.*s\" contains a carriage return or line feed", (int)name_len, hdr);
}

static struct curl_slist *pg_text_array_to_slist(ArrayType *array, struct curl_slist *headers,
char **rejected_reason) {
ArrayIterator iterator;
Datum value;
bool isnull;
Expand All @@ -36,7 +50,17 @@ static struct curl_slist *pg_text_array_to_slist(ArrayType *array, struct curl_s
}

hdr = TextDatumGetCString(value);
EREPORT_CURL_SLIST_APPEND(headers, hdr);

char *reason = crlf_header_rejection(hdr);
if (reason) {
if (*rejected_reason == NULL)
*rejected_reason = reason;
else
pfree(reason);
} else {
EREPORT_CURL_SLIST_APPEND(headers, hdr);
}

pfree(hdr);
}
array_free_iterator(iterator);
Expand Down Expand Up @@ -67,7 +91,7 @@ void init_curl_handle(CurlHandle *handle, RequestQueueRow row) {
ArrayType *pgHeaders = DatumGetArrayTypeP(row.headersBin.value);
struct curl_slist *request_headers = NULL;

request_headers = pg_text_array_to_slist(pgHeaders, request_headers);
request_headers = pg_text_array_to_slist(pgHeaders, request_headers, &handle->rejected_reason);

EREPORT_CURL_SLIST_APPEND(request_headers, "User-Agent: pg_net/" EXTVERSION);

Expand Down
120 changes: 120 additions & 0 deletions test/test_http_header_crlf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import pytest
from common import pg_collect_response, pg_http_request
from test_http_timeout import wait_for_responses


@pytest.mark.parametrize(
"header, reported_name",
[
('{"X-Test": "value\\n"}', "X-Test"),
('{"X-Test": "value\\r"}', "X-Test"),
('{"X-Test": "value\\r\\nInjected: yes"}', "X-Test"),
('{"X-Te\\nst": "value"}', "X-Te"),
('{"\\r\\nInjected": "yes"}', ""),
],
)
def test_headers_with_cr_or_lf_are_rejected(conn, header, reported_name):
"""A header containing CR or LF is not sent and gets an ERROR response naming the header"""

request_id = pg_http_request(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@utkarash2991 I feel like the validation/error should be happening in http_get and friends instead of the worker. I get the downside of that is probably that we would need validation on both ends because the client has the ability to directly insert into the table to bypass any restrictions. This can possibly be alleviated by making these security definer functions or the work we are doing to convert the queue into an in memory data structure.

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.

Not a simple fix at this point but ideally we'd had the headers as a custom type instead of a JSON. That type would validate the input coming from the user.

Depending on the urgency of the fix we could design the right interface now. We'd need to change it anyway for #27 to fix #174.

@AndrewJackson2020 AndrewJackson2020 Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Depending on the urgency of the fix we could design the right interface now.

Agree, we need to get a sense of urgency. Looking at the below quote from the docs I'm not seeing a huge vulnerability here. My understanding is that any vulnerability would only happen if the server improperly parsed headers as well. Defense in depth is important but I don't think that would be pg_net's fault.

libcurl terminates headers with CRLF and an embedded one would let the header smuggle extra headers or a body into the request.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm pretty confident at this point that this should be a low priority fix. Also IMO a blanket rejection is not the correct approach. Take a look at my repro in the original issue along with corresponding behavior of curl and pgsql-http.

#274 (comment)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is not for security but usability, although I'm not ruling out the defence in depth security aspects. It's not urgent in that not many people have reported it. Blanket reject is the correct approach and curl and pgsql-http are the odd-ones-out here; many other major http client libraries in languages like Python, Rust, Java, Go, and Javascript reject such requests. Without such rejections we are putting the burden of validating the data on the users who may not be familiar with the intricacies of http protocol rules and building the request is exactly the right spot to validate and reject them.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

And curl is trying to fix this as well: curl/curl#22309

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

IMO we should merge it, not because it's urgent but because this is a small focussed fix and it's not much rework to later design a better interface (via domain type) as suggested by @steve-chavez above.

conn,
"select net.http_get(url := 'http://localhost:8080/headers', headers := %s::jsonb)",
(header,),
)

(status_code, error_msg, timed_out) = wait_for_responses(conn, [request_id])[

@AndrewJackson2020 AndrewJackson2020 Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Another question: should we be rejecting these requests?

Currently we are straight up rejecting to execute them but pgsql-http allows them to go through and forms the header value with the newline character in it. I suspect their design is correct and ours is wrong here but happy to be wrong. Is there no potential use value to having a newline in a json value?

12:59:12 postgres@[local:/tmp/nix-shell.kK5gyH/tmp.ZC3GwJ4sbh]:5432/postgres  97371 =#
SELECT * FROM net.http_get('http://localhost:8000', NULL, '{"test": "wafdsas\ndfsdaf"}'::jsonb, 5000);
 http_get
----------
        2
(1 row)

12:59:17 postgres@[local:/tmp/nix-shell.kK5gyH/tmp.ZC3GwJ4sbh]:5432/postgres  97371 =#
SELECT * FROM net._http_response \gx
-[ RECORD 1 ]+------------------------------------------------------
id           | 1
status_code  |
content_type |
headers      |
content      |
timed_out    | f
error_msg    | header "test" contains a carriage return or line feed
created      | 2026-09-24 12:56:30.118897-05
-[ RECORD 2 ]+------------------------------------------------------
id           | 2
status_code  |
content_type |
headers      |
content      |
timed_out    | f
error_msg    | header "test" contains a carriage return or line feed
created      | 2026-09-24 12:59:17.310172-05

pgsql-http example call

13:03:00 aj@localhost:5432/postgres  6443 =#
SELECT *
FROM http(ROW(
'GET'::text::http_method,
'http://localhost:8000'::character varying,
ARRAY[
http_header('test','afdsfsd\nasdfadsf')
],
NULL,
NULL
)::http_request);
 status | content_type |                                                      headers                                                      |              content
--------+--------------+-------------------------------------------------------------------------------------------------------------------+------------------------------------
    200 | text/plain   | {"(Server,\"BaseHTTP/0.6 Python/3.9.6\")","(Date,\"Thu, 24 Sep 2026 18:03:01 GMT\")","(Content-type,text/plain)"} | Headers printed to server console.
(1 row)

pgsql http forms header with newline

--- INCOMING REQUEST HEADERS ---
Host: localhost:8000
User-Agent: PostgreSQL 17.6 on aarch64-apple-darwin25.5.0, compiled by clang version 21.1.7, 64-bit
Accept: */*
Accept-Encoding: deflate, gzip, br, zstd
Charsets: utf-8
test: afdsfsd\nasdfadsf
Connection: close


--------------------------------

127.0.0.1 - - [24/Sep/2026 13:03:01] "GET / HTTP/1.1" 200 -

example python web server to print headers

from http.server import BaseHTTPRequestHandler, HTTPServer

class HeaderPrinterHandler(BaseHTTPRequestHandler):

    def do_POST(self):
        print("\n--- INCOMING REQUEST HEADERS ---")
        print(self.headers)
        print("\n--- INCOMING REQUEST BODY ---")
        print(self.rfile.read())
        print("--------------------------------\n")

        self.send_response(200)
        self.send_header("Content-type", "text/plain")
        self.end_headers()
        self.wfile.write(b"Headers printed to server console.")

    def do_GET(self):
        print("\n--- INCOMING REQUEST HEADERS ---")

        print(self.headers)

        print("--------------------------------\n")
        self.send_response(200)
        self.send_header("Content-type", "text/plain")
        self.end_headers()
        self.wfile.write(b"Headers printed to server console.")

def run(port=8000):
    server_address = ('', port)
    httpd = HTTPServer(server_address, HeaderPrinterHandler)
    print(f"Starting server on port {port}...")
    httpd.serve_forever()

if __name__ == '__main__':
    run()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

After further analysis curl does not reject these types of requests either. Given that I don't think we should either.

#274 (comment)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We definitely should reject such requests. pgsql-http and curl are in the wrong here, not us trying to steer users clear of building requests which will be incorrect 100% of the time. Adding this to docs doesn't cut it because users do not always read them and it's far better to prevent a problem than to debug it. Docs are not executable.

request_id
]

assert status_code is None
assert timed_out is False
assert (
error_msg == f'header "{reported_name}" contains a carriage return or line feed'
)
assert "value" not in error_msg
assert "Injected" not in error_msg


def test_headers_without_cr_or_lf_are_sent(conn):
"""Ordinary headers still reach the server"""

request_id = pg_http_request(
conn,
"""select net.http_get(
url := 'http://localhost:8080/headers',
headers := '{"X-Test": "plain value", "accept": "application/json"}'::jsonb
)""",
)

response = pg_collect_response(conn, request_id)

assert response["status"] == "SUCCESS"
assert "X-Test" in response["body"]


def test_rejected_header_does_not_affect_the_rest_of_the_batch(conn):
"""One bad header in a batch gets an ERROR row, the other requests are sent normally"""

bad = pg_http_request(
conn,
"""select net.http_get(url := 'http://localhost:8080/headers', headers := '{"X-Test": "a\\nb"}'::jsonb)""",
)
get = pg_http_request(
conn, "select net.http_get(url := 'http://localhost:8080/headers')"
)
post = pg_http_request(
conn,
"select net.http_post(url := 'http://localhost:8080/anything', body := '{}'::jsonb)",
)

responses = wait_for_responses(conn, [bad, get, post])

assert responses[bad][0] is None
assert (
responses[bad][1] == 'header "X-Test" contains a carriage return or line feed'
)
assert responses[get][0] == 200
assert responses[post][0] == 200


def test_direct_insert_with_cr_or_lf_header_is_rejected(conn):
"""Rows inserted straight into the queue go through the same check"""

request_id = pg_http_request(
conn,
"""insert into net.http_request_queue(method, url, headers, timeout_milliseconds)
values ('GET', 'http://localhost:8080/headers', '{"X-Test": "a\\rb"}'::jsonb, 5000)
returning id""",
)
conn.execute("select net.wake()")
conn.commit()

(status_code, error_msg, _) = wait_for_responses(conn, [request_id])[request_id]

assert status_code is None
assert error_msg == 'header "X-Test" contains a carriage return or line feed'

follow_up = pg_http_request(
conn, "select net.http_get(url := 'http://localhost:8080/headers')"
)
assert wait_for_responses(conn, [follow_up])[follow_up][0] == 200


def test_timeout_rejection_is_reported_before_the_header_one(conn):
"""A request with both a bad timeout and a bad header gets the timeout message"""

request_id = pg_http_request(
conn,
"""select net.http_get(
url := 'http://localhost:8080/headers',
headers := '{"X-Test": "a\\nb"}'::jsonb,
timeout_milliseconds := 0
)""",
)

(_, error_msg, _) = wait_for_responses(conn, [request_id])[request_id]

assert (
error_msg
== "timeout_milliseconds must be between 1 and 600000 (pg_net.max_timeout_ms), got 0"
)
Loading