Skip to content

gate-7 Pattern 8 is blind to the raw-body family: a parameterless #[PublicPage] SOAP/webhook handler carries its selector in the body (2 live in procest, one queried persons by BSN) #413

Description

@rubenvdlinde

The gap

Pattern 8 (#[PublicPage] handlers, added in #373/#398) decides scope by asking
whether a caller-supplied SCALAR reaches a selection call, and explicitly
discounts payloads — from check_no_admin_idor.py:

in scope — a caller-supplied SCALAR value reaches a selection call
(array $data is a payload, not a selector; a $token / $secret IS the
authorisation … and neither is a selector).

That is right for REST. It is structurally blind to the SOAP / webhook /
raw-body family
, where the method takes no parameters at all and the
selector arrives inside the request body.

Measured, in procest at 55280c070

#[PublicPage]
#[NoCSRFRequired]
public function zaken(): DataDisplayResponse {
    return $this->dispatcher->dispatch(
        rawBody: file_get_contents('php://input'),
        service: 'zaken'
    );
}

StufController::zaken() and ::personen() are #[PublicPage] +
#[NoCSRFRequired] and reached StufZknMessageResponder::respond() with no
authentication of any kind
— no token, no JWT, no signature. That responder
dispatches zakLk01 (case create/update), zakLv01 (case query), npsLv01
(person query by BSN) and edcLk01 (document create/update).

gate-7 reports zero findings on procest's 79 @PublicPage methods, these two
included. Every finding in the app carries rule=no-auth-guard-in-body; not one
carries rule=publicpage-unscoped-object-lookup.

The silence is correct for the other 77 — the ZGW controllers all call
validateJwtAuth(), the share routes are $token-gated, and the
zaakeigenschappen* / publish* one-liners are cleared by clear (e), the
resolved delegation hop. Clear (e) in particular did better than my own body
grep, which called nine guarded delegations unguarded.
The gap is narrow and
specific: a parameterless method whose selector is in the body.

Fixed app-side in ConductionNL/procest#828 — the guard is the WSSE verification
the same controller's third public route already performed.

Why it matters more than one app

The shape is common in this fleet's integration surface: StUF, notification
webhooks, NrcController::notificatieCreate(), payment callbacks. All of them
are legitimately #[PublicPage] (the caller has no Nextcloud session) and all
of them carry their identifiers in the body.

⚠️ And it is exactly the failure mode Pattern 8's own commentary warns about,
one level up.
The comment says the repaired failure mode "is DISTRUST" and that
the fleet "came to trust its silences". A zero on 79 methods is a very loud
silence, and here it was right 77 times and wrong twice — which is precisely the
ratio that makes a silence persuasive.

Suggested predicate

Treat a #[PublicPage] method that reads the raw request body
(file_get_contents('php://input'), $this->request->getContent(), or a
collaborator call passing one of those) as in scope, with the same clears
already defined — a signature/WSSE/HMAC verification, a JWT validation, or a
resolved delegation hop reaching one, should clear it.

The two-directional control is available in-tree: StufController::inkomend()
verified WSSE before ConductionNL/procest#828 and ::zaken() did not, in the
same file, same commit, same checker.

What I did not do

I did not write a fixture or run the arms. This is a report from a per-app
audit, not a verified gate change — the shape above is a suggestion and needs
the usual both-arms fixture before it lands.

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