diff --git a/datasette/app.py b/datasette/app.py index 37b4ed3d..5e3d3af5 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -14,6 +14,7 @@ from pathlib import Path import click from markupsafe import Markup +from itsdangerous import URLSafeSerializer import jinja2 from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader, escape from jinja2.environment import Template @@ -163,12 +164,14 @@ class Datasette: static_mounts=None, memory=False, config=None, + secret=None, version_note=None, config_dir=None, ): assert config_dir is None or isinstance( config_dir, Path ), "config_dir= should be a pathlib.Path" + self._secret = secret or os.urandom(32).hex() self.files = tuple(files) + tuple(immutables or []) if config_dir: self.files += tuple([str(p) for p in config_dir.glob("*.db")]) @@ -281,6 +284,12 @@ class Datasette: self._register_renderers() + def sign(self, value, namespace="default"): + return URLSafeSerializer(self._secret, namespace).dumps(value) + + def unsign(self, signed, namespace="default"): + return URLSafeSerializer(self._secret, namespace).loads(signed) + def get_database(self, name=None): if name is None: return next(iter(self.databases.values())) diff --git a/datasette/cli.py b/datasette/cli.py index c59fb6e0..dba3a612 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -299,6 +299,11 @@ def package( help="Set config option using configname:value datasette.readthedocs.io/en/latest/config.html", multiple=True, ) +@click.option( + "--secret", + help="Secret used for signing secure values, such as signed cookies", + envvar="DATASETTE_SECRET", +) @click.option("--version-note", help="Additional note to show on /-/versions") @click.option("--help-config", is_flag=True, help="Show available config options") def serve( @@ -317,6 +322,7 @@ def serve( static, memory, config, + secret, version_note, help_config, return_instance=False, @@ -362,6 +368,7 @@ def serve( static_mounts=static, config=dict(config), memory=memory, + secret=secret, version_note=version_note, ) diff --git a/docs/datasette-serve-help.txt b/docs/datasette-serve-help.txt index 5265c294..ab27714a 100644 --- a/docs/datasette-serve-help.txt +++ b/docs/datasette-serve-help.txt @@ -29,6 +29,9 @@ Options: --config CONFIG Set config option using configname:value datasette.readthedocs.io/en/latest/config.html + --secret TEXT Secret used for signing secure values, such as signed + cookies + --version-note TEXT Additional note to show on /-/versions --help-config Show available config options --help Show this message and exit. diff --git a/docs/internals.rst b/docs/internals.rst index 2ba70722..68a35312 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -183,6 +183,34 @@ Use ``is_memory`` if the connection is to an in-memory SQLite database. This removes a database that has been previously added. ``name=`` is the unique name of that database, also used in the URL for it. +.. _datasette_sign: + +.sign(value, namespace="default") +--------------------------------- + +``value`` - any serializable type + The value to be signed. + +``namespace`` - string, optional + An alternative namespace, see the `itsdangerous salt documentation `__. + +Utility method for signing values, such that you can safely pass data to and from an untrusted environment. This is a wrapper around the `itsdangerous `__ library. + +This method returns a signed string, which can be decoded and verified using :ref:`datasette_unsign`. + +.. _datasette_unsign: + +.unsign(value, namespace="default") +----------------------------------- + +``signed`` - any serializable type + The signed string that was created using :ref:`datasette_sign`. + +``namespace`` - string, optional + The alternative namespace, if one was used. + +Returns the original, decoded object that was passed to :ref:`datasette_sign`. If the signature is not valid this raises a ``itsdangerous.BadSignature`` exception. + .. _internals_database: Database class diff --git a/setup.py b/setup.py index d9c70de5..93628266 100644 --- a/setup.py +++ b/setup.py @@ -55,6 +55,7 @@ setup( "janus>=0.4,<0.6", "PyYAML~=5.3", "mergedeep>=1.1.1,<1.4.0", + "itsdangerous~=1.1", ], entry_points=""" [console_scripts] diff --git a/tests/test_cli.py b/tests/test_cli.py index ac5746c6..f52f17b4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -75,6 +75,7 @@ def test_metadata_yaml(): static=[], memory=False, config=[], + secret=None, version_note=None, help_config=False, return_instance=True, diff --git a/tests/test_internals_datasette.py b/tests/test_internals_datasette.py index 4993250d..0be0b932 100644 --- a/tests/test_internals_datasette.py +++ b/tests/test_internals_datasette.py @@ -1,6 +1,7 @@ """ Tests for the datasette.app.Datasette class """ +from itsdangerous import BadSignature from .fixtures import app_client import pytest @@ -21,3 +22,14 @@ def test_get_database_no_argument(datasette): # Returns the first available database: db = datasette.get_database() assert "fixtures" == db.name + + +@pytest.mark.parametrize("value", ["hello", 123, {"key": "value"}]) +@pytest.mark.parametrize("namespace", [None, "two"]) +def test_sign_unsign(datasette, value, namespace): + extra_args = [namespace] if namespace else [] + signed = datasette.sign(value, *extra_args) + assert value != signed + assert value == datasette.unsign(signed, *extra_args) + with pytest.raises(BadSignature): + datasette.unsign(signed[:-1] + ("!" if signed[-1] != "!" else ":"))