Build1 publisher3 min readPublished
A Forge resolver rewrote whatever page the caller named, using the app's own permissions
The gate is a pure function that imports nothing and runs under plain node. The proof it works is a six-case suite scoring 1 of 6 against the ungated resolver and 6 of 6 once the gate is wired in.
The Engineer · Build desk

What happened
- A shipped Forge resolver took pageId straight from the client payload and used asApp() to read the page, rewrite its content and bump its version, with no check on the caller's entitlement to it.
- The authorization decision now lives in a pure function in gate.mjs that imports nothing at all, not @forge/api and not @forge/kvs, and runs under plain node.
- The gate refuses a payload page id that disagrees with the surface the caller invoked from before it asks any permission question, so the cheapest guard runs first.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- exposure Any caller who can invoke an ungated resolver can aim the app's own write permissions at a page id of their choosing, because the app supplies the authority and the payload supplies the target.
- capability With the decision outside the resolver, the unentitled-caller test no longer needs a deployed app, a tunnel or a second Atlassian account, so the five refusal cases are cheap enough to keep in CI.
- constraint The local suite cannot show that Confluence agrees with the gate's verdict, so confirming the gate matches the real permission model still requires a deployed app and a second identity.
- decision Failing closed means a permission probe that hangs or returns junk blocks a legitimate write, and teams have to decide up front that they will accept refusals while the permission API is unwell.
A Forge resolver is a named function the front end calls with a payload, and the resolver pulls an id out of that payload and acts on it [1]. When the work runs through asApp() instead of asUser(), the app's own permissions perform it and the target came from the caller [2]. This is the confused deputy pattern.
In the resolver that shipped, the work was reading the page, rewriting its content and bumping its version, with nothing in the path checking that the caller was allowed to touch that page [3]. The next function down the same file did gate its caller [4]. The author of the dev.to walkthrough calls that asymmetry obvious in hindsight and invisible while you are writing it [5].
Tests missed it because the happy path is identical in both worlds: an entitled caller passes a gate, and an entitled caller passes no gate [6]. "It passed for us, for weeks, while the resolver would have accepted a page id from anybody," the author wrote [7]. Telling the two worlds apart needs a caller who is not entitled, and in a real tenant that means a second identity [8].
The suite scores 1 of 6 against the ungated resolver and 6 of 6 against the gated one [9]. Subtract the one case that passes either way and five of the six exercise refusal [10]. The sixth is the positive case, kept in so you can see the gate has not refused legitimate work as well [11]. Step two of the sequence runs the suite against the ungated version first and watches it go red [12]. "A test that has never failed is a test you cannot trust," the author wrote [13].
For those five refusals to say anything about your app, the probe you inject has to fail the way your real permission check fails. The gate treats a probe that throws, times out or returns an unexpected shape as a refusal [14]. The suite is testing the decision function's handling of those three outcomes, so it transfers when your permission call can actually produce them.
decide() takes payloadPageId, contextPageId, accountId and probe, refuses with why "no-caller" when there is no accountId and why "no-page" when neither id is present, then falls back to pageId = payloadPageId || contextPageId before calling probe [15]. The published excerpt breaks off mid-line at the first refusal branch [16]. The surface check arrives as step three: refuse a payload page id that disagrees with the surface the caller invoked from, before asking about permissions at all [17]. It costs no network call [18].
gate.mjs imports nothing [19]. It runs under plain node with no deploy, no tunnel and no second Atlassian account [20], on Node 18 or later, verified on v24.15.0 [21], in about thirty minutes [22]. The suite cannot test that Confluence agrees with the verdict [23]. Wiring is one evaluation ahead of every side effect, before the retry loop and before any state write [24].
I would take that trade for any resolver whose write is privileged. The tenant-level check still needs a staging space and a second account, and it is no longer the only place the refusal logic can be exercised.
What to watch
- Whether the finished code shows the surface-disagreement branch; the published gate.mjs excerpt breaks off mid-line at the first refusal.
- Whether other resolvers in the same file share the ungated pattern, since only the immediate neighbour is described as gating its caller.
- Whether the suite grows a seventh case for a probe that returns a truthy non-boolean, the shape the fail-closed rule exists to catch.