OpenCode部署边界的思考

2026-04-11 10:161阅读0评论SEO资源
  • 内容介绍
  • 文章标签
  • 相关推荐
问题描述:

OpenCode部署边界的思考

关于应用模式导致的边界问题的思考,

我们知道很多是有方便本地用户而设置的默认功能,服务就会省略鉴权等等的功能

很多时候在服务配置边界不明确的情况下,对于鉴权是极其宽松的

在我审计opencode的边界时,尤为感受深刻。

在opencode的server,web模式下启动的时候,会默认bind到4096端口

function createOpencode() { const host = "127.0.0.1" const port = 4096 const url = `http://${host}:${port}` const proc = spawn(`opencode`, [`serve`, `--hostname=${host}`, `--port=${port}`]) const client = createOpencodeClient({ baseUrl: url })

这种场景下的SSRF显得尤为危险,虽然默认绑定的是127.0.0.1,但是如果未默认直接起的服务就会暴漏以下

.route("/project", ProjectRoutes()) .route("/pty", PtyRoutes()) .route("/config", ConfigRoutes()) .route("/experimental", ExperimentalRoutes()) .route("/session", SessionRoutes()) .route("/permission", PermissionRoutes()) .route("/question", QuestionRoutes()) .route("/provider", ProviderRoutes()) .route("/", FileRoutes()) .route("/mcp", McpRoutes()) .route("/tui", TuiRoutes())

绑定的端口在检查query是否为字符串之后就可以直接访问了,这里列举两个高危的接口

看看FileRoutes的接入

列举两个高危接口做下示范

new Hono() .get( "/find", describeRoute({ summary: "Find text", description: "Search for text patterns across files in the project using ripgrep.", operationId: "find.text", responses: { 200: { description: "Matches", content: { "application/json": { schema: resolver(Ripgrep.Match.shape.data.array()), }, }, }, }, }), validator( "query", z.object({ pattern: z.string(), }), ), async (c) => { const pattern = c.req.valid("query").pattern const result = await Ripgrep.search({ cwd: Instance.directory, pattern, limit: 10, }) return c.json(result) }, )

对于query只是检查了是否是字符串,就直接push进了pattern,看看search的逻辑

export async function search(input: { cwd: string pattern: string glob?: string[] limit?: number follow?: boolean }) { const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"] if (input.follow) args.push("--follow") if (input.glob) { for (const g of input.glob) { args.push(`--glob=${g}`) } } if (input.limit) { args.push(`--max-count=${input.limit}`) } args.push("--") args.push(input.pattern) const command = args.join(" ") const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow() if (result.exitCode !== 0) { return [] }

对于${{raw : xxxx}}是不会经过任何转义的,并且还拼接了args,也就把pattern也拼进去了

这样如此便RCE,可以继续连接自己服务器进行进一步混淆和持久化操作

第二个是

.get( "/find/file", describeRoute({ summary: "Find files", description: "Search for files or directories by name or pattern in the project directory.", operationId: "find.files", responses: { 200: { description: "File paths", content: { "application/json": { schema: resolver(z.string().array()), }, }, }, }, }), validator( "query", z.object({ query: z.string(), dirs: z.enum(["true", "false"]).optional(), type: z.enum(["file", "directory"]).optional(), limit: z.coerce.number().int().min(1).max(200).optional(), }), ), async (c) => { const query = c.req.valid("query").query const dirs = c.req.valid("query").dirs const type = c.req.valid("query").type const limit = c.req.valid("query").limit const results = await File.search({ query, limit: limit ?? 10, dirs: dirs !== "false", type, }) return c.json(results) }, )

依旧query原样检查字符串后直接拼入,看看file search逻辑

export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { const query = input.query.trim() const limit = input.limit ?? 100 const kind = input.type ?? (input.dirs === false ? "file" : "all") log.info("search", { query, kind }) const result = await state().then((x) => x.files()) const hidden = (item: string) => { const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1) } const preferHidden = query.startsWith(".") || query.includes("/.") const sortHiddenLast = (items: string[]) => { if (preferHidden) return items const visible: string[] = [] const hiddenItems: string[] = [] for (const item of items) { const isHidden = hidden(item) if (isHidden) hiddenItems.push(item) if (!isHidden) visible.push(item) } return [...visible, ...hiddenItems] } if (!query) { if (kind === "file") return result.files.slice(0, limit) return sortHiddenLast(result.dirs.toSorted()).slice(0, limit) } const items = kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs] const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target) const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted log.info("search", { query, kind, results: output.length }) return output } }

即使非本机文件的泄露,但是

const result = await state()*.then((x) => x.*files())

也会造成.env以及本项目文件的泄露

这也是对于服务端很危险的,当然对于前者,在本机运行的其他项目如果暴漏在局域网或者公网中时

这个边界跳板就显得尤为重要,这也是xss进行SSRF个人认为比较核心的点,由XSS到SSRF到RCE.

网友解答:
--【壹】--:

OpenCode部署边界的思考

关于应用模式导致的边界问题的思考,

我们知道很多是有方便本地用户而设置的默认功能,服务就会省略鉴权等等的功能

很多时候在服务配置边界不明确的情况下,对于鉴权是极其宽松的

在我审计opencode的边界时,尤为感受深刻。

在opencode的server,web模式下启动的时候,会默认bind到4096端口

function createOpencode() { const host = "127.0.0.1" const port = 4096 const url = `http://${host}:${port}` const proc = spawn(`opencode`, [`serve`, `--hostname=${host}`, `--port=${port}`]) const client = createOpencodeClient({ baseUrl: url })

这种场景下的SSRF显得尤为危险,虽然默认绑定的是127.0.0.1,但是如果未默认直接起的服务就会暴漏以下

.route("/project", ProjectRoutes()) .route("/pty", PtyRoutes()) .route("/config", ConfigRoutes()) .route("/experimental", ExperimentalRoutes()) .route("/session", SessionRoutes()) .route("/permission", PermissionRoutes()) .route("/question", QuestionRoutes()) .route("/provider", ProviderRoutes()) .route("/", FileRoutes()) .route("/mcp", McpRoutes()) .route("/tui", TuiRoutes())

绑定的端口在检查query是否为字符串之后就可以直接访问了,这里列举两个高危的接口

看看FileRoutes的接入

列举两个高危接口做下示范

new Hono() .get( "/find", describeRoute({ summary: "Find text", description: "Search for text patterns across files in the project using ripgrep.", operationId: "find.text", responses: { 200: { description: "Matches", content: { "application/json": { schema: resolver(Ripgrep.Match.shape.data.array()), }, }, }, }, }), validator( "query", z.object({ pattern: z.string(), }), ), async (c) => { const pattern = c.req.valid("query").pattern const result = await Ripgrep.search({ cwd: Instance.directory, pattern, limit: 10, }) return c.json(result) }, )

对于query只是检查了是否是字符串,就直接push进了pattern,看看search的逻辑

export async function search(input: { cwd: string pattern: string glob?: string[] limit?: number follow?: boolean }) { const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"] if (input.follow) args.push("--follow") if (input.glob) { for (const g of input.glob) { args.push(`--glob=${g}`) } } if (input.limit) { args.push(`--max-count=${input.limit}`) } args.push("--") args.push(input.pattern) const command = args.join(" ") const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow() if (result.exitCode !== 0) { return [] }

对于${{raw : xxxx}}是不会经过任何转义的,并且还拼接了args,也就把pattern也拼进去了

这样如此便RCE,可以继续连接自己服务器进行进一步混淆和持久化操作

第二个是

.get( "/find/file", describeRoute({ summary: "Find files", description: "Search for files or directories by name or pattern in the project directory.", operationId: "find.files", responses: { 200: { description: "File paths", content: { "application/json": { schema: resolver(z.string().array()), }, }, }, }, }), validator( "query", z.object({ query: z.string(), dirs: z.enum(["true", "false"]).optional(), type: z.enum(["file", "directory"]).optional(), limit: z.coerce.number().int().min(1).max(200).optional(), }), ), async (c) => { const query = c.req.valid("query").query const dirs = c.req.valid("query").dirs const type = c.req.valid("query").type const limit = c.req.valid("query").limit const results = await File.search({ query, limit: limit ?? 10, dirs: dirs !== "false", type, }) return c.json(results) }, )

依旧query原样检查字符串后直接拼入,看看file search逻辑

export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { const query = input.query.trim() const limit = input.limit ?? 100 const kind = input.type ?? (input.dirs === false ? "file" : "all") log.info("search", { query, kind }) const result = await state().then((x) => x.files()) const hidden = (item: string) => { const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1) } const preferHidden = query.startsWith(".") || query.includes("/.") const sortHiddenLast = (items: string[]) => { if (preferHidden) return items const visible: string[] = [] const hiddenItems: string[] = [] for (const item of items) { const isHidden = hidden(item) if (isHidden) hiddenItems.push(item) if (!isHidden) visible.push(item) } return [...visible, ...hiddenItems] } if (!query) { if (kind === "file") return result.files.slice(0, limit) return sortHiddenLast(result.dirs.toSorted()).slice(0, limit) } const items = kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs] const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target) const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted log.info("search", { query, kind, results: output.length }) return output } }

即使非本机文件的泄露,但是

const result = await state()*.then((x) => x.*files())

也会造成.env以及本项目文件的泄露

这也是对于服务端很危险的,当然对于前者,在本机运行的其他项目如果暴漏在局域网或者公网中时

这个边界跳板就显得尤为重要,这也是xss进行SSRF个人认为比较核心的点,由XSS到SSRF到RCE.

标签:网络安全
问题描述:

OpenCode部署边界的思考

关于应用模式导致的边界问题的思考,

我们知道很多是有方便本地用户而设置的默认功能,服务就会省略鉴权等等的功能

很多时候在服务配置边界不明确的情况下,对于鉴权是极其宽松的

在我审计opencode的边界时,尤为感受深刻。

在opencode的server,web模式下启动的时候,会默认bind到4096端口

function createOpencode() { const host = "127.0.0.1" const port = 4096 const url = `http://${host}:${port}` const proc = spawn(`opencode`, [`serve`, `--hostname=${host}`, `--port=${port}`]) const client = createOpencodeClient({ baseUrl: url })

这种场景下的SSRF显得尤为危险,虽然默认绑定的是127.0.0.1,但是如果未默认直接起的服务就会暴漏以下

.route("/project", ProjectRoutes()) .route("/pty", PtyRoutes()) .route("/config", ConfigRoutes()) .route("/experimental", ExperimentalRoutes()) .route("/session", SessionRoutes()) .route("/permission", PermissionRoutes()) .route("/question", QuestionRoutes()) .route("/provider", ProviderRoutes()) .route("/", FileRoutes()) .route("/mcp", McpRoutes()) .route("/tui", TuiRoutes())

绑定的端口在检查query是否为字符串之后就可以直接访问了,这里列举两个高危的接口

看看FileRoutes的接入

列举两个高危接口做下示范

new Hono() .get( "/find", describeRoute({ summary: "Find text", description: "Search for text patterns across files in the project using ripgrep.", operationId: "find.text", responses: { 200: { description: "Matches", content: { "application/json": { schema: resolver(Ripgrep.Match.shape.data.array()), }, }, }, }, }), validator( "query", z.object({ pattern: z.string(), }), ), async (c) => { const pattern = c.req.valid("query").pattern const result = await Ripgrep.search({ cwd: Instance.directory, pattern, limit: 10, }) return c.json(result) }, )

对于query只是检查了是否是字符串,就直接push进了pattern,看看search的逻辑

export async function search(input: { cwd: string pattern: string glob?: string[] limit?: number follow?: boolean }) { const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"] if (input.follow) args.push("--follow") if (input.glob) { for (const g of input.glob) { args.push(`--glob=${g}`) } } if (input.limit) { args.push(`--max-count=${input.limit}`) } args.push("--") args.push(input.pattern) const command = args.join(" ") const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow() if (result.exitCode !== 0) { return [] }

对于${{raw : xxxx}}是不会经过任何转义的,并且还拼接了args,也就把pattern也拼进去了

这样如此便RCE,可以继续连接自己服务器进行进一步混淆和持久化操作

第二个是

.get( "/find/file", describeRoute({ summary: "Find files", description: "Search for files or directories by name or pattern in the project directory.", operationId: "find.files", responses: { 200: { description: "File paths", content: { "application/json": { schema: resolver(z.string().array()), }, }, }, }, }), validator( "query", z.object({ query: z.string(), dirs: z.enum(["true", "false"]).optional(), type: z.enum(["file", "directory"]).optional(), limit: z.coerce.number().int().min(1).max(200).optional(), }), ), async (c) => { const query = c.req.valid("query").query const dirs = c.req.valid("query").dirs const type = c.req.valid("query").type const limit = c.req.valid("query").limit const results = await File.search({ query, limit: limit ?? 10, dirs: dirs !== "false", type, }) return c.json(results) }, )

依旧query原样检查字符串后直接拼入,看看file search逻辑

export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { const query = input.query.trim() const limit = input.limit ?? 100 const kind = input.type ?? (input.dirs === false ? "file" : "all") log.info("search", { query, kind }) const result = await state().then((x) => x.files()) const hidden = (item: string) => { const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1) } const preferHidden = query.startsWith(".") || query.includes("/.") const sortHiddenLast = (items: string[]) => { if (preferHidden) return items const visible: string[] = [] const hiddenItems: string[] = [] for (const item of items) { const isHidden = hidden(item) if (isHidden) hiddenItems.push(item) if (!isHidden) visible.push(item) } return [...visible, ...hiddenItems] } if (!query) { if (kind === "file") return result.files.slice(0, limit) return sortHiddenLast(result.dirs.toSorted()).slice(0, limit) } const items = kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs] const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target) const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted log.info("search", { query, kind, results: output.length }) return output } }

即使非本机文件的泄露,但是

const result = await state()*.then((x) => x.*files())

也会造成.env以及本项目文件的泄露

这也是对于服务端很危险的,当然对于前者,在本机运行的其他项目如果暴漏在局域网或者公网中时

这个边界跳板就显得尤为重要,这也是xss进行SSRF个人认为比较核心的点,由XSS到SSRF到RCE.

网友解答:
--【壹】--:

OpenCode部署边界的思考

关于应用模式导致的边界问题的思考,

我们知道很多是有方便本地用户而设置的默认功能,服务就会省略鉴权等等的功能

很多时候在服务配置边界不明确的情况下,对于鉴权是极其宽松的

在我审计opencode的边界时,尤为感受深刻。

在opencode的server,web模式下启动的时候,会默认bind到4096端口

function createOpencode() { const host = "127.0.0.1" const port = 4096 const url = `http://${host}:${port}` const proc = spawn(`opencode`, [`serve`, `--hostname=${host}`, `--port=${port}`]) const client = createOpencodeClient({ baseUrl: url })

这种场景下的SSRF显得尤为危险,虽然默认绑定的是127.0.0.1,但是如果未默认直接起的服务就会暴漏以下

.route("/project", ProjectRoutes()) .route("/pty", PtyRoutes()) .route("/config", ConfigRoutes()) .route("/experimental", ExperimentalRoutes()) .route("/session", SessionRoutes()) .route("/permission", PermissionRoutes()) .route("/question", QuestionRoutes()) .route("/provider", ProviderRoutes()) .route("/", FileRoutes()) .route("/mcp", McpRoutes()) .route("/tui", TuiRoutes())

绑定的端口在检查query是否为字符串之后就可以直接访问了,这里列举两个高危的接口

看看FileRoutes的接入

列举两个高危接口做下示范

new Hono() .get( "/find", describeRoute({ summary: "Find text", description: "Search for text patterns across files in the project using ripgrep.", operationId: "find.text", responses: { 200: { description: "Matches", content: { "application/json": { schema: resolver(Ripgrep.Match.shape.data.array()), }, }, }, }, }), validator( "query", z.object({ pattern: z.string(), }), ), async (c) => { const pattern = c.req.valid("query").pattern const result = await Ripgrep.search({ cwd: Instance.directory, pattern, limit: 10, }) return c.json(result) }, )

对于query只是检查了是否是字符串,就直接push进了pattern,看看search的逻辑

export async function search(input: { cwd: string pattern: string glob?: string[] limit?: number follow?: boolean }) { const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"] if (input.follow) args.push("--follow") if (input.glob) { for (const g of input.glob) { args.push(`--glob=${g}`) } } if (input.limit) { args.push(`--max-count=${input.limit}`) } args.push("--") args.push(input.pattern) const command = args.join(" ") const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow() if (result.exitCode !== 0) { return [] }

对于${{raw : xxxx}}是不会经过任何转义的,并且还拼接了args,也就把pattern也拼进去了

这样如此便RCE,可以继续连接自己服务器进行进一步混淆和持久化操作

第二个是

.get( "/find/file", describeRoute({ summary: "Find files", description: "Search for files or directories by name or pattern in the project directory.", operationId: "find.files", responses: { 200: { description: "File paths", content: { "application/json": { schema: resolver(z.string().array()), }, }, }, }, }), validator( "query", z.object({ query: z.string(), dirs: z.enum(["true", "false"]).optional(), type: z.enum(["file", "directory"]).optional(), limit: z.coerce.number().int().min(1).max(200).optional(), }), ), async (c) => { const query = c.req.valid("query").query const dirs = c.req.valid("query").dirs const type = c.req.valid("query").type const limit = c.req.valid("query").limit const results = await File.search({ query, limit: limit ?? 10, dirs: dirs !== "false", type, }) return c.json(results) }, )

依旧query原样检查字符串后直接拼入,看看file search逻辑

export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { const query = input.query.trim() const limit = input.limit ?? 100 const kind = input.type ?? (input.dirs === false ? "file" : "all") log.info("search", { query, kind }) const result = await state().then((x) => x.files()) const hidden = (item: string) => { const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1) } const preferHidden = query.startsWith(".") || query.includes("/.") const sortHiddenLast = (items: string[]) => { if (preferHidden) return items const visible: string[] = [] const hiddenItems: string[] = [] for (const item of items) { const isHidden = hidden(item) if (isHidden) hiddenItems.push(item) if (!isHidden) visible.push(item) } return [...visible, ...hiddenItems] } if (!query) { if (kind === "file") return result.files.slice(0, limit) return sortHiddenLast(result.dirs.toSorted()).slice(0, limit) } const items = kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs] const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target) const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted log.info("search", { query, kind, results: output.length }) return output } }

即使非本机文件的泄露,但是

const result = await state()*.then((x) => x.*files())

也会造成.env以及本项目文件的泄露

这也是对于服务端很危险的,当然对于前者,在本机运行的其他项目如果暴漏在局域网或者公网中时

这个边界跳板就显得尤为重要,这也是xss进行SSRF个人认为比较核心的点,由XSS到SSRF到RCE.

标签:网络安全