Summary
When a form POST cannot be resolved to a form on the page being posted to, FormPlugin::getForm() falls back to findFormByName(), which searches every page on the site for a form matching the posted __form-name__ and builds the form from whichever page owns it. That fallback never checks whether the owning page is published or restricted by an access: rule. An anonymous visitor can therefore execute the process actions (save, upload, email, call) of a form defined on a login-restricted or unpublished page, simply by POSTing to any public page with __form-name__ set to the restricted form's name.
The form-nonce CSRF layer itself works correctly — a bad nonce is rejected and nothing is saved. The missing check is page authorization.
Threat model / trust boundary: an unauthenticated visitor reaches a privileged sink — functionality the site owner deliberately placed behind a login wall.
Affected versions
The vulnerable code lives in the Form plugin (getgrav/grav-plugin-form), not in Grav core.
Reported against form plugin 9.1.21 on Grav 2.0.19. The same by-name resolution is present in every 6.x, 7.x, 8.x and 9.x release, and functionally equivalent code exists back through the 5.x line. Because the form plugin declares compatibility: grav: ['1.7', '2.0'], both Grav lines are affected and both are covered by a single plugin release.
Details
In user/plugins/form/form.php:
- POST entry.
form() reads __form-name__ from the request and passes it to getForm() as a bare string, so no route is supplied and the cross-page fallback is eligible.
- The fallback.
getForm() — when the current page has no form by that name, findFormByName($name) scans the site-wide form registry, and the owning page is loaded with $pages->find($route). No published() check, no access: evaluation, no user check. The only filter applied inside findFormByName() is _page_routable, which excludes modular pages and nothing else.
- The registry includes protected pages. Form definitions are collected in
onPageProcessed, which core fires for every page during the page-tree walk, before any publish or access filtering.
- Processing runs as the restricted page. The resulting
Form binds $this->page to the owning page's route, so self@ in a destination resolves to that page's directory.
- No authorization at submit time.
Form::post() verifies the form-nonce and nothing else.
- The nonce is not a barrier.
GET ?task=get-nonce&action=form issues a session-bound form nonce to anyone, anonymously, by design.
Page access rules are enforced by the Login plugin's authorizePage(), which evaluates only $grav['page'] — the page actually requested. A POST to a public route passes that check, and the restricted page is never authorized at all.
PoC
Lab setup: a page /members restricted to logged-in users, defining a form memform whose save action writes to user/data/memform/. Anonymous GET /members returns the login wall, confirming the ACL is enforced at render.
# control A — bad nonce is rejected (CSRF layer intact):
POST / __form-name__=memform&form-nonce=000...0&data[note]=x -> nothing saved
# control B — nonexistent form name:
POST / __form-name__=nosuchform&form-nonce=<valid>&data[note]=x -> nothing saved
# attack — obtain an anonymous form nonce, then invoke the restricted form:
GET /?task=get-nonce&action=form -> {"nonce":"<session-bound nonce>"}
POST / __form-name__=memform&form-nonce=<nonce>&data[note]=pwned
# -> data file lands in user/data/memform/ although the attacker cannot view /members
Impact
Anonymous execution of the process actions defined on a restricted or unpublished page's form:
save — write attacker-controlled data into user/data/, as demonstrated.
email — send mail as the site.
upload — if the restricted form defines a file field, drop files into the restricted page's own directory, since self@ resolves there. Extension allowlists still apply, and page-content extensions (.md, .yaml, .yml, .json, .twig, .ini) are hard-blocked independently of the form's accept policy, so this cannot become page-content takeover. With avoid_overwriting: false (the default) an existing same-named media file can still be replaced.
call — invoke a site-defined callable.
Two adjacent sinks are not reachable this way and do not contribute to severity: the Login plugin's update_user handler refuses a session whose user does not exist, and register_user requires site-wide registration to be enabled, which already implies a public registration path. There is therefore no account takeover and no account creation available through this issue.
Patches
Fixed in getgrav/grav-plugin-form: the cross-page fallback now verifies that the owning page is published and that the current user is authorized for it, delegating the access-rule evaluation to the Login plugin so that parent-inherited rules, incomplete 2FA sessions, and third-party vetoes are all honoured. Forms submitted back to their own page and forms fetched with an explicit route are unaffected.
Credits
Reported by @1K0CT.
Summary
When a form POST cannot be resolved to a form on the page being posted to,
FormPlugin::getForm()falls back tofindFormByName(), which searches every page on the site for a form matching the posted__form-name__and builds the form from whichever page owns it. That fallback never checks whether the owning page ispublishedor restricted by anaccess:rule. An anonymous visitor can therefore execute theprocessactions (save,upload,email,call) of a form defined on a login-restricted or unpublished page, simply by POSTing to any public page with__form-name__set to the restricted form's name.The
form-nonceCSRF layer itself works correctly — a bad nonce is rejected and nothing is saved. The missing check is page authorization.Threat model / trust boundary: an unauthenticated visitor reaches a privileged sink — functionality the site owner deliberately placed behind a login wall.
Affected versions
The vulnerable code lives in the Form plugin (
getgrav/grav-plugin-form), not in Grav core.Reported against form plugin 9.1.21 on Grav 2.0.19. The same by-name resolution is present in every 6.x, 7.x, 8.x and 9.x release, and functionally equivalent code exists back through the 5.x line. Because the form plugin declares
compatibility: grav: ['1.7', '2.0'], both Grav lines are affected and both are covered by a single plugin release.Details
In
user/plugins/form/form.php:form()reads__form-name__from the request and passes it togetForm()as a bare string, so no route is supplied and the cross-page fallback is eligible.getForm()— when the current page has no form by that name,findFormByName($name)scans the site-wide form registry, and the owning page is loaded with$pages->find($route). Nopublished()check, noaccess:evaluation, no user check. The only filter applied insidefindFormByName()is_page_routable, which excludes modular pages and nothing else.onPageProcessed, which core fires for every page during the page-tree walk, before any publish or access filtering.Formbinds$this->pageto the owning page's route, soself@in adestinationresolves to that page's directory.Form::post()verifies theform-nonceand nothing else.GET ?task=get-nonce&action=formissues a session-boundformnonce to anyone, anonymously, by design.Page access rules are enforced by the Login plugin's
authorizePage(), which evaluates only$grav['page']— the page actually requested. A POST to a public route passes that check, and the restricted page is never authorized at all.PoC
Lab setup: a page
/membersrestricted to logged-in users, defining a formmemformwhosesaveaction writes touser/data/memform/. AnonymousGET /membersreturns the login wall, confirming the ACL is enforced at render.Impact
Anonymous execution of the
processactions defined on a restricted or unpublished page's form:save— write attacker-controlled data intouser/data/, as demonstrated.email— send mail as the site.upload— if the restricted form defines a file field, drop files into the restricted page's own directory, sinceself@resolves there. Extension allowlists still apply, and page-content extensions (.md,.yaml,.yml,.json,.twig,.ini) are hard-blocked independently of the form'sacceptpolicy, so this cannot become page-content takeover. Withavoid_overwriting: false(the default) an existing same-named media file can still be replaced.call— invoke a site-defined callable.Two adjacent sinks are not reachable this way and do not contribute to severity: the Login plugin's
update_userhandler refuses a session whose user does not exist, andregister_userrequires site-wide registration to be enabled, which already implies a public registration path. There is therefore no account takeover and no account creation available through this issue.Patches
Fixed in
getgrav/grav-plugin-form: the cross-page fallback now verifies that the owning page is published and that the current user is authorized for it, delegating the access-rule evaluation to the Login plugin so that parent-inherited rules, incomplete 2FA sessions, and third-party vetoes are all honoured. Forms submitted back to their own page and forms fetched with an explicit route are unaffected.Credits
Reported by @1K0CT.