Request log: a hook's secret is masked in uvicorn's lines and the tunnel connector's failure line
This commit is contained in:
+17
-8
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user