mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge pull request #11887 from Kilo-Org/mark/harden-allow-everything-auth
fix(cli): require auth for allow everything endpoint
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Require authentication before enabling allow-everything permissions over HTTP.
|
||||
@@ -179,9 +179,11 @@ export const TuiThreadCommand = cmd({
|
||||
// kilocode_change start - default TUI sessions attach to the daemon unless explicitly disabled
|
||||
if (await KiloTuiThreadDaemon.attach({ args, cwd, input: () => input(args.prompt), start })) return
|
||||
// kilocode_change end
|
||||
const auth = KiloTuiThreadDaemon.workerAuth() // kilocode_change - protect TUI-owned HTTP routes from unauthenticated local callers
|
||||
const env = sanitizedProcessEnv({
|
||||
[KILO_PROCESS_ROLE]: "worker",
|
||||
[KILO_RUN_ID]: ensureRunID(),
|
||||
...auth.env, // kilocode_change
|
||||
KILO_BACKGROUND_PROCESS_PORTS: "true", // kilocode_change - TUI surfaces inferred background process ports
|
||||
})
|
||||
|
||||
@@ -308,11 +310,13 @@ export const TuiThreadCommand = cmd({
|
||||
? {
|
||||
url: (await client.call("server", network)).url,
|
||||
fetch: undefined,
|
||||
headers: auth.headers, // kilocode_change
|
||||
events: undefined,
|
||||
}
|
||||
: {
|
||||
url: "http://kilo.internal",
|
||||
fetch: createWorkerFetch(client),
|
||||
headers: auth.headers, // kilocode_change
|
||||
events: createEventSource(client),
|
||||
}
|
||||
|
||||
@@ -322,6 +326,7 @@ export const TuiThreadCommand = cmd({
|
||||
sessionID: localSessionID(args), // kilocode_change
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
headers: transport.headers, // kilocode_change
|
||||
})
|
||||
} catch (error) {
|
||||
UI.error(errorMessage(error))
|
||||
@@ -340,6 +345,7 @@ export const TuiThreadCommand = cmd({
|
||||
const sdk = createKiloClient({
|
||||
baseUrl: transport.url,
|
||||
fetch: transport.fetch,
|
||||
headers: transport.headers, // kilocode_change
|
||||
directory: cwd,
|
||||
})
|
||||
const id = await importCloudSession(sdk, args.session).catch(() => undefined)
|
||||
@@ -365,6 +371,7 @@ export const TuiThreadCommand = cmd({
|
||||
config,
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
headers: transport.headers, // kilocode_change
|
||||
events: transport.events,
|
||||
args: {
|
||||
continue: args.continue,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { UI } from "@/cli/ui"
|
||||
import type { NetworkOptions } from "@/cli/network"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import { validateSession } from "@/cli/cmd/tui/validate-session"
|
||||
@@ -45,6 +48,18 @@ async function session(input: Input, daemon: DaemonClient.Connection) {
|
||||
}
|
||||
|
||||
export namespace KiloTuiThreadDaemon {
|
||||
// Protect TUI-owned HTTP routes from unauthenticated local callers: derive
|
||||
// worker credentials once so the spawned worker server and the TUI's SDK
|
||||
// clients share the same Basic auth material.
|
||||
export function workerAuth() {
|
||||
const password = Flag.KILO_SERVER_PASSWORD ?? randomUUID()
|
||||
const username = Flag.KILO_SERVER_USERNAME ?? "kilo"
|
||||
return {
|
||||
env: { KILO_SERVER_USERNAME: username, KILO_SERVER_PASSWORD: password },
|
||||
headers: ServerAuth.headers({ password, username }),
|
||||
}
|
||||
}
|
||||
|
||||
export async function attach(input: Input) {
|
||||
const daemon = await DaemonClient.maybe()
|
||||
if (!daemon) return false
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from "path"
|
||||
import { existsSync } from "fs"
|
||||
import { spawn } from "child_process"
|
||||
import { createServer } from "net"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { open, readFile, rm, mkdir } from "fs/promises"
|
||||
import z from "zod"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
@@ -179,6 +180,7 @@ export namespace Daemon {
|
||||
}
|
||||
|
||||
export function matches(state: State, input: Options, explicit: readonly NetworkOption[]) {
|
||||
if (state.password === "kilo") return false
|
||||
const options = Network.parse(input)
|
||||
return explicit.every((name) => {
|
||||
if (name === "hostname") return state.hostname === options.hostname
|
||||
@@ -204,7 +206,7 @@ export namespace Daemon {
|
||||
if (alive(current.state.pid)) await terminate(current.state.pid, true)
|
||||
}
|
||||
await clear()
|
||||
const password = "kilo"
|
||||
const password = randomUUID()
|
||||
const token = auth(password)
|
||||
const out = log()
|
||||
await mkdir(path.dirname(out), { recursive: true })
|
||||
|
||||
@@ -9,6 +9,9 @@ import { UnauthorizedError } from "../errors"
|
||||
const AUTH_TOKEN_QUERY = "auth_token"
|
||||
const UNAUTHORIZED = 401
|
||||
const WWW_AUTHENTICATE = 'Basic realm="Secure Area"'
|
||||
// kilocode_change start - require auth for high-risk permission toggles even when global auth is optional
|
||||
const REQUIRED_AUTH_PATHS = new Set(["/permission/allow-everything"])
|
||||
// kilocode_change end
|
||||
|
||||
// Avoid HttpApiSecurity alternatives here: Effect security middleware wraps the
|
||||
// full handler, so a downstream failure can make the next auth alternative run
|
||||
@@ -45,9 +48,10 @@ function validateCredential<A, E, R>(
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
credential: ServerAuth.DecodedCredentials,
|
||||
config: ServerAuth.Info,
|
||||
force = ServerAuth.required(config), // kilocode_change - allow endpoint-specific required auth
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
if (!ServerAuth.required(config)) return yield* effect
|
||||
if (!force) return yield* effect // kilocode_change
|
||||
if (!ServerAuth.authorized(credential, config)) {
|
||||
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
|
||||
Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
|
||||
@@ -58,6 +62,12 @@ function validateCredential<A, E, R>(
|
||||
})
|
||||
}
|
||||
|
||||
// kilocode_change start - fail closed for high-risk unauthenticated endpoints
|
||||
function guarded(url: URL, config: ServerAuth.Info) {
|
||||
return ServerAuth.required(config) || REQUIRED_AUTH_PATHS.has(url.pathname)
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
function decodeCredential(input: string) {
|
||||
return Effect.fromResult(Encoding.decodeBase64String(input)).pipe(
|
||||
Effect.match({
|
||||
@@ -123,12 +133,13 @@ export const authorizationLayer = Layer.effect(
|
||||
Authorization,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* ServerAuth.Config
|
||||
if (!ServerAuth.required(config)) return Authorization.of((effect) => effect)
|
||||
return Authorization.of((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, "http://localhost") // kilocode_change - inspect endpoint-specific auth policy
|
||||
if (!guarded(url, config)) return yield* effect // kilocode_change
|
||||
return yield* credentialFromRequest(request).pipe(
|
||||
Effect.flatMap((credential) => validateCredential(effect, credential, config)),
|
||||
Effect.flatMap((credential) => validateCredential(effect, credential, config, true)), // kilocode_change
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ function state(input: Partial<Daemon.Network> = {}) {
|
||||
port: options.port,
|
||||
url: `http://${options.hostname}:${options.port}`,
|
||||
username: "kilo",
|
||||
password: "kilo",
|
||||
password: "secret",
|
||||
token: "token",
|
||||
version: "test",
|
||||
startedAt: new Date(0).toISOString(),
|
||||
@@ -99,6 +99,10 @@ describe("console daemon startup", () => {
|
||||
expect(Daemon.matches(state(), opts({ port: 0 }), ["port"])).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects legacy fixed-password daemon state", () => {
|
||||
expect(Daemon.matches({ ...state(), password: "kilo" }, opts(), [])).toBe(false)
|
||||
})
|
||||
|
||||
test("supports daemon state written before network options were persisted", () => {
|
||||
const current = { ...state(), options: undefined }
|
||||
|
||||
|
||||
@@ -146,6 +146,31 @@ describe("daemon manager", () => {
|
||||
).toStrictEqual(["/tmp/bun", "--conditions=browser", "/tmp/kilo/src/index.ts"])
|
||||
})
|
||||
|
||||
test("does not reuse legacy fixed-password daemons", () => {
|
||||
const input = {
|
||||
hostname: "127.0.0.1",
|
||||
port: 4097,
|
||||
mdns: false,
|
||||
mdnsDomain: "kilo.local",
|
||||
cors: [],
|
||||
}
|
||||
const state: Daemon.State = {
|
||||
pid: 1,
|
||||
hostname: input.hostname,
|
||||
port: input.port,
|
||||
url: "http://127.0.0.1:4097",
|
||||
username: "kilo",
|
||||
password: "kilo",
|
||||
token: Buffer.from("kilo:kilo").toString("base64"),
|
||||
version: "test",
|
||||
startedAt: new Date(0).toISOString(),
|
||||
log: "/tmp/daemon.log",
|
||||
options: input,
|
||||
}
|
||||
|
||||
expect(Daemon.matches(state, input, [])).toBe(false)
|
||||
})
|
||||
|
||||
test("reuses one daemon across caller directories", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const env = opts(tmp.path)
|
||||
@@ -169,6 +194,8 @@ describe("daemon manager", () => {
|
||||
expect(started.running).toBe(true)
|
||||
expect(started.state?.pid).toBeGreaterThan(0)
|
||||
expect(started.state?.token).toBeTruthy()
|
||||
expect(started.state?.password).not.toBe("kilo")
|
||||
expect(started.state?.token).not.toBe(Buffer.from("kilo:kilo").toString("base64"))
|
||||
expect(started.state?.port).toBeGreaterThan(0)
|
||||
|
||||
const blocked = await fetch(`${started.state!.url}/config?directory=${encodeURIComponent(tmp.path)}`)
|
||||
|
||||
@@ -515,7 +515,7 @@ export const kiloScenarios: Scenario[] = [
|
||||
headers: ctx.headers(),
|
||||
body: { enable: true, sessionID: ctx.state.id },
|
||||
}))
|
||||
.json(200, (body) => check(body === true, "allow everything should return true")),
|
||||
.status(401),
|
||||
http.protected
|
||||
.post("/session/viewed", "session.viewed")
|
||||
.at((ctx) => ({ path: "/session/viewed", headers: ctx.headers(), body: { focused: [], open: [] } }))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// kilocode_change - new file
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Bus } from "../../../src/bus"
|
||||
@@ -22,6 +23,30 @@ const env = Layer.mergeAll(
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
)
|
||||
const it = testEffect(env)
|
||||
const original = {
|
||||
password: Flag.KILO_SERVER_PASSWORD,
|
||||
username: Flag.KILO_SERVER_USERNAME,
|
||||
envPassword: process.env.KILO_SERVER_PASSWORD,
|
||||
envUsername: process.env.KILO_SERVER_USERNAME,
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Flag.KILO_SERVER_PASSWORD = original.password
|
||||
Flag.KILO_SERVER_USERNAME = original.username
|
||||
if (original.envPassword === undefined) delete process.env.KILO_SERVER_PASSWORD
|
||||
else process.env.KILO_SERVER_PASSWORD = original.envPassword
|
||||
if (original.envUsername === undefined) delete process.env.KILO_SERVER_USERNAME
|
||||
else process.env.KILO_SERVER_USERNAME = original.envUsername
|
||||
})
|
||||
|
||||
const auth = () => `Basic ${Buffer.from("kilo:secret").toString("base64")}`
|
||||
|
||||
const requireAuth = () => {
|
||||
Flag.KILO_SERVER_PASSWORD = "secret"
|
||||
Flag.KILO_SERVER_USERNAME = undefined
|
||||
process.env.KILO_SERVER_PASSWORD = "secret"
|
||||
delete process.env.KILO_SERVER_USERNAME
|
||||
}
|
||||
|
||||
const ask = (input: Permission.AskInput) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -47,20 +72,28 @@ const wait = () =>
|
||||
|
||||
describe("AllowEverythingPermission", () => {
|
||||
test("handles disable requests through the HTTP endpoint", async () => {
|
||||
requireAuth()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const enable = await Server.Default().app.request("/permission/allow-everything", {
|
||||
const blocked = await Server.Default().app.request("/permission/allow-everything", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-kilo-directory": tmp.path },
|
||||
body: JSON.stringify({ enable: true }),
|
||||
})
|
||||
expect(blocked.status).toBe(401)
|
||||
|
||||
const enable = await Server.Default().app.request("/permission/allow-everything", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-kilo-directory": tmp.path, authorization: auth() },
|
||||
body: JSON.stringify({ enable: true }),
|
||||
})
|
||||
expect(enable.status).toBe(200)
|
||||
|
||||
const disable = await Server.Default().app.request("/permission/allow-everything", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-kilo-directory": tmp.path },
|
||||
headers: { "Content-Type": "application/json", "x-kilo-directory": tmp.path, authorization: auth() },
|
||||
body: JSON.stringify({ enable: false }),
|
||||
})
|
||||
expect(disable.status).toBe(200)
|
||||
|
||||
Reference in New Issue
Block a user