Keep query-string tokens out of the uvicorn, nginx and Traefik access logs
This commit is contained in:
@@ -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}"
|
||||
|
||||
@@ -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]'
|
||||
]
|
||||
@@ -21,6 +21,7 @@ services:
|
||||
- --entrypoints.http.address=:80
|
||||
- --entrypoints.https.address=:443
|
||||
- --accesslog
|
||||
- --accesslog.fields.queryparameters.defaultmode=drop
|
||||
- --log
|
||||
- --log.level=DEBUG
|
||||
- --api
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
"~^(?<request_head>.*?[?&][\w-]*token=)[^&\s]*(?<request_tail>.*)$" "${request_head}***${request_tail}";
|
||||
default $request;
|
||||
}
|
||||
map $http_referer $referer_masked {
|
||||
"~^(?<referer_head>.*?[?&][\w-]*token=)[^&\s]*(?<referer_tail>.*)$" "${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.
|
||||
|
||||
Reference in New Issue
Block a user