Skip to content

Bind Users_Intent acceptance to a session, so a leaked token is not a replayable login - #19

Open
zattak1 wants to merge 1 commit into
Qbix:mainfrom
zattak1:fix/intent-accept-session-binding
Open

Bind Users_Intent acceptance to a session, so a leaked token is not a replayable login#19
zattak1 wants to merge 1 commit into
Qbix:mainfrom
zattak1:fix/intent-accept-session-binding

Conversation

@zattak1

@zattak1 zattak1 commented Aug 27, 2026

Copy link
Copy Markdown

This acts on the SECURITY DECISION comment that has been sitting on
handlers/Users/before/Q_objects.php since 42492f4:

SECURITY DECISION: evenIfCompleted=true allows replay of leaked intent tokens.
Remove unless a specific flow requires re-acceptance, in which case bind to
session/origin to prevent replay from elsewhere.

Both halves of that instruction turn out to be needed, and the first one alone
is not enough.

What the hole actually is

Users_Intent::accept() calls Users::setLoggedInUser($intent->userId, ...).
It returns early on a missing row, a past endTime, or a non-null
completedTime — and that last check is skipped under evenIfCompleted. So
for the life of endTime the token is a bearer login for that user, accepted
an unbounded number of times
, presented as Q.Users.intent: a URL query
parameter, which leaks by the ordinary means — Referer on outbound links,
webserver and proxy access logs, browser history, a pasted link.

Two things make the obvious fix wrong:

  1. accept() never marks the intent used. Only complete() writes
    completedTime. So simply dropping evenIfCompleted closes nothing: an
    intent that was accepted and never completed was already replayable without
    it.
  2. evenIfCompleted is load-bearing. The external-platform return leg
    (Users/intent PUT → redirect back to $intent->url) lands on that URL
    after completedTime has been stamped. Removing the option breaks
    Telegram and Web3 login.

The fix: bind to the session, as the comment suggested

accept() now also requires authorizeAcceptingSession(). The row already
records the opening session (beforeSave stores Q_Session::requestedId()),
so the binding needs no schema change. Two acceptances are legitimate, and only
two:

  1. The session that opened the intent — unlimited. Re-accepting there grants
    it nothing it does not already have, and the return leg genuinely lands on it
    more than once.
  2. Exactly one other session, and only when the action declares
    "handoff": true under Users/intents/actions. That is the QR /
    second-device case, and it is now opt-in rather than the default.
    Users/authenticate declares it here, since authenticating another session
    is its entire purpose. Assets/charge does not, and neither does any action
    that a plugin registers as a bare true.

The consuming session is remembered as a keyed fingerprint (hash_hmac under
Q/internal/secret, falling back to sha256) in the instructions, never as a
session id, and the same session may re-present the token — so a reload or a
back button does not break a handoff, while a third session is refused. A
leaked token is therefore dead once it has been used.

The policy is factored into a pure static, Users_Intent::acceptDecision(),
returning ACCEPT_DENY / ACCEPT_ALLOW / ACCEPT_CLAIM. That split is
deliberate: constructing a Db_Row opens a database connection, so a rule
expressed only as an instance method cannot be covered by a test that has no
database — and this is exactly the kind of rule that should be covered.

Known limit, stated rather than papered over: this makes a leaked handoff
token single-use, not unusable. An attacker who steals one before the real
second device uses it still wins the race. But the theft then becomes visible
(the real device is refused) instead of silent and repeatable. Closing that
would need the accepting device to prove something the token does not carry,
which the QR flow has no channel for.

Two disclosures on the same three lines

Found while verifying the above; both are in this PR:

  • exportArray() returned every column, sessionId included. Q_objects
    publishes that array as Q.plugins.Users.intent script data to whoever
    presented the token — so it handed out the originating session's id, a
    credential that outlives the intent's own endTime. (main already unsets
    it; this keeps that and adds the reasoning, plus withholds the new
    fingerprint.)
  • That script data was published whether or not the accept succeeded. A
    holder refused the login still received the intent's instructions — which for
    an Assets/charge intent carry the payer's userId, the community, the
    reason and the amount. It is now published only on a successful accept.

Compatibility

  • No schema change, no migration.
  • Users/authenticate keeps working across devices, because it declares
    handoff.
  • An app or plugin that genuinely needs cross-device acceptance for its own
    action adds "handoff": true to that action's config. Anything registered as
    a bare true is closed by default, which is the safe direction.
  • evenIfCompleted is now read with empty() rather than as a bare index,
    which also silences a PHP 8 warning on the Telegram accept path — it passes
    no options at all.

Verification

  • PHP unit tier, headless Q bootstrap: 11 new cases pinning acceptDecision()
    — the replay rejection, the single-claim handoff, the reload-idempotent
    re-accept, the closed default for unconfigured actions, and the config
    allow-list. Whole suite green (112 tests).
  • Browser tier against a real stack: an intent minted by a logged-in user and
    replayed from a cookie-free browser context authenticates nobody and
    receives no script data at all; the Assets/pay flow that mints it still
    completes end to end. Full suite green.

Users_Intent::accept() logged in the intent's user for anyone who presented
the token, an unbounded number of times, for the whole life of endTime.
Users/before/Q_objects.php feeds it from Q.Users.intent - a URL query
parameter - so the credential leaks by the ordinary means: Referer headers
on outbound links, webserver and proxy access logs, browser history, a
pasted link. The SECURITY DECISION comment on that line named this exactly
and left it open.

accept() now also requires authorizeAcceptingSession(). Two acceptances are
legitimate and only two:

  1. The session that opened the intent. Unlimited: re-accepting grants it
     nothing it does not already have, and the external-platform return leg
     genuinely lands there more than once - which is why Q_objects passes
     evenIfCompleted, and why deleting that option was not the fix.
  2. Exactly one OTHER session, and only for an action that declares
     "handoff": true. That is the QR / second-device case, and it is now
     opt-in rather than the default. Users/authenticate declares it (its
     whole purpose is authenticating another session); Assets/charge - the
     only intent action live in a stock deployment, opened server-side by
     Assets::pay() - does not, so its token can never log in a second
     browser at all.

The consuming session is remembered as a keyed fingerprint in the
instructions, not as a session id, and re-presenting the token from the same
session still works so a reload or a back button does not break the handoff.

The policy is a pure static, acceptDecision(), because constructing a Db_Row
opens a database connection and a rule this load-bearing has to be pinnable
by a test without one.

Two disclosures on the same three lines, found while verifying the above:

  - exportArray() no longer exports sessionId, matching what upstream
    arrived at after this pin. Q_objects puts that array into script data
    for whoever presents the token, so it was handing out the ORIGINATING
    session's id - a credential that outlives the intent's endTime, and
    strictly worse than the replay. The acceptedBy fingerprint is withheld
    for the same reason.
  - Q_objects publishes that script data only on a SUCCESSFUL accept. It
    published unconditionally, so a session refused the login still received
    the intent's instructions - which for Assets/charge carry the payer's
    userId, the community, the reason and the amount.

evenIfCompleted is read with empty() rather than as a bare index, which also
silences a PHP 8 warning on the Telegram accept path, which passes no options.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant