Refuse an unentitled caller in a Forge resolver, and prove it with a test that goes red without the gate
DEV Community

Refuse an unentitled caller in a Forge resolver, and prove it with a test that goes red without the gate

Refuse an unentitled caller in a Forge resolver, and prove it with a test that goes red without the gate Key takeaways - END STATE: a resolver that refuses an unentitled caller, proven by a test suite that scores 1/6 against the ungated version and 6/6 against the gated one. - The happy path passes either way. That is why a positive-only test proves nothing about authorization. - Fail CLOSED: a probe that throws, times out, or returns an unexpected shape must refuse, not allow. - A payload page id that disagrees with the surface the caller invoked from is the attack - refuse the disagreement before you even ask about permissions. - Keep the decision a pure function so it runs under plain node, with no deploy, no tunnel and no second test identity. Forge resolvers are easy to write and easy to get wrong in one specific way. The resolver receives a payload from the front end, pulls an id out of it, and does the work. If that work is a privileged write - asApp() rather than asUser() - then the app's own permissions are doing the writing, and the id came from whoever called you. We shipped one of those. The resolver took pageId from the payload, read the page, rewrote its content and bumped its version, all through asApp() , with nothing checking that the caller was allowed to touch that page. The function immediately below it in the same file did gate its caller. That asymmetry is what makes it obvious in hindsight and invisible while you're writing it. By the end of this you'll have a gate that refuses an unentitled caller, and - more to the point - a test that goes red without the gate. That second half is what separates a fix from a belief. Prerequisites Node 18 or later. Verified here on v24.15.0. A text editor and an empty directory. That's genuinely all - every step below runs locally. No Forge app, no forge deploy , no tunnel and no second Atlassian account. The decision logic is written as a pure function precisely so you can test the part that matters without any of that.Familiarity with what a Forge resolver is. If you've written one resolver.define() you're fine.About thirty minutes. Why the obvious test is worthless here Before the steps, the thing that makes authorization bugs survive test suites. Write the natural test for a sealing resolver and it looks like this: call it as a user who's allowed to seal, assert the seal appears. That test passes. It passed for us, for weeks, while the resolver would have accepted a page id from anybody. It passes because the happy path is identical whether or not the gate exists. An entitled caller gets through a gate, and an entitled caller gets through no gate. The only test that can tell those two worlds apart is one where the caller is not entitled - and that's the test nobody writes, because it's the awkward one that needs a second identity. So the trick is to move the decision out of the resolver, where it needs a real tenant and a real second account, and into a pure function, where it needs neither. You lose the ability to test that Confluence agrees with you. You gain the ability to test every refusal path, including the ones that only happen when the permission check itself misbehaves - which, as it turns out, is where the interesting failures live. - Write the decision as a pure function take the resolver's authorization choice out of the resolver, so it can run under plain node with no deploy. - Prove the test can fail run the suite against an ungated version first and watch it go red. A test that has never failed is a test you cannot trust. - Refuse a payload id that disagrees with the caller's surface the cheapest guard, and it kills the attack before any network call. - Make the probe tri-state so the gate fails closed treat "I could not ask" as a refusal, never as permission. - Wire the gate into the resolver ahead of every side effect one evaluation, before the retry loop and before any state write. - Run the full suite and confirm 6 of 6 both the negative cases and the positive one, so you know you haven't fixed it into uselessness. Step 1 - Write the decision as a pure function Create a directory and put this in gate.mjs . Note what it doesn't import: nothing from @forge/api , nothing from @forge/kvs , nothing at all. export function decide({ payloadPageId, contextPageId, accountId, probe }) { if (!accountId) return { allow: false, why: "no-caller" }; if (!payloadPageId && !contextPageId) return { allow: false, why: "no-page" }; const pageId = payloadPageId || contextPageId; let verdict; verdict = probe(accountId, pageId); if (verdict === false) return { allow: false, why: "denied" }; return { allow: true, why: "entitled", pageId }; } That's deliberately incomplete - Steps 3 and 4 add the two guards it's missing. Start here because it's the shape most people would write, and it's worth seeing it fail. probe is injected rather than imported. In production it's a function that asks Confluence whether this account may edit this page; in the tests it's three lines. That injection is the whole reason this is testable without a tenant. Check it loads: node -e "import('./gate.mjs').then(m => console.log(typeof m.decide))" You should see function . If you get ERR_MODULE_NOT_FOUND , you're in the wrong directory; if you get a syntax error about export , rename the file to .mjs or add "type": "module" to a package.json . Step 2 - Prove the test can fail Here's the part most guides skip. Write the test suite, then run it against a version you know is broken, and confirm it goes red. A test suite that has only ever been run against correct code is an assertion about nothing. Put this in gate.test.mjs : import assert from "node:assert/strict"; import { decide } from "./gate.mjs"; const OWNER = "acc-owner", STRANGER = "acc-stranger", PAGE = "111", OTHER = "999"; const probe = (acc) => acc === OWNER; // only the owner may edit const ctx = { contextPageId: PAGE, probe }; let pass = 0, fail = 0; const t = (name, fn) => { try { fn(); pass++; console.log( ok ${name}); } catch (e) { fail++; console.log( FAIL ${name}\n ${e.message}); } }; // THE NEGATIVE CASE - this is the test that matters. t("refuses a caller with no edit rights", () => assert.equal(decide({ ...ctx, payloadPageId: PAGE, accountId: STRANGER }).allow, false)); t("refuses a payload pageId aimed at another page", () => assert.equal(decide({ ...ctx, payloadPageId: OTHER, accountId: OWNER }).why, "page-mismatch")); t("refuses when the probe cannot answer", () => assert.equal(decide({ ...ctx, payloadPageId: PAGE, accountId: OWNER, probe: () => undefined }).why, "probe-indeterminate")); t("refuses when the probe throws", () => assert.equal(decide({ ...ctx, payloadPageId: PAGE, accountId: OWNER, probe: () => { throw new Error("429"); } }).why, "probe-threw")); t("refuses an anonymous caller", () => assert.equal(decide({ ...ctx, payloadPageId: PAGE, accountId: null }).allow, false)); // THE POSITIVE CASE - do not fix it into uselessness. t("still allows the entitled caller", () => assert.equal(decide({ ...ctx, payloadPageId: PAGE, accountId: OWNER }).allow, true)); console.log(\ngate: ${pass}/${pass + fail} passed); process.exit(fail ? 1 : 0); Now build the thing it should reject. Put this in gate-ungated.mjs - it's the resolver as we shipped it, reduced to its decision: export function decide({ payloadPageId, contextPageId }) { return { allow: true, why: "no-gate", pageId: payloadPageId || contextPageId }; } Point a copy of the suite at it and run: sed 's#./gate.mjs#./gate-ungated.mjs#' gate.test.mjs > gate-ungated.test.mjs node gate-ungated.test.mjs; echo "exit=$?" How you know it worked. You should see five failures, one pass, and a non-zero exit. This is the real output: FAIL refuses a caller with no edit rights Expected values to be strictly equal: true !== false FAIL refuses a payload pageId aimed at another page Expected values to be strictly equal: + actual - expected + 'no-gate' - 'page-mismatch' FAIL refuses when the probe cannot answer FAIL refuses when the probe throws FAIL refuses an anonymous caller ok still allows the entitled caller gate: 1/6 passed exit=1 Look at the last two lines before the score. The positive case passes against the completely ungated version. That's the whole argument of this tutorial sitting in one line of output: if your suite had only contained that test, it would be green right now, against a resolver that writes to any page anybody names. If instead you got 6/6 here, your sed didn't take - check that gate-ungated.test.mjs imports ./gate-ungated.mjs and not ./gate.mjs . Step 3 - Refuse a payload id that disagrees with the surface Now start closing it. The first guard costs nothing and needs no network call. A Forge resolver invoked from a page surface knows which page it's on, independently of what the caller sent. The front end reads that from the extension context. So when a payload id and a context id disagree, the caller is aiming the resolver at a page they didn't open - which is the attack, stated exactly. Add this to gate.mjs above the pageId assignment: if (payloadPageId && contextPageId && payloadPageId !== contextPageId) { return { allow: false, why: "page-mismatch" }; } Two things worth being careful about. Only refuse when you have both ids - a resolver legitimately invoked with only one of them shouldn't be caught by this. And this guard is not sufficient on its own, because a caller who omits the context id entirely walks straight past it. It's cheap depth, not the load-bearing check. Check just this behaviour: node -e "import('./gate.mjs').then(({decide}) => console.log(decide({payloadPageId:'999',contextPageId:'111',accountId:'a',probe:()=>true}).why))" You should see page-mismatch . If you see entitled , the guard is sitting below the probe call instead of above it, and a permitted caller is bypassing it. Step 4 - Make the probe tri-state so the gate fails closed This is the step that matters most and the one that's easiest to write wrongly. Y

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.