Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/secure-external-downloads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Validate GitHub attachments and language server release paths before downloading or executing them.
2 changes: 2 additions & 0 deletions packages/kilo-docs/source-links.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@
<!-- packages/opencode/src/provider/transform.ts -->
- <https://git-scm.com>
<!-- packages/kilo-vscode/src/agent-manager/WorktreeManager.ts -->
- <https://github.com>
<!-- packages/opencode/src/kilocode/security/github.ts -->
- <https://github.com/apps/kiloconnect>
<!-- packages/opencode/src/cli/cmd/github.ts -->
- <https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/overflow.ts>
Expand Down
6 changes: 5 additions & 1 deletion packages/opencode/src/cli/cmd/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process"
import { parseGitHubRemote } from "@/util/repository"
import { Effect } from "effect"
import { GitHubSecurity } from "@/kilocode/security/github" // kilocode_change

type GitHubAuthor = {
login: string
Expand Down Expand Up @@ -842,7 +843,10 @@ export const GithubRunCommand = effectCmd({
let offset = 0
for (const m of matches) {
const tag = m[0]
const url = m[1]
// kilocode_change start - only fetch canonical GitHub attachment routes
const url = GitHubSecurity.attachment(m[1])
if (!url) continue
// kilocode_change end
const start = m.index
const filename = path.basename(url)

Expand Down
26 changes: 26 additions & 0 deletions packages/opencode/src/kilocode/security/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export namespace GitHubSecurity {
export function attachment(value: string) {
if (!URL.canParse(value)) return

const url = new URL(value)
if (url.origin !== "https://github.com") return
if (url.search || url.hash) return

const asset = url.pathname.match(/^\/user-attachments\/assets\/([0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12})$/i)
if (asset) return `https://github.com/user-attachments/assets/${encodeURIComponent(asset[1])}`
Comment thread
marius-kilocode marked this conversation as resolved.

const file = url.pathname.match(/^\/user-attachments\/files\/([0-9]+)\/([^/]+)$/)
if (!file) return

const name = (() => {
try {
return decodeURIComponent(file[2])
} catch {
return
}
})()
if (!name || name.includes("/") || name.includes("\\") || name.includes("\0")) return

return `https://github.com/user-attachments/files/${encodeURIComponent(file[1])}/${encodeURIComponent(name)}`
}
}
12 changes: 9 additions & 3 deletions packages/opencode/src/lsp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1015,10 +1015,12 @@ export const Clangd: Info = {
} = await releaseResponse.json()

const tag = release.tag_name
if (!tag) {
// kilocode_change start - reject release metadata before it becomes an executable path
if (!tag || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(tag)) {
log.error("clangd release did not include a tag name")
return
}
// kilocode_change end
const platform = process.platform
const tokens: Record<string, string> = {
darwin: "mac",
Expand Down Expand Up @@ -1055,7 +1057,9 @@ export const Clangd: Info = {
return
}

const archive = path.join(Global.Path.bin, name)
// kilocode_change start - do not use remote metadata as a local path
const archive = path.join(Global.Path.bin, name.endsWith(".zip") ? "clangd.zip" : "clangd.tar.xz")
// kilocode_change end
const buf = await downloadResponse.arrayBuffer()
if (buf.byteLength === 0) {
log.error("Failed to write clangd archive")
Expand Down Expand Up @@ -1492,7 +1496,9 @@ export const LuaLS: Info = {
return
}

const tempPath = path.join(Global.Path.bin, assetName)
// kilocode_change start - use a fixed local archive name
const tempPath = path.join(Global.Path.bin, `lua-language-server.${ext}`)
// kilocode_change end
if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)

// Unlike zls which is a single self-contained binary,
Expand Down
12 changes: 10 additions & 2 deletions packages/opencode/src/util/archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,16 @@ export async function extractZip(zipPath: string, destDir: string) {
const winZipPath = path.resolve(zipPath)
const winDestDir = path.resolve(destDir)
// $global:ProgressPreference suppresses PowerShell's blue progress bar popup
const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force`
await Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd])
// kilocode_change start - keep paths out of the PowerShell program
const cmd =
"$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath $env:OPENCODE_ARCHIVE_PATH -DestinationPath $env:OPENCODE_ARCHIVE_DESTINATION -Force"
await Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd], {
env: {
OPENCODE_ARCHIVE_PATH: winZipPath,
OPENCODE_ARCHIVE_DESTINATION: winDestDir,
},
})
// kilocode_change end
return
}

Expand Down
27 changes: 27 additions & 0 deletions packages/opencode/test/kilocode/archive-security.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Archive } from "@/util/archive"
import { Process } from "@/util/process"
import { tmpdir } from "../fixture/fixture"

test("extracts ZIP paths with PowerShell metacharacters literally", async () => {
if (process.platform !== "win32") return

await using tmp = await tmpdir()
const source = path.join(tmp.path, "content.txt")
const archive = path.join(tmp.path, "archive'; exit 42; #.zip")
const dest = path.join(tmp.path, "dest'; exit 42; #")
const cmd = "Compress-Archive -LiteralPath $env:KILO_TEST_SOURCE -DestinationPath $env:KILO_TEST_ARCHIVE -Force"

await fs.writeFile(source, "safe")
await Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd], {
env: {
KILO_TEST_SOURCE: source,
KILO_TEST_ARCHIVE: archive,
},
})
await Archive.extractZip(archive, dest)

expect(await fs.readFile(path.join(dest, "content.txt"), "utf8")).toBe("safe")
})
28 changes: 28 additions & 0 deletions packages/opencode/test/kilocode/security-github.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test"
import { GitHubSecurity } from "@/kilocode/security/github"

describe("GitHubSecurity.attachment", () => {
test("accepts canonical GitHub attachment URLs", () => {
expect(
GitHubSecurity.attachment("https://github.com/user-attachments/assets/123e4567-e89b-12d3-a456-426614174000"),
).toBe("https://github.com/user-attachments/assets/123e4567-e89b-12d3-a456-426614174000")
expect(GitHubSecurity.attachment("https://github.com/user-attachments/files/12345/report%20final.txt")).toBe(
"https://github.com/user-attachments/files/12345/report%20final.txt",
)
})

test.each([
"https://github.com/user-attachments/assets/../../settings/profile",
"https://github.com/user-attachments/assets/%2e%2e/%2e%2e/settings/profile",
"https://github.com/user-attachments/files/12345/%2Fsettings",
"https://github.com/user-attachments/files/12345/folder/report.txt",
"https://github.com/user-attachments/assets/not-a-uuid",
"https://github.com/user-attachments/assets/123e4567-e89b-12d3-a456-426614174000?download=1",
"https://github.com/user-attachments/assets/123e4567-e89b-12d3-a456-426614174000#fragment",
"https://example.com/user-attachments/assets/123e4567-e89b-12d3-a456-426614174000",
"https://github.com/user-attachments/assets/123e4567-e89b-12d3-a456-426614174000",
"not a URL",
])("rejects non-canonical attachment URL %s", (url) => {
expect(GitHubSecurity.attachment(url)).toBeUndefined()
})
})
Loading