Skip to content

Commit bc38fe7

Browse files
committed
fix(collab): iframe auth via base64 path + SPA bootstrap allowlist + git safe.directory + preview-port wildcard filter
Four issues collected from the /collab/<id> HAR + 'Open Pull Request' error + phantom preview chips screenshot. Single deploy cycle. P1 — iframe URL was 401'ing → 302 to OAuth → ERR_BLOCKED_BY_CSP. Iframe URL is /<base64(workspaceDirectory)>/session/<sid>?cs=<csid> — directory is in the path, not in ?directory= or x-opencode-directory. cookie-auth's workspaceParamFrom() now decodes the first path segment as URL-safe base64 (matching packages/app/src/utils/base64.ts) and falls back to that as the workspace addressing. P2 — SPA's GlobalSyncProvider hits /global/event, /global/config, /path, /project, /provider on every route (including /collab/*). None are workspace-scoped; in collab mode they got 'deny' on fallthrough → 401 thrash. Added them to cookieAllowedWithoutScope as a small explicit set (not a prefix — guards against accidentally inheriting the unscoped pass on future per-workspace API groups). P3 — 'fatal: detected dubious ownership' on git push from the workspace. EFS access points are configured with posix_user uid=0 (efs.tf) while the container runs as uid 10001 (ADR-0003). Git 2.35+ refuses operations on a repo whose .git dir isn't owned by the current uid. Workaround: 'git config --global --add safe.directory *' in entrypoint.sh. Single-tenant container, no other repos to worry about. Proper fix is to align EFS access point uid with the container uid — tracked as future TF change. P4 — Phantom preview chips (e.g. :20681, :20959) in the SPA's Repos column. Those are Bun internals / SSM agent loopback listeners, not user-facing dev servers. readListeningPorts() in router.ts now filters to wildcard-bind only (0.0.0.0 / ::), dropping loopback and per-interface listeners that aren't reachable from the browser anyway.
1 parent eecb97d commit bc38fe7

3 files changed

Lines changed: 81 additions & 8 deletions

File tree

packages/opencode/src/collab/cookie-auth.ts

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
import { Database } from "@/storage/db"
2222
import { eq, and, isNull } from "drizzle-orm"
23+
import { base64Decode } from "@opencode-ai/core/util/encode"
2324
import {
2425
CollabAuthSessionTable,
2526
CollabSessionTable,
@@ -174,16 +175,42 @@ function participantOfNativeSession(nativeSessionId: string, githubId: number):
174175
})
175176
}
176177

177-
/** Extract a workspace-addressing parameter from a Request. */
178+
/** Extract a workspace-addressing parameter from a Request.
179+
*
180+
* The iframe URL shape is `/<base64(workspaceDirectory)>/session/<sid>?cs=<csid>`
181+
* — the directory is encoded in the first path segment, not in
182+
* `?directory=` or `x-opencode-directory`. Without this third lookup,
183+
* cookie-auth's rule (b) misses the iframe entirely → fallthrough → 302
184+
* to OAuth → CSP-blocked because iframes can't navigate to github.com.
185+
*
186+
* base64Decode here mirrors packages/app/src/utils/base64.ts which is
187+
* what the SPA uses to construct the URL — URL-safe base64 (`-`/`_` in
188+
* place of `+`/`/`, no padding).
189+
*/
178190
function workspaceParamFrom(req: Request): string | null {
179191
const dirHeader = req.headers.get("x-opencode-directory")
180192
if (dirHeader) return dirHeader
181193
const url = new URL(req.url, "http://localhost")
182-
return (
183-
url.searchParams.get("directory") ||
184-
url.searchParams.get("location[directory]") ||
185-
null
186-
)
194+
const queryDir = url.searchParams.get("directory") || url.searchParams.get("location[directory]")
195+
if (queryDir) return queryDir
196+
return directoryFromBase64Path(url.pathname)
197+
}
198+
199+
function directoryFromBase64Path(pathname: string): string | null {
200+
// First path segment, e.g. /<base64>/session/... → "<base64>".
201+
const first = pathname.split("/", 2)[1]
202+
if (!first) return null
203+
// URL-safe base64 alphabet: [A-Za-z0-9_-]. Anything else (digits-only,
204+
// dotted, etc.) is not a directory-encoded path.
205+
if (!/^[A-Za-z0-9_-]+$/.test(first)) return null
206+
try {
207+
const decoded = base64Decode(first)
208+
// Decoded value must look like an absolute filesystem path.
209+
if (decoded.startsWith("/")) return decoded
210+
} catch {
211+
// Not base64 — fall through.
212+
}
213+
return null
187214
}
188215

