Repository: aio-libs/aiohttp

Issue:
Client-sent Host header can include port as "None"
## Long story short

<!-- Please describe your problem and why the fix is important. -->
When the client forms the Host header, it is possible for it to include the port as "None".

This came up for me when using `aiodocker` to try to connect to the Docker API container attach websocket endpoint, which used a URL of the form "unix://localhost/..." and let to a "Host" header of "localhost:None", triggering a 400 error from docker with a message like:

    parse ws://localhost:None/v1.35/containers/CONTAINER_ID/attach/ws?stdin=1&stdout=0&stderr=0&stream=1: invalid port ":None" after host

## Expected behaviour

<!-- What is the behaviour you expect? -->
At least, not to send "None" as a port number for the Host header.

According to [RFC 7230 Section 5.4](https://tools.ietf.org/html/rfc7230#section-5.4):
> If the authority component is missing or undefined for the target URI, then a client MUST send a Host header field with an empty field-value.

So perhaps it should be possible for the `aiohttp` client to get and recognize such a URI and send a blank Host header field.

At the moment though, I think, it doesn't seem possible to send such an "authority"-less URL to `ws_connect` nor does there currently exist a conditional path for the Host header construction to make a blank Host header field: [client_reqrep.py lines 314-320](https://github.com/aio-libs/aiohttp/blob/21b062199ff8da1a8d48b262f3d75fb616cc275f/aiohttp/client_reqrep.py#L314-L320)

## Actual behaviour

<!-- What's actually happening? -->
The Host header includes the string "None" as the port when making requests whose URL registers as not `is_default_port()` but has no port defined, e.g. `unix://localhost/path/to/endpoint`.

## Steps to reproduce

<!-- Please describe steps to reproduce the issue.
     If you have a script that does that please include it here within
     markdown code markup -->
This occurred for me while using the `aiodocker` package to attach to `stdin` of a running container.

A sort of silly example server/client that displays the behavior is as follows:
```python
from aiohttp import web
from asyncio import sleep, create_task
import aiohttp

SOCK_PATH = '/tmp/example.sock'

async def hello(request):
  print('Host: '+request.headers['Host'])
  return web.Response()

async def make_request():
  await sleep(1) # Let the server become available.
  conn = aiohttp.UnixConnector(path=SOCK_PATH)
  async with aiohttp.ClientSession(connector=conn) as session:
    async with session.get('unix://localhost/'):
      pass # Produces a Host of "localhost:None"
    async with session.get('http://localhost/'):
      pass # Produces a Host of "localhost"

async def schedule_request(_):
  create_task(make_request())

app = web.Application()
app.add_routes([web.get('/', hello)])
app.on_startup.append(schedule_request)

web.run_app(app, path=SOCK_PATH)
```
Output:
```
======== Running on http://unix:/tmp/example.sock: ========
(Press CTRL+C to quit)
Host: localhost:None
Host: localhost
```

## Your environment

<!-- Describe the environment you have that lead to your issue.
     This includes aiohttp version, OS, proxy server and other bits that
     are related to your case. 
     
     IMPORTANT: aiohttp is both server framework and client library.
     For getting rid of confusing please put 'server', 'client' or 'both'
     word here.
     -->
* Debian 9
* Python 3.7.4
* aiohttp 3.5.4
* aiodocker 0.14.0
* Docker 19.03.2-ce

BTW the specific thing that I think make this appear where it didn't before was a security update to Go that make URL parsing more strict: https://github.com/golang/go/issues?q=milestone%3AGo1.12.8
Client-sent Host header can include port as "None"
## Long story short

<!-- Please describe your problem and why the fix is important. -->
When the client forms the Host header, it is possible for it to include the port as "None".

This came up for me when using `aiodocker` to try to connect to the Docker API container attach websocket endpoint, which used a URL of the form "unix://localhost/..." and let to a "Host" header of "localhost:None", triggering a 400 error from docker with a message like:

    parse ws://localhost:None/v1.35/containers/CONTAINER_ID/attach/ws?stdin=1&stdout=0&stderr=0&stream=1: invalid port ":None" after host

## Expected behaviour

<!-- What is the behaviour you expect? -->
At least, not to send "None" as a port number for the Host header.

According to [RFC 7230 Section 5.4](https://tools.ietf.org/html/rfc7230#section-5.4):
> If the authority component is missing or undefined for the target URI, then a client MUST send a Host header field with an empty field-value.

So perhaps it should be possible for the `aiohttp` client to get and recognize such a URI and send a blank Host header field.

At the moment though, I think, it doesn't seem possible to send such an "authority"-less URL to `ws_connect` nor does there currently exist a conditional path for the Host header construction to make a blank Host header field: [client_reqrep.py lines 314-320](https://github.com/aio-libs/aiohttp/blob/21b062199ff8da1a8d48b262f3d75fb616cc275f/aiohttp/client_reqrep.py#L314-L320)

## Actual behaviour

<!-- What's actually happening? -->
The Host header includes the string "None" as the port when making requests whose URL registers as not `is_default_port()` but has no port defined, e.g. `unix://localhost/path/to/endpoint`.

## Steps to reproduce

<!-- Please describe steps to reproduce the issue.
     If you have a script that does that please include it here within
     markdown code markup -->
This occurred for me while using the `aiodocker` package to attach to `stdin` of a running container.

A sort of silly example server/client that displays the behavior is as follows:
```python
from aiohttp import web
from asyncio import sleep, create_task
import aiohttp

SOCK_PATH = '/tmp/example.sock'

async def hello(request):
  print('Host: '+request.headers['Host'])
  return web.Response()

async def make_request():
  await sleep(1) # Let the server become available.
  conn = aiohttp.UnixConnector(path=SOCK_PATH)
  async with aiohttp.ClientSession(connector=conn) as session:
    async with session.get('unix://localhost/'):
      pass # Produces a Host of "localhost:None"
    async with session.get('http://localhost/'):
      pass # Produces a Host of "localhost"

async def schedule_request(_):
  create_task(make_request())

app = web.Application()
app.add_routes([web.get('/', hello)])
app.on_startup.append(schedule_request)

web.run_app(app, path=SOCK_PATH)
```
Output:
```
======== Running on http://unix:/tmp/example.sock: ========
(Press CTRL+C to quit)
Host: localhost:None
Host: localhost
```

## Your environment

<!-- Describe the environment you have that lead to your issue.
     This includes aiohttp version, OS, proxy server and other bits that
     are related to your case. 
     
     IMPORTANT: aiohttp is both server framework and client library.
     For getting rid of confusing please put 'server', 'client' or 'both'
     word here.
     -->
* Debian 9
* Python 3.7.4
* aiohttp 3.5.4
* aiodocker 0.14.0
* Docker 19.03.2-ce

BTW the specific thing that I think make this appear where it didn't before was a security update to Go that make URL parsing more strict: https://github.com/golang/go/issues?q=milestone%3AGo1.12.8


Discussion:

Resolving patch:
diff --git a/aiohttp/client_reqrep.py b/aiohttp/client_reqrep.py
--- a/aiohttp/client_reqrep.py
+++ b/aiohttp/client_reqrep.py
@@ -315,7 +315,7 @@ def update_headers(self, headers: Optional[LooseHeaders]) -> None:
         netloc = cast(str, self.url.raw_host)
         if helpers.is_ipv6_address(netloc):
             netloc = '[{}]'.format(netloc)
-        if not self.url.is_default_port():
+        if self.url.port is not None and not self.url.is_default_port():
             netloc += ':' + str(self.url.port)
         self.headers[hdrs.HOST] = netloc
 


Test patch:
diff --git a/tests/test_client_request.py b/tests/test_client_request.py
--- a/tests/test_client_request.py
+++ b/tests/test_client_request.py
@@ -199,6 +199,11 @@ def test_host_port_nondefault_wss(make_request) -> None:
     assert req.is_ssl()
 
 
+def test_host_port_none_port(make_request) -> None:
+    req = make_request('get', 'unix://localhost/path')
+    assert req.headers['Host'] == 'localhost'
+
+
 def test_host_port_err(make_request) -> None:
     with pytest.raises(ValueError):
         make_request('get', 'http://python.org:123e/')