Mobile wallpaper 1Mobile wallpaper 2Mobile wallpaper 3Mobile wallpaper 4
926 字
5 分钟
OpenCode部署安全边界的思考
2026-04-10
统计加载中...

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部署安全边界的思考
https://steins-gate.cn/posts/opencode_1216/
作者
萦梦sora~Nya
发布于
2026-04-10
许可协议
YMsora

部分信息可能已经过时

封面
示例歌曲
示例艺术家
封面
示例歌曲
示例艺术家
0:00 / 0:00