SEC2-079: Server-supplied addresses (review_url, listing_url) are opened in the review window without an instance-origin check #36

Closed
opened 2026-10-17 10:40:15 +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: Informational · Category: Input Validation · Reference: CWE-601 (URL Redirection to Untrusted Site); OWASP A01:2021 (Broken Access Control)

Location:

  • src/review.js:71-80 (goToReview and the click that opens $("review").href)
  • src/captures.js:108-115 (the Review link of a sent capture)
  • src/lib/known.js:44-66 (sayKnown: open.href = link)
  • src/popup.js:66-86 (showReviewList: scheme checked, origin not)
  • src/lib/window.js:32-66 (openInReviewWindow: tabs.update/windows.create with the address as given)
  • src/lib/rendering.js:21-30 (the one place that does check the origin)

Issue

The capture answer's review_url, the /captures/known answer's listing_url and review_url,
and the rows of GET /captures are stored and later opened. renderingOffer sets the standard
for a server-supplied address: "The address must be on the instance's own origin"
(src/lib/rendering.js:11, 23-26). The other paths do less:

// src/review.js:73 — then openInReviewWindow($("review").href)
$("review").href = record.reviewUrl;
// src/captures.js:110
const review = element("a", { href: record.reviewUrl, target: "_blank", rel: "noopener", className: "button primary" }, t("review"));
// src/lib/known.js:61
open.href = link;

The popup checks only that the row's link starts with http(s):// (src/popup.js:67, 76-77);
review.js, captures.js and known.js check nothing. openInReviewWindow then navigates the
remembered review window (a popup-type window the person has learnt to treat as Postulo's review
screen, the one that asks them to sign in, README.md:82) to whatever address was given.

Impact

Whoever can answer as the instance decides where the review window goes: the operator of a shared
instance, or anyone on the path of a plain http://…local instance. The token is not sent there
(every call builds its address from settings.instance, and the rendering PUT checks the
origin), so the consequence is a convincing place to ask for the Postulo password inside the
extension's own flow. It needs a hostile or compromised instance, which the threat model does not
treat as an adversary; recorded as Informational.

Fix

One helper beside renderingOffer, used by every consumer of a server-supplied address:

// src/lib/rendering.js (or api.js)
/** `url` as an address on the instance's own origin, or "" when it is not one. */
export function onInstance(url, instance) {
  try {
    const given = new URL(url, instance);
    return /^https?:$/.test(given.protocol) && given.origin === new URL(instance).origin ? given.toString() : "";
  } catch {
    return "";
  }
}

Apply it in goToReview (hide the button when it returns ""), in captures.js item, in
sayKnown (say the sentence without the link), and in showReviewList in place of web(); store
the checked value when the capture is marked sent (src/lib/outbox.js:86), and check a record
kept before the fix when it is drawn.

Done when test/review.test.mjs/test/captures.test.mjs show that an answer whose review_url
names another origin leaves no link to it, and that a path-only review_url resolved against the
instance is kept.

#7 and #8 (open) describe the review list and window and say nothing of checking addresses.

Found on 2026-10-16 in a read-only security review of `postulo-chromium` at `b6c1009`, checked by a second, independent pass. **Severity:** Informational · **Category:** Input Validation · **Reference:** CWE-601 (URL Redirection to Untrusted Site); OWASP A01:2021 (Broken Access Control) **Location:** - `src/review.js:71-80` (`goToReview` and the click that opens `$("review").href`) - `src/captures.js:108-115` (the Review link of a sent capture) - `src/lib/known.js:44-66` (`sayKnown`: `open.href = link`) - `src/popup.js:66-86` (`showReviewList`: scheme checked, origin not) - `src/lib/window.js:32-66` (`openInReviewWindow`: `tabs.update`/`windows.create` with the address as given) - `src/lib/rendering.js:21-30` (the one place that does check the origin) ## Issue The capture answer's `review_url`, the `/captures/known` answer's `listing_url` and `review_url`, and the rows of `GET /captures` are stored and later opened. `renderingOffer` sets the standard for a server-supplied address: "The address must be on the instance's own origin" (`src/lib/rendering.js:11, 23-26`). The other paths do less: ```js // src/review.js:73 — then openInReviewWindow($("review").href) $("review").href = record.reviewUrl; // src/captures.js:110 const review = element("a", { href: record.reviewUrl, target: "_blank", rel: "noopener", className: "button primary" }, t("review")); // src/lib/known.js:61 open.href = link; ``` The popup checks only that the row's link starts with `http(s)://` (`src/popup.js:67, 76-77`); `review.js`, `captures.js` and `known.js` check nothing. `openInReviewWindow` then navigates the remembered review window (a popup-type window the person has learnt to treat as Postulo's review screen, the one that asks them to sign in, `README.md:82`) to whatever address was given. ## Impact Whoever can answer as the instance decides where the review window goes: the operator of a shared instance, or anyone on the path of a plain `http://…local` instance. The token is not sent there (every call builds its address from `settings.instance`, and the rendering `PUT` checks the origin), so the consequence is a convincing place to ask for the Postulo password inside the extension's own flow. It needs a hostile or compromised instance, which the threat model does not treat as an adversary; recorded as Informational. ## Fix One helper beside `renderingOffer`, used by every consumer of a server-supplied address: ```js // src/lib/rendering.js (or api.js) /** `url` as an address on the instance's own origin, or "" when it is not one. */ export function onInstance(url, instance) { try { const given = new URL(url, instance); return /^https?:$/.test(given.protocol) && given.origin === new URL(instance).origin ? given.toString() : ""; } catch { return ""; } } ``` Apply it in `goToReview` (hide the button when it returns ""), in `captures.js` `item`, in `sayKnown` (say the sentence without the link), and in `showReviewList` in place of `web()`; store the checked value when the capture is marked `sent` (`src/lib/outbox.js:86`), and check a record kept before the fix when it is drawn. Done when `test/review.test.mjs`/`test/captures.test.mjs` show that an answer whose `review_url` names another origin leaves no link to it, and that a path-only `review_url` resolved against the instance is kept. ## Related #7 and #8 (open) describe the review list and window and say nothing of checking addresses.
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#36
No description provided.