CVE-2022-24737 (published 2022-03-07T23:15Z)

HTTPie is a command-line HTTP client. HTTPie has the practical concept of sessions, which help users to persistently store some of the state that belongs to the outgoing requests and incoming responses on the disk for further usage. Before 3.1.0, HTTPie didn‘t distinguish between cookies and hosts they belonged. This behavior resulted in the exposure of some cookies when there are redirects originating from the actual host to a third party website. Users are advised to upgrade. There are no known workarounds.

Severity: MEDIUM
CVSS v2 base score: 4.3 (AV:N/AC:M/Au:N/C:P/I:N/A:N)
CVSS v3 base score: 6.5, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N)

Weakness information:
cwe_id: CWE-200
cwe_name: Exposure of Sensitive Information to an Unauthorized Actor
description: The product exposes sensitive information to an actor that is not explicitly authorized to have access to that information.
extended_description: 
	   There are many different kinds of mistakes that introduce information exposures. The severity of the error can range widely, depending on the context in which the product operates, the type of sensitive information that is revealed, and the benefits it may provide to an attacker.  Some kinds of sensitive information include:
       
         private, personal information, such as personal messages, financial data, health records, geographic location, or contact details
         system status and environment, such as the operating system and installed packages
		 business secrets and intellectual property
		 network status and configuration
		 the product's own code or internal state
         metadata, e.g. logging of connections or message headers
		 indirect information, such as a discrepancy between two internal operations that can be observed by an outsider
       
	    Information might be sensitive to different parties, each of which may have their own expectations for whether the information should be protected.  These parties include:
               
                  the product's own users
                  people or organizations whose information is created or used by the product, even if they are not direct product users
		  the product's administrators, including the admins of the system(s) and/or networks on which the product operates
                  the developer
               
	    Information exposures can occur in different ways:
               
                  the code explicitly inserts sensitive information into resources or messages that are intentionally made accessible to unauthorized actors, but should not contain the information - i.e., the information should have been "scrubbed" or "sanitized"
                  a different weakness or mistake indirectly inserts the sensitive information into resources, such as a web script error revealing the full system path of the program.
                  the code manages resources that intentionally contain sensitive information, but the resources are unintentionally made accessible to unauthorized actors. In this case, the information exposure is resultant - i.e., a different weakness enabled the access to the information in the first place.
               
	    It is common practice to describe any loss of confidentiality as an "information exposure," but this can lead to overuse of CWE-200 in CWE mapping. From the CWE perspective, loss of confidentiality is a technical impact that can arise from dozens of different weaknesses, such as insecure file permissions or out-of-bounds read.  CWE-200 and its lower-level descendants are intended to cover the mistakes that occur in behaviors that explicitly manage, store, transfer, or cleanse sensitive information.
         
         

Fix information:
repo_url: https://github.com/httpie/httpie
commit_details: repo_url: https://github.com/httpie/httpie
author: Batuhan Taskaya
author_date: 2022-02-01 12:14:24+03:00
msg: Implement new style cookies
repository: repo_url: https://github.com/httpie/httpie
repo_name: httpie/cli
description: 🥧 HTTPie CLI  — modern, user-friendly command-line HTTP client for the API era. JSON support, colors, sessions, downloads, plugins & more.
repo_language: Python
owner: httpie
file_changes: filename: README.md
change_type: ModificationType.MODIFY
diff: @@ -2157,6 +2157,85 @@ $ http --session-read-only=./ro-session.json pie.dev/headers Custom-Header:orig-
 $ http --session-read-only=./ro-session.json pie.dev/headers Custom-Header:new-value
 ```
 
+### Host-based Cookie Policy
+
+Cookies in stored HTTPie sessions have a `domain` field which is binding them to the
+specified hostname. For example, in the following session:
+
+```json
+{
+    "cookies": [
+        {
+            "domain": "pie.dev",
+            "name": "secret_cookie",
+            "value": "value_1"
+        },
+        {
+            "domain": "httpbin.org",
+            "name": "secret_cookie",
+            "value": "value_2"
+        }
+    ]
+}
+```
+
+we will send `Cookie:secret_cookie=value_1` only when you are making a request against `pie.dev` (it
+also includes the domains, like `api.pie.dev`), and `Cookie:secret_cookie=value_2` when you use `httpbin.org`.
+
+```bash
+$ http --session=./session.json pie.dev/cookies
+```
+
+```json
+{
+    "cookies": {
+        "secret_cookie": "value_1"
+    }
+}
+```
+
+```bash
+$ http --session=./session.json httpbin.org/cookies
+```
+
+```json
+{
+    "cookies": {
+        "secret_cookie": "value_2"
+    }
+}
+```
+
+If you want to make a cookie domain unbound, you can simply set the `domain`
+field to `null` by editing the session file directly:
+
+```json
+{
+    "cookies": [
+        {
+            "domain": null,
+            "expires": null,
+            "name": "generic_cookie",
+            "path": "/",
+            "secure": false,
+            "value": "generic_value"
+        }
+    ]
+}
+```
+
+```bash
+$ http --session=./session.json pie.dev/cookies
+```
+
+```json
+{
+    "cookies": {
+        "generic_cookie": "generic_value"
+    }
+}
+```
+
 ### Cookie Storage Behavior
 
 **TL;DR:** Cookie storage priority: Server response > Command line request > Session file
@@ -2208,6 +2287,50 @@ Expired cookies are never stored.
 If a cookie in a session file expires, it will be removed before sending a new request.
 If the server expires an existing cookie, it will also be removed from the session file.
 
+### Upgrading Sessions
+
+In rare circumstances, HTTPie makes changes in it's session layout. For allowing a smoother transition of existing files
+from the old layout to the new layout we offer 2 interfaces:
+
+- `httpie cli sessions upgrade`
+- `httpie cli sessions upgrade-all`
+
+
+With `httpie cli sessions upgrade`, you can upgrade a single session with it's name (or it's path, if it is an
+[anonymous session](#anonymous-sessions)) and the hostname it belongs to. For example:
+
+([named session](#named-sessions))
+
+```bash
+$ httpie cli sessions upgrade pie.dev api_auth
+Refactored 'api_auth' (for 'pie.dev') to the version 3.1.0.
+```
+
+([anonymous session](#anonymous-sessions))
+
+```bash
+$ httpie cli sessions upgrade pie.dev ./session.json
+Refactored 'session' (for 'pie.dev') to the version 3.1.0.
+```
+
+If you want to upgrade every existing [named session](#named-sessions), you can use `httpie cli sessions upgrade-all` (be aware
+that this won't upgrade [anonymous sessions](#anonymous-sessions)):
+
+```bash
+$ httpie cli sessions upgrade-all
+Refactored 'api_auth' (for 'pie.dev') to the version 3.1.0.
+Refactored 'login_cookies' (for 'httpie.io') to the version 3.1.0.
+```
+
+#### Additional Customizations
+
+| Flag             | Description                                                                                                                                                                                                                  |
+|------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `--bind-cookies` | Bind all the unbound cookies to the hostname that session belongs. By default, if the cookie is unbound (the `domain` attribute does not exist / set to an empty string) then it will still continue to be a generic cookie. |
+
+These flags can be used to customize the defaults during an `upgrade` operation. They can
+be used in both `sessions upgrade` and `sessions upgrade-all`.
+
 ## Config
 
 HTTPie uses a simple `config.json` file.
@@ -2299,6 +2422,23 @@ And since there’s neither data nor `EOF`, it will get stuck. So unless you’r
 
 Also, it might be good to set a connection `--timeout` limit to prevent your program from hanging if the server never responds.
 
+### Security
+
+#### Exposure of Cookies To The 3rd Party Hosts On Redirects
+
+*Vulnerability Type*: [CWE-200](https://cwe.mitre.org/data/definitions/200.html)
+*Severity Level*: LOW
+*Affected Versions*: `<3.1.0`
+
+The handling of [cookies](#cookies) was not compatible with the [RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265)
+on the point of handling the `Domain` attribute when they were saved into [session](#sessions) files. All cookies were shared
+across all hosts during the runtime, including redirects to the 3rd party hosts.
+
+This vulnerability has been fixed in [3.1.0](https://github.com/httpie/httpie/releases/tag/3.1.0) and the
+[`httpie cli sessions upgrade`](#upgrading-sessions)/[`httpie cli sessions upgrade-all`]((#upgrading-sessions) commands
+have been put in place in order to allow a smooth transition to the new session layout from the existing [session](#sessions)
+files.
+
 ## Plugin manager
 
 HTTPie offers extensibility through a [plugin API](https://github.com/httpie/httpie/blob/master/httpie/plugins/base.py),

programming_language: Markdown
filename: client.py
change_type: ModificationType.MODIFY
diff: @@ -44,6 +44,7 @@ def collect_messages(
     httpie_session_headers = None
     if args.session or args.session_read_only:
         httpie_session = get_httpie_session(
+            env=env,
             config_dir=env.config.directory,
             session_name=args.session or args.session_read_only,
             host=args.headers.get('Host'),
@@ -130,10 +131,7 @@ def collect_messages(
     if httpie_session:
         if httpie_session.is_new() or not args.session_read_only:
             httpie_session.cookies = requests_session.cookies
-            httpie_session.remove_cookies(
-                # TODO: take path & domain into account?
-                cookie['name'] for cookie in expired_cookies
-            )
+            httpie_session.remove_cookies(expired_cookies)
             httpie_session.save()
 
 

programming_language: Python
filename: config.py
change_type: ModificationType.MODIFY
diff: @@ -1,7 +1,7 @@
 import json
 import os
 from pathlib import Path
-from typing import Union
+from typing import Any, Dict, Union
 
 from . import __version__
 from .compat import is_windows
@@ -62,6 +62,21 @@ class ConfigFileError(Exception):
     pass
 
 
+def read_raw_config(config_type: str, path: Path) -> Dict[str, Any]:
+    try:
+        with path.open(encoding=UTF8) as f:
+            try:
+                return json.load(f)
+            except ValueError as e:
+                raise ConfigFileError(
+                    f'invalid {config_type} file: {e} [{path}]'
+                )
+    except FileNotFoundError:
+        pass
+    except OSError as e:
+        raise ConfigFileError(f'cannot read {config_type} file: {e}')
+
+
 class BaseConfigDict(dict):
     name = None
     helpurl = None
@@ -77,26 +92,25 @@ class BaseConfigDict(dict):
     def is_new(self) -> bool:
         return not self.path.exists()
 
+    def pre_process_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
+        """Hook for processing the incoming config data."""
+        return data
+
+    def post_process_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
+        """Hook for processing the outgoing config data."""
+        return data
+
     def load(self):
         config_type = type(self).__name__.lower()
-        try:
-            with self.path.open(encoding=UTF8) as f:
-                try:
-                    data = json.load(f)
-                except ValueError as e:
-                    raise ConfigFileError(
-                        f'invalid {config_type} file: {e} [{self.path}]'
-                    )
-                self.update(data)
-        except FileNotFoundError:
-            pass
-        except OSError as e:
-            raise ConfigFileError(f'cannot read {config_type} file: {e}')
-
-    def save(self):
-        self['__meta__'] = {
-            'httpie': __version__
-        }
+        data = read_raw_config(config_type, self.path)
+        if data is not None:
+            data = self.pre_process_data(data)
+            self.update(data)
+
+    def save(self, *, bump_version: bool = False):
+        self.setdefault('__meta__', {})
+        if bump_version or 'httpie' not in self['__meta__']:
+            self['__meta__']['httpie'] = __version__
         if self.helpurl:
             self['__meta__']['help'] = self.helpurl
 
@@ -106,13 +120,19 @@ class BaseConfigDict(dict):
         self.ensure_directory()
 
         json_string = json.dumps(
-            obj=self,
+            obj=self.post_process_data(self),
             indent=4,
             sort_keys=True,
             ensure_ascii=True,
         )
         self.path.write_text(json_string + '\n', encoding=UTF8)
 
+    @property
+    def version(self):
+        return self.get(
+            '__meta__', {}
+        ).get('httpie', __version__)
+
 
 class Config(BaseConfigDict):
     FILENAME = 'config.json'

programming_language: Python
filename: cli.py
change_type: ModificationType.MODIFY
diff: @@ -2,6 +2,15 @@ from textwrap import dedent
 from httpie.cli.argparser import HTTPieManagerArgumentParser
 from httpie import __version__
 
+CLI_SESSION_UPGRADE_FLAGS = [
+    {
+        'variadic': ['--bind-cookies'],
+        'action': 'store_true',
+        'default': False,
+        'help': 'Bind domainless cookies to the host that session belongs.'
+    }
+]
+
 COMMANDS = {
     'plugins': {
         'help': 'Manage HTTPie plugins.',
@@ -34,6 +43,34 @@ COMMANDS = {
             'List all installed HTTPie plugins.'
         ],
     },
+    'cli': {
+        'help': 'Manage HTTPie for Terminal',
+        'sessions': {
+            'help': 'Manage HTTPie sessions',
+            'upgrade': [
+                'Upgrade the given HTTPie session with the latest '
+                'layout. A list of changes between different session versions '
+                'can be found in the official documentation.',
+                {
+                    'dest': 'hostname',
+                    'metavar': 'HOSTNAME',
+                    'help': 'The host this session belongs.'
+                },
+                {
+                    'dest': 'session',
+                    'metavar': 'SESSION_NAME_OR_PATH',
+                    'help': 'The name or the path for the session that will be upgraded.'
+                },
+                *CLI_SESSION_UPGRADE_FLAGS
+            ],
+            'upgrade-all': [
+                'Upgrade all named sessions with the latest layout. A list of '
+                'changes between different session versions can be found in the official '
+                'documentation.',
+                *CLI_SESSION_UPGRADE_FLAGS
+            ],
+        }
+    }
 }
 
 
@@ -54,6 +91,8 @@ def generate_subparsers(root, parent_parser, definitions):
     )
     for command, properties in definitions.items():
         is_subparser = isinstance(properties, dict)
+        properties = properties.copy()
+
         descr = properties.pop('help', None) if is_subparser else properties.pop(0)
         command_parser = actions.add_parser(command, description=descr)
         command_parser.root = root
@@ -62,7 +101,9 @@ def generate_subparsers(root, parent_parser, definitions):
             continue
 
         for argument in properties:
-            command_parser.add_argument(**argument)
+            argument = argument.copy()
+            variadic = argument.pop('variadic', [])
+            command_parser.add_argument(*variadic, **argument)
 
 
 parser = HTTPieManagerArgumentParser(

programming_language: Python
filename: core.py
change_type: ModificationType.MODIFY
diff: @@ -1,9 +1,11 @@
 import argparse
+from typing import Optional
 
 from httpie.context import Environment
 from httpie.manager.plugins import PluginInstaller
 from httpie.status import ExitStatus
 from httpie.manager.cli import missing_subcommand, parser
+from httpie.manager.tasks import CLI_TASKS
 
 MSG_COMMAND_CONFUSION = '''\
 This command is only for managing HTTPie plugins.
@@ -22,6 +24,13 @@ MSG_NAKED_INVOCATION = f'''\
 '''.rstrip("\n").format(args='POST pie.dev/post hello=world')
 
 
+def dispatch_cli_task(env: Environment, action: Optional[str], args: argparse.Namespace) -> ExitStatus:
+    if action is None:
+        parser.error(missing_subcommand('cli'))
+
+    return CLI_TASKS[action](env, args)
+
+
 def program(args: argparse.Namespace, env: Environment) -> ExitStatus:
     if args.action is None:
         parser.error(MSG_NAKED_INVOCATION)
@@ -29,5 +38,7 @@ def program(args: argparse.Namespace, env: Environment) -> ExitStatus:
     if args.action == 'plugins':
         plugins = PluginInstaller(env, debug=args.debug)
         return plugins.run(args.plugins_action, args)
+    elif args.action == 'cli':
+        return dispatch_cli_task(env, args.cli_action, args)
 
     return ExitStatus.SUCCESS

programming_language: Python
filename: tasks.py
change_type: ModificationType.ADD
diff: @@ -0,0 +1,134 @@
+import argparse
+from typing import TypeVar, Callable, Tuple
+
+from httpie.sessions import SESSIONS_DIR_NAME, Session, get_httpie_session
+from httpie.status import ExitStatus
+from httpie.context import Environment
+from httpie.manager.cli import missing_subcommand, parser
+
+T = TypeVar('T')
+
+CLI_TASKS = {}
+
+
+def task(name: str) -> Callable[[T], T]:
+    def wrapper(func: T) -> T:
+        CLI_TASKS[name] = func
+        return func
+    return wrapper
+
+
+@task('sessions')
+def cli_sessions(env: Environment, args: argparse.Namespace) -> ExitStatus:
+    action = args.cli_sessions_action
+    if action is None:
+        parser.error(missing_subcommand('cli', 'sessions'))
+
+    if action == 'upgrade':
+        return cli_upgrade_session(env, args)
+    elif action == 'upgrade-all':
+        return cli_upgrade_all_sessions(env, args)
+    else:
+        raise ValueError(f'Unexpected action: {action}')
+
+
+def is_version_greater(version_1: str, version_2: str) -> bool:
+    # In an ideal scenerio, we would depend on `packaging` in order
+    # to offer PEP 440 compatible parsing. But since it might not be
+    # commonly available for outside packages, and since we are only
+    # going to parse HTTPie's own version it should be fine to compare
+    # this in a SemVer subset fashion.
+
+    def split_version(version: str) -> Tuple[int, ...]:
+        parts = []
+        for part in version.split('.')[:3]:
+            try:
+                parts.append(int(part))
+            except ValueError:
+                break
+        return tuple(parts)
+
+    return split_version(version_1) > split_version(version_2)
+
+
+def fix_cookie_layout(session: Session, hostname: str, args: argparse.Namespace) -> None:
+    if not isinstance(session['cookies'], dict):
+        return None
+
+    session['cookies'] = [
+        {
+            'name': key,
+            **value
+        }
+        for key, value in session['cookies'].items()
+    ]
+    for cookie in session.cookies:
+        if cookie.domain == '':
+            if args.bind_cookies:
+                cookie.domain = hostname
+            else:
+                cookie._rest['is_explicit_none'] = True
+
+
+FIXERS_TO_VERSIONS = {
+    '3.1.0': fix_cookie_layout
+}
+
+
+def upgrade_session(env: Environment, args: argparse.Namespace, hostname: str, session_name: str):
+    session = get_httpie_session(
+        env=env,
+        config_dir=env.config.directory,
+        session_name=session_name,
+        host=hostname,
+        url=hostname,
+        refactor_mode=True
+    )
+
+    session_name = session.path.stem
+    if session.is_new():
+        env.log_error(f'{session_name!r} (for {hostname!r}) does not exist.')
+        return ExitStatus.ERROR
+
+    fixers = [
+        fixer
+        for version, fixer in FIXERS_TO_VERSIONS.items()
+        if is_version_greater(version, session.version)
+    ]
+
+    if len(fixers) == 0:
+        env.stdout.write(f'{session_name!r} (for {hostname!r}) is already up-to-date.\n')
+        return ExitStatus.SUCCESS
+
+    for fixer in fixers:
+        fixer(session, hostname, args)
+
+    session.save(bump_version=True)
+    env.stdout.write(f'Refactored {session_name!r} (for {hostname!r}) to the version {session.version}.\n')
+    return ExitStatus.SUCCESS
+
+
+def cli_upgrade_session(env: Environment, args: argparse.Namespace) -> ExitStatus:
+    return upgrade_session(
+        env,
+        args=args,
+        hostname=args.hostname,
+        session_name=args.session
+    )
+
+
+def cli_upgrade_all_sessions(env: Environment, args: argparse.Namespace) -> ExitStatus:
+    session_dir_path = env.config_dir / SESSIONS_DIR_NAME
+
+    status = ExitStatus.SUCCESS
+    for host_path in session_dir_path.iterdir():
+        hostname = host_path.name
+        for session_path in host_path.glob("*.json"):
+            session_name = session_path.stem
+            status |= upgrade_session(
+                env,
+                args=args,
+                hostname=hostname,
+                session_name=session_name
+            )
+    return status

programming_language: Python
filename: sessions.py
change_type: ModificationType.MODIFY
diff: @@ -6,15 +6,17 @@ import os
 import re
 
 from http.cookies import SimpleCookie
+from http.cookiejar import Cookie
 from pathlib import Path
-from typing import Iterable, Optional, Union
-from urllib.parse import urlsplit
+from typing import Any, Dict, Optional, Union
 
 from requests.auth import AuthBase
-from requests.cookies import RequestsCookieJar, create_cookie
+from requests.cookies import RequestsCookieJar, remove_cookie_by_name
 
+from .context import Environment
 from .cli.dicts import HTTPHeadersDict
 from .config import BaseConfigDict, DEFAULT_CONFIG_DIR
+from .utils import url_as_host
 from .plugins.registry import plugin_manager
 
 
@@ -26,27 +28,88 @@ VALID_SESSION_NAME_PATTERN = re.compile('^[a-zA-Z0-9_.-]+$')
 # <https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Requests>
 SESSION_IGNORED_HEADER_PREFIXES = ['Content-', 'If-']
 
+# Cookie related options
+KEPT_COOKIE_OPTIONS = ['name', 'expires', 'path', 'value', 'domain', 'secure']
+DEFAULT_COOKIE_PATH = '/'
+
+INSECURE_COOKIE_JAR_WARNING = '''\
+Outdated layout detected for the current session. Please consider updating it,
+in order to not get affected by potential security problems.
+
+For fixing the current session:
+
+    With binding all cookies to the current host (secure):
+        $ httpie cli sessions upgrade --bind-cookies {hostname} {session_id}
+
+    Without binding cookies (leaving them as is) (insecure):
+        $ httpie cli sessions upgrade {hostname} {session_id}
+'''
+
+INSECURE_COOKIE_JAR_WARNING_FOR_NAMED_SESSIONS = '''\
+
+For fixing all named sessions:
+
+    With binding all cookies to the current host (secure):
+        $ httpie cli sessions upgrade-all --bind-cookies
+
+    Without binding cookies (leaving them as is) (insecure):
+        $ httpie cli sessions upgrade-all
+
+See https://pie.co/docs/security for more information.
+'''
+
+
+def is_anonymous_session(session_name: str) -> bool:
+    return os.path.sep in session_name
+
+
+def materialize_cookie(cookie: Cookie) -> Dict[str, Any]:
+    materialized_cookie = {
+        option: getattr(cookie, option)
+        for option in KEPT_COOKIE_OPTIONS
+    }
+
+    if (
+        cookie._rest.get('is_explicit_none')
+        and materialized_cookie['domain'] == ''
+    ):
+        materialized_cookie['domain'] = None
+
+    return materialized_cookie
+
 
 def get_httpie_session(
+    env: Environment,
     config_dir: Path,
     session_name: str,
     host: Optional[str],
     url: str,
+    *,
+    refactor_mode: bool = False
 ) -> 'Session':
-    if os.path.sep in session_name:
+    bound_hostname = host or url_as_host(url)
+    if not bound_hostname:
+        # HACK/FIXME: httpie-unixsocket's URLs have no hostname.
+        bound_hostname = 'localhost'
+
+    # host:port => host_port
+    hostname = bound_hostname.replace(':', '_')
+    if is_anonymous_session(session_name):
         path = os.path.expanduser(session_name)
+        session_id = path
     else:
-        hostname = host or urlsplit(url).netloc.split('@')[-1]
-        if not hostname:
-            # HACK/FIXME: httpie-unixsocket's URLs have no hostname.
-            hostname = 'localhost'
-
-        # host:port => host_port
-        hostname = hostname.replace(':', '_')
         path = (
             config_dir / SESSIONS_DIR_NAME / hostname / f'{session_name}.json'
         )
-    session = Session(path)
+        session_id = session_name
+
+    session = Session(
+        path,
+        env=env,
+        session_id=session_id,
+        bound_host=bound_hostname.split(':')[0],
+        refactor_mode=refactor_mode
+    )
     session.load()
     return session
 
@@ -55,15 +118,86 @@ class Session(BaseConfigDict):
     helpurl = 'https://httpie.io/docs#sessions'
     about = 'HTTPie session file'
 
-    def __init__(self, path: Union[str, Path]):
+    def __init__(
+        self,
+        path: Union[str, Path],
+        env: Environment,
+        bound_host: str,
+        session_id: str,
+        refactor_mode: bool = False,
+    ):
         super().__init__(path=Path(path))
         self['headers'] = {}
-        self['cookies'] = {}
+        self['cookies'] = []
         self['auth'] = {
             'type': None,
             'username': None,
             'password': None
         }
+        self.env = env
+        self.cookie_jar = RequestsCookieJar()
+        self.session_id = session_id
+        self.bound_host = bound_host
+        self.refactor_mode = refactor_mode
+
+    def pre_process_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
+        cookies = data.get('cookies')
+        if isinstance(cookies, dict):
+            normalized_cookies = [
+                {
+                    'name': key,
+                    **value
+                }
+                for key, value in cookies.items()
+            ]
+        elif isinstance(cookies, list):
+            normalized_cookies = cookies
+        else:
+            normalized_cookies = []
+
+        should_issue_warning = False
+        for cookie in normalized_cookies:
+            domain = cookie.get('domain', '')
+            if domain == '' and isinstance(cookies, dict):
+                should_issue_warning = True
+            elif domain is None:
+                # domain = None means explicitly lack of cookie, though
+                # requests requires domain to be string so we'll cast it
+                # manually.
+                cookie['domain'] = ''
+                cookie['rest'] = {'is_explicit_none': True}
+
+            self.cookie_jar.set(**cookie)
+
+        if should_issue_warning and not self.refactor_mode:
+            warning = INSECURE_COOKIE_JAR_WARNING.format(hostname=self.bound_host, session_id=self.session_id)
+            if not is_anonymous_session(self.session_id):
+                warning += INSECURE_COOKIE_JAR_WARNING_FOR_NAMED_SESSIONS
+
+            self.env.log_error(
+                warning,
+                level='warning'
+            )
+
+        return data
+
+    def post_process_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
+        cookies = data.get('cookies')
+        # Save in the old-style fashion
+
+        normalized_cookies = [
+            materialize_cookie(cookie)
+            for cookie in self.cookie_jar
+        ]
+        if isinstance(cookies, dict):
+            data['cookies'] = {
+                cookie.pop('name'): cookie
+                for cookie in normalized_cookies
+            }
+        else:
+            data['cookies'] = normalized_cookies
+
+        return data
 
     def update_headers(self, request_headers: HTTPHeadersDict):
         """
@@ -73,10 +207,10 @@ class Session(BaseConfigDict):
         """
         headers = self.headers
         for name, value in request_headers.copy().items():
-
             if value is None:
                 continue  # Ignore explicitly unset headers
 
+            original_value = value
             if type(value) is not str:
                 value = value.decode()
 
@@ -85,8 +219,15 @@ class Session(BaseConfigDict):
 
             if name.lower() == 'cookie':
                 for cookie_name, morsel in SimpleCookie(value).items():
-                    self['cookies'][cookie_name] = {'value': morsel.value}
-                del request_headers[name]
+                    if not morsel['path']:
+                        morsel['path'] = DEFAULT_COOKIE_PATH
+                    self.cookie_jar.set(cookie_name, morsel)
+
+                all_cookie_headers = request_headers.getall(name)
+                if len(all_cookie_headers) > 1:
+                    all_cookie_headers.remove(original_value)
+                else:
+                    request_headers.popall(name)
                 continue
 
             for prefix in SESSION_IGNORED_HEADER_PREFIXES:
@@ -103,23 +244,21 @@ class Session(BaseConfigDict):
 
     @property
     def cookies(self) -> RequestsCookieJar:
-        jar = RequestsCookieJar()
-        for name, cookie_dict in self['cookies'].items():
-            jar.set_cookie(create_cookie(
-                name, cookie_dict.pop('value'), **cookie_dict))
-        jar.clear_expired_cookies()
-        return jar
+        self.cookie_jar.clear_expired_cookies()
+        return self.cookie_jar
 
     @cookies.setter
     def cookies(self, jar: RequestsCookieJar):
-        # <https://docs.python.org/3/library/cookielib.html#cookie-objects>
-        stored_attrs = ['value', 'path', 'secure', 'expires']
-        self['cookies'] = {}
-        for cookie in jar:
-            self['cookies'][cookie.name] = {
-                attname: getattr(cookie, attname)
-                for attname in stored_attrs
-            }
+        self.cookie_jar = jar
+
+    def remove_cookies(self, cookies: Dict[str, str]):
+        for cookie in cookies:
+            remove_cookie_by_name(
+                self.cookie_jar,
+                cookie['name'],
+                domain=cookie.get('domain', None),
+                path=cookie.get('path', None)
+            )
 
     @property
     def auth(self) -> Optional[AuthBase]:
@@ -154,8 +293,3 @@ class Session(BaseConfigDict):
     def auth(self, auth: dict):
         assert {'type', 'raw_auth'} == auth.keys()
         self['auth'] = auth
-
-    def remove_cookies(self, names: Iterable[str]):
-        for name in names:
-            if name in self['cookies']:
-                del self['cookies'][name]

programming_language: Python
filename: utils.py
change_type: ModificationType.MODIFY
diff: @@ -9,6 +9,7 @@ from collections import OrderedDict
 from http.cookiejar import parse_ns_headers
 from pathlib import Path
 from pprint import pformat
+from urllib.parse import urlsplit
 from typing import Any, List, Optional, Tuple, Callable, Iterable, TypeVar
 
 import requests.auth
@@ -237,3 +238,7 @@ def unwrap_context(exc: Exception) -> Optional[Exception]:
         return unwrap_context(context)
     else:
         return exc
+
+
+def url_as_host(url: str) -> str:
+    return urlsplit(url).netloc.split('@')[-1]

programming_language: Python
filename: setup.py
change_type: ModificationType.MODIFY
diff: @@ -11,6 +11,7 @@ import httpie
 tests_require = [
     'pytest',
     'pytest-httpbin>=0.0.6',
+    'pytest-lazy-fixture>=0.0.6',
     'responses',
 ]
 dev_require = [

programming_language: Python
filename: conftest.py
change_type: ModificationType.MODIFY
diff: @@ -4,7 +4,11 @@ import socket
 import pytest
 from pytest_httpbin import certs
 
-from .utils import HTTPBIN_WITH_CHUNKED_SUPPORT_DOMAIN, HTTPBIN_WITH_CHUNKED_SUPPORT
+from .utils import ( # noqa
+    HTTPBIN_WITH_CHUNKED_SUPPORT_DOMAIN,
+    HTTPBIN_WITH_CHUNKED_SUPPORT,
+    mock_env
+)
 from .utils.plugins_cli import ( # noqa
     broken_plugin,
     dummy_plugin,

programming_language: Python
filename: __init__.py
change_type: ModificationType.MODIFY
diff: @@ -1,6 +1,9 @@
 """Test data"""
+import json
 from pathlib import Path
+from typing import Optional, Dict, Any
 
+import httpie
 from httpie.encoding import UTF8
 from httpie.output.formatters.xml import pretty_xml, parse_xml
 
@@ -19,10 +22,20 @@ FILE_PATH = FIXTURES_ROOT / 'test.txt'
 JSON_FILE_PATH = FIXTURES_ROOT / 'test.json'
 JSON_WITH_DUPE_KEYS_FILE_PATH = FIXTURES_ROOT / 'test_with_dupe_keys.json'
 BIN_FILE_PATH = FIXTURES_ROOT / 'test.bin'
+
 XML_FILES_PATH = FIXTURES_ROOT / 'xmldata'
 XML_FILES_VALID = list((XML_FILES_PATH / 'valid').glob('*_raw.xml'))
 XML_FILES_INVALID = list((XML_FILES_PATH / 'invalid').glob('*.xml'))
 
+SESSION_FILES_PATH = FIXTURES_ROOT / 'session_data'
+SESSION_FILES_OLD = sorted((SESSION_FILES_PATH / 'old').glob('*.json'))
+SESSION_FILES_NEW = sorted((SESSION_FILES_PATH / 'new').glob('*.json'))
+
+SESSION_VARIABLES = {
+    '__version__': httpie.__version__,
+    '__host__': 'null',
+}
+
 FILE_PATH_ARG = patharg(FILE_PATH)
 BIN_FILE_PATH_ARG = patharg(BIN_FILE_PATH)
 JSON_FILE_PATH_ARG = patharg(JSON_FILE_PATH)
@@ -40,3 +53,14 @@ BIN_FILE_CONTENT = BIN_FILE_PATH.read_bytes()
 UNICODE = FILE_CONTENT
 XML_DATA_RAW = '<?xml version="1.0" encoding="utf-8"?><root><e>text</e></root>'
 XML_DATA_FORMATTED = pretty_xml(parse_xml(XML_DATA_RAW))
+
+
+def read_session_file(session_file: Path, *, extra_variables: Optional[Dict[str, str]] = None) -> Any:
+    with open(session_file) as stream:
+        data = stream.read()
+
+    session_vars = {**SESSION_VARIABLES, **(extra_variables or {})}
+    for variable, value in session_vars.items():
+        data = data.replace(variable, value)
+
+    return json.loads(data)

programming_language: Python
filename: cookies_dict.json
change_type: ModificationType.ADD
diff: @@ -0,0 +1,31 @@
+{
+    "__meta__": {
+        "about": "HTTPie session file",
+        "help": "https://httpie.io/docs#sessions",
+        "httpie": "__version__"
+    },
+    "auth": {
+        "password": null,
+        "type": null,
+        "username": null
+    },
+    "cookies": [
+        {
+            "domain": __host__,
+            "expires": null,
+            "name": "baz",
+            "path": "/",
+            "secure": false,
+            "value": "quux"
+        },
+        {
+            "domain": __host__,
+            "expires": null,
+            "name": "foo",
+            "path": "/",
+            "secure": false,
+            "value": "bar"
+        }
+    ],
+    "headers": {}
+}

programming_language: JavaScript
filename: cookies_dict_dev_version.json
change_type: ModificationType.ADD
diff: @@ -0,0 +1,31 @@
+{
+    "__meta__": {
+        "about": "HTTPie session file",
+        "help": "https://httpie.io/docs#sessions",
+        "httpie": "__version__"
+    },
+    "auth": {
+        "password": null,
+        "type": null,
+        "username": null
+    },
+    "cookies": [
+        {
+            "domain": __host__,
+            "expires": null,
+            "name": "baz",
+            "path": "/",
+            "secure": false,
+            "value": "quux"
+        },
+        {
+            "domain": __host__,
+            "expires": null,
+            "name": "foo",
+            "path": "/",
+            "secure": false,
+            "value": "bar"
+        }
+    ],
+    "headers": {}
+}

programming_language: JavaScript
filename: cookies_dict_with_extras.json
change_type: ModificationType.ADD
diff: @@ -0,0 +1,33 @@
+{
+    "__meta__": {
+        "about": "HTTPie session file",
+        "help": "https://httpie.io/docs#sessions",
+        "httpie": "__version__"
+    },
+    "auth": {
+        "raw_auth": "foo:bar",
+        "type": "basic"
+    },
+    "cookies": [
+        {
+            "domain": __host__,
+            "expires": null,
+            "name": "baz",
+            "path": "/",
+            "secure": false,
+            "value": "quux"
+        },
+        {
+            "domain": __host__,
+            "expires": null,
+            "name": "foo",
+            "path": "/",
+            "secure": false,
+            "value": "bar"
+        }
+    ],
+    "headers": {
+        "X-Data": "value",
+        "X-Foo": "bar"
+    }
+}

programming_language: JavaScript
filename: empty_cookies_dict.json
change_type: ModificationType.ADD
diff: @@ -0,0 +1,14 @@
+{
+    "__meta__": {
+        "about": "HTTPie session file",
+        "help": "https://httpie.io/docs#sessions",
+        "httpie": "__version__"
+    },
+    "auth": {
+        "password": null,
+        "type": null,
+        "username": null
+    },
+    "cookies": [],
+    "headers": {}
+}

programming_language: TypeScript
filename: empty_cookies_list.json
change_type: ModificationType.ADD
diff: @@ -0,0 +1,14 @@
+{
+    "__meta__": {
+        "about": "HTTPie session file",
+        "help": "https://httpie.io/docs#sessions",
+        "httpie": "__version__"
+    },
+    "auth": {
+        "password": null,
+        "type": null,
+        "username": null
+    },
+    "cookies": [],
+    "headers": {}
+}

programming_language: TypeScript
filename: cookies_dict.json
change_type: ModificationType.ADD
diff: @@ -0,0 +1,27 @@
+{
+    "__meta__": {
+        "about": "HTTPie session file",
+        "help": "https://httpie.io/docs#sessions",
+        "httpie": "3.0.2"
+    },
+    "auth": {
+        "password": null,
+        "type": null,
+        "username": null
+    },
+    "cookies": {
+        "baz": {
+            "expires": null,
+            "path": "/",
+            "secure": false,
+            "value": "quux"
+        },
+        "foo": {
+            "expires": null,
+            "path": "/",
+            "secure": false,
+            "value": "bar"
+        }
+    },
+    "headers": {}
+}

programming_language: TypeScript
filename: cookies_dict_dev_version.json
change_type: ModificationType.ADD
diff: @@ -0,0 +1,27 @@
+{
+    "__meta__": {
+        "about": "HTTPie session file",
+        "help": "https://httpie.io/docs#sessions",
+        "httpie": "2.7.0.dev0"
+    },
+    "auth": {
+        "password": null,
+        "type": null,
+        "username": null
+    },
+    "cookies": {
+        "baz": {
+            "expires": null,
+            "path": "/",
+            "secure": false,
+            "value": "quux"
+        },
+        "foo": {
+            "expires": null,
+            "path": "/",
+            "secure": false,
+            "value": "bar"
+        }
+    },
+    "headers": {}
+}

programming_language: TypeScript
filename: cookies_dict_with_extras.json
change_type: ModificationType.ADD
diff: @@ -0,0 +1,29 @@
+{
+    "__meta__": {
+        "about": "HTTPie session file",
+        "help": "https://httpie.io/docs#sessions",
+        "httpie": "3.0.2"
+    },
+    "auth": {
+        "raw_auth": "foo:bar",
+        "type": "basic"
+    },
+    "cookies": {
+        "baz": {
+            "expires": null,
+            "path": "/",
+            "secure": false,
+            "value": "quux"
+        },
+        "foo": {
+            "expires": null,
+            "path": "/",
+            "secure": false,
+            "value": "bar"
+        }
+    },
+    "headers": {
+        "X-Data": "value",
+        "X-Foo": "bar"
+    }
+}

programming_language: TypeScript
filename: empty_cookies_dict.json
change_type: ModificationType.ADD
diff: @@ -0,0 +1,14 @@
+{
+    "__meta__": {
+        "about": "HTTPie session file",
+        "help": "https://httpie.io/docs#sessions",
+        "httpie": "3.0.2"
+    },
+    "auth": {
+        "password": null,
+        "type": null,
+        "username": null
+    },
+    "cookies": {},
+    "headers": {}
+}

programming_language: TypeScript
filename: empty_cookies_list.json
change_type: ModificationType.ADD
diff: @@ -0,0 +1,14 @@
+{
+    "__meta__": {
+        "about": "HTTPie session file",
+        "help": "https://httpie.io/docs#sessions",
+        "httpie": "3.0.2"
+    },
+    "auth": {
+        "password": null,
+        "type": null,
+        "username": null
+    },
+    "cookies": [],
+    "headers": {}
+}

programming_language: TypeScript
filename: test_cookie_on_redirects.py
change_type: ModificationType.ADD
diff: @@ -0,0 +1,262 @@
+import pytest
+from .utils import http
+
+
+@pytest.fixture
+def remote_httpbin(httpbin_with_chunked_support):
+    return httpbin_with_chunked_support
+
+
+def _stringify(fixture):
+    return fixture + ''
+
+
+@pytest.mark.parametrize('instance', [
+    pytest.lazy_fixture('httpbin'),
+    pytest.lazy_fixture('remote_httpbin'),
+])
+def test_explicit_user_set_cookie(httpbin, instance):
+    # User set cookies ARE NOT persisted within redirects
+    # when there is no session, even on the same domain.
+
+    r = http(
+        '--follow',
+        httpbin + '/redirect-to',
+        f'url=={_stringify(instance)}/cookies',
+        'Cookie:a=b'
+    )
+    assert r.json == {'cookies': {}}
+
+
+@pytest.mark.parametrize('instance', [
+    pytest.lazy_fixture('httpbin'),
+    pytest.lazy_fixture('remote_httpbin'),
+])
+def test_explicit_user_set_cookie_in_session(tmp_path, httpbin, instance):
+    # User set cookies ARE persisted within redirects
+    # when there is A session, even on the same domain.
+
+    r = http(
+        '--follow',
+        '--session',
+        str(tmp_path / 'session.json'),
+        httpbin + '/redirect-to',
+        f'url=={_stringify(instance)}/cookies',
+        'Cookie:a=b'
+    )
+    assert r.json == {'cookies': {'a': 'b'}}
+
+
+@pytest.mark.parametrize('instance', [
+    pytest.lazy_fixture('httpbin'),
+    pytest.lazy_fixture('remote_httpbin'),
+])
+def test_saved_user_set_cookie_in_session(tmp_path, httpbin, instance):
+    # User set cookies ARE persisted within redirects
+    # when there is A session, even on the same domain.
+
+    http(
+        '--follow',
+        '--session',
+        str(tmp_path / 'session.json'),
+        httpbin + '/get',
+        'Cookie:a=b'
+    )
+    r = http(
+        '--follow',
+        '--session',
+        str(tmp_path / 'session.json'),
+        httpbin + '/redirect-to',
+        f'url=={_stringify(instance)}/cookies',
+    )
+    assert r.json == {'cookies': {'a': 'b'}}
+
+
+@pytest.mark.parametrize('instance', [
+    pytest.lazy_fixture('httpbin'),
+    pytest.lazy_fixture('remote_httpbin'),
+])
+@pytest.mark.parametrize('session', [True, False])
+def test_explicit_user_set_headers(httpbin, tmp_path, instance, session):
+    # User set headers ARE persisted within redirects
+    # even on different domains domain with or without
+    # an active session.
+    session_args = []
+    if session:
+        session_args.extend([
+            '--session',
+            str(tmp_path / 'session.json')
+        ])
+
+    r = http(
+        '--follow',
+        *session_args,
+        httpbin + '/redirect-to',
+        f'url=={_stringify(instance)}/get',
+        'X-Custom-Header:value'
+    )
+    assert 'X-Custom-Header' in r.json['headers']
+
+
+@pytest.mark.parametrize('session', [True, False])
+def test_server_set_cookie_on_redirect_same_domain(tmp_path, httpbin, session):
+    # Server set cookies ARE persisted on the same domain
+    # when they are forwarded.
+
+    session_args = []
+    if session:
+        session_args.extend([
+            '--session',
+            str(tmp_path / 'session.json')
+        ])
+
+    r = http(
+        '--follow',
+        *session_args,
+        httpbin + '/cookies/set/a/b',
+    )
+    assert r.json['cookies'] == {'a': 'b'}
+
+
+@pytest.mark.parametrize('session', [True, False])
+def test_server_set_cookie_on_redirect_different_domain(tmp_path, http_server, httpbin, session):
+    # Server set cookies ARE persisted on different domains
+    # when they are forwarded.
+
+    session_args = []
+    if session:
+        session_args.extend([
+            '--session',
+            str(tmp_path / 'session.json')
+        ])
+
+    r = http(
+        '--follow',
+        *session_args,
+        http_server + '/cookies/set-and-redirect',
+        f"X-Redirect-To:{httpbin + '/cookies'}",
+        'X-Cookies:a=b'
+    )
+    assert r.json['cookies'] == {'a': 'b'}
+
+
+def test_saved_session_cookies_on_same_domain(tmp_path, httpbin):
+    # Saved session cookies ARE persisted when making a new
+    # request to the same domain.
+    http(
+        '--session',
+        str(tmp_path / 'session.json'),
+        httpbin + '/cookies/set/a/b'
+    )
+    r = http(
+        '--session',
+        str(tmp_path / 'session.json'),
+        httpbin + '/cookies'
+    )
+    assert r.json == {'cookies': {'a': 'b'}}
+
+
+def test_saved_session_cookies_on_different_domain(tmp_path, httpbin, remote_httpbin):
+    # Saved session cookies ARE persisted when making a new
+    # request to a different domain.
+    http(
+        '--session',
+        str(tmp_path / 'session.json'),
+        httpbin + '/cookies/set/a/b'
+    )
+    r = http(
+        '--session',
+        str(tmp_path / 'session.json'),
+        remote_httpbin + '/cookies'
+    )
+    assert r.json == {'cookies': {}}
+
+
+@pytest.mark.parametrize('initial_domain, first_request_domain, second_request_domain, expect_cookies', [
+    (
+        # Cookies are set by    Domain A
+        # Initial domain is     Domain A
+        # Redirected domain is  Domain A
+        pytest.lazy_fixture('httpbin'),
+        pytest.lazy_fixture('httpbin'),
+        pytest.lazy_fixture('httpbin'),
+        True,
+    ),
+    (
+        # Cookies are set by    Domain A
+        # Initial domain is     Domain B
+        # Redirected domain is  Domain B
+        pytest.lazy_fixture('httpbin'),
+        pytest.lazy_fixture('remote_httpbin'),
+        pytest.lazy_fixture('remote_httpbin'),
+        False,
+    ),
+    (
+        # Cookies are set by    Domain A
+        # Initial domain is     Domain A
+        # Redirected domain is  Domain B
+        pytest.lazy_fixture('httpbin'),
+        pytest.lazy_fixture('httpbin'),
+        pytest.lazy_fixture('remote_httpbin'),
+        False,
+    ),
+    (
+        # Cookies are set by    Domain A
+        # Initial domain is     Domain B
+        # Redirected domain is  Domain A
+        pytest.lazy_fixture('httpbin'),
+        pytest.lazy_fixture('remote_httpbin'),
+        pytest.lazy_fixture('httpbin'),
+        True,
+    ),
+])
+def test_saved_session_cookies_on_redirect(tmp_path, initial_domain, first_request_domain, second_request_domain, expect_cookies):
+    http(
+        '--session',
+        str(tmp_path / 'session.json'),
+        initial_domain + '/cookies/set/a/b'
+    )
+    r = http(
+        '--session',
+        str(tmp_path / 'session.json'),
+        '--follow',
+        first_request_domain + '/redirect-to',
+        f'url=={_stringify(second_request_domain)}/cookies'
+    )
+    if expect_cookies:
+        expected_data = {'cookies': {'a': 'b'}}
+    else:
+        expected_data = {'cookies': {}}
+    assert r.json == expected_data
+
+
+def test_saved_session_cookie_pool(tmp_path, httpbin, remote_httpbin):
+    http(
+        '--session',
+        str(tmp_path / 'session.json'),
+        httpbin + '/cookies/set/a/b'
+    )
+    http(
+        '--session',
+        str(tmp_path / 'session.json'),
+        remote_httpbin + '/cookies/set/a/c'
+    )
+    http(
+        '--session',
+        str(tmp_path / 'session.json'),
+        remote_httpbin + '/cookies/set/b/d'
+    )
+
+    response = http(
+        '--session',
+        str(tmp_path / 'session.json'),
+        httpbin + '/cookies'
+    )
+    assert response.json['cookies'] == {'a': 'b'}
+
+    response = http(
+        '--session',
+        str(tmp_path / 'session.json'),
+        remote_httpbin + '/cookies'
+    )
+    assert response.json['cookies'] == {'a': 'c', 'b': 'd'}

programming_language: Python
filename: test_httpie_cli.py
change_type: ModificationType.ADD
diff: @@ -0,0 +1,125 @@
+import pytest
+import shutil
+import json
+from httpie.sessions import SESSIONS_DIR_NAME
+from httpie.status import ExitStatus
+from tests.utils import DUMMY_HOST, httpie
+from tests.fixtures import SESSION_FILES_PATH, SESSION_FILES_NEW, SESSION_FILES_OLD, read_session_file
+
+
+OLD_SESSION_FILES_PATH = SESSION_FILES_PATH / 'old'
+
+
+@pytest.mark.requires_installation
+def test_plugins_cli_error_message_without_args():
+    # No arguments
+    result = httpie(no_debug=True)
+    assert result.exit_status == ExitStatus.ERROR
+    assert 'usage: ' in result.stderr
+    assert 'specify one of these' in result.stderr
+    assert 'please use the http/https commands:' in result.stderr
+
+
+@pytest.mark.parametrize(
+    'example',
+    [
+        'pie.dev/get',
+        'DELETE localhost:8000/delete',
+        'POST pie.dev/post header:value a=b header_2:value x:=1',
+    ],
+)
+@pytest.mark.requires_installation
+def test_plugins_cli_error_messages_with_example(example):
+    result = httpie(*example.split(), no_debug=True)
+    assert result.exit_status == ExitStatus.ERROR
+    assert 'usage: ' in result.stderr
+    assert f'http {example}' in result.stderr
+    assert f'https {example}' in result.stderr
+
+
+@pytest.mark.parametrize(
+    'example',
+    [
+        'cli',
+        'plugins',
+        'cli foo',
+        'plugins unknown',
+        'plugins unknown.com A:B c=d',
+        'unknown.com UNPARSABLE????SYNTAX',
+    ],
+)
+@pytest.mark.requires_installation
+def test_plugins_cli_error_messages_invalid_example(example):
+    result = httpie(*example.split(), no_debug=True)
+    assert result.exit_status == ExitStatus.ERROR
+    assert 'usage: ' in result.stderr
+    assert f'http {example}' not in result.stderr
+    assert f'https {example}' not in result.stderr
+
+
+HTTPIE_CLI_SESSIONS_UPGRADE_OPTIONS = [
+    (
+        # Default settings
+        [],
+        {'__host__': json.dumps(None)},
+    ),
+    (
+        # When --bind-cookies is applied, the __host__ becomes DUMMY_URL.
+        ['--bind-cookies'],
+        {'__host__': json.dumps(DUMMY_HOST)},
+    ),
+]
+
+
+@pytest.mark.parametrize(
+    'old_session_file, new_session_file', zip(SESSION_FILES_OLD, SESSION_FILES_NEW)
+)
+@pytest.mark.parametrize(
+    'extra_args, extra_variables',
+    HTTPIE_CLI_SESSIONS_UPGRADE_OPTIONS,
+)
+def test_httpie_sessions_upgrade(tmp_path, old_session_file, new_session_file, extra_args, extra_variables):
+    session_path = tmp_path / 'session.json'
+    shutil.copyfile(old_session_file, session_path)
+
+    result = httpie(
+        'cli', 'sessions', 'upgrade', *extra_args, DUMMY_HOST, str(session_path)
+    )
+    assert result.exit_status == ExitStatus.SUCCESS
+    assert read_session_file(session_path) == read_session_file(
+        new_session_file, extra_variables=extra_variables
+    )
+
+
+def test_httpie_sessions_upgrade_on_non_existent_file(tmp_path):
+    session_path = tmp_path / 'session.json'
+    result = httpie('cli', 'sessions', 'upgrade', DUMMY_HOST, str(session_path))
+    assert result.exit_status == ExitStatus.ERROR
+    assert 'does not exist' in result.stderr
+
+
+@pytest.mark.parametrize(
+    'extra_args, extra_variables',
+    HTTPIE_CLI_SESSIONS_UPGRADE_OPTIONS,
+)
+def test_httpie_sessions_upgrade_all(tmp_path, mock_env, extra_args, extra_variables):
+    mock_env._create_temp_config_dir = False
+    mock_env.config_dir = tmp_path / "config"
+
+    session_dir = mock_env.config_dir / SESSIONS_DIR_NAME / DUMMY_HOST
+    session_dir.mkdir(parents=True)
+    for original_session_file in SESSION_FILES_OLD:
+        shutil.copy(original_session_file, session_dir)
+
+    result = httpie(
+        'cli', 'sessions', 'upgrade-all', *extra_args, env=mock_env
+    )
+    assert result.exit_status == ExitStatus.SUCCESS
+
+    for refactored_session_file, expected_session_file in zip(
+        sorted(session_dir.glob("*.json")),
+        SESSION_FILES_NEW
+    ):
+        assert read_session_file(refactored_session_file) == read_session_file(
+            expected_session_file, extra_variables=extra_variables
+        )

programming_language: Python
filename: test_plugins_cli.py
change_type: ModificationType.MODIFY
diff: @@ -1,7 +1,6 @@
 import pytest
 
 from httpie.status import ExitStatus
-from tests.utils import httpie
 from tests.utils.plugins_cli import parse_listing
 
 
@@ -149,45 +148,3 @@ def test_broken_plugins(httpie_plugins, httpie_plugins_success, dummy_plugin, br
     # No warning now, since it is uninstalled.
     data = parse_listing(httpie_plugins_success('list'))
     assert len(data) == 1
-
-
-@pytest.mark.requires_installation
-def test_plugins_cli_error_message_without_args():
-    # No arguments
-    result = httpie(no_debug=True)
-    assert result.exit_status == ExitStatus.ERROR
-    assert 'usage: ' in result.stderr
-    assert 'specify one of these' in result.stderr
-    assert 'please use the http/https commands:' in result.stderr
-
-
-@pytest.mark.parametrize(
-    'example', [
-        'pie.dev/get',
-        'DELETE localhost:8000/delete',
-        'POST pie.dev/post header:value a=b header_2:value x:=1'
-    ]
-)
-@pytest.mark.requires_installation
-def test_plugins_cli_error_messages_with_example(example):
-    result = httpie(*example.split(), no_debug=True)
-    assert result.exit_status == ExitStatus.ERROR
-    assert 'usage: ' in result.stderr
-    assert f'http {example}' in result.stderr
-    assert f'https {example}' in result.stderr
-
-
-@pytest.mark.parametrize(
-    'example', [
-        'plugins unknown',
-        'plugins unknown.com A:B c=d',
-        'unknown.com UNPARSABLE????SYNTAX',
-    ]
-)
-@pytest.mark.requires_installation
-def test_plugins_cli_error_messages_invalid_example(example):
-    result = httpie(*example.split(), no_debug=True)
-    assert result.exit_status == ExitStatus.ERROR
-    assert 'usage: ' in result.stderr
-    assert f'http {example}' not in result.stderr
-    assert f'https {example}' not in result.stderr

programming_language: Markdown
filename: test_sessions.py
change_type: ModificationType.MODIFY
diff: @@ -1,12 +1,16 @@
 import json
 import os
 import shutil
+from contextlib import contextmanager
 from datetime import datetime
 from unittest import mock
+from pathlib import Path
+from typing import Iterator
 
 import pytest
 
 from .fixtures import FILE_PATH_ARG, UNICODE
+from httpie.context import Environment
 from httpie.encoding import UTF8
 from httpie.plugins import AuthPlugin
 from httpie.plugins.builtin import HTTPBasicAuth
@@ -14,7 +18,7 @@ from httpie.plugins.registry import plugin_manager
 from httpie.sessions import Session
 from httpie.utils import get_expired_cookies
 from .test_auth_plugins import basic_auth
-from .utils import HTTP_OK, MockEnvironment, http, mk_config_dir
+from .utils import DUMMY_HOST, HTTP_OK, MockEnvironment, http, mk_config_dir
 from base64 import b64encode
 
 
@@ -203,9 +207,9 @@ class TestSession(SessionTestBase):
         """
         self.start_session(httpbin)
         session_data = {
-            "headers": {
-                "cookie": "...",
-                "zzz": "..."
+            'headers': {
+                'cookie': '...',
+                'zzz': '...'
             }
         }
         session_path = self.config_dir / 'session-data.json'
@@ -307,7 +311,7 @@ class TestSession(SessionTestBase):
             auth_type = 'test-prompted'
 
             def get_auth(self, username=None, password=None):
-                basic_auth_header = "Basic " + b64encode(self.raw_auth.encode()).strip().decode('latin1')
+                basic_auth_header = 'Basic ' + b64encode(self.raw_auth.encode()).strip().decode('latin1')
                 return basic_auth(basic_auth_header)
 
         plugin_manager.register(Plugin)
@@ -359,7 +363,7 @@ class TestSession(SessionTestBase):
              )
         updated_session = json.loads(self.session_path.read_text(encoding=UTF8))
         assert updated_session['auth']['type'] == 'test-saved'
-        assert updated_session['auth']['raw_auth'] == "user:password"
+        assert updated_session['auth']['raw_auth'] == 'user:password'
         plugin_manager.unregister(Plugin)
 
 
@@ -368,12 +372,12 @@ class TestExpiredCookies(CookieTestBase):
     @pytest.mark.parametrize(
         'initial_cookie, expired_cookie',
         [
-            ({'id': {'value': 123}}, 'id'),
-            ({'id': {'value': 123}}, 'token')
+            ({'id': {'value': 123}}, {'name': 'id'}),
+            ({'id': {'value': 123}}, {'name': 'token'})
         ]
     )
-    def test_removes_expired_cookies_from_session_obj(self, initial_cookie, expired_cookie, httpbin):
-        session = Session(self.config_dir)
+    def test_removes_expired_cookies_from_session_obj(self, initial_cookie, expired_cookie, httpbin, mock_env):
+        session = Session(self.config_dir, env=mock_env, session_id=None, bound_host=None)
         session['cookies'] = initial_cookie
         session.remove_cookies([expired_cookie])
         assert expired_cookie not in session.cookies
@@ -524,3 +528,165 @@ class TestCookieStorage(CookieTestBase):
         updated_session = json.loads(self.session_path.read_text(encoding=UTF8))
 
         assert updated_session['cookies']['cookie1']['value'] == expected
+
+
+@pytest.fixture
+def basic_session(httpbin, tmp_path):
+    session_path = tmp_path / 'session.json'
+    http(
+        '--session', str(session_path),
+        httpbin + '/get'
+    )
+    return session_path
+
+
+@contextmanager
+def open_session(path: Path, env: Environment, read_only: bool = False) -> Iterator[Session]:
+    session = Session(path, env, session_id='test', bound_host=DUMMY_HOST)
+    session.load()
+    yield session
+    if not read_only:
+        session.save()
+
+
+@contextmanager
+def open_raw_session(path: Path, read_only: bool = False) -> None:
+    with open(path) as stream:
+        raw_session = json.load(stream)
+
+    yield raw_session
+
+    if not read_only:
+        with open(path, 'w') as stream:
+            json.dump(raw_session, stream)
+
+
+def read_stderr(env: Environment) -> bytes:
+    env.stderr.seek(0)
+    stderr_data = env.stderr.read()
+    if isinstance(stderr_data, str):
+        return stderr_data.encode()
+    else:
+        return stderr_data
+
+
+def test_old_session_version_saved_as_is(basic_session, mock_env):
+    with open_session(basic_session, mock_env) as session:
+        session['__meta__'] = {'httpie': '0.0.1'}
+
+    with open_session(basic_session, mock_env, read_only=True) as session:
+        assert session['__meta__']['httpie'] == '0.0.1'
+
+
+def test_old_session_cookie_layout_warning(basic_session, mock_env):
+    with open_session(basic_session, mock_env) as session:
+        # Use the old layout & set a cookie
+        session['cookies'] = {}
+        session.cookies.set('foo', 'bar')
+
+    assert read_stderr(mock_env) == b''
+
+    with open_session(basic_session, mock_env, read_only=True) as session:
+        assert b'Outdated layout detected' in read_stderr(mock_env)
+
+
+@pytest.mark.parametrize('cookies, expect_warning', [
+    # Old-style cookie format
+    (
+        # Without 'domain' set
+        {'foo': {'value': 'bar'}},
+        True
+    ),
+    (
+        # With 'domain' set to empty string
+        {'foo': {'value': 'bar', 'domain': ''}},
+        True
+    ),
+    (
+        # With 'domain' set to null
+        {'foo': {'value': 'bar', 'domain': None}},
+        False,
+    ),
+    (
+        # With 'domain' set to a URL
+        {'foo': {'value': 'bar', 'domain': DUMMY_HOST}},
+        False,
+    ),
+    # New style cookie format
+    (
+        # Without 'domain' set
+        [{'name': 'foo', 'value': 'bar'}],
+        False
+    ),
+    (
+        # With 'domain' set to empty string
+        [{'name': 'foo', 'value': 'bar', 'domain': ''}],
+        False
+    ),
+    (
+        # With 'domain' set to null
+        [{'name': 'foo', 'value': 'bar', 'domain': None}],
+        False,
+    ),
+    (
+        # With 'domain' set to a URL
+        [{'name': 'foo', 'value': 'bar', 'domain': DUMMY_HOST}],
+        False,
+    ),
+])
+def test_cookie_security_warnings_on_raw_cookies(basic_session, mock_env, cookies, expect_warning):
+    with open_raw_session(basic_session) as raw_session:
+        raw_session['cookies'] = cookies
+
+    with open_session(basic_session, mock_env, read_only=True):
+        warning = b'Outdated layout detected'
+        stderr = read_stderr(mock_env)
+
+        if expect_warning:
+            assert warning in stderr
+        else:
+            assert warning not in stderr
+
+
+def test_old_session_cookie_layout_loading(basic_session, httpbin, mock_env):
+    with open_session(basic_session, mock_env) as session:
+        # Use the old layout & set a cookie
+        session['cookies'] = {}
+        session.cookies.set('foo', 'bar')
+
+    response = http(
+        '--session', str(basic_session),
+        httpbin + '/cookies'
+    )
+    assert response.json['cookies'] == {'foo': 'bar'}
+
+
+@pytest.mark.parametrize('layout_type', [
+    dict, list
+])
+def test_session_cookie_layout_preservance(basic_session, mock_env, layout_type):
+    with open_session(basic_session, mock_env) as session:
+        session['cookies'] = layout_type()
+        session.cookies.set('foo', 'bar')
+        session.save()
+
+    with open_session(basic_session, mock_env, read_only=True) as session:
+        assert isinstance(session['cookies'], layout_type)
+
+
+@pytest.mark.parametrize('layout_type', [
+    dict, list
+])
+def test_session_cookie_layout_preservance_on_new_cookies(basic_session, httpbin, mock_env, layout_type):
+    with open_session(basic_session, mock_env) as session:
+        session['cookies'] = layout_type()
+        session.cookies.set('foo', 'bar')
+        session.save()
+
+    http(
+        '--session', str(basic_session),
+        httpbin + '/cookies/set/baz/quux'
+    )
+
+    with open_session(basic_session, mock_env, read_only=True) as session:
+        assert isinstance(session['cookies'], layout_type)

programming_language: Python
filename: __init__.py
change_type: ModificationType.MODIFY
diff: @@ -6,6 +6,8 @@ import time
 import json
 import tempfile
 import warnings
+import pytest
+from contextlib import suppress
 from io import BytesIO
 from pathlib import Path
 from typing import Any, Optional, Union, List, Iterable
@@ -16,6 +18,7 @@ import httpie.manager.__main__ as manager
 from httpie.status import ExitStatus
 from httpie.config import Config
 from httpie.context import Environment
+from httpie.utils import url_as_host
 
 
 # pytest-httpbin currently does not support chunked requests:
@@ -39,6 +42,7 @@ HTTP_OK_COLOR = (
 )
 
 DUMMY_URL = 'http://this-should.never-resolve'  # Note: URL never fetched
+DUMMY_HOST = url_as_host(DUMMY_URL)
 
 
 def strip_colors(colorized_msg: str) -> str:
@@ -187,6 +191,13 @@ class ExitStatusError(Exception):
     pass
 
 
+@pytest.fixture
+def mock_env() -> MockEnvironment:
+    env = MockEnvironment(stdout_mode='')
+    yield env
+    env.cleanup()
+
+
 def normalize_args(args: Iterable[Any]) -> List[str]:
     return [str(arg) for arg in args]
 
@@ -201,7 +212,7 @@ def httpie(
     status.
     """
 
-    env = kwargs.setdefault('env', MockEnvironment())
+    env = kwargs.setdefault('env', MockEnvironment(stdout_mode=''))
     cli_args = ['httpie']
     if not kwargs.pop('no_debug', False):
         cli_args.append('--debug')
@@ -214,7 +225,16 @@ def httpie(
     env.stdout.seek(0)
     env.stderr.seek(0)
     try:
-        response = StrCLIResponse(env.stdout.read())
+        output = env.stdout.read()
+        if isinstance(output, bytes):
+            with suppress(UnicodeDecodeError):
+                output = output.decode()
+
+        if isinstance(output, bytes):
+            response = BytesCLIResponse(output)
+        else:
+            response = StrCLIResponse(output)
+
         response.stderr = env.stderr.read()
         response.exit_status = exit_status
         response.args = cli_args

programming_language: Python
filename: http_server.py
change_type: ModificationType.MODIFY
diff: @@ -85,6 +85,19 @@ def status_custom_msg(handler):
     handler.end_headers()
 
 
+@TestHandler.handler('GET', '/cookies/set-and-redirect')
+def set_cookie_and_redirect(handler):
+    handler.send_response(302)
+
+    redirect_to = handler.headers.get('X-Redirect-To', '/headers')
+    handler.send_header('Location', redirect_to)
+
+    raw_cookies = handler.headers.get('X-Cookies', 'a=b')
+    for cookie in raw_cookies.split(', '):
+        handler.send_header('Set-Cookie', cookie)
+    handler.end_headers()
+
+
 @pytest.fixture(scope="function")
 def http_server():
     """A custom HTTP server implementation for our tests, that is

programming_language: Python