public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [pve-devel] [PATCH v2 http-server 0/4] refactor HTTP request processing
@ 2023-03-03 17:29 Max Carrara
  2023-03-03 17:29 ` [pve-devel] [PATCH v2 http-server 1/4] anyevent: move header processing into separate subroutine Max Carrara
                   ` (4 more replies)
  0 siblings, 5 replies; 6+ messages in thread
From: Max Carrara @ 2023-03-03 17:29 UTC (permalink / raw)
  To: pve-devel

This patch series refactors the request processing algorithm and
also provides a patch for redirecting HTTP requests to HTTPS.

The refactor provided in the first two patches is much simpler than
the one made in v1[0], as it only moves the header processing,
authentication handling, and further request handling into two
separate subroutines, as suggested[1]. Contrary to v1[0], the
behaviour remains *actually* identical.

The third patch builds upon the refactor of the first two patches
and implements the enhancement proposed in #4494[2], adding another
subroutine which ensures that clients are connecting via TLS/SSL
(if the server itself uses it).

The fourth patch fixes some whitespace formatting issues scattered
across the file and may be dropped if not desired.


Testing
-------

Each patch was tested separately on a VM which can not only be
accessed via its IP, but also via a FQDN that a DNS in my virtual
network is able to resolve. Using PVE (logging in, uploading ISOs,
accessing the VNC console, creating VMs, etc.) worked as it did before
each patch, regardless of whether the VM is accessed via its IP or
its FQDN.

The redirection from HTTP to HTTPS works consistently, irrespective
of which actions are performed.


Notes Regarding Security
------------------------

If a server uses TLS/SSL, such as pveproxy, every single request from
clients which have not initiated and successfully completed a TLS
handshake is answered with either a '301 Moved Permanently' or a
'308 Permanent Redirect'.

However:
> The user agent MAY use the Location field value for automatic
> redirection. The server's response content usually contains a
> short hypertext note with a hyperlink to the new URI(s).
  - RFC9110, 15.4.2. [3] and 15.4.9. [4]

The client doesn't have to follow the `Location` field that's
provided by the server. They may therefore still send HTTP requests,
which in this case will just be answered with more 301s or 308s.
If such an HTTP request contains something like an authentication
cookie, said cookie could be stolen (e.g. a man-in-the-middle attack).
However, our authentication cookie[5] uses the Secure Attribute specified
in RFC 6265[6], so any compliant browser will include that cookie
only over HTTPS connections.

This can be verified e.g. via Firefox:

1. Open the PVE web UI after applying patches 1-3
2. Open the Firefox Developer Tools using CTRL+SHIFT+I
3. Navigate to the "Network" tab
4. Inspect any secure request's header - you will find that the
   `Cookie` header field includes the `PVEAuthCookie`
5. Change `https://` of the domain of your PVE web UI to `http://`
   and hit Enter
6. Check the insecure request that you just forced Firefox to make
   in the network tab - the `Cookie` header might not even be included
   anymore, because the `PVEAuthCookie` isn't being used.

Therefore, as long as the user's browser is compliant, there shouldn't
be any security concerns in our case.


Further Considerations
----------------------

Despite all the above, we do not support HSTS[7] and SameSite Cookies[8]
yet, which we should implement eventually, since Firefox e.g. even
emits a warning regarding SameSite Cookies in the developer console:

> Cookie “PVEAuthCookie” does not have a proper “SameSite” attribute 
> value. Soon, cookies without the “SameSite” attribute or with an
> invalid value will be treated as “Lax”. This means that the cookie
> will no longer be sent in third-party contexts. If your application
> depends on this cookie being available in such contexts, please add
> the “SameSite=None“ attribute to it. To know more about the
> “SameSite“ attribute, read https://developer.mozilla.org/docs/Web/HTTP/Headers/Set-Cookie/SameSite


If required, I can implement both HSTS and SameSite cookies in a
follow-up series (if this one happens to get applied) or in a v3.



[0] https://lists.proxmox.com/pipermail/pve-devel/2023-February/055809.html
[1] https://lists.proxmox.com/pipermail/pve-devel/2023-February/055950.html
[2] https://bugzilla.proxmox.com/show_bug.cgi?id=4494
[3] https://httpwg.org/specs/rfc9110.html#status.301
[4] https://httpwg.org/specs/rfc9110.html#status.308
[5] https://git.proxmox.com/?p=pve-http-server.git;a=blob;f=src/PVE/APIServer/Formatter.pm;h=20455a02704e579c18f0455abb66b88eb04098f7;hb=HEAD#l95
[6] https://httpwg.org/specs/rfc6265.html#secure-attribute
[7] https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security
[8] https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite


Max Carrara (4):
  anyevent: move header processing into separate subroutine
  anyevent: move auth and request handling into separate subroutine
  fix #4494: anyevent: redirect HTTP to HTTPS
  anyevent: fix whitespace

 src/PVE/APIServer/AnyEvent.pm | 451 ++++++++++++++++++++--------------
 1 file changed, 271 insertions(+), 180 deletions(-)

-- 
2.30.2





^ permalink raw reply	[flat|nested] 6+ messages in thread

* [pve-devel] [PATCH v2 http-server 1/4] anyevent: move header processing into separate subroutine
  2023-03-03 17:29 [pve-devel] [PATCH v2 http-server 0/4] refactor HTTP request processing Max Carrara
@ 2023-03-03 17:29 ` Max Carrara
  2023-03-03 17:29 ` [pve-devel] [PATCH v2 http-server 2/4] anyevent: move auth and request handling " Max Carrara
                   ` (3 subsequent siblings)
  4 siblings, 0 replies; 6+ messages in thread
From: Max Carrara @ 2023-03-03 17:29 UTC (permalink / raw)
  To: pve-devel

The code concerned with processing the request's header in
`unshift_read_header` is moved into the new `process_header`
subroutine.

If `process_header` doesn't return early, it returns `1` for further
control flow purposes.

Some minor things are formatted or renamed for readability's sake.

Signed-off-by: Max Carrara <m.carrara@proxmox.com>
---
 src/PVE/APIServer/AnyEvent.pm | 93 ++++++++++++++++++++---------------
 1 file changed, 53 insertions(+), 40 deletions(-)

diff --git a/src/PVE/APIServer/AnyEvent.pm b/src/PVE/APIServer/AnyEvent.pm
index 3cd77fa..294c035 100644
--- a/src/PVE/APIServer/AnyEvent.pm
+++ b/src/PVE/APIServer/AnyEvent.pm
@@ -1320,51 +1320,12 @@ sub unshift_read_header {
 		$r->push_header($state->{key}, $state->{val})
 		    if $state->{key};
 
-		if (!$known_methods->{$method}) {
-		    my $resp = HTTP::Response->new(HTTP_NOT_IMPLEMENTED, "method '$method' not available");
-		    $self->response($reqstate, $resp);
-		    return;
-		}
-
-		my $conn = $r->header('Connection');
-		my $accept_enc = $r->header('Accept-Encoding');
-		$reqstate->{accept_gzip} = ($accept_enc && $accept_enc =~ m/gzip/) ? 1 : 0;
-
-		if ($conn) {
-		    $reqstate->{keep_alive} = 0 if $conn =~ m/close/oi;
-		} else {
-		    if ($reqstate->{proto}->{ver} < 1001) {
-			$reqstate->{keep_alive} = 0;
-		    }
-		}
-
-		my $te  = $r->header('Transfer-Encoding');
-		if ($te && lc($te) eq 'chunked') {
-		    # Handle chunked transfer encoding
-		    $self->error($reqstate, 501, "chunked transfer encoding not supported");
-		    return;
-		} elsif ($te) {
-		    $self->error($reqstate, 501, "Unknown transfer encoding '$te'");
-		    return;
-		}
-
-		my $pveclientip = $r->header('PVEClientIP');
 		my $base_uri = $self->{base_uri};
 
-		# fixme: how can we make PVEClientIP header trusted?
-		if ($self->{trusted_env} && $pveclientip) {
-		    $reqstate->{peer_host} = $pveclientip;
-		} else {
-		    $r->header('PVEClientIP', $reqstate->{peer_host});
-		}
-
 		my $len = $r->header('Content-Length');
-
 		my $host_header = $r->header('Host');
-		if (my $rpcenv = $self->{rpcenv}) {
-		    $rpcenv->set_request_host($host_header);
-		}
 
+		$self->process_header($reqstate) or return;
 		# header processing complete - authenticate now
 
 		my $auth = {};
@@ -1518,6 +1479,58 @@ sub unshift_read_header {
     });
 };
 
+sub process_header {
+    my ($self, $reqstate) = @_;
+
+    my $request = $reqstate->{request};
+
+    my $path = uri_unescape($request->uri->path());
+    my $method = $request->method();
+
+    if (!$known_methods->{$method}) {
+	my $resp = HTTP::Response->new(HTTP_NOT_IMPLEMENTED, "method '$method' not available");
+	$self->response($reqstate, $resp);
+	return;
+    }
+
+    my $conn = $request->header('Connection');
+    my $accept_enc = $request->header('Accept-Encoding');
+    $reqstate->{accept_gzip} = ($accept_enc && $accept_enc =~ m/gzip/) ? 1 : 0;
+
+    if ($conn) {
+	$reqstate->{keep_alive} = 0 if $conn =~ m/close/oi;
+    } else {
+	if ($reqstate->{proto}->{ver} < 1001) {
+	    $reqstate->{keep_alive} = 0;
+	}
+    }
+
+    my $te  = $request->header('Transfer-Encoding');
+    if ($te && lc($te) eq 'chunked') {
+	# Handle chunked transfer encoding
+	$self->error($reqstate, 501, "chunked transfer encoding not supported");
+	return;
+    } elsif ($te) {
+	$self->error($reqstate, 501, "Unknown transfer encoding '$te'");
+	return;
+    }
+
+    my $pveclientip = $request->header('PVEClientIP');
+
+    # fixme: how can we make PVEClientIP header trusted?
+    if ($self->{trusted_env} && $pveclientip) {
+	$reqstate->{peer_host} = $pveclientip;
+    } else {
+	$request->header('PVEClientIP', $reqstate->{peer_host});
+    }
+
+    if (my $rpcenv = $self->{rpcenv}) {
+	$rpcenv->set_request_host($request->header('Host'));
+    }
+
+    return 1;
+}
+
 sub push_request_header {
     my ($self, $reqstate) = @_;
 
-- 
2.30.2





^ permalink raw reply	[flat|nested] 6+ messages in thread

* [pve-devel] [PATCH v2 http-server 2/4] anyevent: move auth and request handling into separate subroutine
  2023-03-03 17:29 [pve-devel] [PATCH v2 http-server 0/4] refactor HTTP request processing Max Carrara
  2023-03-03 17:29 ` [pve-devel] [PATCH v2 http-server 1/4] anyevent: move header processing into separate subroutine Max Carrara
