fix(server): authenticate the path the router routes, not the raw target (#1124)

The authentication hook decided whether a request needs a session from the
raw request target, while Fastify's router matches a decoded path. A
percent-encoded or absolute-form target therefore reached protected routes
without a session: "/%61pi/workspaces" routed as "/api/workspaces", and
"/%73idecars/<id>/..." reached a SideCar, on any listener the server
exposes, including LAN listeners protected by the server password. Remote
Control was not affected: its gate already judged a decoded path.

Both checks now share `routedPath()` (server/routed-path.ts), which returns
exactly the path find-my-way matches: cut at the first "?" or ";" (Fastify's
default semicolon delimiter) and decoded with decodeURI, so reserved
characters such as %2F stay encoded and %25 is never decoded twice. Targets
the router would reinterpret (absolute-form, a leading "//", a fragment) or
reject (malformed percent-encoding) are refused with 400 before any access
decision. The Remote Control gate used a broader decodeURIComponent
variant; sharing the router-exact helper keeps the two classifications
identical to routing.

Validation: `routed-path.test.ts` checks the helper against the decoder of
the router Fastify itself loads and its refusals. `request-path-auth.test.ts`
drives the real HTTP application over raw sockets: encoded /api, /sidecars
and /workspaces targets, queries and semicolons get 401 without a session,
reinterpreted or malformed targets get 400, public routes stay public and a
session still reaches encoded paths. It fails on the previous hook
("/%61pi/test/echo" answered 200). The Remote Control surface tests and the
whole server suite pass, and the server typecheck is clean.
This commit is contained in:
Pascal André 2026-10-24 10:17:31 +02:00 • committed by GitHub
parent 5994944f84
commit 9ac792fffe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 107 additions and 19 deletions

View file

@ -53,7 +53,7 @@ browser ─TLS─▶ OpenTunnel relay (forwards ciphertext) ─▶ OpenTunnel SD
tunnel `Host` and the public `Origin` on mutations, and strips forwarding
headers. It accepts origin-form targets only (no absolute-form, leading `//`
or fragment, which the router would reinterpret) and judges the path the
router routes: percent-decoded and cut at `;`. It denies host-only paths: login and token
router routes (`server/routed-path.ts`). It denies host-only paths: login and token
bootstrap, Remote Control management, remote windows/proxy, SideCars,
previews and the automation bridge. Static UI bundle files stay public, as
on local listeners, because browsers fetch the PWA manifest without cookies.

View file

@ -45,6 +45,8 @@ CodeNomad control APIs live under `/api/*`. Important routes include:
- `/api/opencode-plugin/automation`, authenticated by a per-process loopback token and restricted to CodeNomad-owned locations
- `/api/remote-control/*`, managed from the host only; `POST /api/remote-control/pair` and `/remote-pair` answer only through Remote Control ([REMOTE_CONTROL.md](REMOTE_CONTROL.md))
Authentication and Remote Control admission judge `routedPath()` (`server/routed-path.ts`), the path Fastify's router matches (`/%61pi/x` routes as `/api/x`), and refuse targets it would reinterpret. Never base an access decision on the raw request target.
Native OpenCode requests use `/workspaces/:id/instance/api/*`. The Fastify proxy exposes an explicit method/path allowlist, adds shared-service authorization, validates prompt files, defaults safe requests to the workspace location, and rejects locations/directories outside the selected workspace or its worktrees. Session routes also verify `session.location.directory`. Never trust a browser-supplied worktree path: resolve ownership server-side. Upstream additions are exposed only after an explicit proxy review.
Yolo state endpoints live at `/workspaces/:id/yolo/sessions/:sessionId`; Yolo notifications use `/api/events`.

View file

@ -2,6 +2,7 @@ import type { Socket } from "node:net"
import type { FastifyReply, FastifyRequest } from "fastify"
import { parseCookies, wantsHtml } from "../auth/http-auth"
import type { RemoteControlDevice } from "../api-types"
import { routedPath } from "../server/routed-path"
import { attachRemoteDevice, isRemoteRequest } from "./request-origin"
export const DEVICE_COOKIE_NAME = "__Host-codenomad_device"
@ -85,22 +86,6 @@ function reject(reply: FastifyReply, status: number, error: string): void {
reply.header("connection", "close").code(status).send({ error })
}
/**
* The path the router will route, or null for a target to refuse. Browsers send
* origin-form targets only; the router also routes absolute-form targets and
* strips fragments, so those would otherwise be judged as different paths. The
* result is percent-decoded, so encoding cannot slip past host-only prefixes, and
* cut at ";", which the router treats as a path-parameter delimiter.
*/
function routedPath(target: string): string | null {
if (!target.startsWith("/") || target.startsWith("//") || target.includes("#")) return null
try {
return decodeURIComponent(target.split("?")[0]!).split(";")[0]!
} catch {
return null
}
}
function isStaticBundlePath(pathname: string): boolean {
return /\.[A-Za-z0-9]+$/.test(pathname) && !/\.html?$/i.test(pathname)
&& !pathname.startsWith("/api/") && !pathname.startsWith("/workspaces/")

View file

@ -0,0 +1,48 @@
import assert from "node:assert/strict"
import net from "node:net"
import test from "node:test"
import { startRemoteSurface } from "../../remote-control/test-support/surface"
// Writes a raw request line, so the target reaches the server exactly as written.
function send(target: string, requestTarget: string, options: { method?: string; cookie?: string; body?: string } = {}): Promise<{ status: number; head: string }> {
const [hostname, port] = target.split(":")
const body = options.body ?? ""
const headers = [
`${options.method ?? "GET"} ${requestTarget} HTTP/1.1`, `Host: ${target}`, "Connection: close",
...(options.cookie ? [`Cookie: ${options.cookie}`] : []),
...(body ? ["Content-Type: application/json", `Content-Length: ${Buffer.byteLength(body)}`] : []),
]
return new Promise((resolve, reject) => {
// "Connection: close" makes the server end the socket once it has replied.
const socket = net.connect(Number(port), hostname, () => socket.write(`${headers.join("\r\n")}\r\n\r\n${body}`))
let data = ""
socket.on("data", (chunk) => { data += chunk })
socket.on("close", () => resolve({ status: Number(data.split(" ")[1]), head: data.split("\r\n\r\n")[0] ?? "" }))
socket.on("error", reject)
})
}
test("local authentication judges the path the router routes, not the raw request target", async (t) => {
const { local } = await startRemoteSurface(t)
for (const target of [
"/api/test/echo", "/%61pi/test/echo", "/%61%70%69/test/echo", "/%61pi/test/echo?x=1", "/api/test/echo;x",
"/%73idecars/example/", "/%77orkspaces/example/instance/api/session",
]) {
assert.equal((await send(local, target)).status, 401, target)
}
// The router would also route these, as "/api/test/echo" or not at all.
for (const target of ["http://127.0.0.1/api/test/echo", "//api/test/echo", "/api/test/echo#x", "/%61pi/%E0%A4%A"]) {
assert.equal((await send(local, target)).status, 400, target)
}
// Public routes stay public, and a session still reaches encoded paths.
assert.equal((await send(local, "/%61pi/auth/status")).status, 200)
const login = await send(local, "/api/auth/login", {
method: "POST",
body: JSON.stringify({ username: "codenomad", password: "local-password" }),
})
assert.equal(login.status, 200, login.head)
const cookie = login.head.match(/^set-cookie: ([^;]+)/im)?.[1]
assert.ok(cookie, login.head)
assert.equal((await send(local, "/%61pi/test/echo", { cookie })).status, 200)
})

View file

@ -71,6 +71,7 @@ import { proxyWebSocket } from "./proxy-websocket"
import type { PreviewManager } from "../previews/manager"
import type { RemoteControlManager } from "../remote-control/manager"
import { gateRemoteRequest, PAIR_EXCHANGE_PATH, PAIR_PAGE_PATH } from "../remote-control/gate"
import { routedPath } from "./routed-path"
import { buildPreviewRuntimeBridge, rewritePreviewImportMap, rewritePreviewJavaScriptImports } from "../previews/runtime-bridge"
import { forwardRuntimeRequest } from "../opencode/compatibility/proxy"
import { requestAdmission } from "./request-admission"
@ -239,8 +240,12 @@ export function createHttpServer(deps: HttpServerDeps) {
registerAuthRoutes(app, { authManager: deps.authManager })
app.addHook("preHandler", (request, reply, done) => {
const rawUrl = request.raw.url ?? request.url
const pathname = (rawUrl.split("?")[0] ?? "").trim()
// Judge the path the router routed: the raw target can differ ("/%61pi" routes as "/api").
const pathname = routedPath(request.raw.url ?? request.url)
if (pathname === null) {
reply.code(400).send({ error: "Invalid request path" })
return
}
// Pairing routes answer only through the Remote Control ingress.
const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout", PAIR_EXCHANGE_PATH])

View file

@ -0,0 +1,26 @@
import assert from "node:assert/strict"
import { createRequire } from "node:module"
import { it } from "node:test"
import { routedPath } from "./routed-path"
// The path decoder of the router Fastify itself loads, wherever npm placed it.
const fastifyRequire = createRequire(createRequire(import.meta.url).resolve("fastify"))
const { safeDecodeURI } = fastifyRequire("find-my-way/lib/url-sanitizer") as {
safeDecodeURI(path: string, useSemicolonDelimiter: boolean): { path: string }
}
it("returns exactly the path Fastify's router matches", () => {
for (const target of [
"/api/workspaces", "/%61pi/workspaces", "/%61%70%69/workspaces", "/%73idecars/id/", "/%77orkspaces/id/instance/api/x",
"/api/x?y=1", "/%61pi/x?y=%2F", "/api/x;y", "/api;x/y", "/api/x%3By", "/api/auth%2Flogin", "/api%2Fauth/login",
"/%2561pi/x", "/a%25b", "/a%2525b", "/previews/token/%2E%2E/x", "/%E2%82%AC", "/",
]) {
assert.equal(routedPath(target), safeDecodeURI(target, true).path, target)
}
})
it("refuses targets the router would reinterpret or reject", () => {
for (const target of ["http://127.0.0.1/api/workspaces", "https://host/api", "//api/workspaces", "/api#x", "*", "", "/%E0%A4%A", "/%zz"]) {
assert.equal(routedPath(target), null, target)
}
})

View file

@ -0,0 +1,22 @@
/**
* The path Fastify's router matches for a request target, so admission checks
* judge the route that will actually run, never the raw target. Like the router,
* it ends at the first "?" or ";" (Fastify's default semicolon delimiter) and is
* decoded with decodeURI: "/%61pi" routes as "/api", while reserved characters
* such as "%2F" stay encoded and "%25" is never decoded twice.
*
* Returns null for a target to refuse: anything but origin-form (the router
* also routes absolute-form and "//" targets and strips fragments, which
* browsers never send) and malformed percent-encoding, which the router answers
* with 400.
*/
export function routedPath(target: string): string | null {
if (!target.startsWith("/") || target.startsWith("//") || target.includes("#")) return null
const end = target.search(/[?;]/)
const path = end < 0 ? target : target.slice(0, end)
try {
return decodeURI(path.replace(/%25/g, "%2525"))
} catch {
return null
}
}