189216
/**
@@ -203,7 +230,28 @@ function cookieAllowedWithoutScope(pathname: string): boolean {
203230
// /preview/<port>/* — see ADR-0001 + the inline note in preview-router.ts:
204231
// participants already have shell-level trust via the iframe terminal, so
205232
// strict port↔session binding is deferred to v2.
206-
return pathname.startsWith("/preview/")
233+
if (pathname.startsWith("/preview/")) return true
234+
235+
// SPA bootstrap endpoints used by GlobalSyncProvider on every route
236+
// (including /collab/*). None of them are workspace-scoped — they
237+
// describe the running opencode binary or the user's global config.
238+
// Any authenticated unleashlive org member may read them. Without this
239+
// they fail cookie-auth's scope check, fall through to "deny" in collab
240+
// mode, and the SPA's GlobalSyncProvider thrashes with 401s.
241+
//
242+
// Explicitly listed (not a prefix) so future per-workspace HttpApi groups
243+
// mounted under e.g. /provider/<id>/... wouldn't accidentally inherit the
244+
// unscoped pass.
245+
const UNSCOPED_SPA_BOOTSTRAP = new Set([
246+
"/global/event",
247+
"/global/config",
248+
"/path",
249+
"/project",
250+
"/provider",
251+
])
252+
if (UNSCOPED_SPA_BOOTSTRAP.has(pathname)) return true
253+
254+
return false
207255
}
208256

209257
export type CookieAuthDecision = "allow" | "deny" | "fallthrough"

packages/opencode/src/collab/router.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ const PROMPT_BODY_MAX_BYTES = 32 * 1024
6161
* Read TCP ports the container is currently LISTENING on, by parsing
6262
* /proc/net/tcp + /proc/net/tcp6. Filters out:
6363
* - non-LISTEN states (only state 0A = LISTEN)
64+
* - listeners NOT bound to a wildcard address (0.0.0.0 / ::) — loopback-
65+
* only and per-iface listeners are Bun internals, SSM agent connections,
66+
* etc., not user-facing dev servers
6467
* - opencode's own port (4096)
6568
* - ports under 1024 (system services)
6669
* - well-known DB ports we definitely don't want to expose (5432, 6379, etc.)
@@ -81,7 +84,18 @@ async function readListeningPorts(): Promise<number[]> {
8184
const local = parts[1]
8285
const state = parts[3]
8386
if (state !== "0A") continue // LISTEN only
84-
const portHex = local!.split(":").pop()!
87+
const [addrHex, portHex] = local!.split(":")
88+
if (!addrHex || !portHex) continue
89+
// Wildcard-bind only. /proc/net/tcp uses little-endian hex for
90+
// IPv4 addresses; 0.0.0.0 is "00000000". /proc/net/tcp6 stores
91+
// the address as four 32-bit big-endian-pair words concatenated;
92+
// both :: (all zeros) and the v4-mapped 0.0.0.0 ("0000000000000000FFFF000000000000")
93+
// are wildcards. Anything else is a per-iface or loopback listener
94+
// (likely internal — Bun, SSM, side channels) and not a dev server.
95+
const isV4Wildcard = addrHex === "00000000"
96+
const isV6Wildcard = addrHex === "00000000000000000000000000000000"
97+
const isV4MappedWildcard = addrHex === "0000000000000000FFFF000000000000"
98+
if (!isV4Wildcard && !isV6Wildcard && !isV4MappedWildcard) continue
8599
const port = parseInt(portHex, 16)
86100
if (!Number.isInteger(port)) continue
87101
if (port < 1024 || port > 65535) continue

scripts/entrypoint.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ if [ -n "${CLAUDE_CREDENTIALS_JSON:-}" ]; then
3030
chmod 0600 "$HOME_DIR/.claude/.credentials.json"
3131
fi
3232

33+
# Git "dubious ownership" workaround. EFS access points (terraform/opencode-
34+
# collab/efs.tf) currently mount with uid=0/gid=0, while the container runs
35+
# as uid 10001 (ADR-0003). Git 2.35+ refuses operations on a repo whose
36+
# .git directory isn't owned by the current uid — clone works because git
37+
# creates the .git dir itself, but subsequent push/log/diff calls fail with
38+
# "fatal: detected dubious ownership". Wildcard '*' tells git to trust any
39+
# directory; safe enough on this single-tenant container.
40+
# Proper fix is to align the EFS access point posix_user.uid with the
41+
# container uid; tracked separately.
42+
git config --global --add safe.directory '*'
43+
3344
# Hand off to the real server. $@ propagates whatever args ECS / CMD passed.
3445
exec bun run --cwd packages/opencode src/index.ts serve \
3546
--port 4096 --hostname 0.0.0.0 --print-logs "$@"

0 commit comments

Comments
 (0)