@ 2023-03-03 17:29 ` Max Carrara
  2023-03-03 17:29 ` [pve-devel] [PATCH v2 http-server 3/4] fix #4494: anyevent: redirect HTTP to HTTPS Max Carrara
                   ` (2 subsequent siblings)
  4 siblings, 0 replies; 6+ messages in thread
From: Max Carrara @ 2023-03-03 17:29 UTC (permalink / raw)
  To: pve-devel

The part responsible for authentication and subsequent request
handling is moved into the new `authenticate_and_handle_request`
subroutine.

If `authenticate_and_handle_request` doesn't return early, it returns
`1` for further control flow purposes.

Some minor things are formatted or renamed for readability's sake.

Signed-off-by: Max Carrara <m.carrara@proxmox.com>
---
 src/PVE/APIServer/AnyEvent.pm | 324 +++++++++++++++++++---------------
 1 file changed, 180 insertions(+), 144 deletions(-)

diff --git a/src/PVE/APIServer/AnyEvent.pm b/src/PVE/APIServer/AnyEvent.pm
index 294c035..636502b 100644
--- a/src/PVE/APIServer/AnyEvent.pm
+++ b/src/PVE/APIServer/AnyEvent.pm
@@ -1314,156 +1314,13 @@ sub unshift_read_header {
 	    my $r = $reqstate->{request};
 	    if ($line eq '') {
 
-		my $path = uri_unescape($r->uri->path());
-		my $method = $r->method();
-
 		$r->push_header($state->{key}, $state->{val})
 		    if $state->{key};
 
-		my $base_uri = $self->{base_uri};
-
-		my $len = $r->header('Content-Length');
-		my $host_header = $r->header('Host');
-
 		$self->process_header($reqstate) or return;
 		# header processing complete - authenticate now
+		$self->authenticate_and_handle_request($reqstate) or return;
 
-		my $auth = {};
-		if ($self->{spiceproxy}) {
-		    my $connect_str = $host_header;
-		    my ($vmid, $node, $port) = $self->verify_spice_connect_url($connect_str);
-		    if (!(defined($vmid) && $node && $port)) {
-			$self->error($reqstate, HTTP_UNAUTHORIZED, "invalid ticket");
-			return;
-		    }
-		    $self->handle_spice_proxy_request($reqstate, $connect_str, $vmid, $node, $port);
-		    return;
-		} elsif ($path =~ m/^\Q$base_uri\E/) {
-		    my $token = $r->header('CSRFPreventionToken');
-		    my $cookie = $r->header('Cookie');
-		    my $auth_header = $r->header('Authorization');
-
-		    # prefer actual cookie
-		    my $ticket = PVE::APIServer::Formatter::extract_auth_value($cookie, $self->{cookie_name});
-
-		    # fallback to cookie in 'Authorization' header
-		    $ticket = PVE::APIServer::Formatter::extract_auth_value($auth_header, $self->{cookie_name})
-			if !$ticket;
-
-		    # finally, fallback to API token if no ticket has been provided so far
-		    my $api_token;
-		    $api_token = PVE::APIServer::Formatter::extract_auth_value($auth_header, $self->{apitoken_name})
-			if !$ticket;
-
-		    my ($rel_uri, $format) = &$split_abs_uri($path, $self->{base_uri});
-		    if (!$format) {
-			$self->error($reqstate, HTTP_NOT_IMPLEMENTED, "no such uri");
-			return;
-		    }
-
-		    eval {
-			$auth = $self->auth_handler($method, $rel_uri, $ticket, $token, $api_token,
-						    $reqstate->{peer_host});
-		    };
-		    if (my $err = $@) {
-			# HACK: see Note 1
-			Net::SSLeay::ERR_clear_error();
-			# always delay unauthorized calls by 3 seconds
-			my $delay = 3;
-
-			if (ref($err) eq "PVE::Exception") {
-
-			    $err->{code} ||= HTTP_INTERNAL_SERVER_ERROR,
-			    my $resp = HTTP::Response->new($err->{code}, $err->{msg});
-			    $self->response($reqstate, $resp, undef, 0, $delay);
-
-			} elsif (my $formatter = PVE::APIServer::Formatter::get_login_formatter($format)) {
-			    my ($raw, $ct, $nocomp) =
-				$formatter->($path, $auth, $self->{formatter_config});
-			    my $resp;
-			    if (ref($raw) && (ref($raw) eq 'HTTP::Response')) {
-				$resp = $raw;
-			    } else {
-				$resp = HTTP::Response->new(HTTP_UNAUTHORIZED, "Login Required");
-				$resp->header("Content-Type" => $ct);
-				$resp->content($raw);
-			    }
-			    $self->response($reqstate, $resp, undef, $nocomp, $delay);
-			} else {
-			    my $resp = HTTP::Response->new(HTTP_UNAUTHORIZED, $err);
-			    $self->response($reqstate, $resp, undef, 0, $delay);
-			}
-			return;
-		    }
-		}
-
-		$reqstate->{log}->{userid} = $auth->{userid};
-
-		if ($len) {
-
-		    if (!($method eq 'PUT' || $method eq 'POST')) {
-			$self->error($reqstate, 501, "Unexpected content for method '$method'");
-			return;
-		    }
-
-		    my $ctype = $r->header('Content-Type');
-		    my ($ct, $boundary) = $ctype ? parse_content_type($ctype) : ();
-
-		    if ($auth->{isUpload} && !$self->{trusted_env}) {
-			die "upload 'Content-Type '$ctype' not implemented\n"
-			    if !($boundary && $ct && ($ct eq 'multipart/form-data'));
-
-			die "upload without content length header not supported" if !$len;
-
-			die "upload without content length header not supported" if !$len;
-
-			$self->dprint("start upload $path $ct $boundary");
-
-			my $tmpfilename = get_upload_filename();
-			my $outfh = IO::File->new($tmpfilename, O_RDWR|O_CREAT|O_EXCL, 0600) ||
-			    die "unable to create temporary upload file '$tmpfilename'";
-
-			$reqstate->{keep_alive} = 0;
-
-			my $boundlen = length($boundary) + 8; # \015?\012--$boundary--\015?\012
-
-			my $state = {
-			    size => $len,
-			    boundary => $boundary,
-			    ctx => Digest::MD5->new,
-			    boundlen =>  $boundlen,
-			    maxheader => 2048 + $boundlen, # should be large enough
-			    params => decode_urlencoded($r->url->query()),
-			    phase => 0,
-			    read => 0,
-			    post_size => 0,
-			    starttime => [gettimeofday],
-			    outfh => $outfh,
-			};
-			$reqstate->{tmpfilename} = $tmpfilename;
-			$reqstate->{hdl}->on_read(sub {
-			    $self->file_upload_multipart($reqstate, $auth, $method, $path, $state);
-			});
-			return;
-		    }
-
-		    if ($len > $limit_max_post) {
-			$self->error($reqstate, 501, "for data too large");
-			return;
-		    }
-
-		    if (!$ct || $ct eq 'application/x-www-form-urlencoded' || $ct eq 'application/json') {
-			$reqstate->{hdl}->unshift_read(chunk => $len, sub {
-			    my ($hdl, $data) = @_;
-			    $r->content($data);
-			    $self->handle_request($reqstate, $auth, $method, $path);
-		        });
-		    } else {
-			$self->error($reqstate, 506, "upload 'Content-Type '$ctype' not implemented");
-		    }
-		} else {
-		    $self->handle_request($reqstate, $auth, $method, $path);
-		}
 	    } elsif ($line =~ /^([^:\s]+)\s*:\s*(.*)/) {
 		$r->push_header($state->{key}, $state->{val}) if $state->{key};
 		($state->{key}, $state->{val}) = ($1, $2);
@@ -1531,6 +1388,185 @@ sub process_header {
     return 1;
 }
 
+sub authenticate_and_handle_request {
+    my ($self, $reqstate) = @_;
+
+    my $request = $reqstate->{request};
+    my $method = $request->method();
+
+    my $path = uri_unescape($request->uri->path());
+    my $base_uri = $self->{base_uri};
+
+    my $auth = {};
+
+    if ($self->{spiceproxy}) {
+	my $connect_str = $request->header('Host');
+	my ($vmid, $node, $port) = $self->verify_spice_connect_url($connect_str);
+
+	if (!(defined($vmid) && $node && $port)) {
+	    $self->error($reqstate, HTTP_UNAUTHORIZED, "invalid ticket");
+	    return;
+	}
+
+	$self->handle_spice_proxy_request($reqstate, $connect_str, $vmid, $node, $port);
+	return;
+
+    } elsif ($path =~ m/^\Q$base_uri\E/) {
+	my $token = $request->header('CSRFPreventionToken');
+	my $cookie = $request->header('Cookie');
+	my $auth_header = $request->header('Authorization');
+
+	# prefer actual cookie
+	my $ticket = PVE::APIServer::Formatter::extract_auth_value(
+	    $cookie,
+	    $self->{cookie_name}
+	);
+
+	# fallback to cookie in 'Authorization' header
+	if (!$ticket) {
+	    $ticket = PVE::APIServer::Formatter::extract_auth_value(
+		$auth_header,
+		$self->{cookie_name}
+	    );
+	}
+
+	# finally, fallback to API token if no ticket has been provided so far
+	my $api_token;
+	if (!$ticket) {
+	    $api_token = PVE::APIServer::Formatter::extract_auth_value(
+		$auth_header,
+		$self->{apitoken_name}
+	    );
+	}
+
+	my ($rel_uri, $format) = &$split_abs_uri($path, $self->{base_uri});
+	if (!$format) {
+	    $self->error($reqstate, HTTP_NOT_IMPLEMENTED, "no such uri");
+	    return;
+	}
+
+	eval {
+	    $auth = $self->auth_handler(
+		$method,
+		$rel_uri,
+		$ticket,
+		$token,
+		$api_token,
+		$reqstate->{peer_host}
+	    );
+	};
+	if (my $err = $@) {
+	    # HACK: see Note 1
+	    Net::SSLeay::ERR_clear_error();
+	    # always delay unauthorized calls by 3 seconds
+	    my $delay = 3;
+
+	    if (ref($err) eq "PVE::Exception") {
+
+		$err->{code} ||= HTTP_INTERNAL_SERVER_ERROR,
+		my $resp = HTTP::Response->new($err->{code}, $err->{msg});
+		$self->response($reqstate, $resp, undef, 0, $delay);
+
+	    } elsif (my $formatter = PVE::APIServer::Formatter::get_login_formatter($format)) {
+		my ($raw, $ct, $nocomp) =
+		    $formatter->($path, $auth, $self->{formatter_config});
+
+		my $resp;
+		if (ref($raw) && (ref($raw) eq 'HTTP::Response')) {
+		    $resp = $raw;
+
+		} else {
+		    $resp = HTTP::Response->new(HTTP_UNAUTHORIZED, "Login Required");
+		    $resp->header("Content-Type" => $ct);
+		    $resp->content($raw);
+		}
+
+		$self->response($reqstate, $resp, undef, $nocomp, $delay);
+
+	    } else {
+		my $resp = HTTP::Response->new(HTTP_UNAUTHORIZED, $err);
+		$self->response($reqstate, $resp, undef, 0, $delay);
+	    }
+
+	    return;
+	}
+    }
+
+    $reqstate->{log}->{userid} = $auth->{userid};
+    my $len = $request->header('Content-Length');
+
+    if ($len) {
+
+	if (!($method eq 'PUT' || $method eq 'POST')) {
+	    $self->error($reqstate, 501, "Unexpected content for method '$method'");
+	    return;
+	}
+
+	my $ctype = $request->header('Content-Type');
+	my ($ct, $boundary) = $ctype ? parse_content_type($ctype) : ();
+
+	if ($auth->{isUpload} && !$self->{trusted_env}) {
+	    die "upload 'Content-Type '$ctype' not implemented\n"
+		if !($boundary && $ct && ($ct eq 'multipart/form-data'));
+
+	    die "upload without content length header not supported" if !$len;
+
+	    die "upload without content length header not supported" if !$len;
+
+	    $self->dprint("start upload $path $ct $boundary");
+
+	    my $tmpfilename = get_upload_filename();
+	    my $outfh = IO::File->new($tmpfilename, O_RDWR|O_CREAT|O_EXCL, 0600) ||
+		die "unable to create temporary upload file '$tmpfilename'";
+
+	    $reqstate->{keep_alive} = 0;
+
+	    my $boundlen = length($boundary) + 8; # \015?\012--$boundary--\015?\012
+
+	    my $state = {
+		size => $len,
+		boundary => $boundary,
+		ctx => Digest::MD5->new,
+		boundlen =>  $boundlen,
+		maxheader => 2048 + $boundlen, # should be large enough
+		params => decode_urlencoded($request->url->query()),
+		phase => 0,
+		read => 0,
+		post_size => 0,
+		starttime => [gettimeofday],
+		outfh => $outfh,
+	    };
+	    $reqstate->{tmpfilename} = $tmpfilename;
+	    $reqstate->{hdl}->on_read(sub {
+		$self->file_upload_multipart($reqstate, $auth, $method, $path, $state);
+	    });
+
+	    return;
+	}
+
+	if ($len > $limit_max_post) {
+	    $self->error($reqstate, 501, "for data too large");
+	    return;
+	}
+
+	if (!$ct || $ct eq 'application/x-www-form-urlencoded' || $ct eq 'application/json') {
+	    $reqstate->{hdl}->unshift_read(chunk => $len, sub {
+		my ($hdl, $data) = @_;
+		$request->content($data);
+		$self->handle_request($reqstate, $auth, $method, $path);
+	    });
+
+	} else {
+	    $self->error($reqstate, 506, "upload 'Content-Type '$ctype' not implemented");
+	}
+
+    } else {
+	$self->handle_request($reqstate, $auth, $method, $path);
+    }
+
+    return 1;
+}
+
 sub push_request_header {
     my ($self, $reqstate) = @_;
 
-- 
2.30.2





^ permalink raw reply	[flat|nested] 6+ messages in thread

* [pve-devel] [PATCH v2 http-server 3/4] fix #4494: anyevent: redirect HTTP to HTTPS
  2023-03-03 17:29 [pve-devel] [PATCH v2 http-server 0/4] refactor HTTP request processing Max Carrara
  2023-03-03 17:29 ` [pve-devel] [PATCH v2 http-server 1/4] anyevent: move header processing into separate subroutine Max Carrara
  2023-03-03 17:29 ` [pve-devel] [PATCH v2 http-server 2/4] anyevent: move auth and request handling " Max Carrara
@ 2023-03-03 17:29 ` Max Carrara
  2023-03-03 17:29 ` [pve-devel] [PATCH v2 http-server 4/4] anyevent: fix whitespace Max Carrara
  2023-03-07 10:20 ` [pve-devel] applied series: [PATCH v2 http-server 0/4] refactor HTTP request processing Fabian Grünbichler
  4 siblings, 0 replies; 6+ messages in thread
