Repository: freedomofpress/securedrop

Issue:
`/create` fails after `/generate` has set per-`tab_id` codenames exceeding maximum cookie size
## Description

RFC 6265 [states](https://www.rfc-editor.org/rfc/rfc6265.html#section-6.1) that "user agents SHOULD provide [support for] at least 4096 bytes per cookie".  In practice, browsers [including Firefox](https://searchfox.org/mozilla-central/source/netwerk/cookie/CookieCommons.h#58) reject cookies larger than this size.

If loaded repeatedly, `/generate` will accumulate per-`tab_id` codenames that are sent in a cookie larger than 4096 bytes, with this warning:

    /opt/venvs/securedrop-app-code/lib/python3.8/site-packages/werkzeug/wrappers/base_response.py:470: UserWarning: The "b'ss'" cookie is too large: the value was 4179 bytes but the header required 21 extra bytes. The final size was 4200 bytes but the limit is 4093 bytes. Browsers may silently ignore cookies larger than this.

If the browser has indeed rejected this cookie, `/create` may subsequently fail, because the browser will return an older cookie that does not include the latest `tab_id` value, producing this error:

      File "/Users/cfm/work/securedrop/securedrop/source_app/main.py", line 66, in create
        codename = session['codenames'][tab_id]
    KeyError: 'xXOU8ChEZfCzeCN4cboAze9zMkJpPFfNpJ1ym54Jyc73b1leelikcdB-wI5U033QdTRrXELjNc_SqfQX2an7zg=='

## Steps to Reproduce

1. Load `/generate`.

2. In the same browser session, refresh repeatedly, via either the browser or the **Generate Codename** button, 30 or more times.

3. Click **Submit Documents**.

Or run test:

```diff
--- a/securedrop/tests/functional/test_source.py
+++ b/securedrop/tests/functional/test_source.py
@@ -157,3 +157,9 @@ class TestDuplicateSourceInterface(
         codename_lookup_b = self.get_codename_lookup()
         assert codename_lookup_b == codename_a2
         self._source_submits_a_message()
+
+    def test_too_many_codenames(self):
+        for i in range(50):  # limit will be approximately n = 4096 bytes / length of average {tab_id: codename} pair
+            self._source_visits_source_homepage()
+            self._source_chooses_to_submit_documents()
+        self._source_continues_to_submit_page()
```

## Expected Behavior

`/create` and `/lookup` load properly.

## Actual Behavior

An HTTP `500` error is returned (in debug mode, a `KeyError`).

## Comments

This is unlikely to occur in real-world use, but getting out of this state requires either deleting the `ss` cookie or resetting your (Tor) browser session.

In `generate()`, the simplest solution will be to truncate `codenames` at `n = 4096 bytes / length of average {tab_id: codename} pair` entries, on the assumption that someone is unlikely to wind up calling `/create` with their 29th-newest (or older) codename.

https://github.com/freedomofpress/securedrop/blob/1a2494d2b27129708e0da6b4278a7e888746daaf/securedrop/source_app/main.py#L49-L51

Now that [dictionary order is guaranteed to be insertion order](https://docs.python.org/3/library/stdtypes.html#dict), it may be possible to make this change in place, without refactoring the `codenames` session key as a list of `(tab_id, codename)` tuples (serialized to JSON as an array of arrays) for list operations.



Discussion:

Resolving patch:
diff --git a/securedrop/source_app/main.py b/securedrop/source_app/main.py
--- a/securedrop/source_app/main.py
+++ b/securedrop/source_app/main.py
@@ -19,7 +19,8 @@
 from sdconfig import SDConfig
 from source_app.decorators import login_required
 from source_app.utils import (logged_in, generate_unique_codename,
-                              normalize_timestamps, valid_codename)
+                              normalize_timestamps, valid_codename,
+                              fit_codenames_into_cookie)
 from source_app.forms import LoginForm, SubmissionForm
 
 
@@ -48,7 +49,7 @@ def generate() -> Union[str, werkzeug.Response]:
         tab_id = urlsafe_b64encode(os.urandom(64)).decode()
         codenames = session.get('codenames', {})
         codenames[tab_id] = codename
-        session['codenames'] = codenames
+        session['codenames'] = fit_codenames_into_cookie(codenames)
 
         session['new_user'] = True
         return render_template('generate.html', codename=codename, tab_id=tab_id)
diff --git a/securedrop/source_app/utils.py b/securedrop/source_app/utils.py
--- a/securedrop/source_app/utils.py
+++ b/securedrop/source_app/utils.py
@@ -1,3 +1,4 @@
+import json
 import subprocess
 
 from flask import session, current_app, abort, g
@@ -91,3 +92,23 @@ def check_url_file(path: str, regexp: str) -> 'Optional[str]':
 def get_sourcev3_url() -> 'Optional[str]':
     return check_url_file("/var/lib/securedrop/source_v3_url",
                           r"^[a-z0-9]{56}\.onion$")
+
+
+def fit_codenames_into_cookie(codenames: dict) -> dict:
+    """
+    If `codenames` will approach `werkzeug.Response.max_cookie_size` once
+    serialized, incrementally pop off the oldest codename until the remaining
+    (newer) ones will fit.
+    """
+
+    serialized = json.dumps(codenames).encode()
+    if len(codenames) > 1 and len(serialized) > 4000:  # werkzeug.Response.max_cookie_size = 4093
+        if current_app:
+            current_app.logger.warn(f"Popping oldest of {len(codenames)} "
+                                    f"codenames ({len(serialized)} bytes) to "
+                                    f"fit within maximum cookie size")
+        del codenames[list(codenames)[0]]  # FIFO
+
+        return fit_codenames_into_cookie(codenames)
+
+    return codenames


Test patch:
diff --git a/securedrop/tests/functional/test_source.py b/securedrop/tests/functional/test_source.py
--- a/securedrop/tests/functional/test_source.py
+++ b/securedrop/tests/functional/test_source.py
@@ -1,3 +1,6 @@
+import werkzeug
+
+from ..test_journalist import VALID_PASSWORD
 from . import source_navigation_steps, journalist_navigation_steps
 from . import functional_test
 from sdconfig import config
@@ -157,3 +160,15 @@ def test_refreshed_duplicate_generate_pages(self):
         codename_lookup_b = self.get_codename_lookup()
         assert codename_lookup_b == codename_a2
         self._source_submits_a_message()
+
+    def test_codenames_exceed_max_cookie_size(self):
+        # Test generation of enough codenames (from multiple tabs and/or serial
+        # refreshes) that the resulting cookie exceeds the recommended
+        # `werkzeug.Response.max_cookie_size` = 4093 bytes.  (#6043)
+
+        too_many = 2*(werkzeug.Response.max_cookie_size // len(VALID_PASSWORD))
+        for i in range(too_many):
+            self._source_visits_source_homepage()
+            self._source_chooses_to_submit_documents()
+
+        self._source_continues_to_submit_page()
diff --git a/securedrop/tests/test_source_utils.py b/securedrop/tests/test_source_utils.py
--- a/securedrop/tests/test_source_utils.py
+++ b/securedrop/tests/test_source_utils.py
@@ -1,7 +1,11 @@
 # -*- coding: utf-8 -*-
+import json
 import os
 
-from source_app.utils import check_url_file
+import werkzeug
+
+from source_app.utils import check_url_file, fit_codenames_into_cookie
+from .test_journalist import VALID_PASSWORD
 
 
 def test_check_url_file(config):
@@ -28,3 +32,32 @@ def write_url_file(path, content):
     finally:
         if os.path.exists(url_path):
             os.unlink(url_path)
+
+
+def test_fit_codenames_into_cookie(config):
+    # A single codename should never be truncated.
+    codenames = {'a': VALID_PASSWORD}
+    assert(fit_codenames_into_cookie(codenames) == codenames)
+
+    # A reasonable number of codenames should never be truncated.
+    codenames = {
+        'a': VALID_PASSWORD,
+        'b': VALID_PASSWORD,
+        'c': VALID_PASSWORD,
+    }
+    assert(fit_codenames_into_cookie(codenames) == codenames)
+
+    # A single gargantuan codename is undefined behavior---but also should not
+    # be truncated.
+    codenames = {'a': werkzeug.Response.max_cookie_size*VALID_PASSWORD}
+    assert(fit_codenames_into_cookie(codenames) == codenames)
+
+    # Too many codenames of the expected length should be truncated.
+    codenames = {}
+    too_many = 2*(werkzeug.Response.max_cookie_size // len(VALID_PASSWORD))
+    for i in range(too_many):
+        codenames[i] = VALID_PASSWORD
+    serialized = json.dumps(codenames).encode()
+    assert(len(serialized) > werkzeug.Response.max_cookie_size)
+    serialized = json.dumps(fit_codenames_into_cookie(codenames)).encode()
+    assert(len(serialized) < werkzeug.Response.max_cookie_size)