Repository: aio-libs/aiohttp

Issue:
Client: ClientSession will not send secure cookie to localhost on unsecure connection.
A ClientSession with an unsafe cookiejar will not send a secure cookie to a localhost endpoint, when that connection is over http (i,e. unsecure).

According to [Mozilla documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies):

> A cookie with the Secure attribute is sent to the server only with an encrypted request over the HTTPS protocol, never with unsecured HTTP (except on localhost), and therefore can't easily be accessed by a man-in-the-middle attacker. Insecure sites (with http: in the URL) can't set cookies with the Secure attribute

Note the **(except on localhost)** clause.

💡 **To Reproduce**
The following example sets up a test (`TestIntegration::test_cookie_is_sent_server`) which fails.  Supplementary tests are included to demonstrate the setup operates as expected.  Scroll to the end of the codeblock to see that particular test.

```py
#! /usr/bin/env pytest
# -*- coding: utf-8 -*-

"""Test module."""

import base64
import http
from typing import cast

from aiohttp import ClientSession
from aiohttp import web
from aiohttp.cookiejar import CookieJar
from aiohttp.test_utils import TestClient

from aiohttp_session import get_session, setup
from aiohttp_session.cookie_storage import EncryptedCookieStorage

from cryptography import fernet

import pytest


fernet_key = fernet.Fernet.generate_key()


async def index_get_handler(request: web.Request) -> web.Response:
    session = await get_session(request)
    secret_key = session.get('session_id') or 'not set'
    key = 'key: {}'.format(secret_key)

    response = web.Response(
        status=http.HTTPStatus(200),
        headers={'Content-Type': 'text/plain; charset=UTF-8',
                 },
        body=key,
    )

    return response


async def login_post_handler(request: web.Request) -> web.Response:
    session = await get_session(request)
    session['session_id'] = 'some secret data'

    response = web.Response(
        status=http.HTTPStatus(303),
        headers={'Content-Type': 'text/plain; charset=UTF-8',
                 'Location': '/',
                 },
    )

    return response


@pytest.fixture
def application_instance() -> web.Application:
    """Generate the application instance."""
    application = web.Application()

    application.add_routes(
        [
            web.get('/', index_get_handler, name='index'),
            web.post('/login', login_post_handler, name='login'),
        ]
    )

    setup(
        app=application,
        storage=EncryptedCookieStorage(
            secret_key=base64.urlsafe_b64decode(fernet_key),
            cookie_name='session_data',
            secure=True,
        ),
    )

    return application


# Tests

class TestIndexHandlerGetMethod:
    async def test_index_handler_returns_default_key_text(
        self,
        aiohttp_client,
        loop,
        application_instance,
    ):
        """index handler returns default text when session cookie not present."""
        jar = CookieJar(unsafe=True)
        client: TestClient = await aiohttp_client(
            application_instance,
            cookie_jar=jar,
        )
        url = 'http://127.0.0.1:{}/'.format(client.port)

        async with client.session as session:
            cast(ClientSession, session)

            response = await session.get(
                url=url,
                allow_redirects=False,
            )
            body = await response.text()
        assert 'key: not set' == body


    async def test_index_page_returns_no_session_cookie(
        self,
        aiohttp_client,
        loop,
        application_instance,
    ):
        """index handler does not set a session cookie."""
        jar = CookieJar(unsafe=True)
        client: TestClient = await aiohttp_client(
            application_instance,
            cookie_jar=jar,
        )
        url = 'http://127.0.0.1:{}/'.format(client.port)

        async with client.session as session:
            cast(ClientSession, session)

            response = await session.get(
                url=url,
                allow_redirects=False,
            )

            assert response.cookies.get('session_data') is None


    async def test_index_page_returns_200_status(
        self,
        aiohttp_client,
        loop,
        application_instance,
    ):
        """index handler returns 200 status."""
        jar = CookieJar(unsafe=True)
        client: TestClient = await aiohttp_client(
            application_instance,
            cookie_jar=jar,
        )
        url = 'http://127.0.0.1:{}/'.format(client.port)

        async with client.session as session:
            cast(ClientSession, session)

            response = await session.get(
                url=url,
                allow_redirects=False,
            )

            assert response.status == 200


class TestLoginHandlerPostMethod:
    async def test_handler_sets_cookie(
        self,
        aiohttp_client,
        loop,
        application_instance,
    ):
        """login handler sets session cookie."""
        jar = CookieJar(unsafe=True)
        client: TestClient = await aiohttp_client(
            application_instance,
            cookie_jar=jar,
        )
        url = 'http://127.0.0.1:{}/login'.format(client.port)

        async with client.session as session:
            cast(ClientSession, session)

            response = await session.post(
                url=url,
                allow_redirects=False,
            )
            assert response.cookies.get('session_data') is not None

    async def test_handler_redirects_to_index(
        self,
        aiohttp_client,
        loop,
        application_instance,
    ):
        """login handler redirects to the index page."""
        jar = CookieJar(unsafe=True)
        client: TestClient = await aiohttp_client(
            application_instance,
            cookie_jar=jar,
        )
        url = 'http://127.0.0.1:{}/login'.format(client.port)

        async with client.session as session:
            cast(ClientSession, session)

            response = await session.post(
                url=url,
                allow_redirects=False,
            )

            assert response.status == 303
            assert response.headers.get('location') == '/'


class TestIntegration:

    async def test_cookie_is_sent_server(
            self,
            aiohttp_client,
            loop,
            application_instance,
        ):
        """A session cookie sent to index handler returns the session secret in response body.

        This test fails as the cookie storate filters away the secure cookied when the request
        asked to send cookies over an unsecure connection."""
        jar = CookieJar(unsafe=True)
        client: TestClient = await aiohttp_client(
            application_instance,
            cookie_jar=jar,
        )

        async with client.session as session:
            cast(ClientSession, session)

            assert session.cookie_jar is jar

            login_post_url = 'http://127.0.0.1:{}/login'.format(client.port)
            response = await session.post(
                url=login_post_url,
            )

            assert response.status == 200
            body = await response.text()
            assert body is not None
            assert body == 'key: some secret data'
```

Requirements:

```
aiohttp           == 3.7.4
aiohttp-session   == 2.9.0
cryptography      == 3.4.6
multidict         == 5.1.0
packaging         == 20.9
py                == 1.10.0
setuptools        == 53.0.0
yarl              == 1.6.3
pytest            == 6.2.2
pytest-aiohttp    ==  0.3.0
```

💡 **Expected behavior**
Secure cookies should be sent to localhost/ 127.0.0.1 on unsecure connection (http)

📋 **Your version of the Python**
```console
python 3.8.5
```

📋 **Your version of the aiohttp/yarl/multidict distributions**
```console
aiohttp: 3.7.4
multidict: 5.1.0
yarl: 1.6.3

```

📋 **Additional context**
This is a client issue.

Proposed Solution: 

change this [line](https://github.com/aio-libs/aiohttp/blob/e74d40cffd1cb352639466e6e3cd830abd99d442/aiohttp/cookiejar.py#L253) of `aiohttp/cookiejar.py`
from 
```py
if is_not_secure and cookie["secure"]:
```
to

```py
if is_not_secure and cookie["secure"] and domain not in ['localhost', '127.0.0.1']:
```
or provide a way to influence the `is_not_secure` variable in order to proceed to sending the cookie, whether the connection is secure or not.  The latter would allow for the ipv4 and ipv6 loopbacks to be affected without exceptional cases (as was the case in the former proposal), and would also provide a way for hosts in `/etc/hosts` to be included in the unsecured requests.

Further rationale:

[This interpretation](https://security.stackexchange.com/a/140944) shows the [RFC section](https://tools.ietf.org/html/rfc6265#section-4.1.2.5) that states:
> The Secure attribute limits the scope of the cookie to "secure"
   channels (where "secure" is defined by the user agent).  When a
   cookie has the Secure attribute, the user agent will include the
   cookie in an HTTP request only if the request is transmitted over a
   secure channel (typically HTTP over Transport Layer Security (TLS)
   [RFC2818]).
 >  Although seemingly useful for protecting cookies from active network
   attackers, the Secure attribute protects only the cookie's
   confidentiality

I, the user agent, consider the sending and receipt of a request on a single machine, not across a network, to be secure.  Particularly when performing unit testing.
 



Discussion:
We are running into the same issue during testing. Another solution would be to allow secure cookies over an insecure connection when the `unsafe` parameter is set. Or split out that check into a class method, so it is easier to replace that part by creating a TestCookieJar.
@DanielDewberry could you send a pull request with those failing tests? Let's incorporate them first and then talk about the possible fix.

@Marco-Kaulea this may be a good solution, I need to think about it. We could talk about this in the PR with tests that would demonstrate how such interfaces could be used. I expect that we can follow TDD in this case and merge in failing (but agreed on) tests marked as xfail. And then, somebody could implement what those tests expect.
@webknjaz Would you like the PR to a new or existing branch?
Just a standalone PR with failing tests against master would be fine. We can then mark the tests as xfail per https://pganssle-talks.github.io/xfail-lightning/ and merge them (that would be followed by a backport PR to the 3.8 branch but you needn't worry about this right now).

Once the tests are in, it'll be clearly documented what behavior is expected going forward as code. The next step would be to produce a separate PR making those tests pass by effectively changing the behavior.

Of course, these changes could be a single PR but it's usually easier to agree upon and merge smaller chunks rather than a big set of changes because in big PRs there's usually some subset of patches that could be merged in right away but they would still be stuck in that PR until all other things are fixed which causes longer review/reiteration times.
@webknjaz @Marco-Kaulea I have submitted a [PR](https://github.com/aio-libs/aiohttp/pull/5652) as requested.
The test that I have added does not resemble the source code previously submitted on this thread.

It is now:
- far simpler
- not dependent upon external dependencies outside those already used in the aiohttp test suite
- focused directly on the CookieJar, not the ClientSession which is where the behaviour lies

Here is the revised test:

```python3
#! /usr/bin/env python3
# -*- coding: utf-8 -*-

from http.cookies import SimpleCookie

from aiohttp.cookiejar import CookieJar

import pytest

from yarl import URL


async def test_secure_cookie_not_filtered_from_unsafe_cookiejar_when_given_unsecured_endpoint() -> None:
    """Secure SimpleCookie should not be filtered from unsafe CookieJar when given an unsecured endpoint.

    There are times when sending a secure cookie to an unsecured endpoint is desireable.  Such an
    occasion is during testing. RFC 6265 section-4.1.2.5 states that this behaviour is a decision
    based on the trust of a network by the user agent.
    """
    endpoint = 'http://127.0.0.1/'

    secure_cookie = SimpleCookie(
        "cookie-key=cookie-value; HttpOnly; Path=/; Secure",
    )

    jar = CookieJar(unsafe=True)

    # Confirm the jar is empty
    assert len(jar) == 0

    jar.update_cookies(
        secure_cookie,
        URL(endpoint),
    )

    # Confirm the jar contains the cookie
    assert len(jar) == 1

    filtered_cookies = jar.filter_cookies(request_url=endpoint)

    # Confirm the filtered results contain the cookie
    assert len(filtered_cookies) == 1
```

Based on that, it looks like you want some way to indicate you are on a safe/secure network, and when that value is set, the secure cookie would not get filtered out.
Or rather, a way to indicate the destination is within the same secure network.
@Dreamsorcerer The decision to send a secure cookie to the connected host should be made by the user agent, independent of which network they are on.  `CookieJar`/ `filter_cookies` should provide an option to include secure cookies from unsecured hosts in the results.

The `CookieJar` interface could be extended like so:
```python
class CookieJar(AbstractCookieJar):
    def __init__(
        self, *, 
        unsafe: bool = False, 
        quote_cookie: bool = True
        include_secure_cookie_over_unsecured_connection: bool = False,   # new kwarg        
    ) -> None:
    self._include_secure_cookie_over_unsecured_connection = include_secure_cookie_over_unsecured_connection
```

Then `filter_cookies` would include this in the security check:
```python
    def filter_cookies(
        self, request_url: URL = URL()
    ) -> Union["BaseCookie[str]", "SimpleCookie[str]"]:
    
    # lines leading up to 253
            if is_not_secure and \
               cookie["secure"] and \
               not self._include_secure_cookie_over_unsecured_connection:
```
(I like verbose names but appreciate that's a long one).

One [suggestion](https://github.com/aio-libs/aiohttp/issues/5571#issuecomment-822270493) was to add a `TestCookieJar` class with the desired functionality, but this would mean the `CookieJar` would continue to reject cookies during user testing on the LAN and the issue I raised would persist.
IMO it would be more flexible to use a predicate instead of boolean flag.
The first check in the loop inside `filter_cookies` would be against this predicate.

Resolving patch:
diff --git a/aiohttp/cookiejar.py b/aiohttp/cookiejar.py
--- a/aiohttp/cookiejar.py
+++ b/aiohttp/cookiejar.py
@@ -1,4 +1,5 @@
 import asyncio
+import contextlib
 import datetime
 import os  # noqa
 import pathlib
@@ -12,6 +13,7 @@
     Dict,
     Iterable,
     Iterator,
+    List,
     Mapping,
     Optional,
     Set,
@@ -24,7 +26,7 @@
 
 from .abc import AbstractCookieJar, ClearCookiePredicate
 from .helpers import is_ip_address, next_whole_second
-from .typedefs import LooseCookies, PathLike
+from .typedefs import LooseCookies, PathLike, StrOrURL
 
 __all__ = ("CookieJar", "DummyCookieJar")
 
@@ -55,7 +57,13 @@ class CookieJar(AbstractCookieJar):
 
     MAX_32BIT_TIME = datetime.datetime.utcfromtimestamp(2 ** 31 - 1)
 
-    def __init__(self, *, unsafe: bool = False, quote_cookie: bool = True) -> None:
+    def __init__(
+        self,
+        *,
+        unsafe: bool = False,
+        quote_cookie: bool = True,
+        treat_as_secure_origin: Union[StrOrURL, List[StrOrURL], None] = None
+    ) -> None:
         self._loop = asyncio.get_running_loop()
         self._cookies = defaultdict(
             SimpleCookie
@@ -63,6 +71,18 @@ def __init__(self, *, unsafe: bool = False, quote_cookie: bool = True) -> None:
         self._host_only_cookies = set()  # type: Set[Tuple[str, str]]
         self._unsafe = unsafe
         self._quote_cookie = quote_cookie
+        if treat_as_secure_origin is None:
+            treat_as_secure_origin = []
+        elif isinstance(treat_as_secure_origin, URL):
+            treat_as_secure_origin = [treat_as_secure_origin.origin()]
+        elif isinstance(treat_as_secure_origin, str):
+            treat_as_secure_origin = [URL(treat_as_secure_origin).origin()]
+        else:
+            treat_as_secure_origin = [
+                URL(url).origin() if isinstance(url, str) else url.origin()
+                for url in treat_as_secure_origin
+            ]
+        self._treat_as_secure_origin = treat_as_secure_origin
         self._next_expiration = next_whole_second()
         self._expirations = {}  # type: Dict[Tuple[str, str], datetime.datetime]
         # #4515: datetime.max may not be representable on 32-bit platforms
@@ -227,7 +247,14 @@ def filter_cookies(
             SimpleCookie() if self._quote_cookie else BaseCookie()
         )
         hostname = request_url.raw_host or ""
-        is_not_secure = request_url.scheme not in ("https", "wss")
+        request_origin = URL()
+        with contextlib.suppress(ValueError):
+            request_origin = request_url.origin()
+
+        is_not_secure = (
+            request_url.scheme not in ("https", "wss")
+            and request_origin not in self._treat_as_secure_origin
+        )
 
         for cookie in self:
             name = cookie.key


Test patch:
diff --git a/docs/testing.rst b/docs/testing.rst
--- a/docs/testing.rst
+++ b/docs/testing.rst
@@ -744,7 +744,8 @@ Test Client
                          first with ``TestServer(app)``.
 
    :param cookie_jar: an optional :class:`aiohttp.CookieJar` instance,
-                      may be useful with ``CookieJar(unsafe=True)``
+                      may be useful with
+                      ``CookieJar(unsafe=True, treat_as_secure_origin="http://127.0.0.1")``
                       option.
 
    :param str scheme: HTTP scheme, non-protected ``"http"`` by default.
diff --git a/tests/test_cookiejar.py b/tests/test_cookiejar.py
--- a/tests/test_cookiejar.py
+++ b/tests/test_cookiejar.py
@@ -756,3 +756,35 @@ async def test_cookie_jar_clear_domain() -> None:
     assert morsel.value == "bar"
     with pytest.raises(StopIteration):
         next(iterator)
+
+
+@pytest.mark.parametrize(
+    "url",
+    [
+        "http://127.0.0.1/index.html",
+        URL("http://127.0.0.1/index.html"),
+        ["http://127.0.0.1/index.html"],
+        [URL("http://127.0.0.1/index.html")],
+    ],
+)
+async def test_treat_as_secure_origin_init(url) -> None:
+    jar = CookieJar(unsafe=True, treat_as_secure_origin=url)
+    assert jar._treat_as_secure_origin == [URL("http://127.0.0.1")]
+
+
+async def test_treat_as_secure_origin() -> None:
+    endpoint = URL("http://127.0.0.1/")
+
+    jar = CookieJar(unsafe=True, treat_as_secure_origin=[endpoint])
+    secure_cookie = SimpleCookie(
+        "cookie-key=cookie-value; HttpOnly; Path=/; Secure",
+    )
+
+    jar.update_cookies(
+        secure_cookie,
+        endpoint,
+    )
+
+    assert len(jar) == 1
+    filtered_cookies = jar.filter_cookies(request_url=endpoint)
+    assert len(filtered_cookies) == 1