fix(renderer): contain a page render throw and get it into the log - #372
fix(renderer): contain a page render throw and get it into the log#372Pixnop wants to merge 3 commits into
Conversation
Zaldaryon
left a comment
There was a problem hiding this comment.
Changes requested
High: render-error logging can write arbitrary secrets
errorLog.ts:16-26 copies name, message, stack, component-stack text, and String(error) directly into the bridge payload. The main-process redactor only recognizes marked fields, known query keys, and selected absolute paths. A bare secret in an error message or non-Error rejection therefore reaches error.log unchanged. This reopens the credential logging class addressed by #368.
Normalize the error into a fixed safe payload before the bridge, and add regressions for an unmarked secret in reason, message, and stack, checking both the bridge payload and the final log.
Medium: the recovery button cannot recover from a Home render failure
The boundary clears its state only when resetKey changes at PageErrorBoundary.tsx:33-35, but the fallback always navigates to / at PageErrorBoundary.tsx:68. The Home route is inside the same boundary. If Home throws, resetKey stays /, so the only recovery action leaves the fallback unchanged.
Provide a valid recovery path when the current route is /, and add an integrated regression with Home throwing during render.
Medium: extraction happens outside the guard and the payload exceeds the IPC limit
The logger reads error properties and calls String(error) before its try block at errorLog.ts:16-23. A hostile getter or toString() can make the global handler throw. It also sends the complete stacks without applying the 16 KiB limit enforced by utilsHandlers.ts:21-28, so oversized events are discarded and replaced by a generic warning.
Extract defensively, bound the payload before sending, and add tests for hostile values and a stack larger than 16 KiB.
The head is also six commits behind current dev (1d5280a3). Update the branch and rerun the five required checks before requesting approval.
e787b6f to
4d38867
Compare
|
@Zaldaryon on the first finding: correct, and it is the worst of the three. The payload is now built rather than copied. The name comes from an allowlist of the built-in names React and the DOM raise, everything else reads Beta.7 still reads Tests are in |
|
On the second finding: correct. I wrote the Two changes. The boundary exposes a Two regressions in |
|
On the third finding: correct on both halves, and both are embarrassing because the file's own comment explains why the write was guarded and then does the reads above the guard. Extraction. Every read and every stringification now happens inside a guard, one per field, so a getter or a Size. Tests in |
Beta.7 shipped a blank Manage Mods page. A null in a ModDB tag list threw on the first render, React unmounted the whole tree, and nothing was written anywhere. It took three player reports and a live probe to find out what it was, because there was nothing to read. A boundary now sits in AnimatedRoutes, around the routes and nothing above them. A page that throws is replaced in place by a message, and the shell outside <main> (main menu, session button, activity center, notifications) stays mounted and usable. It resets on a pathname change, so the fallback's "go to the main menu" leaves it behind instead of sticking for the session. componentDidCatch sends the error name, message, stack and component stack through the preload bridge at error level, so error.log names what threw and which component it was under. Nothing else about the app goes in, and redactSensitiveText still runs on the main-process side. Error boundaries never see throws from event handlers, timers or unawaited promises, so main.tsx installs window "error" and "unhandledrejection" listeners on the same path before the first render. They log and return. The "error" one also picks up a render throw no boundary caught, which is what a throw in the shell itself is. Part of #370
…ck a way out The renderer copied name, message, stack, component stack and String(error) straight onto the bridge. The main-process redactor only recognises marked fields, known query keys and absolute paths, so a bare secret in an error message or in a rejection reason reached error.log untouched. Nothing about an error message is under our control: new Error(token) puts a credential in one. The payload is now built from a fixed shape instead of copied. The name comes from an allowlist of built-in error names. The message is matched against the runtime-error shapes V8 raises by itself and emitted as that shape's own template, with an identifier lifted out of it only when it looks like one; anything else becomes unclassified-message. JS stack lines survive only when they match a frame pattern, so a header line, a thrown string and an interpolated message inside a frame all go. Component-stack lines keep the component name React wrote and drop the location. A rejection that is not an Error contributes its typeof and nothing else. Every read happens field by field inside a guard, so a hostile getter or a toString that throws costs its own field rather than the whole report, and cannot throw out of the global error listener that called it. The report is then bounded to 8 KiB, trimming the frame lists before the header: past the 16 KiB the LOG_MESSAGE handler accepts, the entire event was being dropped and replaced by a generic warning. The fallback also could not recover from a Home render failure. It only navigated to "/", the boundary clears itself on a resetKey change alone, and Home is inside the same boundary, so with Home throwing the one button did nothing at all. Both actions now clear the boundary themselves, and there is a second one leading to Info & Help, which is where the text already points for the logs.
4d38867 to
0715f38
Compare
Zaldaryon
left a comment
There was a problem hiding this comment.
Changes requested
The current logger closes the original raw message, hostile getter, and size failures, but it still forwards attacker-controlled data through two allowlists.
High: identifier-shaped secrets remain in recognized error messages
At src/renderer/src/adapters/errorLog.ts:44-45, asIdentifier accepts any identifier or dotted path up to 48 characters. A renderer error with this message:
new TypeError("PASSWORD123 is not a function")produces a payload containing PASSWORD123 is not a function. The same issue affects the property name in the null dereference and assignment shapes. Identifier syntax proves formatting, not provenance. The secret reaches the bridge and the final log.
Use fixed message categories or an explicit allowlist of application-owned names, and add negative tests with identifier-shaped secrets in each interpolated position.
High: a matching stack frame can carry arbitrary location data
At src/renderer/src/adapters/errorLog.ts:58, the location pattern accepts any non-whitespace, non-parenthesis text up to 400 characters. This matching frame is therefore retained:
at loadProfile (app://renderer/PASSWORD123:12:9)
The secret reaches the bridge and the final log. Restrict the accepted location format to trusted application paths or emit only a sanitized filename and line information. Add a regression with a credential-shaped value in a matching stack location.
The exact head is also behind the current dev. PR #375 merged into dev at 5fa04624a76e4433bf16fe86e1c73d51d657fe4d, while this PR still targets the previous base 1d5280a3db7efc6af7bb2adb32d7214530c1d446. Rebase the branch, rerun all five required checks, and request a fresh review after fixing the leaks.
Verification
- Reviewed head:
4d38867cd0791518f901468b6dd4f88da94da9dd. - Focused boundary and logger tests passed 31 cases.
- The two leak reproductions above passed against the current head and showed the secret in the renderer payload.
- The previous required checks passed on the previous base.
The previous pass kept an identifier out of a recognised message and any non-whitespace location out of a stack frame. Both treat syntax as provenance, which it is not: 'PASSWORD123 is not a function' is a message any code can build, and 'app://renderer/PASSWORD123:12:9' is a location any code can write into error.stack. A recognised message now maps to a fixed token and nothing else. A stack frame is kept only when its location is under one of the two prefixes our own renderer runs from, the packaged app://renderer/assets/ bundle or the vite dev server under /src/, and only the file name plus line and column are emitted. Function names are dropped. Component names still go through because React derives them from our own source, with a shape check and a cap as a backstop.
|
Both findings were correct, and I should have caught them myself: this is the third time this week the same lesson has come up in this repo. Identifier syntax proves formatting, not provenance. A thing that looks like a property name, a function name, a file path, is only safe because of where it came from, never because of its shape, and an error is precisely the object that carries whatever value was in scope when it was built. So I stopped filtering captured text and stopped capturing it. A recognised message maps to a fixed token and nothing else, no identifier and no Pushed on the rebase onto |
Summary
Point 2 of #370. A render exception in one page no longer takes the window down with it, and it now reaches
error.log.Beta.7 blanked Manage Mods for anyone with Vanilla Variants installed. A
nullin a ModDB tag list threw on the first render, React unmounted the whole tree, and nothing was written anywhere. Three player reports and a live probe went into finding a one-line bug. With this in place a log would have named it.Where the boundary sits
In
AnimatedRoutes(App.tsx), around<Routes>and nothing above it. That puts it inside<main>, so the shell keeps running while one page is broken: main menu, session button, activity center, notifications overlay, the ModDB prompt, the global mod update checker. I did not wrap the app in one outer boundary, because a page bug would then leave a player with a message and nothing to click.resetKeyis the current pathname, and a change to it clears the boundary, so walking back into the crashed page gets a real attempt rather than a cached failure. That is not a recovery path on its own, though, which is what the review caught: Home is inside this boundary too, so with Home throwing the pathname never changes and a fallback that only navigates leaves the message on screen for the rest of the session. The fallback's actions now clear the boundary themselves.The shell does not get its own last-resort boundary. A throw in
MainMenuis already an app with no navigation left, so a second fallback would only redraw a dead end. What it does need is the log line, and React rethrows an uncaught render error, which the globalerrorlistener below picks up.What a player sees
Instead of a black window, in the page area: "This page couldn't be displayed", that the rest of the launcher still works, and that the details went to the log files with Info & Help > Debug info naming the folder. Two actions, built from
ListWrapper/ButtonsWrapper/FormButtonlike the rest of the app:/. On any page but Home that is the whole recovery. On Home it re-renders the page that threw, which is the right thing to try and is sometimes enough, since plenty of render throws come from a state a re-render no longer reaches./info-and-help. This is the path that does not depend on Home rendering, and it lands on the page the text above already points at for the logs.The block carries
role="alert"with a label, and focus moves to the first action on mount, since the page the player's focus was on has just gone.New keys live under
components.pageErrorinen-US.json. The other 13 locales fall back until they catch up.What a maintainer finds in error.log
One
errorline per throw, tagged[front] [app] [adapters/errorLog.ts] [PageErrorBoundary]. Nothing in it is text read off the error. The second review is the reason it now reads this way: the previous version kept an identifier out of a recognised message and any non-whitespace location out of a stack frame, and both of those treat syntax as provenance.new TypeError("PASSWORD123 is not a function")is a message any code can build, anderror.stackis a writable property, soat loadProfile (app://renderer/PASSWORD123:12:9)is a frame any code can write. Both findings were right.Error,TypeError,RangeError,SyntaxError,ReferenceError,AbortError,DOMException). Anything else readsError.null-property-read,undefined-property-read,null-property-write,undefined-property-write,not-a-function,not-iterable,stack-overflow,invalid-array-length, orunclassified-message. No identifier, no property name, no placeholder standing in for one. The token says what kind of failure it was; which property it was is not in the log on purpose, because the stack says which file and line to open and the name is right there in our own source.at <file>:<line>:<col>. A frame is kept only when its location is under one of the two prefixes our own renderer actually runs from:app://renderer/assets/, which is wheresrc/main/index.tsloads the packaged window from (app://renderer/index.html, served by theappprotocol handler out of the bundle directory, so every chunk isassets/<name>-<hash>.js), andhttp://localhost:<port>/src/, the vite dev server thatELECTRON_RENDERER_URLpoints at. Those are the same two shapesisAllowedRendererUrlinsrc/ipc/validation.tsaccepts on the main-process side. Everything else is dropped whole. Only the last path segment is emitted, and only when it is a plain file name ending in.js,.mjs,.tsor.tsx; directories, the scheme, the host, the query and the function name all go. Frames are capped at 60.^[A-Z][A-Za-z0-9]{0,40}$) and the cap are a backstop for the abnormal path, since a component stack arrives as a plain string.Errorcontributesnon-error-throwand itstypeof, and nothing from the value.For beta.7 that reads
TypeError: null-property-read, withManageMods-<hash>.jsand its line in the stack andManageModsin the component stack. Which line of which file, plus which of the nine failure kinds, is what turned three player reports into a one-line bug, and none of it needs the property name to get there.What can no longer reach it
An error message is an arbitrary string.
new Error(sessionKey)puts a credential in one, a rejected fetch quotes a signed URL in one, andreject(token)is a rejection reason. The main-process redactor only recognises marked fields, known query keys and absolute paths, so none of those would be touched: the first version of this file forwarded all of them and reopened the class #368 closed, and the second still forwarded two narrower slices of them. Nothing forwarded now is a string the renderer read off the error, so:Error, of any type.Two residuals, both tested and left deliberate. A hand-written
stacknaming a file that does not exist in the bundle,app://renderer/assets/PASSWORD123.js:1:1, still yieldsPASSWORD123.js: one path segment with no separators and a source-file extension, where real frames come from files the build produced. And an uppercase word on a component-stack line passes the shape check; closing that would take a build-time allowlist of every component we ship, for a value React does not put there.Reads are per field and each inside its own guard, so an error with a getter or a
toStringthat throws still produces a report with its other fields intact, and cannot throw out of the globalerrorlistener that called it. The report is then bounded to 8 KiB, the frame lists trimmed before the header:src/ipc/handlers/utilsHandlers.tsrejects anything past 16 KiB and replaces the whole event with a generic warning, so an unbounded stack used to lose the event entirely.PageErrorBoundarylives undercomponents/, whichtests/security-boundaries.test.tsholds off the preload bridge, so the write goes throughsrc/renderer/src/adapters/errorLog.tsrather than touchingwindow.apiitself.What boundaries do not catch
Event handlers, timers, async code and unawaited promises never reach React.
main.tsxinstallswindowerrorandunhandledrejectionlisteners on the same log path before the first render. They log and return: nopreventDefault, no rethrow, so the devtools console still shows what it always did.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/rendererErrorLog.test.tsdriveslogRenderErrordirectly and asserts on two things for every case: the payloadwindow.api.utils.logMessagereceived, and the line that comes out the far end, run through the realassertString(..., 16_384)andredactSensitiveTextthe main process applies.tests/renderer-dom/pageErrorBoundary.test.tsxmounts the realAppwithManageModsmocked to throw the beta.7TypeError, and withHomePagewrapped in a switch a test can flip, which is how "the page stopped throwing" is expressed.Every test is pinned by a mutant:
<PageErrorBoundary>with a fragment inApp.tsxlogRenderErrorcall incomponentDidCatchinfo.componentStackaddEventListener("error", ...)addEventListener("unhandledrejection", ...)messageverbatim instead of classifying itString(error)for a non-Error rejectionmessageread outside its guardThe four mutants for this round, on top of the table above:
is not a functionshapeEach mutant was applied alone to the committed tree and restored after, with
git statuschecked clean between runs. The table above it was verified the same way against the previous head.Six earlier assertions changed with the payload, all of them assertions on text that is now a token: the recognised-message cases, the frame locations, and the beta.7 line in the boundary test. Every other test is untouched and green.
tests/rendererErrorLog.test.ts), 11 passed (tests/renderer-dom/pageErrorBoundary.test.tsx).npm run typecheck: passed for node, web and test configurations.npm run lint:ci: 0 errors, the same 15 existing React hook warnings.npm run format:check: passed.npm run test:coverage: 163 files, 2081 passed, 2 skipped; 93.39% statements, 90.13% branches, 92.81% functions, 94.96% lines.npm run build:unpack: passed with Electron 44.1.1 on Linux.Not run: the packaged binary. Nothing here changes the main process.
Related issues
Part of #370