Grav plugin review
Use when reviewing a third-party Grav 2.0 / Admin2 plugin for compatibility and security, especially a GPM (`getgrav/grav` `[add-resource]`) submission. Covers the review method (clone, map, cross-check against the real api/admin2/login plugins in the workspace), the verified API/Admin2 integration contract (which `onApi*` events exist, `AbstractApiController` shape, public-route mechanism, component-page convention, settings-panel secret-leak vector), and a findings taxonomy with concrete fixes: manifest/GPM correctness, auth granularity, secret handling, unauthenticated public writes, CSV/mail-header injection, XSS escaping, path traversal in file-serving, SVG upload, open proxies, pure-PHP SSE worker exhaustion, CSRF, message/identity spoofing, account mass-assignment, plus citizen-behavior and author-branding-in-defaults smells. Also covers how to produce a paste-ready, file:line-referenced handoff. Trigger when the user asks to review/vet/audit a Grav plugin, asks about a GPM submission, links a `grav-plugin-*` repo for review, or mentions checking a plugin for Grav 2.0 / Admin2 compatibility.From its SKILL.md
npx -y skills add getgrav/grav-skills --skill grav-plugin-reviewAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 4 stars4 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
14.7 KB, as published. Nobody here has run it
Reviewing a Grav 2.0 / Admin2 plugin
Use this when vetting a third-party Grav 2.0 plugin for compatibility and security, typically a GPM [add-resource] submission on getgrav/grav. It encodes the review method, the verified Admin2/API contract to check against, and a findings taxonomy drawn from real reviews so you can be fast and concrete rather than generic.
The deliverable is usually a paste-ready, file:line-referenced handoff (for the maintainer or for another agent to action), not a vague prose summary. Be specific, tag severity, and credit correct practices so they don't get "fixed."
Method
- Get the source. Clone the submitted repo (
git clone --depth 1 <repo>). Note the actual default-branch version may be ahead of the version named in the issue; checkblueprints.yamlversionand the latest comments for hotfix releases. - Map it.
find . -type f -not -path './.git/*' | sortandwc -lthe PHP/JS. Identify the entry plugin class, theclasses/, theadmin-next/web components, public assets, andblueprints.yaml. - Read security-critical files first, in this order of value: the public/engine request handler, flat-file storage/writers, the API bridge controllers (auth), anything that writes
user/accounts/*.yamlor config, file-upload handlers, and the client JS that renders untrusted data. - Cross-check against the real plugins in the workspace, do not trust the README. They are symlinked under
user/plugins/and live at~/Projects/grav/grav-plugin-api,~/Projects/grav/grav-plugin-admin2,~/Projects/grav/grav-plugin-admin. Verify event names, controller signatures, and route mechanics there (see Contract below). - Write the handoff in the output format at the end.
Keep examples generic in any public-facing writeup (do not name the submitter). Internally, be blunt about severity.
The verified API / Admin2 contract (check the plugin against these)
These are confirmed against grav-plugin-api. Re-verify if the API plugin has moved on.
onApi*events that exist (fired by the api plugin):onApiRegisterRoutes,onApiCollectPublicRoutes,onApiSidebarItems,onApiPluginPageInfo,onApiAdminSettingsPanels,onApiFloatingWidgets,onApiMenubarItems,onApiContextPanels,onApiGenerateReports,onApiBlueprintResolved,onApiDashboardWidgets, plus theonApiBefore*/*edlifecycle pairs. If a plugin subscribes to anonApi*name not in the api plugin, it silently never fires.- Controller base.
AbstractApiControllerlives atGrav\Plugin\Api\Controllers\AbstractApiController. Constructor is(Grav $grav, Config $config)with promoted readonly props. The router instantiates handlers asnew $controllerClass($this->container, $this->config), so$this->gravis the container and$this->grav['user']is available. Route params arrive on theroute_paramsrequest attribute (alreadyrawurldecoded). - Permissions.
requirePermission($request, 'api.access')checksapi.accessfirst, then the named permission. Custom permissions register viaPermissionsRegisterEvent+ apermissions.yaml(PermissionsReader::fromYaml). - Public routes.
onApiCollectPublicRoutesexposes$event['prefixes'](matched bystr_starts_with) and$event['exact'](exact match). Anything a plugin adds here is unauthenticated. Adding a whole prefix (e.g./api/v1/<plugin>) makes every sub-route public. Auth-required routes simply must not appear here. - Rate limiting runs on public routes too (the api plugin applies
RateLimitMiddlewareafter the auth bypass), but it limits request rate, not per-record growth or quota burn. - Component plugin page.
onApiPluginPageInforeturns a definition withpage_type: 'component'; admin2 loadsadmin-next/pages/{slug}.js, which mustcustomElements.define('grav-{slug}--page', ...)and readwindow.__GRAV_PAGE_TAG.page_type: 'blueprint'renders a YAML form withdata_endpoint/save_endpoint/actions. - Settings panel secret-leak vector.
onApiAdminSettingsPanelspanels usually setdata_endpoint/save_endpointto the generic/config/plugins/<slug>. That endpoint returns the raw plugin config to the browser (gated onapi.config.read). So any secret stored in plugin config (API tokens, shared keys) is sent to the admin client unless the field is write-only and redacted server-side. A custom controller that masks the secret is bypassed if the panel points at the generic endpoint. - Auth transport. Admin2 web components send
X-API-Token(FPM/FastCGI stripsAuthorizationon some hosts).window.__GRAV_API_TOKEN/__GRAV_API_SERVER_URL/__GRAV_API_PREFIX. - No native dialogs in admin2 web components. Use
window.__GRAV_DIALOGS.confirm(), neverwindow.confirm/alert/prompt.
Findings taxonomy
Each item: what to look for, and the fix. Severity is a starting point, adjust to context.
Manifest / GPM (gate listing)
compatibility.gravlies. A block of['1.7','2.0']while the code uses PHP 8 functions (str_contains,str_starts_with,str_ends_with,match, enums, readonly promoted props) cannot run on Grav 1.7 / PHP 7.x. Setcompatibility.grav: ['2.0'].- Dependencies contradict the pitch. README/issue claims
grav >=2.0.0+admin2+apibutblueprints.yamllists onlygrav >=1.7.0(or omits admin2/api). GPM readsblueprints.yaml, so fix it there. - Duplicate YAML keys in
blueprints.yaml(e.g.bugs:/docs:twice) silently take the last value. Dedupe. - Version drift. A status endpoint or twig var hardcodes an old version (
0.1.0-alpha) whileblueprints.yamlis further along. - Secret fields typed
text. API tokens / keys astype: textshould betype: password, and must be redacted on every read path (see settings-panel vector above).
Admin2 / API integration correctness
class_exists-gated event subscription.getSubscribedEvents()that only subscribes toonApi*events whenclass_exists(ApiRouteCollector::class)is fragile: it depends on plugin load order at registration time. Prefer subscribing unconditionally and guarding inside handlers (the events only fire when the api plugin is present anyway).isAdmin()-gated subscription inonPluginsInitialized. A plugin that doesif ($this->isAdmin()) { $this->enable(['onGetPageTemplates' => ...]) }(oronGetPageBlueprints) registers nothing under Admin2. The classic admin set$grav['admin']early soisAdmin()was true at init; the api plugin'sAdminProxysets it only at route dispatch, afteronPluginsInitializedhas run, so the gatedenable()never fires and the plugin's page template/blueprint is silently missing from the Add Page dropdown. Tell: the handler is a context-free$types->register(...). Fix: subscribe statically ingetSubscribedEvents(); if a handler truly needs admin context, checkisAdmin()inside the handler (dispatch time), not at subscription time. A theme that ships a same-named template file masks the bug, so don't rely on the dropdown looking right on one site.- No autoloader. Hand-rolled
require_onceeverywhere works but is brittle; a properautoload()returning a PSR-4ClassLoaderis the idiom (and fixes "Class not found" boot failures on case-sensitive Linux/cPanel). exit;in the page lifecycle. Frontend fallback handlers thatexitbypass Grav's shutdown/caching. Acceptable for virtual routes by design, but note it, and watch for contradictions (a hardapidependency plus a "no api" fallback path that can never run).- Bridge identity. If an engine reads
$this->grav['user']but the bridge controller never passes the api-authenticated user through, authenticated actions may silently run as anonymous. Confirm the user is wired (some plugins use asetApiUser()pattern). - Component conventions. Verify tag name
grav-{slug}--page, file atadmin-next/pages/{slug}.js, andpage_type.
Security
- Coarse authorization. Every admin endpoint gating on bare
api.accessmeans any API-enabled user (not just admins) can reach PII reads and writes. Register a dedicated permission or require a higher scope for sensitive actions. - Secret handling. See the settings-panel vector. A custom read endpoint that strips one secret but returns the rest of raw config (e.g. leaves moderator/shared keys in) still leaks. Redact every secret on every read; treat as write-only.
- Unauthenticated public writes. Public chat posts, RSVP/form submissions, etc. with only a honeypot and the global rate limiter. Watch for: no per-record cap (storage exhaustion), notification emails fired per submission (mail abuse), and writes that trigger outbound API calls.
- CSV / formula injection. A CSV export that quotes commas/quotes but does not neutralize a leading
=,+,-,@, tab, or CR, fed by public input, executes in Excel/Sheets when an admin opens it. Prefix risky cells with'. - Mail header injection. User-controlled
nameplaced intomail()'s subject after onlytrim(): an embedded CRLF injects headers. Strip line breaks. Also flag hardcodedFrom:on a foreign domain (fails SPF, looks like spam). - XSS in client rendering. The safe pattern is escape-first then re-apply a markdown subset; credit it when present. Flag: an
esc()that omits"(or') while values land inhref="..."/src="..."attributes (attribute breakout); SVG uploads served same-origin (script-bearing); a markdown link builder that does not enforce anhttp(s)scheme (allowsjavascript:). - Path traversal in file-serving. A public route that
readfile()s a path built from a user/admin-settable field (e.g. a free-text "cover path") with norealpath()confinement, and no extension allowlist, becomes an unauthenticated arbitrary-file read (account YAML, config secrets). Confine to the intended media dir, allowlist image extensions, store only the uploaded basename. - File upload. Validate by
finfoMIME, not just client extension; do not allowsvgin an "image" upload (or serve withContent-Disposition: attachment); force the destination filename so a user can only overwrite their own asset. - Account mass-assignment / privilege escalation. A profile/account write that merges arbitrary request keys into
user/accounts/*.yamlcould let a member setaccess.admin.super. The safe pattern whitelists fields and binds the edit to the session user (username == session). A grant routine whose switch only ever writesaccess.site.*cannot escalate, credit that. - Open proxy / quota burn. An unauthenticated server-side proxy (Giphy, etc.) lets anyone spend the site owner's API quota or use the site as a proxy. Rate-limit or require a session.
- Pure-PHP SSE worker exhaustion. A
handle()that doesset_time_limit(0)and asleep()loop holds one PHP-FPM worker per connected client for the whole window; a few dozen tabs starve the pool and the whole site stalls. Often it is also dead in the supported config (the api bridge returns 501 and the client falls back to polling), making the "realtime SSE" claim false. Recommend owning the polling model, or offloading push to an external hub (Mercure, asgrav-plugin-sync-mercuredoes). PHP must never hold a request open to stream. - CSRF. State-changing endpoints authenticated by an ambient session cookie with no token/nonce and wildcard CORS are CSRF-able. Even when bound to the session user (cannot edit others), an attacker can force the victim to change their own data. Add a form nonce.
- Spoofing. A public message endpoint that honors a client-supplied
type: system/formlets guests forge official-looking notices; restrict privileged message types to mod/internal paths. Check for a reserved-name guard that stops guests impersonating members/mods, credit it when present. - Secrets in URLs. A shared key passed as a query param (
?modKey=..., GET) lands in access logs, history, and Referer. Move to a POST body or header. Credithash_equals()for key comparison when present.
Citizen behavior
- Global config rewrite. A plugin that overwrites another plugin's config (e.g.
plugins.login.redirect_after_login, messenger groups) on every request, affecting the whole site, is surprising. Scope it or respect existing config. - Route shadowing. Very-early (
priority 100000) interception of a public route means it can never be a real Grav page, and bypasses Grav caching/headers. By design for virtual routes, but note it.
Brand / fit (maintainer decision, not a code bug)
- Author defaults baked in. Notify emails defaulting to the author's address, hardcoded
From:/footer/upsell URLs on the author's domains, demo data referencing the author's brand, theme/category names tied to the author, optional wire-ins to the author's other unlisted plugins. For a "general-purpose" listing, the generic version must be the default. Flag this separately as a decision for the maintainer.
Output format
Produce a handoff the maintainer can read and another agent can action:
- One-line verdict (listable / fix-first / not as-is) and the headline blockers.
- Credit correct practices explicitly in a "do not fix these" list so a downstream agent does not undo them.
- Findings grouped by category, severity-tagged, each with a
file:linereference and a concrete fix. Order security before manifest before quality. - Brand/fit as a separate section framed as a maintainer decision.
- Wrap the whole thing in a fenced code block so terminal markdown renderers do not mangle it (the user often pastes it verbatim).
Scale effort to the ask: a quick "any red flags?" gets the top 3, a "thorough audit" gets the full taxonomy with the good-practices credits.