fix: reject request headers that contain CR or LF - #299
Conversation
| 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( |
There was a problem hiding this comment.
@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.
There was a problem hiding this comment.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
And curl is trying to fix this as well: curl/curl#22309
There was a problem hiding this comment.
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])[ |
There was a problem hiding this comment.
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()
There was a problem hiding this comment.
After further analysis curl does not reject these types of requests either. Given that I don't think we should either.
There was a problem hiding this comment.
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.
|
|
||
| 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. |
There was a problem hiding this comment.
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.
PR still under discussion, dismissing my review until then.
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
ERRORresponse 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 intonet.http_request_queueare 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.