Skip to content

Commit 1d11ddf

Browse files
hblankensqeeswy
authored andcommitted
sec(collab): cookie-or-basic auth at every gate; preview proxy requires cookie (ADR-0001 finish)
PR #2 enforced OPENCODE_SERVER_PASSWORD but stranded two holes: - The iframe-loaded native HttpApi (/event, /file/*, /pty) was 401-ing OAuth users into the browser's basic-auth dialog. - /preview/<port>/* was fully open — anyone on the internet could reach a dev server bound inside the container. This commit unifies the auth model: cookie OR basic-auth at every gate, with the cookie scoped to the resource it addresses. Cookie scope (CONTEXT.md → "Cookie Authorization Scope"): - public path → allow - x-opencode-directory / directory= / location[directory]= resolves to a collab session the cookie holder is a Participant of → allow - native-session-id in URL (/event/<id>, /session/<id>/...) resolves to a collab session the cookie holder is a Participant of → allow - anything else → fall through to basic-auth (server-internal) A scoped-cookie MISS returns 401 directly without falling through to basic-auth, so we don't leak the existence of a server password to a cookie holder who's just trying to reach the wrong workspace. packages/opencode/src/collab/cookie-auth.ts (new) cookieAuthorizesRequest(req): "allow" | "deny" | "fallthrough" — consulted from validateCredential, validateRawCredential, and the /preview/* proxy gate. parseCookies / lookupCookieIdentity helpers are shared with the WS upgrade path. packages/opencode/src/collab/native-api.ts (new) nativeFetch(path, init) — wraps fetch(localhost:4096/...) with the server's basic-auth credential from ServerAuth.headers(). Replaces every bare self-fetch in router.ts (the 3 sites at lines 188, 295, 330). Fixes a latent ADR-0001 bug: the executor's self-fetches were unauthenticated and would 401 in production. middleware/authorization.ts Both validateCredential and validateRawCredential now consult the cookie helper between isPublicUIPath and basic-auth. server.ts (collabMiddleware) /preview/<port>/* HTTP requests run cookieAuthorizesRequest before forwarding; non-allow returns 403 directly. preview-router.ts (attachPreviewUpgrade) WebSocket upgrades check the cookie BEFORE the handshake completes; invalid cookies get a raw HTTP/1.1 403 then socket.destroy(). Browser sees a clean WS error rather than a successful connect that immediately closes with code 1008.
1 parent ed22169 commit 1d11ddf

7 files changed

Lines changed: 387 additions & 6 deletions

File tree

CONTEXT.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,28 @@ into the `collab_auth_session` table along with the user's GitHub
266266
access token, login, id and avatar URL. Survives container restarts
267267
(was in-memory in the original code).
268268

