mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-24 23:31:20 +00:00
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:
parent
5994944f84
commit
9ac792fffe
7 changed files with 107 additions and 19 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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/")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
26
packages/server/src/server/routed-path.test.ts
Normal file
26
packages/server/src/server/routed-path.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
22
packages/server/src/server/routed-path.ts
Normal file
22
packages/server/src/server/routed-path.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue