From b5e1a4c5b237533361e0c583c5ed8caa0a06e751 Mon Sep 17 00:00:00 2001 From: stroblme Date: Wed, 28 Oct 2026 08:41:52 +0100 Subject: [PATCH] Request log: a hook's secret is masked in uvicorn's lines and the tunnel connector's failure line --- backend/fluksio/main.py | 25 +++++++++++++++++-------- backend/tests/test_access_log.py | 28 +++++++++++++++++++++++++++- docs/reference/node-types.md | 4 ++++ 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/backend/fluksio/main.py b/backend/fluksio/main.py index 5a969ce..dcf2f78 100644 --- a/backend/fluksio/main.py +++ b/backend/fluksio/main.py @@ -66,26 +66,35 @@ logger = logging.getLogger(__name__) #: worker's `?token=`, the pairing poll's `?secret=`. _CREDENTIAL_IN_QUERY = re.compile(r"([?&](?:[\w-]*token|[\w-]*secret|ticket)=)[^&\s]+") +#: A hook's secret, the last segment of `/hooks///`. A hook +#: without one loses its last path segment from the log too, since the log +#: cannot tell which hooks carry a secret. +_SECRET_IN_HOOK_PATH = re.compile(r"(/hooks/[^/\s?]+/(?:[^/\s?]*/)*)[^/\s?]+") + + +def _redact(text: str) -> str: + return _SECRET_IN_HOOK_PATH.sub(r"\1***", _CREDENTIAL_IN_QUERY.sub(r"\1***", text)) + class _RedactTokens(logging.Filter): - """Keep query-string credentials out of uvicorn's request lines. + """Keep credentials out of request lines, from the query string or the path. - uvicorn logs each request and websocket handshake with its query string, - and the live socket, a worker's attach and a device polling for its - pairing carry their credential there. + uvicorn logs each request and websocket handshake with its URL, and the + live socket, a worker's attach and a device polling for its pairing carry + their credential in the query, a webhook or served socket in the path. """ def filter(self, record: logging.LogRecord) -> bool: if isinstance(record.args, tuple): record.args = tuple( - _CREDENTIAL_IN_QUERY.sub(r"\1***", arg) if isinstance(arg, str) else arg - for arg in record.args + _redact(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"): +# The handshake lines go to `uvicorn.error`, the request lines to `uvicorn.access`, +# and the tunnel connector names the path of a call it could not proxy. +for _name in ("uvicorn.access", "uvicorn.error", "fluksio.cloud.connector"): logging.getLogger(_name).addFilter(_RedactTokens()) diff --git a/backend/tests/test_access_log.py b/backend/tests/test_access_log.py index 257d268..4dbe6b7 100644 --- a/backend/tests/test_access_log.py +++ b/backend/tests/test_access_log.py @@ -1,4 +1,4 @@ -"""A credential in a query string stays out of uvicorn's request lines.""" +"""A credential in a query string or a hook's path stays out of uvicorn's request lines.""" import logging @@ -52,3 +52,29 @@ def test_the_pairing_secret_is_not_logged() -> None: assert [record.getMessage() for record in records] == [ '192.0.2.1:0 - "GET /api/v1/devices/pair/ABCD2345?secret=*** HTTP/1.1" 202' ] + + +def test_a_hooks_secret_is_not_logged() -> None: + records: list[logging.LogRecord] = [] + handler = logging.Handler() + handler.emit = records.append # type: ignore[method-assign] + log = logging.getLogger("uvicorn.access") + level = log.level + log.addHandler(handler) + log.setLevel(logging.INFO) + try: + for path in ( + "/hooks/home/tick/s3cr3t", + "/hooks/home/a/b/s3cr3t?x=1", + "/hooks/home//s3cr3t", + ): + log.info('%s - "%s %s HTTP/%s" %d', "192.0.2.1:0", "POST", path, "1.1", 202) + finally: + log.removeHandler(handler) + log.setLevel(level) + + assert [record.getMessage() for record in records] == [ + '192.0.2.1:0 - "POST /hooks/home/tick/*** HTTP/1.1" 202', + '192.0.2.1:0 - "POST /hooks/home/a/b/***?x=1 HTTP/1.1" 202', + '192.0.2.1:0 - "POST /hooks/home//*** HTTP/1.1" 202', + ] diff --git a/docs/reference/node-types.md b/docs/reference/node-types.md index 014902a..6ec0be1 100644 --- a/docs/reference/node-types.md +++ b/docs/reference/node-types.md @@ -83,6 +83,10 @@ publishes nothing. Through the [portal](../interface/portal.md#hooks-and-agents-through-the-portal) a webhook or served socket answers only with a secret. +The secret is part of the URL of a webhook or a served socket. The engine's own +request log prints it as `***` (and the last path segment of a hook without +one), while a reverse proxy in front of the engine logs the path as it sees it. + ### WebSocket **`websocket`** — listen on a socket, or send to it, as a client or a server.