Repository: Kinto/kinto

Issue:
Crash with `psycopg2.errors.InvalidTextRepresentation` (bigint) with last_modified = `1733242309482"`
```
(psycopg2.errors.InvalidTextRepresentation) invalid input syntax for type bigint: "1733242309482""
LINE 7:             AND as_epoch(last_modified) > '1733242309482"'
                                                  ^

[SQL: 
            SELECT id, as_epoch(last_modified) AS last_modified, data
            FROM objects
            WHERE parent_id = %(parent_id)s
            AND resource_name = %(resource_name)s
            
            AND as_epoch(last_modified) > %(filters_value_0)s
            
            ORDER BY last_modified DESC
            LIMIT %(pagination_limit)s;
        ]
[parameters: {'parent_id': '/buckets/main/collections/quicksuggest', 'resource_name': 'record', 'filters_value_0': '1733242309482"', 'pagination_limit': 10001}]
(Background on this error at: https://sqlalche.me/e/20/9h9h

)
```

Discussion:

Patch:
diff --git a/kinto/core/initialization.py b/kinto/core/initialization.py
index 397dc7ce..0b8fa7ad 100644
--- a/kinto/core/initialization.py
+++ b/kinto/core/initialization.py
@@ -1,6 +1,7 @@
 import logging
 import random
 import re
+import urllib.parse
 import warnings
 from datetime import datetime
 from secrets import token_hex
@@ -474,6 +475,7 @@ def setup_metrics(config):
 
         try:
             endpoint = utils.strip_uri_prefix(request.path)
+            endpoint = urllib.parse.quote_plus(endpoint, safe="/?&=-_")
         except UnicodeDecodeError as e:
             # This `on_new_response` callback is also called when a HTTP 400
             # is returned because of an invalid UTF-8 path. We still want metrics.
@@ -507,7 +509,7 @@ def setup_metrics(config):
             unique=[
                 ("method", request.method.lower()),
                 ("endpoint", endpoint),
-                ("status", str(request.response.status_code)),
+                ("status", str(event.response.status_code)),
             ]
             + metrics_matchdict_labels,
         )
@@ -527,7 +529,7 @@ def setup_metrics(config):
         # Observe response size.
         metrics_service.observe(
             "request_size",
-            len(request.response.body or b""),
+            len(event.response.body or b""),
             labels=[("endpoint", endpoint)] + metrics_matchdict_labels,
         )
 
diff --git a/kinto/core/resource/__init__.py b/kinto/core/resource/__init__.py
index f8aaa7b1..da447642 100644
--- a/kinto/core/resource/__init__.py
+++ b/kinto/core/resource/__init__.py
@@ -665,7 +665,7 @@ class Resource:
         obj = self._get_object_or_404(self.object_id)
         self._raise_412_if_modified(obj)
 
-        # Retreive the last_modified information from a querystring if present.
+        # Retrieve the last_modified information from a querystring if present.
         last_modified = self.request.validated["querystring"].get("last_modified")
 
         # If less or equal than current object. Ignore it.
@@ -1060,7 +1060,8 @@ class Resource:
         """Extracts filters from QueryString parameters."""
 
         def is_valid_timestamp(value):
-            return isinstance(value, int) or re.match(r'^"?\d+"?$', str(value))
+            # Is either integer, or integer as string, or integer between 2 quotes.
+            return isinstance(value, int) or re.match(r'^(\d+)$|^("\d+")$', str(value))
 
         queryparams = self.request.validated["querystring"]
 
diff --git a/kinto/plugins/statsd.py b/kinto/plugins/statsd.py
index b6a8830f..5ae89401 100644
--- a/kinto/plugins/statsd.py
+++ b/kinto/plugins/statsd.py
@@ -13,6 +13,14 @@ except ImportError:  # pragma: no cover
     statsd_module = None
 
 
+def sanitize(value):
+    """
+    Telegraf does not support ':' in values.
+    See https://github.com/influxdata/telegraf/issues/4495
+    """
+    return value.replace(":", "") if isinstance(value, str) else value
+
+
 @implementer(metrics.IMetricsService)
 class StatsDService:
     def __init__(self, host, port, prefix):
@@ -22,7 +30,7 @@ class StatsDService:
         return self._client.timer(key)
 
     def observe(self, key, value, labels=[]):
-        return self._client.gauge(key, value)
+        return self._client.gauge(key, sanitize(value))
 
     def count(self, key, count=1, unique=None):
         if unique is None:
@@ -30,7 +38,7 @@ class StatsDService:
         if isinstance(unique, list):
             # [("method", "get")] -> "method.get"
             # [("endpoint", "/"), ("method", "get")] -> "endpoint./.method.get"
-            unique = ".".join(f"{label[0]}.{label[1]}" for label in unique)
+            unique = ".".join(f"{label[0]}.{sanitize(label[1])}" for label in unique)
         else:
             warnings.warn(
                 "`unique` parameter should be of type ``list[tuple[str, str]]``",


Test patch:
diff --git a/tests/core/resource/test_filter.py b/tests/core/resource/test_filter.py
index d34eb8f0..1ccd14ac 100644
--- a/tests/core/resource/test_filter.py
+++ b/tests/core/resource/test_filter.py
@@ -92,6 +92,10 @@ class FilteringTest(BaseTest):
         self.validated["querystring"] = {"lt_last_modified": bad_value}
         self.assertRaises(httpexceptions.HTTPBadRequest, self.resource.plural_get)
 
+    def test_filter_raises_error_if_last_modified_value_has_malformed_quotes(self):
+        self.validated["querystring"] = {"last_modified": '123"'}
+        self.assertRaises(httpexceptions.HTTPBadRequest, self.resource.plural_get)
+
     def test_filter_works_with_since_none(self):
         self.validated["querystring"] = {"_since": None}
         result = self.resource.plural_get()
diff --git a/tests/core/test_initialization.py b/tests/core/test_initialization.py
index bac6dd42..fd57620b 100644
--- a/tests/core/test_initialization.py
+++ b/tests/core/test_initialization.py
@@ -420,6 +420,25 @@ class MetricsConfigurationTest(unittest.TestCase):
             unique=[("method", "get"), ("endpoint", "/__heartbeat__"), ("status", "200")],
         )
 
+    def test_statsd_sanitizes_url_in_metrics(self):
+        kinto.core.initialize(self.config, "0.0.1", "settings_prefix")
+        app = webtest.TestApp(self.config.make_wsgi_app())
+        app.get(
+            "/v0/changeset'%7C%22%3F%3E%3C!DOCTYPE%22http://xh3E'),'/l')%20from%20dual)%7C'",
+            status=404,
+        )
+        self.mocked().count.assert_any_call(
+            "request_summary",
+            unique=[
+                ("method", "get"),
+                (
+                    "endpoint",
+                    "/changeset%27%257C%2522%253F%253E%253C%21DOCTYPE%2522http%3A//xh3E%27%29%2C%27/l%27%29%2520from%2520dual%29%257C%27",
+                ),
+                ("status", "404"),
+            ],
+        )
+
     def test_statsd_observe_request_size(self):
         kinto.core.initialize(self.config, "0.0.1", "settings_prefix")
         app = webtest.TestApp(self.config.make_wsgi_app())
diff --git a/tests/plugins/test_statsd.py b/tests/plugins/test_statsd.py
index e40af66b..d801eea8 100644
--- a/tests/plugins/test_statsd.py
+++ b/tests/plugins/test_statsd.py
@@ -61,6 +61,11 @@ class StatsdClientTest(unittest.TestCase):
             self.client.count("click", unique=[("component", "menu"), ("sound", "off")])
             mocked_client.set.assert_called_with("click", "component.menu.sound.off")
 
+    def test_values_are_sanitized(self):
+        with mock.patch.object(self.client, "_client") as mocked_client:
+            self.client.count("click", unique=[("user", "account:boss")])
+            mocked_client.set.assert_called_with("click", "user.accountboss")
+
     @mock.patch("kinto.plugins.statsd.statsd_module")
     def test_load_from_config(self, module_mock):
         config = testing.setUp()