Skip to content

/pending is unauthenticated and publishes a rolling log of privileged infra approvals #74

Description

@bdelanghe

Cold read of the live site, 2026-09-03. Measured with curl against desk.bounded.tools. Nothing was approved and no state was changed.

Edited shortly after filing. The first version of this issue quoted a live /pending response verbatim, including the operation prose, the Terraform resource and action, and the Actions run id. This repository is public, so that turned a record with a 900-second lifetime into a permanent, indexable one — committing the exact error the issue is about, at greater durability. The payload is described below rather than reproduced. The original text is still in this issue's edit history, which is itself worth knowing about; the named ceremony has long expired and the keeper is passkey-gated, so it is inert, but a maintainer may still want the history scrubbed.

What it returns

GET /pending needs no session and answers any caller with a JSON object shaped:

{
  "kind": "approval",
  "title": "Approve <lane name>",
  "body": "<up to 400 characters of prose>",
  "url": "https://keeper.bounded.tools/a/<ceremony id>",
  "at": "<ISO 8601 timestamp>"
}

For the live response I captured, the body field contained, in plain readable English:

  • which privilege the approval mints, named exactly, down to the GitHub App token scope
  • which infrastructure directory it acts on
  • the precise Terraform operation, including the action and the specific resource being acted on
  • the flag values the run was invoked with
  • the Actions run id

Two polls eight minutes apart returned two different ceremony ids, so this is a live feed rather than one stuck record.

What it is not

I checked before writing this up rather than assuming. Fetching a published approval URL returns the keeper's own page, and that page asks for a passkey. The URL is not a bearer capability and this is not privilege escalation. src/pending.js also already knows the endpoint is anonymous — the comment on the oldestOpenQuestion import reasons explicitly about what an anonymous caller can make the Worker spend.

What it is

The endpoint's cost was considered. Its content does not appear to have been. Anonymous, unauthenticated and free to poll, it yields a continuous record of which privileged operation is pending, what it touches, and the wall-clock instant a 15-minute approval window opened.

APPROVAL_TTL_SECONDS = 900 bounds how long each record lives, not how long an observer keeps it. Polling every ten minutes reconstructs the org's privileged-change history indefinitely, with timestamps.

This sits oddly beside the line claims.bounded.tools holds deliberately and states in its own footer: the public feed drops assignees on purpose, because the board publishes a ranking and not a roster. /pending publishes considerably more than a roster, from the same origin, with no gate at all.

Why the shape makes it awkward

/pending is fetched by the service worker on push wake, and a push arrives with no payload by design (#51) precisely so the text comes over https from the origin instead. The endpoint therefore has to be reachable from a push handler. That is what makes this a design question rather than a one-line fix, and why I am filing rather than proposing a patch.

Directions, roughly by cost:

  1. Gate it on the session the repo already has. SESSION_SECRET, passkey login and gated routes landed in desk login: passkey auth, keeper-gated enrolment, and a session that ends #72. A push handler's fetch can carry same-origin cookies, so a subscribed-and-signed-in device would still read it. The cost is that a device with a subscription but no session falls back to the generic "The board changed." — which is what the code did before The installed app has no icon, and a notification cannot say what it is for #51, and what The installed app has no icon, and a notification cannot say what it is for #51 judged too thin.
  2. Bind it to the subscription. The caller already holds a push endpoint the origin stored. Requiring proof of one turns "anyone" into "a device we have seen".
  3. Say less. The phone needs enough to decide whether to walk to a laptop. "An infra approval is waiting" plus the URL may be the whole requirement, with the operation prose behind the passkey on the keeper.

(3) is cheapest and probably the right first move. I have not measured which of these breaks the iOS wake path, and that is the thing to establish before picking one.

Caveat

This is a cold read from outside. I have not seen the threat model this endpoint was designed against, so it is possible the exposure is known and accepted. If it is, that belongs written down next to where the claims no-roster rule is written down — from outside, the two read as inconsistent.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions