Request log: a hook's secret is masked in uvicorn's lines and the tunnel connector's failure line

This commit is contained in:
2026-10-28 08:41:52 +01:00
parent 5c7a8773ad
commit b5e1a4c5b2
3 changed files with 48 additions and 9 deletions
+17 -8
View File
@@ -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/<flow>/<path>/<secret>`. 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())
+27 -1
View File
@@ -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',
]
+4
View File
@@ -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.