test: require an unconditional denial in the main-process security handlers - #376
Conversation
Zaldaryon
left a comment
There was a problem hiding this comment.
Changes requested
The new reachability checks close the original branch, try, ternary, and early-return examples, but the permission contract still accepts handlers that do not deny.
At tests/security-boundaries.test.ts:477, the assertion accepts any unconditional false expression. Because unconditionalExpressions also collects direct expression statements, both handlers below pass the contract even though the first returns undefined and the second returns true:
session.defaultSession.setPermissionCheckHandler(() => { false })
session.defaultSession.setPermissionCheckHandler(() => { false; return true })Restrict the check to an expression-bodied false or a reachable direct return false, and add both handlers as negative fixtures.
There is a second reachability gap. reachableStatements stops only at exits that appear directly in the handler block. A conditional return or throw before the later denial can still make the denial unreachable while the helper accepts it. Sol reproduced this for the permission request callback, navigation guard, and window-open denial. Reject earlier control flow that can leave the handler, or validate every path, and add regression fixtures for those three handler families.
Verification
The exact head is current with dev. Required checks, test matrices, and SonarCloud pass. The focused security suite passes 29 tests, but it does not cover these bypasses. No merge is eligible until the contract rejects them.
The permission pin from #367 searched the whole handler subtree with findNodes, so a denial in a branch the runtime never takes satisfied it. Zaldaryon is right on #374: `if (false) callback(false)` passed. The rule is now about placement, not only about the argument. A denial has to be a direct statement of the executable handler body: the expression body of a one-expression arrow, or a top-level statement of a block body that nothing before it can skip. Anything nested in an if, a ternary, a try, a loop or another function no longer counts. setPermissionCheckHandler and setWindowOpenHandler get the same rule, and the navigation guards now have to sit in the reachable prefix of their body, which closes an early return in front of the guard.
…swers with
The check-handler assertion accepted any unconditional false expression, so
`() => { false }` and `() => { false; return true }` both passed while returning
undefined and true. The reachability helper also stopped only at exits written
directly in the block, so a conditional return or throw before a denial left it
unreachable and still accepted.
A denial now has to be the value the handler answers with: an expression body,
or a reachable direct return. And a statement that can leave the handler on any
path, wherever the return, throw, break or continue sits inside it, ends the
reachable run.
b0cd057 to
df7a7b8
Compare
|
@Zaldaryon both findings were correct, and I reproduced each of them before touching the file. The bare On the reachability gap I took your option (a). I read the four shipped handlers first: three are a single expression, and the only one that runs anything before its denial is the window-open handler, whose Five mutants on the real |
Zaldaryon
left a comment
There was a problem hiding this comment.
Both of the holes from the last round are closed, and I checked them against the shipped handlers rather than only the fixtures.
assertPermissionCheckHandlerDenies now requires the false to be the value the handler answers with, so () => { false } (hands Electron undefined) and () => { false; return true } (grants) are both rejected, pinned in "rejects a denial written as an expression statement that discards it". canLeaveTheBody plus reachableStatements stop the reachable prefix at the first statement that can return, throw, break or continue, so a denial written after a conditional exit no longer counts, pinned across all four handler families in "rejects a denial reached only when an earlier statement does not leave the handler". canLeaveTheBody skips nested function bodies through ts.isFunctionLike, and it is only ever handed a statement from the handler body's own list, so the handler's own arrow is never the visited node.
The real handlers in src/main/index.ts still pass: setPermissionCheckHandler(() => false) and setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)) are accepted as expression bodies, and the window-open handler's leading try { ... } catch { logMessage(...) } contains no return, throw, break or continue, so reachableStatements does not truncate before its return { action: "deny" }.
npx vitest run tests/security-boundaries.test.ts is 32 passing here, up from 29. Full local gate on df7a7b8 is green: typecheck, lint:ci (0 errors, 15 pre-existing hook warnings), format:check, test:coverage at 93.39% statements and 90.18% branches against the 87/85 floors, and build:unpack on Electron 44.1.1.
One follow-up for a later PR, not a blocker here: { action: "deny", ...override } is accepted, but a spread written after action would override it at runtime. That shape does not happen by accident the way a conditional return does, so it should not hold up the merge this unblocks. A one-line guard rejecting a trailing SpreadAssignment would close it.
Approving.
Zaldaryon's first finding on the beta.8 promotion (#374), quoted in full:
He is right, and I reproduced it before touching anything: with the pin as it stands on
dev, wrapping the realcallback(false)inif (false)leaves the whole suite green. The pin I wrote in #367 checks what the callback is called with, never where the call sits, so a denial in a branch the runtime never takes satisfied it.Second review round
Zaldaryon found two more holes in the first version of this branch, and both were real. I reproduced each one before changing anything.
The first: the check-handler assertion accepted any unconditional
falseexpression, and the helper that collected those expressions took plain expression statements as well as returns. SosetPermissionCheckHandler(() => { false })passed while handing Electronundefined, andsetPermissionCheckHandler(() => { false; return true })passed while granting every permission check.The second: the reachability helper stopped only at an exit written directly in the block.
if (x) return; callback(false)walked straight past it, and so did a conditionalthrow, atry { return } catch {}and a loop containing areturn. The denial after any of those runs on some paths and not others, and the pin still accepted it. It reproduced on the permission request callback, the navigation guards and the window-open denial.The rule as it now stands
Two conditions. The first is about what the denial is, the second about where it sits.
A denial is the value the handler answers with. Electron reads what these handlers return, so evaluating a value and throwing it away is not answering with it. That means one of two shapes: the expression body of a one-expression arrow, which is what
src/main/index.tsships for both permission handlers, or areturnthat the runtime reaches. A barefalseon its own line is not a denial, and neither is a bare({ action: "deny" }). The permission request handler is the exception in spelling, not in principle: there the answer travels through the callback, so the denial is thecallback(false)call, whether written as the expression body, as a statement, or returned. A barefalse, or a mention ofcallbackthat never calls it, answers nothing and is rejected withis not called.Nothing before the denial may be able to leave the handler. This is rule (a) of the two Zaldaryon offered: a statement that contains a
return, athrow, abreakor acontinueanywhere inside it, outside a nested function, ends the reachable run, and anything written after it is treated as unreachable. Bodies of nested functions are skipped, because areturnin there leaves that function rather than this handler.I took (a) rather than validating every path because I read the four shipped handlers first and none of them needs (b). Three of them are one expression. The fourth, the window-open handler, is the only one that runs anything at all before its denial: a
try/catcharoundassertAllowedBrowserUrlandshell.openExternal, and neither block contains areturn, athrow, abreakor acontinue. So (a) costs the real code nothing today, while (b) means a genuine control-flow walk in a contract test, with the denial having to dominate every exit, and every future reader has to trust that walk when the pin goes green.(a) is conservative in one visible way, and I think that is the right trade in a security pin. A
throwwritten inside atrywhosecatchswallows it does not really leave the handler, but this rule rejects a denial written after it.openAfterTryExitin the fixture table records that on purpose. The cost of the false positive is reflowing a handler; the cost of the false negative is a permission denial that never runs.Two helpers carry it.
canLeaveTheBodysays whether a statement can hand control out of the handler on any path.reachableStatementsreturns the direct statements of a block up to and including the first one that can.directExpressionsreturns the expressions the handler evaluates directly, each tagged with whether it is the handler's answer (an expression body or areturn) or a discarded expression statement. A call nested in anif, a ternary, atry, a loop or another function is in none of these, which is the whole point:findNodeswalks the entire subtree, so anything built on it alone accepts a defense the runtime can step over.Per handler:
setPermissionRequestHandler: every call to the callback anywhere still has to passfalse(that rule stays, it catcheslog(false); callback(true)), and one of those calls has to be the reachable direct denial.setPermissionCheckHandler: at most onereturnin the whole handler, andfalsehas to be the expression body or the value of a reachable directreturn.setWindowOpenHandler: at most onereturnanywhere, and theaction: "deny"object has to be the expression body or a reachable directreturn. The expression-bodied form is now accepted, which it was not before; it is the same denial written shorter.will-navigate,will-redirect,will-frame-navigate: the guard is looked up in the reachable prefix, so anything that can leave the handler before it pushes the guard out of that prefix.Failure messages still name the handler, so a red test says
setPermissionCheckHandler: ...rather than leaving the reader to find which of the four broke.Fixtures
Accepted:
src/main/index.tsrespond(false),return false, renameddetails)() => false,=> callback(false),=> ({ action: "deny" })Rejected:
if (false) callback(false)try { callback(false) } catch {}granted ? callback(false) : callback(true)granted ? callback(false) : callback(false)const deny = () => callback(false), never called{ false }{ callback }, referenced and never calledif (granted) return; callback(false)if (granted) throw new Error("no"); callback(false)try { return } catch {} callback(false)for (const p of pending) { return } callback(false)if (false) return falsetry { return false } catch {}granted ? false : falseconst deny = () => falseif (granted) return true; return false{ false }{ false; return true }if (granted) throw new Error("no"); return falsetry { throw new Error("no") } catch {} return falsefor (const p of pending) { return true } return falseif (details.url) { return { action: "deny" } }try { return { action: "deny" } } catch {}throw ...; return { action: "deny" }{ ({ action: "deny" }) }as an expression statementif (details.url) throw new Error("no"); return { action: "deny" }try { throw new Error("no") } catch {} return { action: "deny" }for (const feature of details.features) { throw ... } return { action: "deny" }if (details.url) return; return { action: "deny" }will-navigateguard after an earlyreturnwill-navigateguard inside atrywill-navigateguard afterif (trusted) returnwill-navigateguard afterif (trusted) throw new Error("no")will-navigateguard aftertry { return } catch {}will-navigateguard afterfor (const frame of frames) { return }The comment-only, dead-branch-with-
trueand second-false-call fixtures from #367 are unchanged and still rejected. The two navigation cases that reportmust prevent a rejected URLrather thanone direct guardare the ones where the early exit is itself anif: it becomes the only reachableifof the body, and its condition is not a URL rejection, so the guard lookup fails there instead. Rejected either way, and the message still names the event.Mutants
Each applied alone against the committed tree, the pin run, the mutant reverted,
git statuschecked clean before the next one. All against the real handlers insrc/main/index.ts.First round, kept here for the record:
devcallback(false)inif (false)setPermissionRequestHandler: ... direct statement of the handler bodyreturn, denial moved into atrysetPermissionRequestHandler: ... is not calledif (details.url)will-navigateguard behind an earlyreturnSecond round, the five for the findings above. Every one of them leaves the suite green on the first version of this branch:
{ false }setPermissionCheckHandler: permission check handler must return false, as the expression body of the handler or as a reachable direct statement of the handler body ...{ false; return true }if (details.requestingUrl) returninserted beforecallback(false)setPermissionRequestHandler: permission request callback callback must be called with false as a reachable direct statement of the handler body ...if (details.url) throw new Error("blocked")inserted before the window-open denialsetWindowOpenHandler: window open handler must return action deny, as the expression body of the handler or as a reachable direct statement of the handler body ...if (event) returninserted before thewill-navigateguardwill-navigate must prevent a rejected URL, underblocks any main-frame navigation the renderer policy rejectsControls, which must stay green:
isPermittedMainFrameUrlC2 is worth repeating from the first round. #367 listed a control that renamed the
will-navigateparameters and the guard function and called it green, and that half of the line was wrong:isRejectedUrlConditionmatchesisAllowedMainFrameUrlby name, so renaming the guard has always broken the navigation pin. Parameter renames and reformatting are tolerated, the guard function name is pinned. I left it pinned rather than loosening a security assertion to match an old note.Testing
On head
df7a7b8:npm run typecheck: passed (node, web, tests).npm run lint:ci: 0 errors, the same 15 pre-existing React Hooks warnings.npm run format:check: passed.npm run test:coverage: 161 files, 2043 passed, 2 skipped; 93.4% statements, 90.21% branches, 92.7% functions, 94.97% lines locally, floors 87 / 85 / 85 / 89. The CI run on this head is the reference figure if the two differ by a hundredth.npx vitest run tests/security-boundaries.test.ts: 32 passed, up from 29.src/main/index.tsis untouched. The only file in the diff istests/security-boundaries.test.ts.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.Related issues