diff --git a/.changeset/harden-allow-everything-auth.md b/.changeset/harden-allow-everything-auth.md new file mode 100644 index 00000000000..91a66e9a602 --- /dev/null +++ b/.changeset/harden-allow-everything-auth.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Require authentication before enabling allow-everything permissions over HTTP. diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 7e6d22721a4..5cf44aa4965 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -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, diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/thread.ts b/packages/opencode/src/kilocode/cli/cmd/tui/thread.ts index 9b18b8315a9..ad414345a08 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui/thread.ts @@ -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 diff --git a/packages/opencode/src/kilocode/daemon/daemon.ts b/packages/opencode/src/kilocode/daemon/daemon.ts index 45bf3450dda..0b29dfbc6ac 100644 --- a/packages/opencode/src/kilocode/daemon/daemon.ts +++ b/packages/opencode/src/kilocode/daemon/daemon.ts @@ -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 }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index db6554590f8..9efd9b8ad0f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -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( effect: Effect.Effect, 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( }) } +// 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 ) }), ) diff --git a/packages/opencode/test/kilocode/cli/cmd/console.test.ts b/packages/opencode/test/kilocode/cli/cmd/console.test.ts index a7934b9356a..40876c4a4ff 100644 --- a/packages/opencode/test/kilocode/cli/cmd/console.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/console.test.ts @@ -22,7 +22,7 @@ function state(input: Partial = {}) { 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 } diff --git a/packages/opencode/test/kilocode/daemon.test.ts b/packages/opencode/test/kilocode/daemon.test.ts index 644dfe30bf4..bae0ed42787 100644 --- a/packages/opencode/test/kilocode/daemon.test.ts +++ b/packages/opencode/test/kilocode/daemon.test.ts @@ -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)}`) diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index 7cdc9c25e65..2aafe27e055 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -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: [] } })) diff --git a/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts b/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts index e00ceb824e4..ac028dc3f00 100644 --- a/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts +++ b/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts @@ -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)