RSS Amplifier

Alex Fadeev · Jul 27, 2026

A Browser-Based VSCode Token Theft Chain

0
Sign in to vote or save

Alex Fadeev · Alex Fadeev

A browser editor feels harmless at first glance: open a repo, inspect files, tweak code, maybe send a pull request. But when that editor carries a broadly scoped GitHub token and runs a huge client-side codebase, tiny trust mistakes become serious attack surface. In this case, a victim only needed to open a crafted github.dev page for an attacker to reach a token that could read from and write to repositories the victim could access, including private ones. ⚠️

The issue hinged on how VSCode bridges isolated web content back into the main editor experience. The isolation model itself was solid in many places, but one usability feature created an opening: keyboard events from untrusted webviews could be forwarded upward in a way that let malicious content impersonate user shortcuts. From there, the attack chain used built-in workflows and extension mechanics to install attacker-controlled code and extract the GitHub token.

This post walks through the setup, the webview model, the bug, the proof of concept, practical defenses, and the fixes that landed in early June 2026.

GitHub offers github.dev, which opens a lightweight VSCode experience directly in the browser for any repository you can access. You can switch from github.com to github.dev in the address bar, or launch it from the GitHub UI.

That browser-based editor is not just a read-only viewer. It can browse repository contents, including private repos you are allowed to open. It can also create commits and open pull requests. ✅

To make that work, GitHub sends an OAuth token from github.com to github.dev so the browser editor can act on your behalf. The critical detail is that this token is not restricted to only the repository you opened. If your account can access additional repositories, the token can access those too. 📌

That combination matters:

  • a powerful browser app

  • a valuable GitHub token

  • a very large TypeScript codebase with lots of functionality

For anyone studying client-side editor bugs, that is a highly attractive target.

On the desktop, arbitrary JavaScript execution inside VSCode would effectively mean remote code execution, so sandboxing is essential. One major protection mechanism is the webview.

A webview is rendered inside an iframe that uses a different origin from the main editor window. That separation is what allows things like Markdown previews and Jupyter notebook output to display rich content without handing it direct access to the core VSCode environment. 🛠️

For example, notebook output runs inside a vscode-webview://... origin, while the main editor lives under vscode-file://.... Because of browser cross-origin rules, code inside the webview cannot directly use Node.js integration or invoke VSCode APIs from that isolated frame.

That isolation is good, but rich editor features still need communication. Static rendering alone would be too limited. If the Markdown preview needs to follow the cursor position in the editor, or notebook output needs live updates, the two contexts must coordinate somehow.

Browsers solve that with Window.postMessage(). The main editor can send structured messages into the webview, and the webview can listen for them.

Here is the rough shape of the selection-sync message:

{
  "type": "onDidChangeTextEditorSelection",
  "line": 31
}

And the receiving side can respond like this:

