SEC2-081: Test accepts a token wider than captures without a word, so the threat model's bound on a leaked extension token is unchecked #38

Closed
opened 2026-10-17 10:40:17 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-10-16 in a read-only security review of postulo-chromium at b6c1009, checked by a second, independent pass.

Severity: Low · Category: Authentication/Authorization · Reference: CWE-250 (Execution with Unnecessary Privileges); OWASP A01:2021 (Broken Access Control)

Location:

  • src/options.js:138-142 (the scopes are read and only the absence of captures is said)
  • src/options.html:18-21 (tokenHint: "with the captures scope and nothing more")
  • core docs/THREAT-MODEL.md:20 (the promise)

Issue

const scopes = (me.scopes || []).join(", ");
const canCapture = (me.scopes || []).includes("captures");
await log.add("test", { postulo: hostOf(next.instance), scopes });
const who = scopes ? t("tokenIsScoped", [me.name, me.owner, scopes]) : t("tokenIs", [me.name, me.owner]);
say(canCapture ? who : `${who} ${t("lacksCaptures")}`, !canCapture);

The core's threat model bounds "someone with the token of a browser extension" to "the capture
API only … an extension's token holds captures and nothing else, so the worst it can do is fill
a review queue". The extension stores whatever token is pasted in storage.local, which the
browser keeps unencrypted on disk (PRIVACY.md:12-16), and GET /me tells it the token's
scopes. When they include read, write or listings:bind as well, Test answers in green with
the list, and Save never asks. A token that can read applications, CVs and the home address then
sits in the place the threat model assumes holds only captures.

Impact

Not an exploit by itself: it is the person who issued the wide token. What is lost is the one
moment the extension could keep the promise the core makes about it. A copy of the browser
profile, or a future weakness in the extension, then yields a full-scope token where the threat
model expects a harmless one.

Fix

In the Test handler, after canCapture:

const extra = (me.scopes || []).filter((scope) => scope !== "captures");
if (extra.length) {
  say(`${who} ${t("tokenTooWide", [extra.join(", ")])}`, true);
  return;
}

with tokenTooWide added to en/messages.json ("This token can also $1. The extension needs
captures only: issue one with that scope alone and revoke this one."). The stronger form is to
have Save call whoAmI once the permission is granted and refuse a token with extra scopes.

Done when test/api.test.mjs or a new options test shows the warning for
scopes: ["captures", "read"] and silence for ["captures"].

#1 (closed) is about reading errors, not scope width.

Found on 2026-10-16 in a read-only security review of `postulo-chromium` at `b6c1009`, checked by a second, independent pass. **Severity:** Low · **Category:** Authentication/Authorization · **Reference:** CWE-250 (Execution with Unnecessary Privileges); OWASP A01:2021 (Broken Access Control) **Location:** - `src/options.js:138-142` (the scopes are read and only the absence of `captures` is said) - `src/options.html:18-21` (`tokenHint`: "with the `captures` scope and nothing more") - core `docs/THREAT-MODEL.md:20` (the promise) ## Issue ```js const scopes = (me.scopes || []).join(", "); const canCapture = (me.scopes || []).includes("captures"); await log.add("test", { postulo: hostOf(next.instance), scopes }); const who = scopes ? t("tokenIsScoped", [me.name, me.owner, scopes]) : t("tokenIs", [me.name, me.owner]); say(canCapture ? who : `${who} ${t("lacksCaptures")}`, !canCapture); ``` The core's threat model bounds "someone with the token of a browser extension" to "the capture API only … an extension's token holds `captures` and nothing else, so the worst it can do is fill a review queue". The extension stores whatever token is pasted in `storage.local`, which the browser keeps unencrypted on disk (`PRIVACY.md:12-16`), and `GET /me` tells it the token's scopes. When they include `read`, `write` or `listings:bind` as well, Test answers in green with the list, and Save never asks. A token that can read applications, CVs and the home address then sits in the place the threat model assumes holds only `captures`. ## Impact Not an exploit by itself: it is the person who issued the wide token. What is lost is the one moment the extension could keep the promise the core makes about it. A copy of the browser profile, or a future weakness in the extension, then yields a full-scope token where the threat model expects a harmless one. ## Fix In the Test handler, after `canCapture`: ```js const extra = (me.scopes || []).filter((scope) => scope !== "captures"); if (extra.length) { say(`${who} ${t("tokenTooWide", [extra.join(", ")])}`, true); return; } ``` with `tokenTooWide` added to `en/messages.json` ("This token can also $1. The extension needs `captures` only: issue one with that scope alone and revoke this one."). The stronger form is to have Save call `whoAmI` once the permission is granted and refuse a token with extra scopes. Done when `test/api.test.mjs` or a new options test shows the warning for `scopes: ["captures", "read"]` and silence for `["captures"]`. ## Related #1 (closed) is about reading `errors`, not scope width.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo-chromium#38
No description provided.