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
Open
Bind Users_Intent acceptance to a session, so a leaked token is not a replayable login#19zattak1 wants to merge 1 commit into
zattak1 wants to merge 1 commit into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This acts on the
SECURITY DECISIONcomment that has been sitting onhandlers/Users/before/Q_objects.phpsince42492f4: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()callsUsers::setLoggedInUser($intent->userId, ...).It returns early on a missing row, a past
endTime, or a non-nullcompletedTime— and that last check is skipped underevenIfCompleted. Sofor the life of
endTimethe token is a bearer login for that user, acceptedan unbounded number of times, presented as
Q.Users.intent: a URL queryparameter, which leaks by the ordinary means —
Refereron outbound links,webserver and proxy access logs, browser history, a pasted link.
Two things make the obvious fix wrong:
accept()never marks the intent used. Onlycomplete()writescompletedTime. So simply droppingevenIfCompletedcloses nothing: anintent that was accepted and never completed was already replayable without
it.
evenIfCompletedis load-bearing. The external-platform return leg(
Users/intentPUT → redirect back to$intent->url) lands on that URLafter
completedTimehas been stamped. Removing the option breaksTelegram and Web3 login.
The fix: bind to the session, as the comment suggested
accept()now also requiresauthorizeAcceptingSession(). The row alreadyrecords the opening session (
beforeSavestoresQ_Session::requestedId()),so the binding needs no schema change. Two acceptances are legitimate, and only
two:
it nothing it does not already have, and the return leg genuinely lands on it
more than once.
"handoff": trueunderUsers/intents/actions. That is the QR /second-device case, and it is now opt-in rather than the default.
Users/authenticatedeclares it here, since authenticating another sessionis its entire purpose.
Assets/chargedoes not, and neither does any actionthat a plugin registers as a bare
true.The consuming session is remembered as a keyed fingerprint (
hash_hmacunderQ/internal/secret, falling back tosha256) in the instructions, never as asession 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 isdeliberate: constructing a
Db_Rowopens a database connection, so a ruleexpressed 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,sessionIdincluded.Q_objectspublishes that array as
Q.plugins.Users.intentscript data to whoeverpresented the token — so it handed out the originating session's id, a
credential that outlives the intent's own
endTime. (mainalready unsetsit; this keeps that and adds the reasoning, plus withholds the new
fingerprint.)
holder refused the login still received the intent's instructions — which for
an
Assets/chargeintent carry the payer'suserId, the community, thereason and the amount. It is now published only on a successful accept.
Compatibility
Users/authenticatekeeps working across devices, because it declareshandoff.action adds
"handoff": trueto that action's config. Anything registered asa bare
trueis closed by default, which is the safe direction.evenIfCompletedis now read withempty()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
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).
replayed from a cookie-free browser context authenticates nobody and
receives no script data at all; the
Assets/payflow that mints it stillcompletes end to end. Full suite green.