Skip to content

Client: ClientSession will not send secure cookie to localhost on unsecure connection. #5571

Closed
@DanielDewberry

Description

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:

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.

#! /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

python 3.8.5

📋 Your version of the aiohttp/yarl/multidict distributions

aiohttp: 3.7.4
multidict: 5.1.0
yarl: 1.6.3

📋 Additional context
This is a client issue.

Proposed Solution:

change this line of aiohttp/cookiejar.py
from

if is_not_secure and cookie["secure"]:

to

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 shows the RFC section 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.

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions