Log each request once - #420
Open
krokicki wants to merge 2 commits into
Open
Conversation
AccessLogMiddleware logs every request with the authenticated username and duration, and Uvicorn's own uvicorn.access logger logged it again without them. Uvicorn's --no-access-log suppresses the second copy, but it has to be repeated on every launch command and three of six were missing it: dev-launch-remote, prod-launch-remote, and dev_launch.py. prod-launch-remote is the one that serves data, so every chunk a viewer pulled was logged twice. The app now silences the logger it replaces, next to where the middleware is installed, so no launcher has to remember. This runs the same two lines Uvicorn's own access_log=False does (config.py: clear handlers, stop propagation), and both HTTP protocol implementations gate on access_logger.hasHandlers() before building the record -- so this takes the identical fast path, with no LogRecord constructed per request. That makes the remaining --no-access-log flags and access_log=False kwargs exactly redundant, so they're removed: one mechanism instead of a flag that was already unevenly applied. Uvicorn configures logging before importing the app, in --reload and --workers subprocesses too, so the app-side call always wins. Verified against a launcher that lacked the flag: two access lines before, one after, zero in Uvicorn's format, and Uvicorn's lifecycle logs (startup, shutdown) untouched since only uvicorn.access is silenced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The access log used request.url.path, which is built from scope["path"] and so is percent-decoded per the ASGI spec. A filename containing a literal '%' was sent as '%25' and logged as '%', so the line no longer round-tripped to the request that was actually made -- visible as a mismatch against Uvicorn's own access log, which reports the raw target. Log raw_path instead, falling back to the decoded path since ASGI marks raw_path optional, and escape control characters in both the path and the query string. The escaping is not redundant: Starlette's URL drops CR/LF because urlsplit strips them, but ESC and '|' survive, and both are client-controlled text landing in a log line -- ESC can recolour a terminal tailing it, and '|' is loguru's own field separator. The injection test drives a hand-built ASGI scope rather than TestClient, since httpx normalizes control characters away and cannot express the request being defended against. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Logged twice
AccessLogMiddlewarelogs every request with the authenticated username and duration; Uvicorn's ownuvicorn.accesslogger logged it again without them.--no-access-logsuppresses the second copy, but it has to be repeated on every launch command, and three of six were missing it:dev-launch-remote,prod-launch-remote, anddev_launch.py.prod-launch-remoteis the one that serves data, so every chunk a viewer pulled was logged twice.The app now silences the logger it replaces, next to where the middleware is installed, so no launcher has to remember. That runs the same two lines Uvicorn's own
access_log=Falsedoes (config.py:396-398: clear handlers, stop propagation), and both HTTP protocol implementations gate onaccess_logger.hasHandlers()before building a record (httptools_impl.py:59,h11_impl.py:54) — so this takes the identical fast path, with noLogRecordconstructed per request.That makes the remaining
--no-access-logflags andaccess_log=Falsekwargs exactly redundant, so they're gone: one mechanism instead of a flag that was already unevenly applied. I checked the gate before deleting them — had Uvicorn testedconfig.access_lograther thanhasHandlers(), the flags would have been the cheaper option on a per-chunk path and this would have gone the other way.Uvicorn configures logging before importing the app, in
--reloadand--workerssubprocesses too, so the app-side call always wins.Logged decoded
The middleware used
request.url.path, which Starlette builds fromscope["path"]— percent-decoded per the ASGI spec. A filename containing a literal%is sent as%25and was logged as%, so the line didn't round-trip to the request that was actually made. That's the difference visible above.Now logs
raw_path(the encoded target, which is what Uvicorn's own access log reports), falling back to the decoded path since ASGI marksraw_pathoptional, and escapes control characters in both the path and the query string.On the escaping, stated precisely rather than as a scare: Starlette's
URLgoes throughurlsplit, which strips CR/LF, so forged log lines were already impossible by accident. What survives is ESC and|:Both are client-controlled text landing in a log line: ESC can recolour a terminal tailing it, and
|is loguru's own field separator, so a decoded|in a path corrupts field parsing. So the change stands on fidelity, with the escaping as real but narrow hardening.Validation
pixi run -e test test-backend— 770 passed (768 on main, plus the two new tests intests/test_log.py)uvicorn.accessis silencedcurl --path-as-is: the reported path logs as..._1%25_0.2%25_1hr_..., and%1B%5B2Jy%7Czlogs inert with no ESC byte reaching the fileTestClient— httpx normalizes control characters away, so it cannot express the request being defended against, and a first version of the test passed for the wrong reason@StephanPreibisch @JaneliaSciComp/fileglancer