fix(account): log a classified reason instead of the caught error on login failure - #368
Conversation
Zaldaryon
left a comment
There was a problem hiding this comment.
Changes requested
The raw error message is no longer logged, and the focused regression suite passes. Two correctness gaps still prevent this from being safe to merge.
The new classifier must not interpolate mutable Error.code or Error.name values merely because they match a regular expression. Both fields can carry a valid-format credential, and the same outer catch also handles account-store failures. The inline comments show the concrete cases.
Please use explicit allowlists or fixed categories for every value that reaches the log, add regressions with identifier-shaped secrets in both mutable fields, and keep storage failures distinct from network failures. Then update the branch onto the current dev tip and rerun the required checks.
Verification
- Focused IPC tests: 44 passed.
- Local
npm run typecheck: passed. - Local
npm run lint:ci: 0 errors and 15 existing React hook warnings. - Local
npm run format:check: passed. - GitHub Actions run 33926229635 passed
typecheck,lint,test, and both required platform builds. SonarCloud also passed as an informational check. - The local full coverage run had three renderer-dom failures outside the changed area. The remote test matrix passed.
- The current remote
devtip isa6e91358, four commits ahead of the PRa071d898base. The branch needs updating before re-review.
9b47e42 to
a17246b
Compare
|
@Zaldaryon Your three findings were all correct, and the third one taught me something about my own reasoning: I had convinced myself that an anchored, length-capped pattern proved provenance, and it proves nothing at all. Shape is not origin. The branch is rebased onto the current Writable The Writable Tests: Seven mutants, each applied alone to the committed tree and restored, are in the description; the three that reintroduce exactly what you flagged go red on the tests named after the leak or the misdiagnosis. Gates on the rebased head: typecheck passes, |
Zaldaryon
left a comment
There was a problem hiding this comment.
Changes requested
The classifier still copies mutable HTTP status digits into the log.
At loginFailureReason.ts:182-183, the message Network request failed with status 503 becomes http-status-503. The network implementation creates that message at network.ts:169, and validation.ts:92-97 accepts 503 as a password. A login using password 503 can therefore write the password itself to the log.
Please replace the status-derived token with a fixed HTTP failure category, or another explicit allowlist that cannot carry arbitrary digits. Add a real handler regression with password 503 and a transport rejection carrying status 503, and assert that no log line contains the password.
The head is also six commits behind current dev (1d5280a3). Update the branch, rerun the five required checks, and resolve the three open review threads before requesting approval.
2f171e6 to
4f0044f
Compare
|
@Zaldaryon The finding is correct, and it is the same mistake as the last three, one layer down. I had kept the status digits because the message is one we throw ourselves, so I treated the provenance of the message as proof of the safety of its contents. It is not. The status is now parsed with I kept the named table rather than collapsing to the two ranges because telling a Regressions, both through the real handler: "logs no credential when the password is the same digits as the HTTP status" logs in with password Two mutants for this round, each applied alone to the committed tree, run, then restored with Branch rebased onto The three inline threads from the previous round are resolved. Each carries a reply naming the fix and the test that pins it. |
4f0044f to
eb2d373
Compare
Zaldaryon
left a comment
There was a problem hiding this comment.
Changes requested
The current classifier fixes the three data handling defects from the previous review. It now uses fixed lookup tables for mutable Error.code, mutable Error.name, HTTP status text, and storage failures. The focused IPC suite passes 54 tests.
The head is no longer current with dev. PR #375 merged into dev at 5fa04624a76e4433bf16fe86e1c73d51d657fe4d, while this PR still targets the previous base 1d5280a3db7efc6af7bb2adb32d7214530c1d446. Its required checks therefore do not cover the current integration tip.
Rebase fix/login-log-redaction onto the current dev, rerun typecheck, lint, test, build (ubuntu-latest), and build (windows-latest), then request a fresh review. I am not approving or merging this head until that freshness gate passes.
Verification
- Reviewed head:
4f0044f78a2170dc85a6358deffbc363b8e606ed. - Current
dev:5fa04624a76e4433bf16fe86e1c73d51d657fe4d. - Focused IPC tests passed: 54 cases.
- The previous required checks passed on the previous base.
…login failure The LOGIN handler's catch logged getErrorMessage(error) at debug level. That message comes from whatever threw, and this catch is the only place in the launcher where a password, a two-factor code and a pre-login token are all in scope at once. redactSensitiveText only rewrites password: value, password=value and absolute paths, so a bare secret sitting in prose reached the log file untouched, and that file is what players paste into bug reports. loginFailureReason maps the caught error onto a fixed vocabulary (timeout, response-too-large, http-status-503, network-ENOTFOUND, secure-storage-unavailable, unclassified-TypeError) built only from literals spelled out in that module and from substrings the guards prove are digits or a screaming-snake-case identifier. The message itself never reaches the log, so a future thrower that echoes its input cannot leak through this line, and a maintainer can still tell an outage from a DNS failure from a missing keyring. Tests stub the transport and the account store to throw with the secrets spliced into the message, run the real handler, and read what actually reached electron-log after redaction. Closes #352
…he error's own fields The classifier still copied two writable properties into the log. Error.code was accepted on shape alone, so an error carrying an identifier-shaped password came out as network-PASSWORD123, and the fallback appended Error.name, so a name that was itself a passphrase came out as unclassified-CorrectHorseBatteryStaple. Both reached the log file players attach to bug reports. Every value the classifier can return is now a literal spelled out in the module. code and name are read as lookup keys into tables and never copied: a socket or TLS code that is in the network table logs that table's own string, anything else logs network-other, and a class name outside the small built-in list logs unclassified. The network prefix was also wrong for half the errors reaching it. The handler's catch wraps the round trip and the account-store write alike, so a full disk was reported as network-ENOSPC. saveAccountSecrets failures now leave settle() wrapped in AccountStorageFailure, which is the one place that still knows the origin, and the classifier maps those onto their own tokens: storage-no-space, storage-permission, storage-locked, storage-io, storage-other, next to the existing secure-storage-unavailable and no-system-password-store. Tests put an identifier-shaped secret in both mutable fields and assert it reaches no log line, and run ENOSPC and EACCES from the store write through the real handler to pin that they are named as storage and never as network.
The status in "Network request failed with status 503" comes from the response's own status line, and assertString accepts 503 as a password, so splicing those digits into the reason wrote the password to the log as soon as the auth service went down. The status is now parsed as a number and used as a lookup key into HTTP_STATUSES, the same treatment code and name already get. What reaches the log is that table's own literal, with http-4xx, http-5xx and http-other for statuses outside it, so a 401 still reads differently from a 503 and no digit from the response is ever formatted into a token.
eb2d373 to
18f594d
Compare
Zaldaryon
left a comment
There was a problem hiding this comment.
The findings from the last rounds are all closed, and the rebase onto 7a90aa3 changed nothing: git range-diff shows all three commits identical.
Every value loginFailureReason can return is a literal spelled out in src/ipc/handlers/loginFailureReason.ts: the NETWORK_MESSAGES, STORAGE_MESSAGES, HTTP_STATUSES, NETWORK_CODES, STORAGE_CODES and ERROR_NAMES map values, the http-4xx / http-5xx / http-other range buckets, and the bare network-other, unclassified and non-error-throw fallbacks. Nothing off the error is interpolated on any path. codeOf returns "" for a non-string, so a non-string code cannot become a lookup key, and STATUS_MESSAGE is anchored so a longer message is not sliced for its middle. The HTTP status is parsed with Number and used only as a HTTP_STATUSES key, so the digits never reach a token, which is the fix for the 503-as-password case.
The storage and network split is real: AccountStorageFailure is thrown at the one call site in accountHandlers.ts that still knows the origin, and loginFailureReason checks for it first. The test at tests/ipc/loginFailureReason.test.ts pins that the same ETIMEDOUT maps to network-ETIMEDOUT bare and storage-other wrapped.
The tests hold the property rather than the implementation. They use identifier-shaped secrets on purpose (PASSWORD123, CORRECT_HORSE_9) and assert the value appears nowhere in the answer, and accountHandlers.test.ts reads what actually reached electron-log after the main-process redaction. npx vitest run tests/ipc/loginFailureReason.test.ts tests/ipc/accountHandlers.test.ts is 54 passing. Full local gate is green: typecheck, lint:ci (0 errors, 15 pre-existing hook warnings), format:check, test:coverage at 93.43% statements above the 87 floor, build:unpack on Electron 44.1.1.
Approving.
Summary
The LOGIN handler's catch logged
getErrorMessage(error)at debug level. That message comes from whatever threw, and this catch is the only place in the launcher where a password, a two-factor code and a pre-login token are all in scope at once.redactSensitiveTextonly rewritespassword: value,password=valueand absolute paths, so a bare secret sitting in prose went through untouched, into the log file players paste into bug reports.Nothing on this path puts a credential in an error message today. The point is that the line could not tell: the message came from code
accountHandlers.tsdoes not own, so the property worth holding is "the message never reaches the log", not "no current thrower misbehaves".Which mechanism, and why
The issue offered two. I took the first: drop the raw message and log a classified reason.
The per-call exact-match scrub is the one that looks safer and is not. It only catches the secret verbatim. A message that URL-encodes the body (the request body is a
URLSearchParams, so+for space and%40for@is the likely shape), JSON-escapes it, changes its case, or truncates it at a length cap all defeat an exact match, and each of those is exactly what an HTTP client echoing a request body does. It also has to be re-applied by hand at every future call site that touches a secret, which is the kind of rule that holds until someone adds a third log line.Dropping the message closes the whole class instead of enumerating it, and it matches what this file already does elsewhere:
settlelogsverdict.serverReason(a server enum) andverdict.diagnosis(a field name and a type, never a value), both chosen for exactly this reason, and the renderer already collapses every refusal into one message. It is also the smaller change.loginFailureReasoninsrc/ipc/handlers/loginFailureReason.tsmaps the caught error onto a fixed vocabulary. It sits in the IPC layer rather thansrc/domainon purpose: its entire content is knowledge of whatsrc/ipc/network.tsandsrc/ipc/accountStore.tsthrow, andsrc/domainis forbidden from importing either (theno-restricted-importsrule in.eslintrc.cjs). It lives besideaccountLoginOutcome.tsandloginRequestBody.ts, which are pure modules pulled out ofaccountHandlers.tsfor the same testability reason.Every logged value is a literal from this module
The first version of the classifier accepted
codeandnameon shape, matching them against an anchored identifier pattern and splicing the accepted value into the token. Zaldaryon was right that this was not safe. Both properties are public and writable, so an identifier-shaped value proves nothing about where it came from: a screaming-snake-case password is a valid password, andObject.assign(new Error("boom"), { code: "PASSWORD123" })producednetwork-PASSWORD123in the log.No value the error carries is copied any more.
codeandnameare read as lookup keys into tables, and what gets logged is the table's own string:NETWORK_CODESlists the socket and TLS failures the login round trip can actually surface: the DNS, routing and peer codeshttp(s).requestreports onerror, plus the certificate ones (CERT_HAS_EXPIRED,UNABLE_TO_VERIFY_LEAF_SIGNATUREand the rest), which belong on the list because the login pass goes tohttps://auth3.vintagestory.atthrough the Node transport and a rejected certificate arrives on that sameerrorevent. A code that is not in the table logsnetwork-other.STORAGE_CODEScovers what the account-store write reports when the filesystem refuses it, grouped intostorage-no-space,storage-permission,storage-lockedandstorage-io, withstorage-otherfor the rest.ERROR_NAMESkeeps the handful of built-in classes worth telling apart (Error,TypeError,RangeError,SyntaxError,ReferenceError,AbortError). Everything else is plainunclassified, with nothing appended.HTTP_STATUSESnames the statuses the auth service actually answers with (http-unauthorized,http-forbidden,http-rate-limited,http-unavailableand the rest), keyed by the status parsed out of the message as a number.Zaldaryon was right about the status too, and it is the same mistake one layer down. I had kept the three digits because the message is one this repository throws itself, so I treated the shape of the message as proof of the safety of its contents. It is not:
network.ts:169writes those digits from the response's own status line, andassertStringaccepts503as a password, so a player whose password is503had it written to the log the moment the auth service went down. Nothing unusual had to happen, and no thrower had to misbehave.The status is now parsed with
Numberand used as a lookup key, exactly likecode. What reaches the log isHTTP_STATUSES' own literal, and a status outside the table degrades to its range:http-4xxwhen the service rejected us,http-5xxwhen the service is broken,http-otherfor anything else, including a redirect this transport does not follow and the literalunknownwhen the response had no status line at all. I kept the named table over collapsing everything to the two ranges because telling a401from a429is the first split I want in a field report, and those names are literals in the module. No formatted number reaches a token now.The network and storage split
The
network-prefix was also wrong for half of what reached it. The handler's catch wraps the round trip and the account-store write alike, so anENOSPCfrom the keyring write was reported asnetwork-ENOSPC, which sends whoever reads the field report after the wrong problem.The split lives at the call site that still knows the origin.
settle's existing catch aroundsaveAccountSecretsnow rethrows asAccountStorageFailure, a marker class exported by the classifier, and the classifier checks that first: a wrapped error goes through the storage tables, everything else through the network ones. By the time the outer catch sees the error, anENOSPCfrom a keyring write and anENOSPCfrom a socket are indistinguishable, which is why the wrapping happens where it does rather than in the classifier.AccountStoreUnreadableErroris unaffected: it is still caught above and answered with its own wire status.What a maintainer can still tell from the log
The line reads
Login failure reason: <token>.and the token separates:timeout,response-too-large,response-aborted: the transport's own limits.http-unauthorized,http-forbidden,http-rate-limited,http-unavailable,http-server-errorand the rest ofHTTP_STATUSES: what the auth service answered, so an outage is not read as a wrong password.http-4xx,http-5xxandhttp-otherfor a status outside the table.network-ENOTFOUND,network-ECONNRESET,network-CERT_HAS_EXPIREDand the rest of the allowlist: the socket-level cause.network-otherfor a code outside it.secure-storage-unavailable,no-system-password-store: the credentials were fine and the keyring is what failed.storage-no-space,storage-permission,storage-locked,storage-io,storage-other: the session was good and writing it to disk is what failed.unclassified-TypeError,unclassified-Errorand the four other allowlisted class names: nothing recognised the failure, and the class is what is left.unclassifiedfor anything else.non-error-throw: something that is not anErrorwas thrown.A rejected credential never reaches this catch at all: it resolves through
settle'sbad-credentialsarm, which keeps logging the service's own reason string. An unreadable response resolves throughunreadable-responseand keeps itsdiagnosis. Those two were already safe and are untouched, so a timeout, a refused credential, an unreadable response and a full disk remain four different lines.The other handlers in this file
REMOVE_ACCOUNThas no catch, logs nothing, and takes no secret. Nothing to do.One other
getErrorMessagecall remains in this file, insettle'sAccountStoreUnreadableErrorbranch. It is not the same shape: theinstanceofguard immediately above it means the error is always anAccountStoreUnreadableError, whose message is a compile-time constant ("The account store is unreadable and its bytes could not be preserved"). No caller-supplied text can reach it, so there is no leak to close. It is worth a follow-up for a different reason, which I have left alone here rather than widen the diff: it logs that constant and drops the wrapped cause, so it duplicates the error line directly above it and tells you nothing about whether the copy failed onEACCESor on a full disk.Type
Checklist
dev, notmain.npm run typecheckpasses.npm run lint:cipasses.npm run format:checkpasses.npm run test:coveragepasses, coverage at or above the floor invitest.config.ts.npm run build:unpackpasses.Testing
tests/ipc/loginFailureReason.test.tspins the mapping directly: an identifier-shaped secret incode(PASSWORD123,CORRECT_HORSE_9) and inname(CorrectHorseBatteryStaple) must appear nowhere in the answer, a storage failure must never be named as a network one, down to the same code (ETIMEDOUT) landing on two different tokens depending on which side raised it, and no reason may contain the digits of the status that produced it, checked across401,403,429,500,503,418,599,302andunknown.tests/ipc/accountHandlers.test.tsholds the property end to end rather than the implementation: it stubs the transport and the account store to throw, runs the real handler through the electron mock, and reads what actually reachedelectron-logat every level, afterlogManager's own redaction. Four tests are new there. One puts the secret in both mutable fields. One raisesENOSPCandEACCESfrom the store write and asserts the log names storage and no line saysnetwork. One logs in with the password503against a transport rejecting with status503, and asserts no line carries that password anywhere, message or token, while the line still readshttp-unavailable. One runs401,429and503through and asserts the three land on three different tokens with none of their digits in the log.LEAKY_PASSWORDdeliberately does not contain the word "password", since a value that did would be caught by the keyword pattern and would not exercise the hole.Gate output, measured on the rebased head:
accountHandlers.test.tsandloginFailureReason.test.ts: 54 passed.npm run typecheck: passed for node, web and test configurations.npm run lint:ci: 0 errors, 15 pre-existing React hook warnings (unchanged fromdev).npm run format:check: all matched files use Prettier code style.npm run test:coverage: 162 files, 2056 passed, 2 skipped. 93.44% statements, 90.30% branches, 92.71% functions, 94.99% lines, against floors of 87/85/85/89. Both changed source files are at 100% on all four.npm run build:unpack: passed, Electron 44.1.1, linux x64.1d5280a3.Mutants
Each mutant was applied to the committed tree on its own, the focused suite run, then the file restored with
git checkout --and the tree confirmed clean withgit status.codeaccepted by the identifier regex again, spliced intonetwork-${code}code, even when the secret is shaped exactly like a Node code" and "logs no credential when the secret is sitting in the error's own code and name".unclassified-${error.name}name, even when the secret is shaped exactly like a class name" and "logs no credential when the secret is sitting in the error's own code and name".settlerethrows the store error unwrapped, so it reaches the network classifierNETWORK_CODESinstead ofSTORAGE_CODES${getErrorMessage(error)}againSTATUS_MESSAGEunanchored (^/$removed)Errorbranch returnsString(error)http-status-${status[1]}, the exact line Zaldaryon flaggedLogin failure reason: http-status-503.in "logs no credential when the password is the same digits as the HTTP status".HTTP_STATUSESlookup dropped, so every status falls to its rangeRelated issues
Closes #352