fix: stop camera credentials leaking through logs and the monitor settings UI - #308
Conversation
…structured A camera's password lives in the monitor's source path as URL userinfo (rtsp://admin:secret@cam/live). Redaction never found it: sanitizeUrl fires only on strings starting http:// or https://, and sanitizeLogMessage matches only /https?:\/\//, so rtsp, rtmp, and anything mid-string passed through verbatim. The HTTP client logs response bodies, and its failure branch logs at ERROR, which clears the default level with no opt-in, so a failed monitors.json request wrote every camera credential to the log file. lib/security/url-credentials.ts owns the one regex that knows where a password sits in a URL, whatever the scheme, wherever in the string. The two copies of the string rules in log-sanitizer collapse into one sanitizeString that runs it first. URL handling now precedes form-data handling, so a query string with a password in it stays a readable URL instead of being percent-encoded into one blob. The ERROR path serialized its details with JSON.stringify before sanitizing. The sanitizer redacts by key, and a flattened object has no keys, so every secret in an ERROR detail survived across ~130 call sites. Details are passed as objects at every level now; both sinks already pretty-print them. Also: cookie and credential join the sensitive keys (ZMSESSID rode out in logged headers), a sensitive key holding an object is recursed into rather than stringified to '[obje...', a single-field form body no longer needs an '&' to be recognized, and console.error(error.stack) is sanitized, since the stack embeds the message where the URL usually is. Refs #307 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Server tab maps zmLog.Message straight into the display entry, and the Save and Share buttons export whatever is on screen. zmc and zma log the ffmpeg command line they run, which carries the camera credential, so the log file users attach to a bug report handed out every camera password on the server. Same sanitizeLogMessage the app's own entries go through, applied where the server's entries enter the view. Refs #307 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ZoneMinder returns Path, User, and Pass to any account that can view a monitor, and pre-1.38 servers have nowhere but the source path to keep a camera password. The dialog rendered both verbatim, with a reveal toggle on the password field, so the credentials for every camera were one tap and one screenshot away. While log redaction is on, the source path shows with its password segment masked and the Pass field loses its reveal button. Host, port, and stream path stay readable: that is what makes the field worth showing. Both fields stay editable. maskUrlCredentials produces what is on screen and restoreUrlCredentials puts the real password back on save, so changing a camera's hostname does not silently wipe a password the user cannot see. Typing over the mask means the new value is the new password. The same helpers back the log sanitizer, so what the UI hides and what the logs hide cannot drift. Refs #307 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PipActivity logged the URL it was handed and the raw media3 playback error. The URL is an event video URL, which carries the ZoneMinder access token as a query parameter, and a media3 HTTP failure quotes the URL it failed on with the cause chain repeating it. Both went to logcat in cleartext, where any adb session or bug report picks them up. The onCreate line logs whether a URL arrived rather than the URL. The error line logs the error code name plus a message with query strings stripped, and drops the throwable, whose cause chain reprinted the URL. Not covered by the JS sanitizer: this is native logging, on the other side of the bridge from lib/logger.ts. No gate reaches it, so it is prose in agents/project/native.md rather than a lint rule. Refs #307 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Follow-up after checking the other two log sinks. Capacitor log file: covered. JS HTTP logs: covered. Probed the real Everything sensitive is gone and the line stays readable. Native logging: was not covered, now fixed. The sanitizer lives on the JS side of the bridge and nothing native goes through it.
No gate reaches native logging, so the rule went into
Claude assisting @pliablepixels |
Closes #307 once the maintainer confirms.
A user testing with an unprivileged ZoneMinder account found that the client receives and displays every camera's connection settings, credentials included. The API side is upstream (ZoneMinder has no per-field ACL on
monitors.json), but the client was making it worse: log redaction did not recognize the shape a camera password takes, and the settings dialog put it on screen behind a reveal button.Logs
Each of these was verified by probing the real
sanitizeObject/loggerwith redaction on, not by reading the code.sanitizeUrlfires only onstartsWith('http://'|'https://'),sanitizeLogMessagematches only/https?:\/\//.Monitor.Pathisrtsp://admin:secret@cam/live. Newlib/security/url-credentials.tsowns one scheme-agnostic regex, applied to every string the sanitizer sees, at any position, soMonitor.Options(-i rtsp://admin:secret@cam/live) is covered too.log-sanitizer.tscollapse into onesanitizeString. They had drifted; that is what let a value-position credential through while a message-position one was caught. URL handling now runs before form-data handling, so a query string with a password stays a readable URL instead of being percent-encoded into one blob.JSON.stringifybefore sanitizing. The sanitizer redacts by key and a flattened object has none, so every secret in an ERROR detail survived, across ~130 call sites. Details are passed as objects at every level now.cookieandcredentialjoin the sensitive keys. ZMSESSID was riding out in logged request and response headers.'[obje...'; a single-field form body (pass=secret) no longer needs an&to be recognized;console.error(error.stack)is sanitized, since the stack embeds the message where the URL usually is.zmLog.Messagestraight to the display entry, and Save/Share export what is on screen.zmclogs the ffmpeg command line it runs. That is the file users attach to bug reports.Reachability, for severity: the HTTP client logs response bodies, per-component levels are user-settable, and the HTTP failure branch logs at ERROR, which clears the default INFO level with no opt-in at all.
UI
Decided with the maintainer:
Passfield keeps its row and stays editable, but loses the reveal-eye toggle while redaction is on.Pathdisplays with the password segment masked; host, port, and stream path stay readable.restoreUrlCredentialsputs the real password back on save, so changing a hostname does not wipe a password the user cannot see. Typing over the mask makes what was typed the new password.Not in scope
The assistant already allowlists monitor fields in
mapMonitor, so no credential reaches a model. And none of this substitutes for the upstream problem: any account that can view a monitor cancurlthe credentials directly. Both user docs say so.Verification
npm run gatespasses: 3038 tests, build, three blocking lints within baseline. 30 new tests acrossurl-credentials,log-sanitizer,logger-error-details,Logs, andMonitorSettingsDialog; each was watched failing before the fix landed.No e2e scenario: the masking outcome depends on a monitor whose stored path actually contains a credential, which the live test server does not guarantee. The component tests assert the real outcomes instead (rendered value, and the exact save payload for both the edited-host and typed-over-mask cases).
Docs:
user-guide/logs.mdgains a "What gets redacted" section,user-guide/monitors.mda "Camera Credentials" section,developer-guide/12covers the new module and the pass-details-as-objects rule.agents/project/domain-context.mdrecords the API behavior per M5.Claude assisting @pliablepixels
🤖 Generated with Claude Code