Skip to content

gate-7: the authentication-blanking also erases a real authorisation guard (3 false positives in decidiq VotingController) #569

Description

@rubenvdlinde

What I saw

Running the gates on decidiq, gate-7 reports three methods as no-auth-guard-in-body:

lib/Controller/VotingController.php:134 method=cast         rule=no-auth-guard-in-body
lib/Controller/VotingController.php:276 method=proxy        rule=no-auth-guard-in-body
lib/Controller/VotingController.php:366 method=revokeProxy  rule=no-auth-guard-in-body

All three are guarded, and guarded in exactly the way gate-7's own failure message prescribes ("scope the object to the caller — pass the session identity into the query"):

$nextcloudUid = $this->userSession->getUser()?->getUID() ?? '';
if ($nextcloudUid === '') {                                    // authentication — correctly blanked
    return new JSONResponse(['message' => 'Unauthenticated'], Http::STATUS_UNAUTHORIZED);
}

$participantId = $this->votingService->resolveParticipantUuid($nextcloudUid);
if ($participantId === null) {                                 // AUTHORISATION — blanked too
    return new JSONResponse(['message' => 'Geen deelnemersprofiel …'], Http::STATUS_FORBIDDEN);
}

The identity is derived from the session and never from client input; the vote is then cast as $participantId.

Why the blanking eats it

From check_no_admin_idor.py's own commentary, the neutralised shape is "an if statement whose condition tests only whether a caller identity is ABSENT and whose consequent refuses", kept narrow by three controls — of which control 2 is zero-argument operands only, so that $this->access->canAccess($id, $user) and $account['ownerId'] are never mistaken for an identity.

$participantId passes all three controls: it is an absence test (=== null), on a bare variable with no call arguments and no subscript, with a refusing consequent. So it is blanked — but it is not a caller identity. It is the result of a per-caller authorisation lookup, and blanking it removes the only remaining guard, leaving the method looking bare.

So the controls distinguish "identity absent" from "object check" by operand shape, and this third shape — the caller's resolved authorisation subject — has the shape of the first and the meaning of the second.

Suggested narrowing

Require the blanked operand to be assigned directly from a session/identity accessor (getUser(), getUID(), $this->userId, …) in the same method, rather than from an arbitrary service call. $nextcloudUid above qualifies; $participantId does not, because its right-hand side is $this->votingService->resolveParticipantUuid(...) — a lookup that takes the identity as an argument, which is precisely what makes it an authorisation step.

That keeps the #365 fix intact (the house-style 401 preamble is still blanked) without erasing a guard one line below it.

Why I'm filing rather than fixing

The app code is correct. Changing working authorisation code to satisfy a checker would be the wrong repair — and gate-7's own history (#353, #360) records that false positives are how it lost credibility before.

Not blocking anything today: CI counts gate-7 diff-scoped, so these three pre-existing findings did not fail any PR. Found while getting decidiq#874 green, where gate-7 also flagged a genuine hole of mine (an instantiate endpoint with authentication and no authorisation) — so the gate is earning its keep; this is about one narrowing.

Measured 2026-08-24 against hydra-gates as vendored in decidiq.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions