Skip to content

Commit b1bfbe6

Browse files
committed
fix(server): reject unusable session location directories with 400
Session create and import accepted any string as location.directory. A relative path or one with a NUL byte reached the database and filesystem layers and the client got an empty HTTP 500. The two payloads now use Location.Input, whose directory must be a POSIX, drive or UNC absolute path without NUL bytes, so a bad body location is a 400 InvalidRequestError. Location hints in the query and header are left as they are.
1 parent 986221a commit b1bfbe6

5 files changed

Lines changed: 55 additions & 4 deletions

File tree

‎packages/client/script/generate.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ const effectTypeReferences = [
7878
...namespaceTypes("InstructionEntry", "@opencode/schema/instruction-entry", InstructionEntry),
7979
...namespaceTypes("Integration", "@opencode/schema/integration", Integration),
8080
typeReference("Location.PublicRef", "@opencode/schema/location", Location.PublicRef),
81+
typeReference("Location.Input", "@opencode/schema/location", Location.Input),
8182
typeReference("Location.PublicInfo", "@opencode/schema/location", Location.PublicInfo),
8283
...namespaceTypes("Mcp", "@opencode/schema/mcp", Mcp),
8384
...namespaceTypes("Model", "@opencode/schema/model", Model),

‎packages/client/src/effect/api/api.ts‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/protocol/src/groups/session.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S, FormI ext
169169
title: Schema.String.pipe(Schema.optional),
170170
agent: Agent.ID.pipe(Schema.optional),
171171
model: Model.Ref.pipe(Schema.optional),
172-
location: Location.PublicRef.pipe(Schema.optional),
172+
location: Location.Input.pipe(Schema.optional),
173173
metadata: Session.Metadata.pipe(Schema.optional),
174174
permissions: Permission.Ruleset.pipe(Schema.optional),
175175
}),
@@ -189,7 +189,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S, FormI ext
189189
payload: Schema.Struct({
190190
info: PublicSessionTransfer.fields.info,
191191
messages: ImportedMessages,
192-
location: Location.PublicRef.pipe(Schema.optional),
192+
location: Location.Input.pipe(Schema.optional),
193193
}),
194194
success: Schema.Struct({ data: PublicSessionInfo }),
195195
error: [ConflictError, SessionNotFoundError],

‎packages/schema/src/location.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ export const PublicRef = Schema.Struct(Struct.omit(Ref.fields, ["workspaceID"]))
1616
})
1717
export interface PublicRef extends Schema.Schema.Type<typeof PublicRef> {}
1818

19+
// A directory a client asks to work in: a POSIX, drive or UNC absolute path without NUL bytes.
20+
export const Directory = AbsolutePath.check(
21+
Schema.isPattern(/^(?:\/|[A-Za-z]:[\\/]|\\\\)[^\0]*$/u, { expected: "an absolute path without NUL bytes" }),
22+
).annotate({
23+
identifier: "Location.Directory",
24+
})
25+
26+
export const Input = Schema.Struct({ directory: Directory }).annotate({ identifier: "Location.Input" })
27+
export interface Input extends Schema.Schema.Type<typeof Input> {}
28+
1929
export class Info extends Schema.Class<Info>("Location.Info")({
2030
directory: AbsolutePath,
2131
workspaceID: optional(WorkspaceID),
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { expect } from "bun:test"
2+
import { Effect } from "effect"
3+
import { it } from "../../core/test/lib/effect"
4+
import { ServerFetch } from "../src/fetch"
5+
6+
const server = ServerFetch.make({
7+
app: { version: "test" },
8+
database: { path: ":memory:" },
9+
fs: { filewatcher: false },
10+
})
11+
12+
const createSession = (location: unknown) =>
13+
Effect.gen(function* () {
14+
const handler = yield* server
15+
return yield* Effect.promise(() =>
16+
handler(
17+
new Request("http://opencode.local/api/session", {
18+
method: "POST",
19+
headers: { "content-type": "application/json" },
20+
body: JSON.stringify({ location }),
21+
}),
22+
),
23+
)
24+
})
25+
26+
it.live("rejects a session location directory with a NUL byte", () =>
27+
Effect.gen(function* () {
28+
const response = yield* createSession({ directory: "/tmp\u0000" })
29+
expect(response.status).toBe(400)
30+
expect(yield* Effect.promise(() => response.json())).toMatchObject({ _tag: "InvalidRequestError" })
31+
}).pipe(Effect.scoped),
32+
)
33+
34+
it.live("rejects a relative location directory when creating a session", () =>
35+
Effect.gen(function* () {
36+
const response = yield* createSession({ directory: "relative/dir" })
37+
expect(response.status).toBe(400)
38+
expect(yield* Effect.promise(() => response.json())).toMatchObject({ _tag: "InvalidRequestError" })
39+
}).pipe(Effect.scoped),
40+
)

0 commit comments

Comments
 (0)