window.addEventListener('message', async event => {
  const data = event.data as ToWebviewMessage.Type;
  switch (data.type) {
    ...
    case 'onDidChangeTextEditorSelection':
      highlighter.onDidChangeTextEditorSelection(data.line, docVersion);
      return;

This design gives you both functionality and separation. The editor cannot reach directly into the webview DOM, and the webview cannot directly grab privileged APIs from the main editor. That is the intended boundary.

In practice, users expect embedded content to behave like part of the editor. Clicking links should work. Dragging should work. Keyboard shortcuts should still work even if focus happens to be inside a webview. 🚨

That expectation created the problem.

Normally, cross-origin iframes prevent one page from attaching a keyboard listener to another page’s content, because that would be a disaster for security. A malicious page should not be able to iframe a login page and capture keystrokes meant for it.

But VSCode wanted editor shortcuts to continue functioning even when the cursor was focused inside a webview. So it forwarded keydown information from the inner frame back to the host.

The relevant logic looked like this:

contentWindow.addEventListener('keydown', handleInnerKeydown);
/**
 * @param {KeyboardEvent} e
 */
const handleInnerKeydown = (e) => {
  // ...
  hostMessaging.postMessage('did-keydown', {
    key: e.key,
    keyCode: e.keyCode,
    code: e.code,
    shiftKey: e.shiftKey,
    altKey: e.altKey,
    ctrlKey: e.ctrlKey,
    metaKey: e.metaKey,
    repeat: e.repeat
  });
};

From a UX standpoint, this is convenient. From a trust-boundary standpoint, it is dangerous. The untrusted script inside the webview can now manufacture its own keyboard events and make the outer editor treat them as if they came from the user. ⚠️

That means a malicious webview can trigger keyboard shortcuts on the victim’s behalf.

At first, the attack path looks trivial: trigger Ctrl + Shift + P, open the command palette, type a command, install a malicious extension, and you are done.

Except the browser does not treat synthetic key events as fully authentic user input. So while shortcut handlers that listen directly to keydown can fire, generic text entry does not behave as if a person typed into an <input> element. The command palette uses an HTML input box, so arbitrary fake typing would not fill it. ❌

Still, many built-in VSCode shortcuts operate directly from keydown. After experimentation, the easiest route was to use “Notifications: Accept Notification Primary Action”, which is bound by default to Ctrl + Shift + A.

That leads to the next pivot.

VSCode lets a workspace recommend extensions through .vscode/extensions.json, for example:

{
  "recommendations": [
    "FriendlyDev.my-helper-extension"
  ]
}

When that recommendation appears as a notification, Ctrl + Shift + A can accept its primary action and begin installation.

A newer safeguard in VSCode 1.97 blocked the obvious attack path: first-time installation from an unfamiliar publisher triggers a trust prompt. Even if an attacker can navigate UI focus with Tab, the final “Trust Publisher & Install” step depends on key handling bound to the button itself, not to the whole editor window, so the shortcut-only route breaks there. ✅

The workaround used another built-in feature: local workspace extensions.

If the workspace is trusted, and github.dev / web workspaces are trusted by default in this context, VSCode can load an extension directly from .vscode/extensions. That bypasses the publisher trust dialog because the trust decision comes from the workspace itself.

At first glance, that seems like the final step: drop malicious code into .vscode/extensions/extension.js, install it, and execute arbitrary logic.

But there was one more snag. In the web version, loading a local workspace extension hit a Content Security Policy issue because the extension worker expected sources from vscode-cdn.net. Local workspace extensions did not appear to be thoroughly exercised in the web-hosted variant. 📌

So the attack used local extensions only as a bridge.

Extensions can contribute keybindings through package.json. Since fake keydown events were reliable, the attack defined a custom shortcut that invoked a command to install another extension while explicitly skipping publisher trust checks.

The extension contribution looked like this:

"contributes": {
  "keybindings": [
    {
      "key": "ctrl+f1",
      "command": "runCommands",
      "args": {
        "commands": [
          {
            "command": "workbench.extensions.installExtension",
            "args": [
              "AmmarTest.hello-ammar-github",
              {
                "donotSync": true,
                "context": {
                  "skipPublisherTrust": true
                }
              }
            ]
          }
        ]
      }
    }
  ]
}

Once that local helper extension was active, the rest was straightforward:

1. Wait for VSCode to show the recommended extension notification. 2. Trigger Ctrl + Shift + A to accept it. 3. Pause briefly while the extension installs and activates. 4. Trigger Ctrl + F1 to run the install command that skips publisher trust.

The webview JavaScript payload looked like this:

// Wait for VSCode to load and pop open the notification.
await sleep(10 * 1000);
// ctrl+shift+a, accept the primary notification asking if we want to install
// the recommended extension
window.dispatchEvent(
  new KeyboardEvent("keydown", { key: "a", code: "KeyA", keyCode: 65, ctrlKey: true, shiftKey: true })
);
// Wait a little for the extension to install...
await sleep(500);
// ctrl+f1, the custom keybind to install the chosen extension.
window.dispatchEvent(
  new KeyboardEvent("keydown", { key: "F1", code: "F1", keyCode: 112, ctrlKey: true })
);

To launch this from a repository, the payload could run from a Jupyter notebook using HTML in a Markdown cell:

<img src="data:foobar" onerror="javascript(); goes(); here();">

That was enough to get code execution inside the browser-hosted editor environment.

The full chain used a repository containing two ingredients:

  • a Jupyter notebook with the JavaScript payload

  • a local workspace extension carrying the custom keybinding

When the payload completed, the installed extension retrieved the GitHub API token and then queried /user/repos to enumerate private repositories accessible to the victim. It displayed both the token and the repository list in an information box. 🚨

This issue also affected desktop VSCode, although exploitation there was less convenient. A victim would need to clone the attacker’s repository and open the notebook or another vulnerable webview containing the script payload. Still, if you can reach arbitrary script execution inside a desktop VSCode webview, the result is effectively full remote code execution on the machine. That is a much larger blast radius.

There was one fortunate speed bump. If you had never used github.dev before, an initial dialog appeared when first arriving at the site. That extra prompt created a moment where a user could notice something suspicious and leave before the chain completed. ✅

Because of that, clearing cookies and local site data for github.dev was an effective defensive step. In Chrome, that meant opening the site data controls from the address bar and deleting stored data for the relevant github.dev domains.

If you had already passed that initial dialog and your local browser storage still held the site state, the situation was far worse. There were no CSRF protections in this path, so a redirect from any ordinary link on the internet could send you into the exploit flow. ⚠️

At minimum, users who experimented with the proof of concept needed to clear github.dev data or uninstall the proof-of-concept extension. Otherwise, that installed extension could persist across future github.dev sessions.

Even with this bug, VSCode’s broader security approach still mattered a lot. The platform did not depend on iframe isolation alone. It also used layered controls such as a strict Content Security Policy and sanitization for rendered Markdown. ✅

Those extra defenses limited the damage of adjacent attack ideas. For example, if extension marketplace pages or Markdown previews had allowed arbitrary script execution, the same bug class could have produced even more dangerous one-click outcomes, including direct desktop compromise. A policy like script-src 'none' shut down that route early.

This is a useful reminder: defense in depth does not prevent every flaw, but it often stops one bug from turning into a much worse one.

The decision to publish details immediately was framed as a response to prior negative handling of VSCode security reports. The claim was that an earlier bug had been fixed quietly, with no credit given, and judged as lacking security impact. The author’s stated position afterward was to use full public disclosure for future VSCode findings.

That stance was also influenced by later examples where other VSCode vulnerability reports were considered low severity or ineligible. The broader point was not that the VSCode engineering team did not care, but that the reporting and triage process did not properly respect the effort required to find and weaponize complex bugs. 📌

There is a genuine tension here: editor usability matters, and abrupt changes can hurt workflows. But security tradeoffs need to be treated seriously when the environment holds developer credentials and can execute extensions.

  • June 2, 2026: A heads-up was sent to a GitHub security contact roughly an hour before publication.

  • June 2, 2026: The vulnerability was disclosed publicly and reported on the VSCode issue tracker.

  • June 3, 2026: A temporary mitigation landed by adding a confirmation step when opening notebooks in web VSCode and by preventing commands from skipping trusted publisher checks.

  • June 3, 2026: A more complete fix was merged to stop notebook webviews from bubbling keydown events upward.

  • ⚠️ A crafted github.dev page could steal a GitHub token with access to repositories the victim could read or write, including private ones.

  • 🧱 The core weakness was VSCode forwarding keydown events from an untrusted webview to the main editor, letting malicious content simulate trusted shortcuts.

  • 📦 The exploit chain used recommended extensions, local workspace extensions, and a custom keybinding to install attacker-controlled code.

  • 🛡️ Clearing github.dev site data helped because it restored an initial prompt that interrupted the one-click path for first-time sessions.

  • ✅ VSCode’s CSP and other layered defenses still prevented some even worse variants of the attack.

  • 🗓️ Microsoft shipped mitigations on June 3, 2026, including blocking keydown bubbling from notebook webviews.

No posts

Read the original on afadeev.substack.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.