Page MenuHomePhabricator
Paste P96474

vue-router-poc-vulns.md
ActivePublic

Authored by jsn.sherman on Fri, Sep 18, 6:57 PM.
Project Tags
None
Referenced Files
F103612369: vue-router-poc-vulns.md
Fri, Sep 18, 6:57 PM
Subscribers
None
# PersonalDashboard core-vue-router PoC Security Review
**Target:** `jsn/poc-core-vue-router` branch, commit `7336023a119a` (Bug: T436156)
**Reviewer:** Claude (retroactive audit, requested post-commit)
**Date:** 2026-09-18
## Methodology / Scope
Threat model: an ordinary anonymous or registered Wikimedia user, reachable
only through Special:PersonalDashboard's public rendering and client-side
routing. This is round two of the T436156 review. Round one
(https://phabricator.wikimedia.org/P96452) audited core's vue-router bundling
itself and found no findings, because nothing in core consumed the module
yet; its own recommendation was to re-run against the first real consumer
once one existed. This PoC — swapping PersonalDashboard from its own
vendored copy of vue-router to core's — is that consumer, so this round
answers the three questions the first one deferred: does a route param
render unescaped, does anything build a client-side redirect from
`location.search`, and is a route guard ever used as an authorization
check.
Files reviewed in detail:
- `resources/ext.personalDashboard.special/init.js` (router construction, route table)
- `resources/ext.personalDashboard.special/mediaWikiHistory.js` (custom `RouterHistory`)
- `resources/ext.personalDashboard.special/Dashboard.vue` (route-driven UI state)
- `resources/ext.personalDashboard.special/App.vue`, `ModuleDialog.vue`, `FocusedFrame.vue`, `FocusedHeader.vue` (render sinks for route-derived data)
- `src/Specials/SpecialPersonalDashboard.php` (server-side `$par` resolution, the authorization boundary the client mirrors)
- Full-tree grep for `v-html`, `innerHTML`, navigation guards (`beforeEach`, `beforeResolve`, `meta.requiresAuth`)
- The commit's own diff (`extension.json`, the deleted vendoring hook, the version drop from the extension's previously-vendored `vue-router@5.1.0` to core's `4.5.1`)
## Summary
No findings.
## Findings
None.
## Reviewed without finding
- **Route param never reaches a render sink unescaped.** The one route is a
catch-all, `path: '/:module(.*)'` (`init.js:62`). `Dashboard.vue`'s
`activeName` computed reads `this.$route.params.module` and uses it only
as a lookup key against `this.islands` (`Dashboard.vue:109-110`, a
server-supplied list of known module names) — an unrecognized value
resolves to `''`, never to an unescaped string. Every place a route-derived
value reaches the DOM does so through Vue text interpolation or a Codex
prop (`FocusedHeader.vue:28` `{{ title }}`, `ModuleDialog.vue`'s `:title`
on `CdxDialog`), both auto-escaping, and the `title` value itself is
`island.header`, which comes from server config (`init.js:43`), not from
the route. No `v-html`/`innerHTML` anywhere under
`resources/ext.personalDashboard.special/`.
- **No client-side redirect built from `location.search`.** `mediaWikiHistory.js`
reads `location.search` only to detect which URL form (`?title=` vs.
`/wiki/...`) the visitor arrived on (`titleFromLocation`, `mediaWikiHistory.js:38-45`)
and to carry ambient params (`uselang`, `variant`, ...) forward on writes
(`mediaWikiHistory.js:84-93`). Every write goes through `hrefFor`, which
builds query strings with `URLSearchParams` (encodes correctly) and
targets only the fixed `specialPage` read once at load time
(`mediaWikiHistory.js:81`) — there is no path where a request parameter
picks the redirect destination.
- **No route guard doubles as an authorization check**, confirming the
round-one recommendation held: there is no `beforeEach`/`beforeResolve`/
`meta.requiresAuth` anywhere in the tree. `Dashboard.vue`'s route-driven
logic only picks which already-rendered island to present (dialog vs.
in-page frame vs. nothing) from a client-side list the server already
decided to ship (`init.js:32-48`, gated on `module.enabled`). The real
authorization-relevant resolution happens server-side and independently
of the client router: `SpecialPersonalDashboard::execute()` parses `$par`
itself and checks `$modules[$moduleName]->supports()`
(`SpecialPersonalDashboard.php:149-151`) before deciding what to render or
serve. A client route never grants access to anything the server wouldn't
have served directly.
- **The matcher-default concern from round one (vue-router's
`sensitive: false, strict: false`, the shape of GHSA-mm7m-92g8-7m47) does
not apply here.** There is exactly one route, a catch-all; with only one
route record, case-insensitive/trailing-slash-tolerant matching can't
create the kind of two-routes-disagree ambiguity that advisory depends on.
- **The version drop (extension's previously-vendored `vue-router@5.1.0` to
core's `4.5.1`) is not a functional regression.** vue-router 5.x made no
breaking API changes over 4.x (confirmed against the upstream migration
guide, https://router.vuejs.org/guide/migration/v4-to-v5.html), and the
custom `RouterHistory` in `mediaWikiHistory.js` uses only the stable
`history` interface (`push`/`replace`/`go`/`listen`/`createHref`) that
hasn't changed across that boundary.
- **Removing the extension's own vendored copy and its `ResourceLoaderRegisterModulesHandler`
closes off the exact collision this repo hit in T438387** (a duplicate
`vue-router` RL module registration warning, which was why the extension's
copy was renamed to `personaldashboard-vue-router` in the first place).
With the local copy gone, PersonalDashboard can no longer collide with
core's module under either name.
- **The registration mechanics core now owns are byte-for-byte what round
one already cleared**: a `packageFiles` callback that `file_get_contents()`s
a static, repo-vendored file path selected only by
`$wgVueDevelopmentMode`/RL debug context — no attacker-controlled input
reaches that concatenation. This PoC doesn't change that code; it just
points PersonalDashboard's dependency at it.
## Recommendations beyond the findings
- This closes out the T436156 review's open question for PersonalDashboard
specifically: there is now a real consumer, and it doesn't turn the
vue-router bundling into an exploitable surface. The broader
recommendation from round one still stands for any *other* extension that
adopts core's `vue-router` — re-check the same three questions (unescaped
route params, search-driven redirects, guards-as-auth) against whatever
that extension's own route table and render sinks look like, since the
answer here is specific to PersonalDashboard's one-route, no-guard setup
and doesn't generalize automatically.
- The correctness review run ahead of this commit (not part of this security
pass) flagged that `ext.personalDashboard.special` now hard-depends on an
RL module the extension doesn't own, with no test coverage that resolves
a real router against `createMediaWikiHistory()` — worth tracking
separately as a deployability gap, not a vulnerability, before this ever
moves past PoC.