OpenCode部署边界的思考
- 内容介绍
- 文章标签
- 相关推荐
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.

