You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/06/02 00:37:29 UTC

(superset) branch fix/command-api-cache-hardening created (now 59f0784825f)

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a change to branch fix/command-api-cache-hardening
in repository https://gitbox.apache.org/repos/asf/superset.git


      at 59f0784825f fix(commands,api): enforce command validation, sanitize export filename/token, set cache TTLs

This branch includes the following new commits:

     new 59f0784825f fix(commands,api): enforce command validation, sanitize export filename/token, set cache TTLs

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(superset) 01/01: fix(commands,api): enforce command validation, sanitize export filename/token, set cache TTLs

Posted by ru...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch fix/command-api-cache-hardening
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 59f0784825f719cb991e2aaa91114382b159aa72
Author: Claude Code <no...@anthropic.com>
AuthorDate: Mon Jun 1 17:37:04 2026 -0700

    fix(commands,api): enforce command validation, sanitize export filename/token, set cache TTLs
    
    Hardening and correctness fixes across temporary cache commands and export
    endpoints:
    
    - temporary_cache create command: call validate() before create() to honor the
      BaseCommand contract so future validation cannot be silently bypassed.
    - async chart data job command: guard run() with a clear RuntimeError when
      invoked before validate() instead of surfacing a bare AttributeError.
    - chart data CSV export: apply secure_filename() to the client-provided
      filename before placing it in the Content-Disposition header.
    - database/dataset export endpoints: validate the client-provided "token"
      query param against a strict allow-list before using it as a cookie name.
    - filter-state and explore form-data create commands: pass an explicit cache
      timeout sourced from the relevant cache config instead of relying on backend
      defaults.
    
    Adds/extends unit tests for each change.
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 superset/charts/data/api.py                        |  4 ++
 .../chart/data/create_async_job_command.py         |  5 ++
 superset/commands/dashboard/filter_state/create.py |  9 ++-
 superset/commands/explore/form_data/create.py      | 11 +++-
 superset/commands/temporary_cache/create.py        |  1 +
 superset/databases/api.py                          |  3 +-
 superset/datasets/api.py                           |  4 +-
 superset/utils/core.py                             | 21 +++++++
 .../unit_tests/charts/data/__init__.py             | 22 -------
 .../charts/data/streaming_filename_test.py         | 59 ++++++++++++++++++
 .../chart/create_async_job_command_test.py         | 51 +++++++++++++++
 .../commands/dashboard/filter_state_create_test.py | 72 ++++++++++++++++++++++
 .../commands/explore/form_data/test_create.py      | 41 ++++++++++++
 .../commands/temporary_cache_create_test.py        | 63 +++++++++++++++++++
 tests/unit_tests/utils/test_core.py                | 31 ++++++++++
 15 files changed, 366 insertions(+), 31 deletions(-)

diff --git a/superset/charts/data/api.py b/superset/charts/data/api.py
index 3b6fd48b4fe..67c4aaf3577 100644
--- a/superset/charts/data/api.py
+++ b/superset/charts/data/api.py
@@ -727,6 +727,10 @@ class ChartDataRestApi(ChartRestApi):
 
             # Sanitize chart name for filename
             filename = secure_filename(f"superset_{chart_name}_{timestamp}.csv")
+        else:
+            # Sanitize the client-provided filename before placing it in the
+            # Content-Disposition header to avoid header/path injection.
+            filename = secure_filename(filename) or "export.csv"
 
         logger.info("Creating streaming CSV response: %s", filename)
         if expected_rows:
diff --git a/superset/commands/chart/data/create_async_job_command.py b/superset/commands/chart/data/create_async_job_command.py
index da126277ee7..a212308d104 100644
--- a/superset/commands/chart/data/create_async_job_command.py
+++ b/superset/commands/chart/data/create_async_job_command.py
@@ -33,6 +33,11 @@ class CreateAsyncChartDataJobCommand:
         )
 
     def run(self, form_data: dict[str, Any], user_id: Optional[int]) -> dict[str, Any]:
+        if not getattr(self, "_async_channel_id", None):
+            raise RuntimeError(
+                "CreateAsyncChartDataJobCommand.run() called before validate(); "
+                "the async channel id was not initialized."
+            )
         return async_query_manager.submit_chart_data_job(
             self._async_channel_id, form_data, user_id
         )
diff --git a/superset/commands/dashboard/filter_state/create.py b/superset/commands/dashboard/filter_state/create.py
index 1f105ac5c27..9b8ca4a1a1e 100644
--- a/superset/commands/dashboard/filter_state/create.py
+++ b/superset/commands/dashboard/filter_state/create.py
@@ -16,7 +16,7 @@
 # under the License.
 from typing import cast
 
