Repository: Kinto/kinto

Issue:
Reusing a token on paginated delete removes other records
I don't think we should allow reusing tokens on paginated delete operations, but if we do, I think we should just return the tombstones returned on the first time it was called.

```bash
# create three records
> http post localhost:8888/v1/buckets -a a:a
> http post localhost:8888/v1/buckets -a a:a
> http post localhost:8888/v1/buckets -a a:a
(...)

gsurita-30820:~ gsurita$ http delete localhost:8888/v1/buckets?_limit=1 -a a:a
HTTP/1.1 200 OK
Access-Control-Expose-Headers: Backoff, Retry-After, Content-Length, Alert
Content-Length: 73
Content-Type: application/json
Date: Sun, 26 Mar 2017 13:04:04 GMT
Etag: "1490533444819"
Last-Modified: Sun, 26 Mar 2017 13:04:04 GMT
Next-Page: http://localhost:8888/v1/buckets?_limit=1&_token=eyJsYXN0X3JlY29yZCI6eyJsYXN0X21vZGlmaWVkIjoxNDkwNTMzNDQ0ODE5fSwib2Zmc2V0IjoxfQ%3D%3D
Server: waitress
Total-Records: 3
X-Content-Type-Options: nosniff

{
    "data": [
        {
            "deleted": true,
            "id": "wKQI-XKA",
            "last_modified": 1490533444819
        }
    ]
}

gsurita-30820:~ gsurita$ http delete "http://localhost:8888/v1/buckets?_limit=1&_token=eyJsYXN0X3JlY29yZCI6eyJsYXN0X21vZGlmaWVkIjoxNDkwNTMzNDQ0ODE5fSwib2Zmc2V0IjoxfQ%3D%3D" -a a:a
HTTP/1.1 200 OK
Access-Control-Expose-Headers: Backoff, Retry-After, Content-Length, Alert
Content-Length: 73
Content-Type: application/json
Date: Sun, 26 Mar 2017 13:04:18 GMT
Etag: "1490533458436"
Last-Modified: Sun, 26 Mar 2017 13:04:18 GMT
Server: waitress
Total-Records: 2
X-Content-Type-Options: nosniff

{
    "data": [
        {
            "deleted": true,
            "id": "aM2SK729",
            "last_modified": 1490533458436
        }
    ]
}

# Same token of the last request (response is different)
gsurita-30820:~ gsurita$ http delete "http://localhost:8888/v1/buckets?_limit=1&_token=eyJsYXN0X3JlY29yZCI6eyJsYXN0X21vZGlmaWVkIjoxNDkwNTMzNDQ0ODE5fSwib2Zmc2V0IjoxfQ%3D%3D" -a a:a
HTTP/1.1 200 OK
Access-Control-Expose-Headers: Backoff, Retry-After, Content-Length, Alert
Content-Length: 73
Content-Type: application/json
Date: Sun, 26 Mar 2017 13:04:19 GMT
Etag: "1490533459708"
Last-Modified: Sun, 26 Mar 2017 13:04:19 GMT
Server: waitress
Total-Records: 1
X-Content-Type-Options: nosniff

{
    "data": [
        {
            "deleted": true,
            "id": "ENUcJpjm",
            "last_modified": 1490533459708
        }
    ]
}
```
Reusing a token on paginated delete removes other records
I don't think we should allow reusing tokens on paginated delete operations, but if we do, I think we should just return the tombstones returned on the first time it was called.

```bash
# create three records
> http post localhost:8888/v1/buckets -a a:a
> http post localhost:8888/v1/buckets -a a:a
> http post localhost:8888/v1/buckets -a a:a
(...)

gsurita-30820:~ gsurita$ http delete localhost:8888/v1/buckets?_limit=1 -a a:a
HTTP/1.1 200 OK
Access-Control-Expose-Headers: Backoff, Retry-After, Content-Length, Alert
Content-Length: 73
Content-Type: application/json
Date: Sun, 26 Mar 2017 13:04:04 GMT
Etag: "1490533444819"
Last-Modified: Sun, 26 Mar 2017 13:04:04 GMT
Next-Page: http://localhost:8888/v1/buckets?_limit=1&_token=eyJsYXN0X3JlY29yZCI6eyJsYXN0X21vZGlmaWVkIjoxNDkwNTMzNDQ0ODE5fSwib2Zmc2V0IjoxfQ%3D%3D
Server: waitress
Total-Records: 3
X-Content-Type-Options: nosniff

{
    "data": [
        {
            "deleted": true,
            "id": "wKQI-XKA",
            "last_modified": 1490533444819
        }
    ]
}

gsurita-30820:~ gsurita$ http delete "http://localhost:8888/v1/buckets?_limit=1&_token=eyJsYXN0X3JlY29yZCI6eyJsYXN0X21vZGlmaWVkIjoxNDkwNTMzNDQ0ODE5fSwib2Zmc2V0IjoxfQ%3D%3D" -a a:a
HTTP/1.1 200 OK
Access-Control-Expose-Headers: Backoff, Retry-After, Content-Length, Alert
Content-Length: 73
Content-Type: application/json
Date: Sun, 26 Mar 2017 13:04:18 GMT
Etag: "1490533458436"
Last-Modified: Sun, 26 Mar 2017 13:04:18 GMT
Server: waitress
Total-Records: 2
X-Content-Type-Options: nosniff

{
    "data": [
        {
            "deleted": true,
            "id": "aM2SK729",
            "last_modified": 1490533458436
        }
    ]
}

# Same token of the last request (response is different)
gsurita-30820:~ gsurita$ http delete "http://localhost:8888/v1/buckets?_limit=1&_token=eyJsYXN0X3JlY29yZCI6eyJsYXN0X21vZGlmaWVkIjoxNDkwNTMzNDQ0ODE5fSwib2Zmc2V0IjoxfQ%3D%3D" -a a:a
HTTP/1.1 200 OK
Access-Control-Expose-Headers: Backoff, Retry-After, Content-Length, Alert
Content-Length: 73
Content-Type: application/json
Date: Sun, 26 Mar 2017 13:04:19 GMT
Etag: "1490533459708"
Last-Modified: Sun, 26 Mar 2017 13:04:19 GMT
Server: waitress
Total-Records: 1
X-Content-Type-Options: nosniff

{
    "data": [
        {
            "deleted": true,
            "id": "ENUcJpjm",
            "last_modified": 1490533459708
        }
    ]
}
```


Discussion:
I guess we could add an expiry to the token to achieve that.

Just for you to know:

```pycon
>>> import base64
>>> base64.b64decode("eyJsYXN0X3JlY29yZCI6eyJsYXN0X21vZGlmaWVkIjoxNDkwNTMzNDQ0ODE5fSwib2Zmc2V0IjoxfQ==")
'{"last_record":{"last_modified":1490533444819},"offset":1}'

```
We could even want to sign them using jwt to make them less hackable.
I guess we could add an expiry to the token to achieve that.

Just for you to know:

```pycon
>>> import base64
>>> base64.b64decode("eyJsYXN0X3JlY29yZCI6eyJsYXN0X21vZGlmaWVkIjoxNDkwNTMzNDQ0ODE5fSwib2Zmc2V0IjoxfQ==")
'{"last_record":{"last_modified":1490533444819},"offset":1}'

```
We could even want to sign them using jwt to make them less hackable.

Resolving patch:
diff --git a/kinto/core/__init__.py b/kinto/core/__init__.py
--- a/kinto/core/__init__.py
+++ b/kinto/core/__init__.py
@@ -62,6 +62,7 @@
     'newrelic_config': None,
     'newrelic_env': 'dev',
     'paginate_by': None,
+    'pagination_token_validity_seconds': 10 * 60,
     'permission_backend': '',
     'permission_url': '',
     'permission_pool_size': 25,
diff --git a/kinto/core/resource/__init__.py b/kinto/core/resource/__init__.py
--- a/kinto/core/resource/__init__.py
+++ b/kinto/core/resource/__init__.py
@@ -1,6 +1,7 @@
 import logging
 import re
 import functools
+from uuid import uuid4
 
 import colander
 import venusian
@@ -1041,14 +1042,26 @@ def _extract_pagination_rules_from_token(self, limit, sorting):
         filters = []
         offset = 0
         if token:
+            error_msg = None
             try:
                 tokeninfo = json.loads(decode64(token))
                 if not isinstance(tokeninfo, dict):
                     raise ValueError()
                 last_record = tokeninfo['last_record']
                 offset = tokeninfo['offset']
+                nonce = tokeninfo['nonce']
             except (ValueError, KeyError, TypeError):
                 error_msg = '_token has invalid content'
+
+            # We don't want pagination tokens to be reused several times (#1171).
+            # The cache backend is used to keep track of "nonces".
+            if self.request.method.lower() == "delete" and error_msg is None:
+                registry = self.request.registry
+                deleted = registry.cache.delete(nonce)
+                if deleted is None:
+                    error_msg = '_token was already used or has expired.'
+
+            if error_msg:
                 error_details = {
                     'location': 'querystring',
                     'description': error_msg
@@ -1056,6 +1069,7 @@ def _extract_pagination_rules_from_token(self, limit, sorting):
                 raise_invalid(self.request, **error_details)
 
             filters = self._build_pagination_rules(sorting, last_record)
+
         return filters, offset
 
     def _next_page_url(self, sorting, limit, last_record, offset):
@@ -1076,9 +1090,16 @@ def _build_pagination_token(self, sorting, last_record, offset):
         the last_record.
 
         """
+        nonce = "pagination-token-{}".format(uuid4())
+        if self.request.method.lower() == "delete":
+            registry = self.request.registry
+            validity = registry.settings["pagination_token_validity_seconds"]
+            registry.cache.set(nonce, "", validity)
+
         token = {
             'last_record': {},
-            'offset': offset
+            'offset': offset,
+            'nonce': nonce,
         }
 
         for field, _ in sorting:


Test patch:
diff --git a/tests/core/resource/__init__.py b/tests/core/resource/__init__.py
--- a/tests/core/resource/__init__.py
+++ b/tests/core/resource/__init__.py
@@ -3,7 +3,8 @@
 
 from kinto.core import resource
 from kinto.core.authorization import RouteFactory
-from kinto.core.storage import memory
+from kinto.core.storage import memory as storage_memory
+from kinto.core.cache import memory as cache_memory
 from kinto.core.testing import DummyRequest
 
 
@@ -16,7 +17,8 @@ class BaseTest(unittest.TestCase):
     resource_class = resource.UserResource
 
     def setUp(self):
-        self.storage = memory.Storage()
+        self.storage = storage_memory.Storage()
+        self.cache = cache_memory.Cache(cache_prefix="", cache_max_size_bytes=1000)
         self.resource = self.resource_class(request=self.get_request(),
                                             context=self.get_context())
         self.resource.schema = StrictSchema
@@ -32,6 +34,7 @@ def tearDown(self):
     def get_request(self, resource_name=''):
         request = DummyRequest(method='GET')
         request.current_resource_name = resource_name
+        request.registry.cache = self.cache
         request.registry.storage = self.storage
         return request
 
diff --git a/tests/core/resource/test_pagination.py b/tests/core/resource/test_pagination.py
--- a/tests/core/resource/test_pagination.py
+++ b/tests/core/resource/test_pagination.py
@@ -101,11 +101,11 @@ def test_next_page_url_gives_distinct_records_with_forced_limit(self):
 
     def test_twice_the_same_next_page(self):
         self.validated['querystring'] = {'_limit': 10}
-        self.resource.collection_get()
-        first_next = self.last_response.headers['Next-Page']
-        self.resource.collection_get()
-        second_next = self.last_response.headers['Next-Page']
-        self.assertEqual(first_next, second_next)
+        records = self.resource.collection_get()
+        first_ids = [r['id'] for r in records['data']]
+        records = self.resource.collection_get()
+        second_ids = [r['id'] for r in records['data']]
+        self.assertEqual(first_ids, second_ids)
 
     def test_stops_giving_next_page_at_the_end_of_first_page(self):
         self.resource.collection_get()
@@ -228,6 +228,15 @@ def test_return_total_records_in_headers_matching_deletable(self):
         count = headers['Total-Records']
         self.assertEquals(int(count), 20)
 
+    def test_token_cannot_be_reused_twice(self):
+        self.resource.request.method = "DELETE"
+        self.validated['querystring']['_limit'] = 3
+        self.resource.collection_delete()
+        self._setup_next_page()
+        self.resource.collection_delete()
+        # Reuse previous token.
+        self.assertRaises(HTTPBadRequest, self.resource.collection_delete)
+
 
 class BuildPaginationTokenTest(BaseTest):
     def setUp(self):