From: Max Carrara @ 2023-03-03 17:29 UTC (permalink / raw)
  To: pve-devel

Allow HTTP connections up until the request's header has been
parsed and processed. If no TLS handshake has been completed
beforehand, the server now responds with either a
'301 Moved Permanently' or a '308 Permanent Redirect' as noted in the
MDN web docs[1].

This is done after the header was parsed; for the redirect to work,
the `Host` header field of the request is used to create the
`Location` field of the response. This makes redirections independent
of how the server is accessed (e.g. via IP, localhost, FQDN, ...)
possible.

Upon redirection the client is immediately disconnected; otherwise,
they would have to wait for the connection to time out until
they may reconnect via TLS again.

[1] https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/301

Signed-off-by: Max Carrara <m.carrara@proxmox.com>
---
 src/PVE/APIServer/AnyEvent.pm | 46 +++++++++++++++++++++++++++++++++--
 1 file changed, 44 insertions(+), 2 deletions(-)

diff --git a/src/PVE/APIServer/AnyEvent.pm b/src/PVE/APIServer/AnyEvent.pm
index 636502b..d1bba3c 100644
--- a/src/PVE/APIServer/AnyEvent.pm
+++ b/src/PVE/APIServer/AnyEvent.pm
@@ -1318,7 +1318,7 @@ sub unshift_read_header {
 		    if $state->{key};
 
 		$self->process_header($reqstate) or return;
-		# header processing complete - authenticate now
+		$self->ensure_tls_connection($reqstate) or return;
 		$self->authenticate_and_handle_request($reqstate) or return;
 
 	    } elsif ($line =~ /^([^:\s]+)\s*:\s*(.*)/) {
@@ -1388,6 +1388,43 @@ sub process_header {
     return 1;
 }
 
+sub ensure_tls_connection {
+    my ($self, $reqstate) = @_;
+
+    # Skip if server doesn't use TLS
+    if (!$self->{tls_ctx}) {
+	return 1;
+    }
+
+    # TLS session exists, so the handshake has succeeded
+    if ($reqstate->{hdl}->{tls}) {
+	return 1;
+    }
+
+    my $request = $reqstate->{request};
+    my $method = $request->method();
+
+    my $h_host = $reqstate->{request}->header('Host');
+
+    die "Header field 'Host' not found in request\n"
+	if !$h_host;
+
+    my $secure_host = "https://" . ($h_host =~ s/^http(s)?:\/\///r);
+
+    my $header = HTTP::Headers->new('Location' => $secure_host . $request->uri());
+
+    if ($method eq 'GET' || $method eq 'HEAD') {
+	$self->error($reqstate, 301, 'Moved Permanently', $header);
+    } else {
+	$self->error($reqstate, 308, 'Permanent Redirect', $header);
+    }
+
+    # disconnect the client so they may immediately connect again via HTTPS
+    $self->client_do_disconnect($reqstate);
+
+    return;
+}
+
 sub authenticate_and_handle_request {
     my ($self, $reqstate) = @_;
 
@@ -1795,11 +1832,16 @@ sub accept_connections {
 		    };
 		    if (my $err = $@) { syslog('err', "$err"); }
 		},
-		($self->{tls_ctx} ? (tls => "accept", tls_ctx => $self->{tls_ctx}) : ()));
+	    );
 	    $handle_creation = 0;
 
 	    $self->dprint("ACCEPT FH" .  $clientfh->fileno() . " CONN$self->{conn_count}");
 
+	    if ($self->{tls_ctx}) {
+		$self->dprint("Setting TLS to autostart");
+		$reqstate->{hdl}->unshift_read(tls_autostart => $self->{tls_ctx}, "accept");
+	    }
+
 	    $self->push_request_header($reqstate);
 	}
     };
-- 
2.30.2





^ permalink raw reply	[flat|nested] 6+ messages in thread

* [pve-devel] [PATCH v2 http-server 4/4] anyevent: fix whitespace
  2023-03-03 17:29 [pve-devel] [PATCH v2 http-server 0/4] refactor HTTP request processing Max Carrara
                   ` (2 preceding siblings ...)
  2023-03-03 17:29 ` [pve-devel] [PATCH v2 http-server 3/4] fix #4494: anyevent: redirect HTTP to HTTPS Max Carrara
@ 2023-03-03 17:29 ` Max Carrara
  2023-03-07 10:20 ` [pve-devel] applied series: [PATCH v2 http-server 0/4] refactor HTTP request processing Fabian Grünbichler
  4 siblings, 0 replies; 6+ messages in thread
From: Max Carrara @ 2023-03-03 17:29 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Max Carrara <m.carrara@proxmox.com>
---
 src/PVE/APIServer/AnyEvent.pm | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)

NOTE: This patch can be dropped if not needed.

diff --git a/src/PVE/APIServer/AnyEvent.pm b/src/PVE/APIServer/AnyEvent.pm
index d1bba3c..0595e08 100644
--- a/src/PVE/APIServer/AnyEvent.pm
+++ b/src/PVE/APIServer/AnyEvent.pm
@@ -650,7 +650,7 @@ sub websocket_proxy {
 			    $proxyhdl->{block_disconnect} = 1 if length $proxyhdl->{wbuf};
 
 			    $proxyhdl->push_shutdown();
-		        }
+			}
 			$hdl->push_shutdown();
 		    } elsif ($opcode == 9) {
 			# ping received, schedule pong
@@ -961,7 +961,7 @@ sub handle_api2_request {
 
 	my $download = $res->{download};
 	$download //= $res->{data}->{download}
-            if defined($res->{data}) && ref($res->{data}) eq 'HASH';
+	    if defined($res->{data}) && ref($res->{data}) eq 'HASH';
 	if (defined($download)) {
 	    send_file_start($self, $reqstate, $download);
 	    return;
@@ -998,12 +998,12 @@ sub handle_spice_proxy_request {
 	my $clientip = $reqstate->{peer_host};
 	my $r = $reqstate->{request};
 
-        my $remip;
+	my $remip;
 
-        if ($node ne 'localhost' && PVE::INotify::nodename() !~ m/^$node$/i) {
-            $remip = $self->remote_node_ip($node);
+	if ($node ne 'localhost' && PVE::INotify::nodename() !~ m/^$node$/i) {
+	    $remip = $self->remote_node_ip($node);
 	    $self->dprint("REMOTE CONNECT $vmid, $remip, $connect_str");
-        } else {
+	} else {
 	    $self->dprint("CONNECT $vmid, $node, $spiceport");
 	}
 
@@ -1106,7 +1106,7 @@ sub handle_spice_proxy_request {
 			$reqstate->{hdl}->push_write($line);
 			$self->client_do_disconnect($reqstate);
 		    }
-                });
+		});
 	    } else {
 		&$startproxy();
 	    }
