Repository navigation
Fix crash logging non-UTF-8 narrow strings - #523
marcusfreisleben wants to merge 1 commit into
Conversation
utility::s2us assumes UTF-8, throws std::range_error otherwise. Log messages carry arbitrary narrow text, e.g. std::exception::what(), with no such guarantee - on Windows typically the ANSI code page. Throw lands on the async logging worker thread, where slog's message_service::run catches only stopped_exception, so it escapes the thread function and terminates the process. Add utility::s2us_lenient: UTF-8 fast path, Windows ANSI code page fallback, Latin-1 otherwise. Never throws, never discards a message. s2us contract unchanged. Validation uses conversions::to_utf16string, the same decoder s2us uses on a wide build, so the fast path accepts exactly what s2us accepts. Note that decoder is not a strict UTF-8 validator - it tolerates surrogates, overlongs and code points above U+10FFFF - so a hand-rolled check would have diverged. Converts s rather than the decoded value, so the result is identical to s2us wherever s2us succeeded, on narrow builds too. Skips the ANSI code page when it is itself UTF-8 (Windows 10 and later), where it would repeat the failed decode and substitute U+FFFD, losing the characters at issue. Applied in json_from_message (message, file, function, categories) and at the what() boundaries in the API error responses. Guard log_gate::service as a last resort, so no logging failure can terminate the application. The invalid bytes also reached the JSON on narrow builds, i.e. the Logging API emitted invalid UTF-8; testInsertLogEventNonUtf8 covers that on all platforms and fails against the unfixed code. Built and tested on darwin only. The ANSI code page branch is untested.
|
How does this relate to #109? |
|
I wonder what the minimum blast radius is... is it better to wrap every external API call that can return/throw a non-UTF8 string or to handle such strings as close as possible to outputting them? |
It does sound like it could be related, yeah. Fixing it defensively at the consumer side has the benefit of also covering other non-Boost sources and we don't have to diverge from the official conan boost recipe. |
|
Hi @marcusfreisleben, thanks for this! Initial (🤖 assisted) review:
|
| Call | Notes |
|---|---|
json_from_message: message, __FILE__, __FUNCTION__ |
Right. This is the logging-thread crash. |
set_error_reply(response, code, exception) |
Right. what() has no UTF-8 guarantee, and range_error is a runtime_error, so a throw here makes the API exception handler call this function again. |
Connection, channel-mapping, and authorization-redirect helpers that copy debug.what() into an error body |
Right. Same pattern, local copies of that helper. |
Control-protocol websocket e.what() error messages |
Right. Same bytes, sent as an error message rather than an HTTP body. |
Authorization error.message in api_utils.cpp |
Right for the JSON body. That std::string is often e.what() (authorization.cpp, authorization_handlers.cpp). |
Log categories in json_from_message |
Maybe unnecessary? 🤔 |
Agree slog << e.what() at the individual log statements does not need its own conversion. The narrow bytes sit in the message until json_from_message converts them once.
Where to keep strict s2us
Strict s2us should stay for data that doesn't come from the local system or a spec says is UTF-8 or ASCII, because a replacement character or a code-page guess would be published as if it were the real value:
- LLDP chassis and port IDs written onto the Node resource. MAC IDs are already formatted as hex; other subtypes may contain opaque octets, which should not be interpreted using a platform code page.
- mDNS instance names, domains, and hostnames (UTF-8 or ASCII). NMOS TXT values used for discovery (
api_proto,api_ver,pri,api_auth) are ASCII. A binary TXT value in the experimental mDNS API is opaque bytes; RFC 6763 §6.5 says to show those as a hex dump, not to invent a character. Hmm. 🤔 - SDP fields. RFC 4566 requires UTF-8 except for
s=andi=whena=charsetsays otherwise, and this code does not implementa=charset. A bad byte inc=ora=fmtpmust fail the parse. Probably? 🤔 - JWT claims, PEM, JSON schemas, settings from
argv, numeric ids passed throughs2us(std::to_string(...)).
Suggestions
- Drop lenient conversion for categories.
- Decode once. On a wide build, return the
to_utf16stringresult instead of decoding again withto_string_t. On a narrow build, that call is only the validity check. - Keep the Windows
CP_ACPfallback for a string that is not valid UTF-8. Drop the Latin-1 fallback. Where the Windows fallback is not used, replace each malformed sequence with U+FFFD and leave the valid UTF-8 around it alone. What do you think? - Move the
tryinlog_gate::serviceabovemodel.write_lock(). Maybe? The catch is right; the lock acquisition is part of the worker and should be inside it, I think? - Tests. One Windows case for
0xFCunder CP1252 becoming U+00FC. One case for a malformed sequence surrounded by valid UTF-8, asserting the valid part survives and only the bad sequence becomes U+FFFD. The current invalid-byte test only checks that the result is non-empty and well-formed. - Authorization header. The JSON body can carry the lenient string.
error_descriptiononWWW-Authenticateis ASCII (RFC 6749); a replacement character does not belong in that parameter. The error code (invalid_token,insufficient_scope) is already there.
Problem
Logging a message containing non-UTF-8 narrow bytes (e.g. a
std::exception::what()built from a Windows ACP/CP1252 string, or a__FILE__path with an accented character) crashes the app.utility::s2usrequires valid UTF-8 and throwsstd::range_errorotherwise.nmos::experimental::details::json_from_messagecalls it directly onmessage.str(),message.file(),message.function()and categories, on the async logging worker thread.slog::message_service::runonly catches its ownstopped_exception, so the throw escapes the thread function and terminates the process.Confirmed on a german Windows 11 installation.
Fix
utility::s2us_lenient(cpprest/basic_utils.h/.cpp): UTF-8 fast path, Windows ANSI code page fallback, Latin-1 fallback otherwise. Never throws, never discards the message.s2us's contract and existing call sites are unchanged.conversions::to_utf16string, the same decoders2ususes on a wide build, so the fast path accepts exactly whats2usaccepts. Note this decoder is not a strict UTF-8 validator (tolerates surrogates, overlongs, code points above U+10FFFF) - a hand-rolled check would have diverged from it and reopened the bug.s2uswould have returned, on narrow builds too.MultiByteToWideCharwould just repeat the failed decode and substitute U+FFFD, losing the characters at issue.json_from_message(message, file, function, categories) and at thewhat()/error-message boundaries in the API error responses (api_utils,connection_api,channelmapping_api,authorization_redirect_api,control_protocol_ws_api).log_gate::serviceas a last-resort safety net, so no future logging failure can terminate the app.Testing
basic_utils_test.cpp: valid UTF-8 fast path, invalid-byte fallback (CP1252 umlaut and others), decoder-tolerated sequences pass through unchanged, empty input.log_gate_test.cpp:testInsertLogEventNonUtf8builds a log message with CP1252 umlaut bytes through the same path that crashed and asserts the emitted JSON is valid UTF-8. This fails against the pre-fix code (confirmed) - on non-Windows too, sinces2usis a pass-through there and the invalid bytes previously reached the JSON unmodified.Open question for maintainers
There are ~15 other
utility::s2us(...)call sites taking narrow strings of uncertain origin (mDNS host names, JWT claims, OpenSSL PEM output, etc). Left out of this PR to keep it focused on the crash. Happy to follow up withs2us_lenientat those sites too if that's wanted, or to folds2us_lenientbehavior intos2usitself if you'd rather not carry two functions - whichever you prefer.