Skip to content

fix: reject request headers that contain CR or LF - #299

Merged
utkarash2991 merged 1 commit into
masterfrom
fix/reject-crlf-headers
Sep 28, 2026
Merged

utkarash2991 merged 1 commit into
masterfrom
fix/reject-crlf-headers

Conversation

@utkarash2991

@utkarash2991 utkarash2991 commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Closes #274.

libcurl ends every header with CRLF, so a header value carrying its own CR or LF closes the header early and whatever follows is read by the server as more headers or as the body. Nothing checked for this, the header went straight to curl_slist_append.

Now a header with a CR or LF in it makes the request get rejected before it reaches curl, using the same path #285 added for bad timeouts: the worker writes an ERROR response row naming the header and moves on to the next request. Raising an error in the worker instead would have taken the whole batch down and replayed it on restart, which is why #275 could not be merged as it was. The check lives in the worker so rows inserted directly into net.http_request_queue are covered too.

Only the header name goes into the error message. The value is user data and often holds credentials, so it must not end up in _http_response. If the CR or LF shows up before any :, the name is cut there so the smuggled text can't leak through the name either.

Thanks to @hamodywe for the analysis and the detection logic in #275, this builds on that.

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.

(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.

Comment thread README.md

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.

imor
imor previously approved these changes Sep 25, 2026
@imor
imor dismissed their stale review September 25, 2026 15:06

PR still under discussion, dismissing my review until then.

@utkarash2991
utkarash2991 merged commit 62892ab into master Sep 28, 2026
18 checks passed
@utkarash2991
utkarash2991 deleted the fix/reject-crlf-headers branch September 28, 2026 12:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pg_net doesn't reject headers with \r or \n in them

4 participants