fix(server): align connect route checks (#55177)

This commit is contained in:
James Long
2026-10-13 13:33:55 -04:00
committed by GitHub
Unverified
parent a00475a4dd
commit d8e31d846a
4 changed files with 26 additions and 18 deletions
@@ -9,10 +9,12 @@ import { PTY_CONNECT_TICKET_QUERY, PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_H
export { PTY_CONNECT_TICKET_QUERY, PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE }
const CONNECT_PATH = /^\/api\/experimental\/persistent-pty\/[^/]+\/connect$/
export const PERSISTENT_PTY_CONNECT_PATH = "/api/experimental/persistent-pty/:ptyID/connect"
const PERSISTENT_PTY_CONNECT_URL = /^\/api\/experimental\/persistent-pty\/[^/]+\/connect$/
export function hasPersistentPtyConnectTicketURL(url: URL) {
return CONNECT_PATH.test(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY)
return PERSISTENT_PTY_CONNECT_URL.test(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY)
}
const errors = [InvalidRequestError, ServiceUnavailableError] as const
@@ -100,7 +102,7 @@ export const PersistentPtyGroup = HttpApiGroup.make("server.experimental")
}),
)
.add(
HttpApiEndpoint.get("persistentPty.connect", "/api/experimental/persistent-pty/:ptyID/connect", {
HttpApiEndpoint.get("persistentPty.connect", PERSISTENT_PTY_CONNECT_PATH, {
params: { ptyID: Pty.ID },
success: Schema.Boolean,
error: [ForbiddenError, PtyNotFoundError, ServiceUnavailableError],
+4 -5
View File
@@ -9,13 +9,12 @@ import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const PTY_CONNECT_TICKET_QUERY = "ticket"
export const PTY_CONNECT_TOKEN_HEADER = "x-opencode-ticket"
export const PTY_CONNECT_TOKEN_HEADER_VALUE = "1"
export const PTY_CONNECT_PATH = "/api/pty/:ptyID/connect"
const PTY_CONNECT_PATH = /^\/api\/pty\/[^/]+\/connect$/
const PTY_CONNECT_URL = /^\/api\/pty\/[^/]+\/connect$/
// Authorization middleware skips credential checks when this matches; the PTY connect handler
// is then responsible for consuming and validating the ticket.
export function hasPtyConnectTicketURL(url: URL) {
return PTY_CONNECT_PATH.test(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY)
return PTY_CONNECT_URL.test(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY)
}
export const makePtyGroup = <LocationId extends HttpApiMiddleware.AnyId, LocationService>(
@@ -120,7 +119,7 @@ export const makePtyGroup = <LocationId extends HttpApiMiddleware.AnyId, Locatio
.middleware(locationMiddleware)
.add(
// Query fields are decoded in the raw handler before upgrade work.
HttpApiEndpoint.get("pty.connect", "/api/pty/:ptyID/connect", {
HttpApiEndpoint.get("pty.connect", PTY_CONNECT_PATH, {
params: { ptyID: Pty.ID },
success: Schema.Boolean,
error: [ForbiddenError, PtyNotFoundError, LocationNotFoundError],
+5 -4
View File
@@ -30,11 +30,12 @@ export const PairingSession = Schema.Struct({
}).annotate({ identifier: "PairingSession" })
export type PairingSession = typeof PairingSession.Type
const PAIRING_CONNECT_PATH = /^\/auth\/connect\/[^/]+$/
export const PAIRING_CONNECT_PATH = "/auth/connect/:code"
const PAIRING_CONNECT_URL = /^\/auth\/connect\/[^/]+$/
// Authorization middleware skips credential checks for pairing links; the connect handler consumes the code instead.
export function isPairingConnectURL(url: URL) {
return PAIRING_CONNECT_PATH.test(url.pathname)
return PAIRING_CONNECT_URL.test(url.pathname)
}
export const ServerGroup = HttpApiGroup.make("server.server")
@@ -61,7 +62,7 @@ export const ServerGroup = HttpApiGroup.make("server.server")
),
)
.add(
HttpApiEndpoint.get("server.connect", "/auth/connect/:code", {
HttpApiEndpoint.get("server.connect", PAIRING_CONNECT_PATH, {
params: { code: Schema.String },
success: PairingSession,
error: UnauthorizedError,
@@ -2,12 +2,12 @@ import { ServerAuth } from "../auth"
import { UnauthorizedError } from "@opencode/protocol/errors"
import { Authorization } from "@opencode/protocol/middleware/authorization"
export { Authorization } from "@opencode/protocol/middleware/authorization"
import { hasPtyConnectTicketURL } from "@opencode/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode/protocol/groups/persistent-pty"
import { isPairingConnectURL } from "@opencode/protocol/groups/server"
import { PTY_CONNECT_PATH, PTY_CONNECT_TICKET_QUERY } from "@opencode/protocol/groups/pty"
import { PERSISTENT_PTY_CONNECT_PATH } from "@opencode/protocol/groups/persistent-pty"
import { PAIRING_CONNECT_PATH } from "@opencode/protocol/groups/server"
import { Effect, Layer, Redacted } from "effect"
import { Base64 } from "effect/encoding"
import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/http"
import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"
const AUTH_TOKEN_QUERY = "auth_token"
const WWW_AUTHENTICATE = 'Basic realm="Secure Area"'
@@ -78,10 +78,16 @@ export const authorizationLayer = Layer.effect(
return Authorization.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const route = yield* HttpRouter.RouteContext
// Ticketed PTY connects (browsers cannot set headers on WebSocket upgrades) and pairing links
// skip credential checks here; their handlers consume and validate the ticket or code.
// skip credential checks here; their handlers consume and validate the ticket or code. Use the
// route selected by Effect so authorization and dispatch cannot disagree about the request path.
const url = new URL(request.url, "http://localhost")
if (hasPtyConnectTicketURL(url) || hasPersistentPtyConnectTicketURL(url) || isPairingConnectURL(url))
if (
((route.route.path === PTY_CONNECT_PATH || route.route.path === PERSISTENT_PTY_CONNECT_PATH) &&
!!url.searchParams.get(PTY_CONNECT_TICKET_QUERY)) ||
route.route.path === PAIRING_CONNECT_PATH
)
return yield* effect
if (yield* authorizedRequest(request, config)) return yield* effect
if (challengeRequest(request))