269+
**Cookie Authorization Scope**
270+
A valid `collab_sid` is a **scoped** credential, not a server-admin
271+
credential. Three rules decide whether the cookie alone is enough
272+
to pass the auth gate:
273+
274+
1. **Public path** (e.g. SPA shell, `/global/health`, assets) — allow.
275+
2. **Workspace-addressing request** (header `x-opencode-directory`,
276+
query `directory=…`, or query `location[directory]=…`) — allow
277+
only if the directory resolves to a `Workspace Directory` of a
278+
`Collab Session` the cookie's user is a `Participant` of.
279+
3. **Native-session-addressing request** (e.g. `/event/<sessionId>`)
280+
— allow only if the `Native Session ID` resolves (via
281+
`collab_session.session_id`) to a `Collab Session` the user is
282+
a `Participant` of.
283+
284+
Anything else (e.g. `/global/event`, `/global/config`,
285+
`/global/dispose`, `/global/upgrade`, or an HttpApi route with no
286+
workspace/native-session identifier) the cookie does not gate. Those
287+
routes still accept the server's basic-auth credential (used by
288+
internal self-fetches) but reject cookies — they're server-admin or
289+
cross-tenant by nature.
290+
269291
**Invite Link**
270292
`https://<host>/collab/invite/<uuid-token>`. Rows in `collab_invite`
271293
carry `role`, `created_by`, `expires_at` (default 72 h), and `used_at`
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
/**
2+
* Cookie-based auth helper for the unified auth gate.
3+
*
4+
* The deploy is protected by two credential types:
5+
*
6+
* 1. `collab_sid` cookie (set by the OAuth flow). Identifies a GitHub
7+
* org member. Scoped — see `cookieAuthorizesRequest` below.
8+
* 2. Basic auth (`OPENCODE_SERVER_PASSWORD`). Identifies the server
9+
* itself. Used for internal self-fetches via `nativeFetch`.
10+
*
11+
* `cookieAuthorizesRequest` is the 3-rule decision function called from
12+
* both auth middlewares (the HttpApi-layer `validateCredential` and the
13+
* router-layer `validateRawCredential`) and from the `/preview/<port>/*`
14+
* proxy gate. The middleware checks `isPublicUIPath` first; the cookie
15+
* helper is consulted next; basic auth is the fallthrough.
16+
*
17+
* See CONTEXT.md → "Cookie Authorization Scope" for the design.
18+
* See docs/adr/0001 (preview proxy auth) for the trigger.
19+
*/
20+
21+
import { Database } from "@/storage/db"
22+
import { eq, and, isNull } from "drizzle-orm"
23+
import { join } from "path"
24+
import {
25+
CollabAuthSessionTable,
26+
CollabSessionTable,
27+
CollabParticipantTable,
28+
} from "./schema.sql"
29+
30+
/**
31+
* Parse a `Cookie:` header into a key→value map. Single source of truth so
32+
* the WebSocket upgrade path doesn't have to re-implement it.
33+
*/
34+
export function parseCookies(header: string): Record<string, string> {
35+
const out: Record<string, string> = {}
36+
for (const part of header.split(";")) {
37+
const [k, ...v] = part.trim().split("=")
38+
if (k) out[k.trim()] = decodeURIComponent(v.join("="))
39+
}
40+
return out
41+
}
42+
43+
/** Container workspace root. Mirrors collab/workspace.ts. */
44+
function workspaceRoot(): string {
45+
return process.env["COLLAB_WORKSPACE_ROOT"] ?? "/var/opencode/workspaces"
46+
}
47+
48+
/**
49+
* Resolve a workspace-addressing string (header `x-opencode-directory` or
50+
* query `directory=` / `location[directory]=`) to a collab session id.
51+
*
52+
* The workspace path is computed as `<root>/<collabSessionId>/<repoName?>/`,
53+
* so the first path component after the root IS the collab session id.
54+
* Returns null for paths outside the workspace root (e.g. opencode's
55+
* own source tree during dev) — those won't pass the participation check
56+
* by definition.
57+
*/
58+
export function directoryToCollabSessionId(dir: string): string | null {
59+
const root = workspaceRoot()
60+
// Strip a trailing slash on root once so the prefix check is exact.
61+
const rootWithSlash = root.endsWith("/") ? root : root + "/"
62+
if (!dir.startsWith(rootWithSlash)) return null
63+
const rest = dir.slice(rootWithSlash.length)
64+
const sessionId = rest.split("/")[0]
65+
return sessionId || null
66+
}
67+
68+
/**
69+
* Minimal info the auth gate needs about the cookie holder.
70+
*/
71+
export interface CookieIdentity {
72+
readonly token: string
73+
readonly githubId: number
74+
readonly githubLogin: string
75+
}
76+
77+
/**
78+
* Look up the auth session row for a cookie token. Returns null when the
79+
* cookie is missing, unknown, or expired. Mirrors the opportunistic-delete
80+
* pattern from getSession() in router.ts.
81+
*/
82+
export function lookupCookieIdentity(req: Request): CookieIdentity | null {
83+
const sid = parseCookies(req.headers.get("cookie") ?? "")["collab_sid"]
84+
if (!sid) return null
85+
return Database.use((db) => {
86+
const row = db
87+
.select()
88+
.from(CollabAuthSessionTable)
89+
.where(eq(CollabAuthSessionTable.token, sid))
90+
.get()
91+
if (!row) return null
92+
if (row.expires_at < Date.now()) {
93+
db.delete(CollabAuthSessionTable).where(eq(CollabAuthSessionTable.token, sid)).run()
94+
return null
95+
}
96+
return { token: sid, githubId: row.github_id, githubLogin: row.github_login }
97+
})
98+
}
99+
100+
/** Same lookup but takes a parsed cookies map directly — used by the
101+
* Node-side WebSocket upgrade path where we don't have a `Request`. */
102+
export function lookupCookieIdentityFromHeaders(cookieHeader: string): CookieIdentity | null {
103+
const sid = parseCookies(cookieHeader)["collab_sid"]
104+
if (!sid) return null
105+
return Database.use((db) => {
106+
const row = db
107+
.select()
108+
.from(CollabAuthSessionTable)
109+
.where(eq(CollabAuthSessionTable.token, sid))
110+
.get()
111+
if (!row) return null
112+
if (row.expires_at < Date.now()) {
113+
db.delete(CollabAuthSessionTable).where(eq(CollabAuthSessionTable.token, sid)).run()
114+
return null
115+
}
116+
return { token: sid, githubId: row.github_id, githubLogin: row.github_login }
117+
})
118+
}
119+
120+
/**
121+
* Is the cookie holder a participant of the collab session with this id?
122+
* One indexed SQLite read.
123+
*/
124+
function participantOfSession(collabSessionId: string, githubId: number): boolean {
125+
return Database.use((db) => {
126+
const row = db
127+
.select({ id: CollabSessionTable.id })
128+
.from(CollabSessionTable)
129+
.innerJoin(
130+
CollabParticipantTable,
131+
eq(CollabParticipantTable.collab_session_id, CollabSessionTable.id),
132+
)
133+
.where(
134+
and(
135+
eq(CollabSessionTable.id, collabSessionId),
136+
eq(CollabParticipantTable.github_id, githubId),
137+
isNull(CollabSessionTable.deleted_at),
138+
),
139+
)
140+
.get()
141+
return !!row
142+
})
143+
}
144+
145+
/**
146+
* Is the cookie holder a participant of the collab session whose Native
147+
* Session ID is the given value?
148+
*/
149+
function participantOfNativeSession(nativeSessionId: string, githubId: number): boolean {
150+
return Database.use((db) => {
151+
const row = db
152+
.select({ id: CollabSessionTable.id })
153+
.from(CollabSessionTable)
154+
.innerJoin(
155+
CollabParticipantTable,
156+
eq(CollabParticipantTable.collab_session_id, CollabSessionTable.id),
157+
)
158+
.where(
159+
and(
160+
eq(CollabSessionTable.session_id, nativeSessionId),
161+
eq(CollabParticipantTable.github_id, githubId),
162+
isNull(CollabSessionTable.deleted_at),
163+
),
164+
)
165+
.get()
166+
return !!row
167+
})
168+
}
169+
170+
/** Extract a workspace-addressing parameter from a Request. */
171+
function workspaceParamFrom(req: Request): string | null {
172+
const dirHeader = req.headers.get("x-opencode-directory")
173+
if (dirHeader) return dirHeader
174+
const url = new URL(req.url, "http://localhost")
175+
return (
176+
url.searchParams.get("directory") ||
177+
url.searchParams.get("location[directory]") ||
178+
null
179+
)
180+
}
181+
182+
/**
183+
* Extract a native session id from common URL shapes. Currently we only
184+
* detect the `/event/<sessionId>` and `/session/<sessionId>/...` patterns
185+
* — those are the routes that don't carry `x-opencode-directory` but DO
186+
* scope to a specific Native Session.
187+
*/
188+
function nativeSessionIdFrom(req: Request): string | null {
189+
const url = new URL(req.url, "http://localhost")
190+
const m = url.pathname.match(/^\/(?:event|session)\/([^/]+)/)
191+
return m ? m[1]! : null
192+
}
193+
194+
/** Paths for which a valid cookie alone (no scope check) is enough. */
195+
function cookieAllowedWithoutScope(pathname: string): boolean {
196+
// /preview/<port>/* — see ADR-0001 + the inline note in preview-router.ts:
197+
// participants already have shell-level trust via the iframe terminal, so
198+
// strict port↔session binding is deferred to v2.
199+
return pathname.startsWith("/preview/")
200+
}
201+
202+
export type CookieAuthDecision = "allow" | "deny" | "fallthrough"
203+
204+
/**
205+
* 3-rule decision function — see CONTEXT.md → Cookie Authorization Scope.
206+
*
207+
* - `"allow"` — cookie present, valid, scoped to this resource. Caller
208+
* serves the request.
209+
* - `"deny"` — cookie present, valid, but NOT scoped to this resource.
210+
* Caller MUST 401 immediately; do not fall through to
211+
* basic-auth (avoids signalling the password's existence
212+
* in response to a scoped-cookie miss).
213+
* - `"fallthrough"` — no cookie at all (or invalid/expired). Caller
214+
* proceeds to basic-auth validation.
215+
*/
216+
export function cookieAuthorizesRequest(req: Request): CookieAuthDecision {
217+
const id = lookupCookieIdentity(req)
218+
if (!id) return "fallthrough"
219+
220+
const url = new URL(req.url, "http://localhost")
221+
222+
// Rule (a): cookie-only paths (no scope check needed).
223+
if (cookieAllowedWithoutScope(url.pathname)) return "allow"
224+
225+
// Rule (b): workspace-addressed routes — bind on directory.
226+
const dir = workspaceParamFrom(req)
227+
if (dir) {
228+
const sessionId = directoryToCollabSessionId(dir)
229+
if (!sessionId) return "deny"
230+
return participantOfSession(sessionId, id.githubId) ? "allow" : "deny"
231+
}
232+
233+
// Rule (c): native-session-addressed routes — bind on Native Session ID.
234+
const nativeSessionId = nativeSessionIdFrom(req)
235+
if (nativeSessionId) {
236+
return participantOfNativeSession(nativeSessionId, id.githubId) ? "allow" : "deny"
237+
}
238+
239+
// Rule (d): no addressing → fall through to basic-auth. /global/event,
240+
// /global/config, /global/dispose, /global/upgrade etc. land here and
241+
// require the server credential.
242+
return "fallthrough"
243+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Authenticated wrapper around `fetch(localhost:4096/...)` for the collab
3+
* router's self-fetches into the native opencode HttpApi.
4+
*
5+
* After ADR-0001 the HttpApi refuses unauthenticated requests in production
6+
* (when `OPENCODE_SERVER_PASSWORD` is set). Self-fetches from the collab
7+
* executor are *server-internal* — they carry the server's basic-auth
8+
* credential, not any specific user's cookie.
9+
*
10+
* Use this helper instead of bare `fetch` for every call that targets a
11+
* native opencode endpoint from within this process.
12+
*/
13+
14+
import { ServerAuth } from "@/server/auth"
15+
16+
const NATIVE_API_ORIGIN = "http://localhost:4096"
17+
18+
/**
19+
* Fetch a path on the local opencode HttpApi with the server's basic-auth
20+
* credential attached. Pass-through everything else (method, body, etc.).
21+
*
22+
* In environments without `OPENCODE_SERVER_PASSWORD` (local dev with
23+
* `OPENCODE_ALLOW_UNAUTHENTICATED=1`) `ServerAuth.headers()` returns
24+
* undefined and the request goes out unauthenticated — same behaviour as
25+
* the gate when no password is configured.
26+
*/
27+
export async function nativeFetch(path: string, init?: RequestInit): Promise<Response> {
28+
const authHeaders = ServerAuth.headers()
29+
const headers = new Headers(init?.headers)
30+
if (authHeaders) {
31+
for (const [k, v] of Object.entries(authHeaders)) {
32+
// Don't clobber explicit overrides from the caller.
33+
if (!headers.has(k)) headers.set(k, v)
34+
}
35+
}
36+
return fetch(NATIVE_API_ORIGIN + path, { ...init, headers })
37+
}

packages/opencode/src/collab/preview-router.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import { connect as netConnect } from "node:net"
2525
import type { IncomingMessage } from "node:http"
2626
import type { Socket } from "node:net"
27+
import { lookupCookieIdentityFromHeaders } from "./cookie-auth"
2728

2829
const PREVIEW_PREFIX = "/preview/"
2930

@@ -139,6 +140,20 @@ export function attachPreviewUpgrade(server: {
139140
return
140141
}
141142

143+
// Authenticate the WebSocket upgrade BEFORE the handshake completes.
144+
// The browser sees a clean 403 (vs a successful WS that immediately
145+
// closes with code 1008) and we never touch the WS framing layer for
146+
// unauthorised callers. Cookie-only check — see ADR-0001; v1 doesn't
147+
// bind port to a specific session.
148+
const cookieHeader = (req.headers["cookie"] as string | undefined) ?? ""
149+
if (!lookupCookieIdentityFromHeaders(cookieHeader)) {
150+
try {
151+
clientSocket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n")
152+
} catch {}
153+
try { clientSocket.destroy() } catch {}
154+
return
155+
}
156+
142157
const upstreamSocket = netConnect({ host: "127.0.0.1", port: parsed.port })
143158

144159
const cleanup = (err?: Error) => {

packages/opencode/src/collab/router.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { openCollabPullRequest } from "./github-pr"
4848
import { toggleReaction, isAllowedEmoji } from "./reactions"
4949
import { mentionsToEvents } from "./mentions"
5050
import { insertNote, listRecentNotes } from "./notes"
51+
import { nativeFetch } from "./native-api"
5152

5253
/**
5354
* Read TCP ports the container is currently LISTENING on, by parsing
@@ -185,8 +186,8 @@ async function ensureNativeSession(
185186
mkdirSync(workspacePath, { recursive: true })
186187

187188
console.log("[collab] creating native session for directory:", workspacePath)
188-
const createRes = await fetch(
189-
`http://localhost:4096/session?directory=${encodeURIComponent(workspacePath)}`,
189+
const createRes = await nativeFetch(
190+
`/session?directory=${encodeURIComponent(workspacePath)}`,
190191
{
191192
method: "POST",
192193
headers: { "Content-Type": "application/json" },
@@ -292,8 +293,8 @@ async function sendSeedPrompt(
292293
].join("\n")
293294

294295
try {
295-
const res = await fetch(
296-
`http://localhost:4096/session/${nativeSessionId}/prompt_async?directory=${encodeURIComponent(workspacePath)}`,
296+
const res = await nativeFetch(
297+
`/session/${nativeSessionId}/prompt_async?directory=${encodeURIComponent(workspacePath)}`,
297298
{
298299
method: "POST",
299300
headers: { "Content-Type": "application/json" },
@@ -327,8 +328,8 @@ async function executePromptOnNativeSession(
327328
}
328329

329330
console.log("[collab] sending prompt to native session:", nativeSessionId)
330-
const promptRes = await fetch(
331-
`http://localhost:4096/session/${nativeSessionId}/prompt_async?directory=${encodeURIComponent(workspacePath)}`,
331+
const promptRes = await nativeFetch(
332+
`/session/${nativeSessionId}/prompt_async?directory=${encodeURIComponent(workspacePath)}`,
332333
{
333334
method: "POST",
334335
headers: { "Content-Type": "application/json" },

0 commit comments

Comments
 (0)