-from flask import session
+from flask import current_app as app, session
 
 from superset.commands.dashboard.filter_state.utils import check_access
 from superset.commands.temporary_cache.create import CreateTemporaryCacheCommand
@@ -39,6 +39,9 @@ class CreateFilterStateCommand(CreateTemporaryCacheCommand):
         value = cast(str, cmd_params.value)  # schema ensures that value is not optional
         check_access(resource_id)
         entry: Entry = {"owner": get_user_id(), "value": value}
-        cache_manager.filter_state_cache.set(cache_key(resource_id, key), entry)
-        cache_manager.filter_state_cache.set(contextual_key, key)
+        timeout = app.config["FILTER_STATE_CACHE_CONFIG"].get("CACHE_DEFAULT_TIMEOUT")
+        cache_manager.filter_state_cache.set(
+            cache_key(resource_id, key), entry, timeout=timeout
+        )
+        cache_manager.filter_state_cache.set(contextual_key, key, timeout=timeout)
         return key
diff --git a/superset/commands/explore/form_data/create.py b/superset/commands/explore/form_data/create.py
index b4dbe7fac0a..97a4c2c05f7 100644
--- a/superset/commands/explore/form_data/create.py
+++ b/superset/commands/explore/form_data/create.py
@@ -16,7 +16,7 @@
 # under the License.
 import logging
 
-from flask import session
+from flask import current_app as app, session
 from sqlalchemy.exc import SQLAlchemyError
 
 from superset.commands.base import BaseCommand
@@ -64,8 +64,13 @@ class CreateFormDataCommand(BaseCommand):
                     "chart_id": chart_id,
                     "form_data": form_data,
                 }
-                cache_manager.explore_form_data_cache.set(key, state)
-                cache_manager.explore_form_data_cache.set(contextual_key, key)
+                timeout = app.config["EXPLORE_FORM_DATA_CACHE_CONFIG"].get(
+                    "CACHE_DEFAULT_TIMEOUT"
+                )
+                cache_manager.explore_form_data_cache.set(key, state, timeout=timeout)
+                cache_manager.explore_form_data_cache.set(
+                    contextual_key, key, timeout=timeout
+                )
             return key
         except SQLAlchemyError as ex:
             logger.exception("Error running create command")
diff --git a/superset/commands/temporary_cache/create.py b/superset/commands/temporary_cache/create.py
index 642e812d020..1b418f9eccc 100644
--- a/superset/commands/temporary_cache/create.py
+++ b/superset/commands/temporary_cache/create.py
@@ -32,6 +32,7 @@ class CreateTemporaryCacheCommand(BaseCommand, ABC):
 
     @transaction(on_error=partial(on_error, reraise=TemporaryCacheCreateFailedError))
     def run(self) -> str:
+        self.validate()
         return self.create(self._cmd_params)
 
     def validate(self) -> None:
diff --git a/superset/databases/api.py b/superset/databases/api.py
index f458c558dac..b495a932616 100644
--- a/superset/databases/api.py
+++ b/superset/databases/api.py
@@ -130,6 +130,7 @@ from superset.utils.core import (
     error_msg_from_exception,
     get_username,
     parse_js_uri_path_item,
+    sanitize_cookie_token,
 )
 from superset.utils.decorators import transaction
 from superset.utils.oauth2 import decode_oauth2_state
@@ -1528,7 +1529,7 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
             as_attachment=True,
             download_name=filename,
         )
-        if token := request.args.get("token"):
+        if token := sanitize_cookie_token(request.args.get("token")):
             response.set_cookie(token, "done", max_age=600)
         return response
 
diff --git a/superset/datasets/api.py b/superset/datasets/api.py
index c44a8ba2e37..fcde2b9dbad 100644
--- a/superset/datasets/api.py
+++ b/superset/datasets/api.py
@@ -76,7 +76,7 @@ from superset.datasets.schemas import (
 from superset.exceptions import SupersetSyntaxErrorException, SupersetTemplateException
 from superset.jinja_context import BaseTemplateProcessor, get_template_processor
 from superset.utils import json
-from superset.utils.core import parse_boolean_string
+from superset.utils.core import parse_boolean_string, sanitize_cookie_token
 from superset.views.base import DatasourceFilter
 from superset.views.base_api import (
     BaseSupersetModelRestApi,
@@ -570,7 +570,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
             as_attachment=True,
             download_name=filename,
         )
-        if token := request.args.get("token"):
+        if token := sanitize_cookie_token(request.args.get("token")):
             response.set_cookie(token, "done", max_age=600)
         return response
 
diff --git a/superset/utils/core.py b/superset/utils/core.py
index f361034e10b..22303e0b855 100644
--- a/superset/utils/core.py
+++ b/superset/utils/core.py
@@ -396,6 +396,27 @@ def parse_js_uri_path_item(
     return unquote_plus(item) if unquote and item else item
 
 
+# Matches a safe, opaque token suitable for use as a cookie name. Restricting the
+# allowed characters prevents client-controlled input from injecting unexpected
+# cookie attributes or control characters.
+COOKIE_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
+
+
+def sanitize_cookie_token(token: str | None) -> str | None:
+    """Return the token if it is a valid cookie name, otherwise None.
+
+    The export endpoints echo a client-provided ``token`` query parameter back as
+    a cookie name to signal download completion. Validate it against a strict
+    allow-list before trusting it.
+
+    :param token: the client-provided token value
+    :return: the token if valid, else None
+    """
+    if token and COOKIE_TOKEN_RE.match(token):
+        return token
+    return None
+
+
 def cast_to_num(value: float | int | str | None) -> float | int | None:
     """Casts a value to an int/float
 
diff --git a/superset/commands/chart/data/create_async_job_command.py b/tests/unit_tests/charts/data/__init__.py
similarity index 56%
copy from superset/commands/chart/data/create_async_job_command.py
copy to tests/unit_tests/charts/data/__init__.py
index da126277ee7..13a83393a91 100644
--- a/superset/commands/chart/data/create_async_job_command.py
+++ b/tests/unit_tests/charts/data/__init__.py
@@ -14,25 +14,3 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-import logging
-from typing import Any, Optional
-
-from flask import Request
-
-from superset.extensions import async_query_manager
-
-logger = logging.getLogger(__name__)
-
-
-class CreateAsyncChartDataJobCommand:
-    _async_channel_id: str
-
-    def validate(self, request: Request) -> None:
-        self._async_channel_id = async_query_manager.parse_channel_id_from_request(
-            request
-        )
-
-    def run(self, form_data: dict[str, Any], user_id: Optional[int]) -> dict[str, Any]:
-        return async_query_manager.submit_chart_data_job(
-            self._async_channel_id, form_data, user_id
-        )
diff --git a/tests/unit_tests/charts/data/streaming_filename_test.py b/tests/unit_tests/charts/data/streaming_filename_test.py
new file mode 100644
index 00000000000..444e4e32b5e
--- /dev/null
+++ b/tests/unit_tests/charts/data/streaming_filename_test.py
@@ -0,0 +1,59 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+def _build_response(filename: str | None):
+    from superset.charts.data.api import ChartDataRestApi
+
+    api = ChartDataRestApi.__new__(ChartDataRestApi)
+    result = {"query_context": MagicMock()}
+
+    with patch(
+        "superset.charts.data.api.StreamingCSVExportCommand"
+    ) as mock_command_cls:
+        mock_command = mock_command_cls.return_value
+        mock_command.run.return_value = lambda: iter([b"a,b\n"])
+        return api._create_streaming_csv_response(result, filename=filename)
+
+
+@pytest.mark.parametrize(
+    "provided,expected_substring",
+    [
+        ('"; evil="x', "evil"),  # quotes/special chars stripped
+        ("../../etc/passwd", "etc_passwd"),
+        ("report 2020.csv", "report_2020.csv"),
+    ],
+)
+def test_client_filename_is_sanitized(provided: str, expected_substring: str) -> None:
+    response = _build_response(provided)
+    disposition = response.headers["Content-Disposition"]
+
+    # No raw quotes/newlines/path separators leak into the header.
+    assert "/" not in disposition.split("filename=")[1]
+    assert "\n" not in disposition
+    # Header is well-formed: exactly the opening/closing quotes around the name.
+    assert disposition.count('"') == 2
+    assert expected_substring in disposition
+
+
+def test_blank_filename_falls_back_to_default() -> None:
+    response = _build_response("...")
+    disposition = response.headers["Content-Disposition"]
+    assert 'filename="export.csv"' in disposition
diff --git a/tests/unit_tests/commands/chart/create_async_job_command_test.py b/tests/unit_tests/commands/chart/create_async_job_command_test.py
new file mode 100644
index 00000000000..c2278db415d
--- /dev/null
+++ b/tests/unit_tests/commands/chart/create_async_job_command_test.py
@@ -0,0 +1,51 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from superset.commands.chart.data.create_async_job_command import (
+    CreateAsyncChartDataJobCommand,
+)
+
+
+def test_run_without_validate_raises_clear_error():
+    """run() must raise a clear error when validate() was not called first."""
+    command = CreateAsyncChartDataJobCommand()
+
+    with pytest.raises(RuntimeError, match="called before validate"):
+        command.run(form_data={}, user_id=1)
+
+
+def test_run_after_validate_submits_job():
+    """run() submits the job using the channel id captured during validate()."""
+    command = CreateAsyncChartDataJobCommand()
+
+    with patch(
+        "superset.commands.chart.data.create_async_job_command.async_query_manager",
+        new=MagicMock(),
+    ) as mock_manager:
+        mock_manager.parse_channel_id_from_request.return_value = "channel-123"
+        mock_manager.submit_chart_data_job.return_value = {"job_id": "abc"}
+
+        command.validate(request=object())  # type: ignore[arg-type]
+        result = command.run(form_data={"k": "v"}, user_id=42)
+
+        mock_manager.submit_chart_data_job.assert_called_once_with(
+            "channel-123", {"k": "v"}, 42
+        )
+        assert result == {"job_id": "abc"}
diff --git a/tests/unit_tests/commands/dashboard/filter_state_create_test.py b/tests/unit_tests/commands/dashboard/filter_state_create_test.py
new file mode 100644
index 00000000000..f9da4049864
--- /dev/null
+++ b/tests/unit_tests/commands/dashboard/filter_state_create_test.py
@@ -0,0 +1,72 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from unittest.mock import patch
+
+from flask import current_app
+
+from superset.commands.dashboard.filter_state.create import CreateFilterStateCommand
+from superset.commands.temporary_cache.parameters import CommandParameters
+
+
+def test_run_calls_validate_before_create():
+    """The BaseCommand contract requires validate() to run before create()."""
+    cmd_params = CommandParameters(resource_id=1, value='{"a": 1}')
+    command = CreateFilterStateCommand(cmd_params)
+
+    with (
+        current_app.test_request_context(),
+        patch.object(command, "validate") as mock_validate,
+        patch.object(command, "create", return_value="key") as mock_create,
+    ):
+        command.run()
+
+        mock_validate.assert_called_once()
+        mock_create.assert_called_once()
+
+
+def test_run_sets_cache_with_explicit_timeout():
+    """cache.set must receive an explicit timeout sourced from config."""
+    cmd_params = CommandParameters(resource_id=1, value='{"a": 1}')
+    command = CreateFilterStateCommand(cmd_params)
+
+    expected_timeout = current_app.config["FILTER_STATE_CACHE_CONFIG"].get(
+        "CACHE_DEFAULT_TIMEOUT"
+    )
+
+    with (
+        current_app.test_request_context(),
+        patch("superset.commands.dashboard.filter_state.create.check_access"),
+        patch("superset.commands.dashboard.filter_state.create.cache_key"),
+        patch(
+            "superset.commands.dashboard.filter_state.create.cache_manager"
+        ) as mock_cache_manager,
+        patch(
+            "superset.commands.dashboard.filter_state.create.random_key",
+            return_value="random-key",
+        ),
+        patch(
+            "superset.commands.dashboard.filter_state.create.get_user_id",
+            return_value=1,
+        ),
+    ):
+        mock_cache_manager.filter_state_cache.get.return_value = None
+
+        command.run()
+
+        assert mock_cache_manager.filter_state_cache.set.call_count >= 1
+        for call in mock_cache_manager.filter_state_cache.set.call_args_list:
+            assert call.kwargs.get("timeout") == expected_timeout
diff --git a/tests/unit_tests/commands/explore/form_data/test_create.py b/tests/unit_tests/commands/explore/form_data/test_create.py
index 1f9cc241d85..a01801a7ec3 100644
--- a/tests/unit_tests/commands/explore/form_data/test_create.py
+++ b/tests/unit_tests/commands/explore/form_data/test_create.py
@@ -83,3 +83,44 @@ def test_run_uses_get_session_id():
         mock_cache_key.assert_called_once_with(
             "test-session-id", 1, 1, 1, DatasourceType.TABLE
         )
+
+
+def test_run_sets_cache_with_explicit_timeout():
+    """cache.set must receive an explicit timeout sourced from config."""
+    from flask import current_app
+
+    cmd_params = CommandParameters(
+        datasource_id=1,
+        datasource_type=DatasourceType.TABLE,
+        chart_id=1,
+        tab_id=1,
+        form_data='{"test": "data"}',
+    )
+    command = CreateFormDataCommand(cmd_params)
+
+    expected_timeout = current_app.config["EXPLORE_FORM_DATA_CACHE_CONFIG"].get(
+        "CACHE_DEFAULT_TIMEOUT"
+    )
+
+    with (
+        patch("superset.commands.explore.form_data.create.check_access"),
+        patch("superset.commands.explore.form_data.create.cache_key"),
+        patch(
+            "superset.commands.explore.form_data.create.cache_manager"
+        ) as mock_cache_manager,
+        patch(
+            "superset.commands.explore.form_data.create.random_key",
+            return_value="random-key",
+        ),
+        patch(
+            "superset.commands.explore.form_data.create.get_user_id", return_value=1
+        ),
+        patch.object(command, "_get_session_id", return_value="test-session-id"),
+    ):
+        mock_cache_manager.explore_form_data_cache.get.return_value = None
+
+        command.run()
+
+        for call in mock_cache_manager.explore_form_data_cache.set.call_args_list:
+            assert call.kwargs.get("timeout") == expected_timeout
+        assert mock_cache_manager.explore_form_data_cache.set.call_count >= 1
diff --git a/tests/unit_tests/commands/temporary_cache_create_test.py b/tests/unit_tests/commands/temporary_cache_create_test.py
new file mode 100644
index 00000000000..497547bd36c
--- /dev/null
+++ b/tests/unit_tests/commands/temporary_cache_create_test.py
@@ -0,0 +1,63 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from unittest.mock import MagicMock
+
+import pytest
+from flask import current_app
+
+from superset.commands.temporary_cache.create import CreateTemporaryCacheCommand
+from superset.commands.temporary_cache.parameters import CommandParameters
+
+
+def test_run_invokes_validate_before_create():
+    """run() must call validate() before create() per the BaseCommand contract."""
+    order = []
+
+    class _Command(CreateTemporaryCacheCommand):
+        def validate(self) -> None:
+            order.append("validate")
+
+        def create(self, cmd_params: CommandParameters) -> str:
+            order.append("create")
+            return "key"
+
+    command = _Command(CommandParameters(resource_id=1))
+    with current_app.test_request_context():
+        result = command.run()
+
+    assert result == "key"
+    assert order == ["validate", "create"]
+
+
+def test_run_short_circuits_when_validate_raises():
+    """If validate() raises, create() must not be reached."""
+
+    class _Command(CreateTemporaryCacheCommand):
+        def validate(self) -> None:
+            raise ValueError("invalid")
+
+        create = MagicMock()
+
+    command = _Command(CommandParameters(resource_id=1))
+
+    with (
+        current_app.test_request_context(),
+        pytest.raises(ValueError, match="invalid"),
+    ):
+        command.run()
+
+    command.create.assert_not_called()
diff --git a/tests/unit_tests/utils/test_core.py b/tests/unit_tests/utils/test_core.py
index 5e663dd095a..1492f51d889 100644
--- a/tests/unit_tests/utils/test_core.py
+++ b/tests/unit_tests/utils/test_core.py
@@ -49,6 +49,7 @@ from superset.utils.core import (
     QueryObjectFilterClause,
     QuerySource,
     remove_extra_adhoc_filters,
+    sanitize_cookie_token,
     sanitize_svg_content,
     sanitize_url,
 )
@@ -1730,3 +1731,33 @@ def test_markdown_with_markup_wrap() -> None:
 
     assert isinstance(result, Markup)
     assert "<strong>bold</strong>" in str(result)
+
+
+@pytest.mark.parametrize(
+    "token",
+    [
+        "abc123",
+        "A_b-C_9",
+        "x" * 128,
+    ],
+)
+def test_sanitize_cookie_token_accepts_valid(token: str) -> None:
+    assert sanitize_cookie_token(token) == token
+
+
+@pytest.mark.parametrize(
+    "token",
+    [
+        None,
+        "",
+        "x" * 129,
+        "has space",
+        "semi;colon",
+        "new\nline",
+        "equals=sign",
+        "comma,value",
+        "unicodeāœ“",
+    ],
+)
+def test_sanitize_cookie_token_rejects_invalid(token: Optional[str]) -> None:
+    assert sanitize_cookie_token(token) is None