-- 
2.30.2





^ permalink raw reply	[flat|nested] 6+ messages in thread

* [pve-devel] applied series: [PATCH v2 http-server 0/4] refactor HTTP request processing
  2023-03-03 17:29 [pve-devel] [PATCH v2 http-server 0/4] refactor HTTP request processing Max Carrara
                   ` (3 preceding siblings ...)
  2023-03-03 17:29 ` [pve-devel] [PATCH v2 http-server 4/4] anyevent: fix whitespace Max Carrara
@ 2023-03-07 10:20 ` Fabian Grünbichler
  4 siblings, 0 replies; 6+ messages in thread
From: Fabian Grünbichler @ 2023-03-07 10:20 UTC (permalink / raw)
  To: Proxmox VE development discussion

with some style follow-ups and slight rewording of the commit titles.

On March 3, 2023 6:29 pm, Max Carrara wrote:
> This patch series refactors the request processing algorithm and
> also provides a patch for redirecting HTTP requests to HTTPS.
> 
> The refactor provided in the first two patches is much simpler than
> the one made in v1[0], as it only moves the header processing,
> authentication handling, and further request handling into two
> separate subroutines, as suggested[1]. Contrary to v1[0], the
> behaviour remains *actually* identical.
> 
> The third patch builds upon the refactor of the first two patches
> and implements the enhancement proposed in #4494[2], adding another
> subroutine which ensures that clients are connecting via TLS/SSL
> (if the server itself uses it).
> 
> The fourth patch fixes some whitespace formatting issues scattered
> across the file and may be dropped if not desired.
> 
> 
> Testing
> -------
> 
> Each patch was tested separately on a VM which can not only be
> accessed via its IP, but also via a FQDN that a DNS in my virtual
> network is able to resolve. Using PVE (logging in, uploading ISOs,
> accessing the VNC console, creating VMs, etc.) worked as it did before
> each patch, regardless of whether the VM is accessed via its IP or
> its FQDN.
> 
> The redirection from HTTP to HTTPS works consistently, irrespective
> of which actions are performed.
> 
> 
> Notes Regarding Security
> ------------------------
> 
> If a server uses TLS/SSL, such as pveproxy, every single request from
> clients which have not initiated and successfully completed a TLS
> handshake is answered with either a '301 Moved Permanently' or a
> '308 Permanent Redirect'.
> 
> However:
>> The user agent MAY use the Location field value for automatic
>> redirection. The server's response content usually contains a
>> short hypertext note with a hyperlink to the new URI(s).
>   - RFC9110, 15.4.2. [3] and 15.4.9. [4]
> 
> The client doesn't have to follow the `Location` field that's
> provided by the server. They may therefore still send HTTP requests,
> which in this case will just be answered with more 301s or 308s.
> If such an HTTP request contains something like an authentication
> cookie, said cookie could be stolen (e.g. a man-in-the-middle attack).
> However, our authentication cookie[5] uses the Secure Attribute specified
> in RFC 6265[6], so any compliant browser will include that cookie
> only over HTTPS connections.
> 
> This can be verified e.g. via Firefox:
> 
> 1. Open the PVE web UI after applying patches 1-3
> 2. Open the Firefox Developer Tools using CTRL+SHIFT+I
> 3. Navigate to the "Network" tab
> 4. Inspect any secure request's header - you will find that the
>    `Cookie` header field includes the `PVEAuthCookie`
> 5. Change `https://` of the domain of your PVE web UI to `http://`
>    and hit Enter
> 6. Check the insecure request that you just forced Firefox to make
>    in the network tab - the `Cookie` header might not even be included
>    anymore, because the `PVEAuthCookie` isn't being used.
> 
> Therefore, as long as the user's browser is compliant, there shouldn't
> be any security concerns in our case.
> 
> 
> Further Considerations
> ----------------------
> 
> Despite all the above, we do not support HSTS[7] and SameSite Cookies[8]
> yet, which we should implement eventually, since Firefox e.g. even
> emits a warning regarding SameSite Cookies in the developer console:
> 
>> Cookie “PVEAuthCookie” does not have a proper “SameSite” attribute 
>> value. Soon, cookies without the “SameSite” attribute or with an
>> invalid value will be treated as “Lax”. This means that the cookie
>> will no longer be sent in third-party contexts. If your application
>> depends on this cookie being available in such contexts, please add
>> the “SameSite=None“ attribute to it. To know more about the
>> “SameSite“ attribute, read https://developer.mozilla.org/docs/Web/HTTP/Headers/Set-Cookie/SameSite
> 
> 
> If required, I can implement both HSTS and SameSite cookies in a
> follow-up series (if this one happens to get applied) or in a v3.
> 
> 
> 
> [0] https://lists.proxmox.com/pipermail/pve-devel/2023-February/055809.html
> [1] https://lists.proxmox.com/pipermail/pve-devel/2023-February/055950.html
> [2] https://bugzilla.proxmox.com/show_bug.cgi?id=4494
> [3] https://httpwg.org/specs/rfc9110.html#status.301
> [4] https://httpwg.org/specs/rfc9110.html#status.308
> [5] https://git.proxmox.com/?p=pve-http-server.git;a=blob;f=src/PVE/APIServer/Formatter.pm;h=20455a02704e579c18f0455abb66b88eb04098f7;hb=HEAD#l95
> [6] https://httpwg.org/specs/rfc6265.html#secure-attribute
> [7] https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security
> [8] https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
> 
> 
> Max Carrara (4):
>   anyevent: move header processing into separate subroutine
>   anyevent: move auth and request handling into separate subroutine
>   fix #4494: anyevent: redirect HTTP to HTTPS
>   anyevent: fix whitespace
> 
>  src/PVE/APIServer/AnyEvent.pm | 451 ++++++++++++++++++++--------------
>  1 file changed, 271 insertions(+), 180 deletions(-)
> 
> -- 
> 2.30.2
> 
> 
> 
> _______________________________________________
> pve-devel mailing list
> pve-devel@lists.proxmox.com
> https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
> 




^ permalink raw reply	[flat|nested] 6+ messages in thread

end of thread, other threads:[~2023-03-07 10:20 UTC | newest]

Thread overview: 6+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2023-03-03 17:29 [pve-devel] [PATCH v2 http-server 0/4] refactor HTTP request processing Max Carrara
2023-03-03 17:29 ` [pve-devel] [PATCH v2 http-server 1/4] anyevent: move header processing into separate subroutine Max Carrara
2023-03-03 17:29 ` [pve-devel] [PATCH v2 http-server 2/4] anyevent: move auth and request handling " Max Carrara
2023-03-03 17:29 ` [pve-devel] [PATCH v2 http-server 3/4] fix #4494: anyevent: redirect HTTP to HTTPS Max Carrara
2023-03-03 17:29 ` [pve-devel] [PATCH v2 http-server 4/4] anyevent: fix whitespace Max Carrara
2023-03-07 10:20 ` [pve-devel] applied series: [PATCH v2 http-server 0/4] refactor HTTP request processing Fabian Grünbichler

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal