From 71b0f986e9772316a99aa0a5da68b24ef1e3a965 Mon Sep 17 00:00:00 2001 From: stroblme Date: Tue, 6 Oct 2026 17:32:51 +0200 Subject: [PATCH] Keep query-string tokens out of the uvicorn, nginx and Traefik access logs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ --- backend/fluksio/main.py | 25 +++++++++++++++++++++++++ backend/tests/test_access_log.py | 28 ++++++++++++++++++++++++++++ docker/compose.dev.yml | 1 + docker/compose.traefik.yml | 3 +++ frontend/nginx.conf | 16 ++++++++++++++++ 5 files changed, 73 insertions(+) create mode 100644 backend/tests/test_access_log.py diff --git a/backend/fluksio/main.py b/backend/fluksio/main.py index 88f2641..638f1d9 100644 --- a/backend/fluksio/main.py +++ b/backend/fluksio/main.py @@ -4,6 +4,7 @@ import hashlib import inspect import ipaddress import logging +import re import sys import tempfile from collections.abc import AsyncIterator, Callable, Coroutine @@ -58,6 +59,30 @@ from fluksio.flow.workers import PythonWorkerPool logger = logging.getLogger(__name__) +#: A credential in a query string: the live socket's and a worker's `?token=`. +_TOKEN_IN_QUERY = re.compile(r"([?&][\w-]*token=)[^&\s]+") + + +class _RedactTokens(logging.Filter): + """Keep query-string credentials out of uvicorn's request lines. + + uvicorn logs each request and websocket handshake with its query string, + and the live socket and a worker's attach carry their token there. + """ + + def filter(self, record: logging.LogRecord) -> bool: + if isinstance(record.args, tuple): + record.args = tuple( + _TOKEN_IN_QUERY.sub(r"\1***", arg) if isinstance(arg, str) else arg + for arg in record.args + ) + return True + + +# The handshake lines go to `uvicorn.error`, the request lines to `uvicorn.access`. +for _name in ("uvicorn.access", "uvicorn.error"): + logging.getLogger(_name).addFilter(_RedactTokens()) + def custom_generate_unique_id(route: APIRoute) -> str: return f"{route.tags[0]}-{route.name}" diff --git a/backend/tests/test_access_log.py b/backend/tests/test_access_log.py new file mode 100644 index 0000000..fa830e2 --- /dev/null +++ b/backend/tests/test_access_log.py @@ -0,0 +1,28 @@ +"""A token in a query string stays out of uvicorn's request lines.""" + +import logging + +import fluksio.main # noqa: F401 - installs the filter + + +def test_a_token_in_the_query_is_not_logged() -> None: + records: list[logging.LogRecord] = [] + handler = logging.Handler() + handler.emit = records.append # type: ignore[method-assign] + log = logging.getLogger("uvicorn.error") + level = log.level + log.addHandler(handler) + log.setLevel(logging.INFO) + try: + log.info( + '%s - "WebSocket %s" [accepted]', + "192.0.2.1:0", + "/api/v1/flows/ws?token=s3cr3t&x=1", + ) + finally: + log.removeHandler(handler) + log.setLevel(level) + + assert [record.getMessage() for record in records] == [ + '192.0.2.1:0 - "WebSocket /api/v1/flows/ws?token=***&x=1" [accepted]' + ] diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml index 2618b3e..e9fa1e7 100644 --- a/docker/compose.dev.yml +++ b/docker/compose.dev.yml @@ -21,6 +21,7 @@ services: - --entrypoints.http.address=:80 - --entrypoints.https.address=:443 - --accesslog + - --accesslog.fields.queryparameters.defaultmode=drop - --log - --log.level=DEBUG - --api diff --git a/docker/compose.traefik.yml b/docker/compose.traefik.yml index b5b2088..0353564 100644 --- a/docker/compose.traefik.yml +++ b/docker/compose.traefik.yml @@ -57,6 +57,9 @@ services: - --certificatesresolvers.le.acme.tlschallenge=true # Enable the access log, with HTTP requests - --accesslog + # Without query strings: the live socket and a worker's attach carry their + # token there, and Traefik can drop query values but not mask them + - --accesslog.fields.queryparameters.defaultmode=drop # Enable the Traefik log, for configurations and errors - --log # Enable the Dashboard and API diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 7690ac0..d7bab3f 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,5 +1,21 @@ +# The live socket carries its credential as `?token=`, and so does a password +# reset link, which then rides along as the Referer of everything that page +# loads. nginx's own format logs both as sent; this one masks the value. +map $request $request_masked { + "~^(?.*?[?&][\w-]*token=)[^&\s]*(?.*)$" "${request_head}***${request_tail}"; + default $request; +} +map $http_referer $referer_masked { + "~^(?.*?[?&][\w-]*token=)[^&\s]*(?.*)$" "${referer_head}***${referer_tail}"; + default $http_referer; +} +log_format masked '$remote_addr - $remote_user [$time_local] "$request_masked" ' + '$status $body_bytes_sent "$referer_masked" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + server { listen 80; + access_log /var/log/nginx/access.log masked; # nginx's mime.types predates the manifest, so without this it goes out as # application/octet-stream.