From 6c3d84f1d32c5fc526cc24515e4803ca040b8df0 Mon Sep 17 00:00:00 2001 From: Nayan Date: Sat, 15 Aug 2026 12:17:44 +0530 Subject: [PATCH] feat(server): loopback bind, host allowlist, and an origin gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy bound every interface — server.listen with no host — and the control WebSocket completed any upgrade with no Origin or Host check. That socket drives a coding agent with write access to the project, so anyone routable to the machine, or any page open in the developer's browser (WebSocket handshakes are not subject to CORS), could edit files, commit, and open PRs under the user's identity. Reported in #15 by yarikbright, with the repro this change's tests replay. Three checks, each stopping an attack the other two do not: - bind 127.0.0.1 by default (the posture opencode-server.ts always had) — stops the LAN attacker; --host / AIRSHIP_HOST opts out, with a loud launch warning that a wide bind is an unauthenticated agent - an exact-match Host allowlist (localhost and IP literals always pass; --allowed-hosts adds names) — stops DNS rebinding, where the attacker's Origin and Host match and an Origin check alone waves them through - Origin-matches-Host on every upgrade, control socket and HMR tunnel alike, refused with a real 403 before the upgrade completes. An absent Origin (curl, CLI) is allowed; Origin: null is not. The Host gate sits ahead of even the editor's own assets, so a blocked page cannot fetch overlay.js. requireHost accepts IPv6 literals bare or bracketed, EADDRNOTAVAIL now names the interface it could not find, and the printed URL follows the bind (wildcards present as localhost so it stays clickable). SECURITY.md states the model and its deliberate limits. The README's "runs entirely on localhost" is now an enforced default rather than an unchecked claim. Reported-by: yarikbright Closes #15 --- README.md | 31 ++++- SECURITY.md | 47 +++++++ apps/cli/README.md | 31 ++++- apps/cli/src/commands/serve.test.ts | 64 ++++++++- apps/cli/src/commands/serve.ts | 35 ++++- apps/cli/src/lib/args.test.ts | 23 ++++ apps/cli/src/lib/args.ts | 45 ++++++ apps/cli/src/lib/banner.test.ts | 18 ++- apps/cli/src/lib/banner.ts | 16 +++ apps/web/src/content/faq.json | 4 +- packages/server/src/access.test.ts | 206 ++++++++++++++++++++++++++++ packages/server/src/access.ts | 193 ++++++++++++++++++++++++++ packages/server/src/index.ts | 21 ++- packages/server/src/proxy.ts | 39 ++++++ 14 files changed, 755 insertions(+), 18 deletions(-) create mode 100644 SECURITY.md create mode 100644 packages/server/src/access.test.ts create mode 100644 packages/server/src/access.ts diff --git a/README.md b/README.md index e4df792..0c13c95 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,8 @@ forwarded. | --- | --- | --- | | `-t, --target ` | Port your dev server is already running on. Detected from your `package.json` when omitted. | | | `-p, --port ` | Port for the airship proxy. | `target + 1` | +| `--host
` | Interface the proxy listens on. See [`--host`](#--host) before widening it. | `127.0.0.1` | +| `--allowed-hosts ` | Extra hostnames airship answers to, repeatable or comma-separated. `localhost` and IP addresses are always allowed. | | | `--cwd ` | Project root for edits. | current directory | | `--mode ` | Editor mode: `canvas` or `inline`. Switchable from the editor too. | `canvas` | | `--exec ` | Start your dev server with this command and stop it when airship exits. | | @@ -400,7 +402,8 @@ AIRSHIP_EXEC AIRSHIP_MAX_BUDGET AIRSHIP_OPENCODE_AGENT AIRSHIP_OPEN AIRSHIP_COMMIT AIRSHIP_OPENCODE_CONFIG AIRSHIP_SAFE AIRSHIP_JSON AIRSHIP_OPENCODE_MODEL AIRSHIP_DEBUG AIRSHIP_QUIET AIRSHIP_CLAUDE_MODEL -AIRSHIP_KEEP_CSP AIRSHIP_CODEX_MODEL +AIRSHIP_KEEP_CSP AIRSHIP_HOST AIRSHIP_CODEX_MODEL +AIRSHIP_ALLOWED_HOSTS ``` `AIRSHIP_HELP` and `AIRSHIP_VERSION` are deliberately not read — exporting one would leave the @@ -418,6 +421,25 @@ itself), and `NO_COLOR` / `FORCE_COLOR`. root. Airship needs it to turn the paths your dev server reports (`/src/app.tsx`) into real files on disk. In a monorepo where the app lives in `apps/web`, that's `--cwd apps/web`. +### `--host` + +Airship listens on `127.0.0.1`, so only your own machine can reach the editor. That is a safety +posture, not a limitation: the editor is an unauthenticated server that can drive a coding agent +with write access to your project, and airship warns loudly whenever you widen it. Three setups +need the extra flags: + +- **Docker.** Publishing the editor's port with `docker run -p` needs `--host 0.0.0.0` — a + loopback bind inside the container is unreachable from outside it, and the symptom is a bare + connection-refused. +- **A phone or another machine on your network.** `--host 0.0.0.0`, then open + `http://:`. While airship runs, anything on that network can drive the agent — + treat it like leaving a terminal unlocked. +- **A hostname** — an `/etc/hosts` alias, a tunnel, a reverse proxy. Requests under a name are + refused unless it is the `--host` value or listed in `--allowed-hosts`. `localhost` and IP + addresses are always accepted; names must match exactly (no subdomains), which is what blocks + DNS rebinding. Airship never reads `X-Forwarded-Host` — behind a reverse proxy, put the public + name in `--allowed-hosts`. + ## Port detection Leave `--target` off and Airship works it out, trying each likely port in turn and taking the @@ -521,9 +543,10 @@ previous version of the file, so without a repository you lose undo on those two unaffected. See [Agents](#agents). **Does my code leave my machine?** -Airship runs entirely on localhost. It has no account, telemetry, or hosted service, and your -code isn't sent to Airship. Your chosen coding agent handles requests using the same credentials -and provider it would use from your terminal. +Airship runs entirely on localhost by default: it binds `127.0.0.1` and refuses requests from +other devices and cross-site pages unless you widen that with [`--host`](#--host). It has no +account, telemetry, or hosted service, and your code isn't sent to Airship. Your chosen coding +agent handles requests using the same credentials and provider it would use from your terminal. ## Requirements diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0d39bb7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,47 @@ +# Security + +## Reporting a vulnerability + +Please report vulnerabilities privately through +[GitHub security advisories](https://github.com/0xnyn/airship/security/advisories/new) +rather than a public issue. If that form is unavailable to you, open an issue that says only +that you have a security report and how to reach you — hold the details for a private channel. + +Reports are welcome and credited. Issue #15 is what this policy grew out of. + +## The security model + +Airship is a local development tool: an HTTP proxy in front of your dev server, a WebSocket +that drives a coding agent, and an overlay injected into your own app. The agent can write to +your project and, unless you pass `--safe`, do whatever your agent could do from a terminal. + +What the server enforces, in `packages/server/src/access.ts`: + +- **Loopback bind by default.** The proxy listens on `127.0.0.1` unless `--host` says + otherwise, so other machines cannot reach it at all. +- **A Host allowlist.** Requests are served only for `localhost`, IP literals, the configured + `--host`, and exact `--allowed-hosts` entries. This is what stops DNS rebinding, where an + attacker's page turns its own hostname into your loopback address; an IP literal cannot be + rebound, which is why literals are always accepted. +- **An Origin gate on every WebSocket upgrade** — the control socket and the HMR tunnel + alike. A handshake whose `Origin` does not match its `Host` is refused before the upgrade + completes, which stops cross-site WebSocket hijacking from another browser tab. + `Origin: null` (sandboxed iframes, `file://`) is refused; an *absent* Origin — curl, CLI + tools — is allowed, since it carries the same trust as any local HTTP client. + +## What is deliberately not defended + +This is a reachability boundary, not authentication. Know what you are opting into: + +- **`--host 0.0.0.0` exposes an unauthenticated agent.** Anyone who can reach the interface + can drive an agent with write access to your project — IP-literal Hosts are accepted by + design, since refusing them would break exactly the LAN access you asked for. Airship warns + at launch; use it only on networks where you trust every device. +- **Non-browser clients are not authenticated.** Anything that can open a TCP connection to + the (loopback-only, by default) port can speak the protocol. +- **`X-Forwarded-Host` and `Forwarded` are never read.** Behind a reverse proxy, add the + public name to `--allowed-hosts`. +- **Host-less HTTP/1.0 requests are refused** rather than guessed at. +- **Upstream `Content-Security-Policy` and `X-Frame-Options` headers are stripped** from the + surfaces airship serves (opt back in with `--keep-csp`); your deployed app's headers are + untouched by anything airship does. diff --git a/apps/cli/README.md b/apps/cli/README.md index 53ff297..8d6b50d 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -269,6 +269,8 @@ forwarded. | --- | --- | --- | | `-t, --target ` | Port your dev server is already running on. Detected from your `package.json` when omitted. | | | `-p, --port ` | Port for the airship proxy. | `target + 1` | +| `--host
` | Interface the proxy listens on. See [`--host`](#--host) before widening it. | `127.0.0.1` | +| `--allowed-hosts ` | Extra hostnames airship answers to, repeatable or comma-separated. `localhost` and IP addresses are always allowed. | | | `--cwd ` | Project root for edits. | current directory | | `--mode ` | Editor mode: `canvas` or `inline`. Switchable from the editor too. | `canvas` | | `--exec ` | Start your dev server with this command and stop it when airship exits. | | @@ -402,7 +404,8 @@ AIRSHIP_EXEC AIRSHIP_MAX_BUDGET AIRSHIP_OPENCODE_AGENT AIRSHIP_OPEN AIRSHIP_COMMIT AIRSHIP_OPENCODE_CONFIG AIRSHIP_SAFE AIRSHIP_JSON AIRSHIP_OPENCODE_MODEL AIRSHIP_DEBUG AIRSHIP_QUIET AIRSHIP_CLAUDE_MODEL -AIRSHIP_KEEP_CSP AIRSHIP_CODEX_MODEL +AIRSHIP_KEEP_CSP AIRSHIP_HOST AIRSHIP_CODEX_MODEL +AIRSHIP_ALLOWED_HOSTS ``` `AIRSHIP_HELP` and `AIRSHIP_VERSION` are deliberately not read — exporting one would leave the @@ -420,6 +423,25 @@ itself), and `NO_COLOR` / `FORCE_COLOR`. root. Airship needs it to turn the paths your dev server reports (`/src/app.tsx`) into real files on disk. In a monorepo where the app lives in `apps/web`, that's `--cwd apps/web`. +### `--host` + +Airship listens on `127.0.0.1`, so only your own machine can reach the editor. That is a safety +posture, not a limitation: the editor is an unauthenticated server that can drive a coding agent +with write access to your project, and airship warns loudly whenever you widen it. Three setups +need the extra flags: + +- **Docker.** Publishing the editor's port with `docker run -p` needs `--host 0.0.0.0` — a + loopback bind inside the container is unreachable from outside it, and the symptom is a bare + connection-refused. +- **A phone or another machine on your network.** `--host 0.0.0.0`, then open + `http://:`. While airship runs, anything on that network can drive the agent — + treat it like leaving a terminal unlocked. +- **A hostname** — an `/etc/hosts` alias, a tunnel, a reverse proxy. Requests under a name are + refused unless it is the `--host` value or listed in `--allowed-hosts`. `localhost` and IP + addresses are always accepted; names must match exactly (no subdomains), which is what blocks + DNS rebinding. Airship never reads `X-Forwarded-Host` — behind a reverse proxy, put the public + name in `--allowed-hosts`. + ## Port detection Leave `--target` off and Airship works it out, trying each likely port in turn and taking the @@ -523,9 +545,10 @@ previous version of the file, so without a repository you lose undo on those two unaffected. See [Agents](#agents). **Does my code leave my machine?** -Airship runs entirely on localhost. It has no account, telemetry, or hosted service, and your -code isn't sent to Airship. Your chosen coding agent handles requests using the same credentials -and provider it would use from your terminal. +Airship runs entirely on localhost by default: it binds `127.0.0.1` and refuses requests from +other devices and cross-site pages unless you widen that with [`--host`](#--host). It has no +account, telemetry, or hosted service, and your code isn't sent to Airship. Your chosen coding +agent handles requests using the same credentials and provider it would use from your terminal. ## Requirements diff --git a/apps/cli/src/commands/serve.test.ts b/apps/cli/src/commands/serve.test.ts index 2761f6e..21a4446 100644 --- a/apps/cli/src/commands/serve.test.ts +++ b/apps/cli/src/commands/serve.test.ts @@ -9,9 +9,10 @@ */ import { describe, expect, it } from "vitest"; +import { FLAGS } from "../lib/args"; import { mergeSettings, type Settings } from "../lib/config"; import { CliError } from "../lib/errors"; -import { toServeOptions } from "./serve"; +import { SERVE_FLAGS, toServeOptions } from "./serve"; const CWD = "/tmp/airship-serve-test"; @@ -126,3 +127,64 @@ describe("toServeOptions — the surrounding validation still holds", () => { ).toContain("max-budget"); }); }); + +describe("toServeOptions — host and allowed hosts", () => { + it("leaves both unset by default", () => { + const opts = optionsFor({}); + expect(opts.host).toBeUndefined(); + expect(opts.allowedHosts).toEqual([]); + }); + + it("passes a valid host through, lowercased", () => { + expect(optionsFor({ host: "0.0.0.0" }).host).toBe("0.0.0.0"); + expect(optionsFor({ host: "Dev.Local" }).host).toBe("dev.local"); + }); + + it("accepts IPv6 literals, bare or bracketed", () => { + expect(optionsFor({ host: "::1" }).host).toBe("::1"); + expect(optionsFor({ host: "[::1]" }).host).toBe("::1"); + }); + + it("rejects a host carrying a scheme, port or path", () => { + for (const bad of ["http://x", "localhost:3000", "a/b"]) { + expect(errorFrom(() => optionsFor({ host: bad }))?.message).toBe( + `Invalid --host '${bad}'` + ); + } + }); + + it("splits allowed hosts on commas — the env form cannot repeat", () => { + expect( + optionsFor({ "allowed-hosts": "a.test, B.Test," }).allowedHosts + ).toEqual(["a.test", "b.test"]); + }); + + it("keeps repeated --allowed-hosts entries and still splits each", () => { + const merged = mergeSettings({ + "allowed-hosts": ["a.test", "b.test,c.test"], + } as unknown as Settings); + expect(toServeOptions(merged, CWD).allowedHosts).toEqual([ + "a.test", + "b.test", + "c.test", + ]); + }); + + it("validates each allowed host like a host", () => { + expect( + errorFrom(() => optionsFor({ "allowed-hosts": "https://evil.test" })) + ?.message + ).toContain("Invalid --allowed-hosts"); + }); +}); + +// The half-wired-flag trap: a flag in the registry but missing here works via +// env and config while `assertKnownFlags` rejects it on the command line — a +// state no other test can see. +describe("SERVE_FLAGS", () => { + it("carries every non-global flag", () => { + for (const spec of FLAGS.filter((flag) => flag.group !== "GLOBAL")) { + expect(SERVE_FLAGS).toContain(spec.name); + } + }); +}); diff --git a/apps/cli/src/commands/serve.ts b/apps/cli/src/commands/serve.ts index f9d80ce..9268e68 100644 --- a/apps/cli/src/commands/serve.ts +++ b/apps/cli/src/commands/serve.ts @@ -23,6 +23,7 @@ import { GLOBAL_FLAGS, requireAmount, requireEnum, + requireHost, requireInteger, requireModelRef, requirePort, @@ -44,6 +45,8 @@ import { note, out, setColorEnabled, shouldColor } from "../lib/terminal"; export const SERVE_FLAGS: readonly string[] = [ "target", "port", + "host", + "allowed-hosts", "cwd", "mode", "exec", @@ -70,11 +73,13 @@ export const SERVE_FLAGS: readonly string[] = [ export interface ServeOptions { agent: AgentKind; + allowedHosts: readonly string[]; autoCommit: boolean; codex: CodexSettings; cwd: string; effort?: Effort; exec?: string; + host?: string; json: boolean; keepCsp: boolean; maxBudgetUsd?: number; @@ -108,9 +113,19 @@ export function toServeOptions(settings: Settings, cwd: string): ServeOptions { const codexConfig = parseCodexConfig(asList(settings, "codex-config")); const model = asString(settings, "model"); const opencodeModel = asString(settings, "opencode-model"); + const host = asString(settings, "host"); return { agent: (agent ? requireEnum(agent, "agent") : "claude") as AgentKind, + // Split locally: the env layer has no repeatable form, so + // AIRSHIP_ALLOWED_HOSTS=a,b arrives as one string. + allowedHosts: asList(settings, "allowed-hosts").flatMap((entry) => + entry + .split(",") + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => requireHost(part, "allowed-hosts")) + ), autoCommit: asBoolean(settings, "commit"), codex: { codexPath: asString(settings, "codex-path"), @@ -119,6 +134,7 @@ export function toServeOptions(settings: Settings, cwd: string): ServeOptions { cwd, effort: effort ? (requireEnum(effort, "effort") as Effort) : undefined, exec: asString(settings, "exec"), + host: host ? requireHost(host, "host") : undefined, json: asBoolean(settings, "json"), keepCsp: asBoolean(settings, "keep-csp"), maxBudgetUsd: budget ? requireAmount(budget, "max-budget") : undefined, @@ -233,13 +249,18 @@ async function assertFree(port: number): Promise { * whole ranges of dynamic ports, and a port inside one accepts no connection * yet refuses to be bound — so it looks free right up until it isn't. */ -function asBindError(err: unknown, port: number): unknown { +function asBindError(err: unknown, port: number, host: string): unknown { const code = (err as NodeJS.ErrnoException | null)?.code; if (code === "EADDRINUSE") { return new CliError(`Port ${port} is already in use`, { hint: "Pass --port with a free one.", }); } + if (code === "EADDRNOTAVAIL") { + return new CliError(`No interface on this machine has address ${host}`, { + hint: "Check --host — it must be one of this machine's addresses, or 0.0.0.0 for all of them.", + }); + } if (code === "EACCES") { return new CliError(`Not allowed to bind port ${port}`, { hint: "On Windows this port may sit in a reserved range (see `netsh interface ipv4 show excludedportrange tcp`). Pass --port with one outside it.", @@ -266,8 +287,10 @@ export const serve = defineCommand({ const targetPort = await resolveTarget(opts); // Default to target + 1, but step past anything already bound so a second - // airship in another project does not fail on EADDRINUSE. - const port = opts.port ?? (await firstFreePort(targetPort + 1)); + // airship in another project does not fail on EADDRINUSE. Probed on the + // bind host: a port free on ::1 can still be taken on 127.0.0.1. + const bindHost = opts.host ?? "127.0.0.1"; + const port = opts.port ?? (await firstFreePort(targetPort + 1, bindHost)); // Attaching to a remote server needs no local binary, so the PATH half of // `checkAuth` would be a false alarm there. @@ -304,10 +327,12 @@ export const serve = defineCommand({ try { server = await startServer({ agent: opts.agent, + allowedHosts: opts.allowedHosts, autoCommit: opts.autoCommit, codex: opts.codex, cwd: opts.cwd, effort: opts.effort, + host: opts.host, keepCsp: opts.keepCsp, maxBudgetUsd: opts.maxBudgetUsd, maxTurns: opts.maxTurns, @@ -323,7 +348,7 @@ export const serve = defineCommand({ // We started the dev server; if the proxy cannot come up it is ours to // clean up, or the user is left with a stray process holding the port. await dev?.stop(); - throw asBindError(err, port); + throw asBindError(err, port, bindHost); } if (opts.json) { @@ -332,6 +357,7 @@ export const serve = defineCommand({ { agent: opts.agent, cwd: opts.cwd, + host: bindHost, mode: opts.surface, // The resolved model for the backend that will run, so a scripted // caller can read back which of the four model flags won rather @@ -351,6 +377,7 @@ export const serve = defineCommand({ launchBanner({ agent: opts.agent, cwd: opts.cwd, + host: bindHost, model: opts.models?.[opts.agent], safe: opts.safe, surface: opts.surface, diff --git a/apps/cli/src/lib/args.test.ts b/apps/cli/src/lib/args.test.ts index fc0c35c..cf0d61c 100644 --- a/apps/cli/src/lib/args.test.ts +++ b/apps/cli/src/lib/args.test.ts @@ -4,6 +4,7 @@ import { collectRepeated, requireAmount, requireEnum, + requireHost, requireInteger, requireModelRef, requirePort, @@ -133,6 +134,28 @@ describe("collectRepeated", () => { }); }); +describe("requireHost", () => { + it("accepts IPv4, bare IPv6 and bracketed IPv6", () => { + expect(requireHost("127.0.0.1", "host")).toBe("127.0.0.1"); + expect(requireHost("0.0.0.0", "host")).toBe("0.0.0.0"); + // The naive colon check would reject both of these. + expect(requireHost("::1", "host")).toBe("::1"); + expect(requireHost("[::1]", "host")).toBe("::1"); + }); + + it("accepts a hostname and lowercases it", () => { + expect(requireHost("Dev.Local", "host")).toBe("dev.local"); + }); + + for (const bad of ["http://x", "localhost:3000", "a/b", "", "local host"]) { + it(`rejects ${JSON.stringify(bad)} with a usable hint`, () => { + const err = thrown(() => requireHost(bad, "host")); + expect(err.exitCode).toBe(EXIT.usage); + expect(err.hint).toContain("--port"); + }); + } +}); + describe("requireEnum", () => { it("passes a valid value through", () => { expect(requireEnum("inline", "mode")).toBe("inline"); diff --git a/apps/cli/src/lib/args.ts b/apps/cli/src/lib/args.ts index d8af5fd..78c6b82 100644 --- a/apps/cli/src/lib/args.ts +++ b/apps/cli/src/lib/args.ts @@ -16,6 +16,7 @@ * enums are declared as strings here and checked by `requireEnum`. */ +import net from "node:net"; import type { ArgsDef } from "citty"; import { CliError, didYouMean, EXIT } from "./errors"; @@ -79,6 +80,22 @@ export const FLAGS: readonly FlagSpec[] = [ name: "port", type: "string", }, + { + defaultHint: "127.0.0.1", + group: "CORE", + help: "Interface the airship proxy listens on. Loopback by default; 0.0.0.0 exposes an unauthenticated editor to your whole network.", + hint: "
", + name: "host", + type: "string", + }, + { + group: "CORE", + help: "Extra hostnames airship answers to, repeatable or comma-separated. localhost and IP addresses are always allowed; anything else is refused to block DNS rebinding.", + hint: "", + multiple: true, + name: "allowed-hosts", + type: "string", + }, { defaultHint: "current directory", group: "CORE", @@ -478,6 +495,34 @@ export function requirePort(value: string, name: string): number { return port; } +// A hostname as `--host`/`--allowed-hosts` accept one: letters, digits, dots, +// hyphens. Deliberately narrower than the wire format — schemes, ports and +// paths are the plausible user errors, and each would otherwise surface as a +// raw EADDRNOTAVAIL at listen time. +const HOSTNAME = /^[a-zA-Z0-9.-]+$/; + +/** + * A bind address or hostname: an IP literal (IPv6 bare or bracketed) or a + * plain name. `net.isIP` runs first — a naive colon check would reject `::1`. + */ +export function requireHost(value: string, name: string): string { + const trimmed = value.trim(); + const bare = + trimmed.startsWith("[") && trimmed.endsWith("]") + ? trimmed.slice(1, -1) + : trimmed; + if (net.isIP(bare) !== 0) { + return bare; + } + if (HOSTNAME.test(bare)) { + return bare.toLowerCase(); + } + throw new CliError(`Invalid --${name} '${value}'`, { + exitCode: EXIT.usage, + hint: "Give a bare address or hostname — no scheme, port or path. The port comes from --port.", + }); +} + export function requireInteger(value: string, name: string): number { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 1) { diff --git a/apps/cli/src/lib/banner.test.ts b/apps/cli/src/lib/banner.test.ts index e5e0d83..a84f17f 100644 --- a/apps/cli/src/lib/banner.test.ts +++ b/apps/cli/src/lib/banner.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { warnBackendLimits } from "./banner"; +import { exposureBanner, warnBackendLimits } from "./banner"; import { setColorEnabled } from "./terminal"; /* @@ -175,3 +175,19 @@ describe("warnBackendLimits", () => { expect(out).toBe(""); }); }); + +describe("exposureBanner", () => { + for (const host of ["127.0.0.1", "::1", "[::1]", "localhost"]) { + it(`stays silent for the loopback bind ${host}`, () => { + expect(exposureBanner(host)).toBe(""); + }); + } + + for (const host of ["0.0.0.0", "::", "192.168.1.5", "dev.local"]) { + it(`warns for ${host}, naming the interface`, () => { + const banner = exposureBanner(host); + expect(banner).toContain(host); + expect(banner).toContain("no authentication"); + }); + } +}); diff --git a/apps/cli/src/lib/banner.ts b/apps/cli/src/lib/banner.ts index 1401edc..54a8f8f 100644 --- a/apps/cli/src/lib/banner.ts +++ b/apps/cli/src/lib/banner.ts @@ -29,6 +29,19 @@ export function safetyBanner(agent: AgentKind, safe: boolean): string { ); } +/** Said whenever the bind reaches beyond loopback — there is no auth here. */ +export function exposureBanner(host: string): string { + const name = host.replace(/^\[|\]$/g, "").toLowerCase(); + if (name === "127.0.0.1" || name === "::1" || name === "localhost") { + return ""; + } + return ( + ` ${style.yellow("⚠ Exposed:")} listening on ${host} with no authentication. Anything that\n` + + " can reach this interface can drive a coding agent with write access to\n" + + " this project. Use only on networks where you trust every device.\n\n" + ); +} + /** Everything a backend silently will not do, said once at launch. */ export function warnBackendLimits(o: { agent: AgentKind; @@ -100,6 +113,8 @@ function surfaceLines(surface: AirshipSurface): string { export function launchBanner(info: { agent: AgentKind; cwd: string; + /** The interface the proxy is listening on, for the exposure warning. */ + host: string; /** The resolved model for `agent`, when one was configured. */ model?: string; safe: boolean; @@ -117,6 +132,7 @@ export function launchBanner(info: { `\n ${style.magenta("◆")} ${style.bold("airship")} — editing ${info.cwd} with ${info.agent}${on}\n` + ` → open ${style.cyan(info.url)}\n` + ` ${style.dim(`(proxying your dev server at http://localhost:${info.targetPort})`)}\n\n` + + exposureBanner(info.host) + // Stated at every launch, not just in --help: a tool that can write // anywhere on disk should say so where the user is actually looking. `${safetyBanner(info.agent, info.safe)}\n` + diff --git a/apps/web/src/content/faq.json b/apps/web/src/content/faq.json index 4d2d9fd..876422a 100644 --- a/apps/web/src/content/faq.json +++ b/apps/web/src/content/faq.json @@ -30,7 +30,7 @@ { "id": "safety", "question": "Is it safe to point at a real repository?", - "answer": "Airship runs locally and works directly with the repository you point it at. By default, your coding agent has the same filesystem and network access it normally has. Safe mode can restrict edits to your project and block potentially destructive commands." + "answer": "Airship runs locally and works directly with the repository you point it at. By default, your coding agent has the same filesystem and network access it normally has. Safe mode can restrict edits to your project and block potentially destructive commands. The editor itself listens only on localhost by default, so other devices and web pages can't reach it." }, { "id": "undo", @@ -50,7 +50,7 @@ { "id": "privacy", "question": "Does my code leave my machine?", - "answer": "Airship runs entirely on localhost. It has no account, telemetry, or hosted service, and your code isn't sent to Airship. Your chosen coding agent handles requests using the same credentials and provider it would use from your terminal." + "answer": "Airship runs entirely on localhost by default — it binds 127.0.0.1 and refuses other devices and cross-site pages unless you widen that with --host. It has no account, telemetry, or hosted service, and your code isn't sent to Airship. Your chosen coding agent handles requests using the same credentials and provider it would use from your terminal." } ] } diff --git a/packages/server/src/access.test.ts b/packages/server/src/access.test.ts new file mode 100644 index 0000000..8e1ca44 --- /dev/null +++ b/packages/server/src/access.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "vitest"; +import { + bindUrl, + buildAllowedHosts, + denyResponse, + isAllowedHost, + normalizeHostname, + originMatchesHost, + parseHostHeader, +} from "./access"; + +const NONE: ReadonlySet = new Set(); + +describe("parseHostHeader", () => { + it("parses a plain host and port", () => { + expect(parseHostHeader("localhost:3000")).toEqual({ + host: "localhost:3000", + hostname: "localhost", + }); + }); + + it("parses a bracketed IPv6 literal", () => { + expect(parseHostHeader("[::1]:4001")).toEqual({ + host: "[::1]:4001", + hostname: "::1", + }); + }); + + it("drops a default port the way URL does", () => { + expect(parseHostHeader("example.test:80")?.host).toBe("example.test"); + }); + + for (const bad of [ + undefined, + "", + "evil.com@localhost:3000", + "localhost/path", + "localhost:3000/x", + "local host", + "host\r\nx: y", + ]) { + it(`rejects ${JSON.stringify(bad)}`, () => { + expect(parseHostHeader(bad)).toBeNull(); + }); + } +}); + +describe("originMatchesHost", () => { + it("allows an absent Origin (curl, CLI, tests)", () => { + expect(originMatchesHost(undefined, "localhost:3000")).toBe(true); + }); + + it("allows a matching origin", () => { + expect(originMatchesHost("http://localhost:3000", "localhost:3000")).toBe( + true + ); + }); + + it("cancels default ports through the origin's own scheme", () => { + expect(originMatchesHost("http://localhost", "localhost:80")).toBe(true); + expect(originMatchesHost("https://x.ngrok.io", "x.ngrok.io:443")).toBe( + true + ); + expect(originMatchesHost("https://x.ngrok.io", "x.ngrok.io")).toBe(true); + }); + + it("is case-insensitive and tolerates a trailing dot mismatch in case", () => { + expect(originMatchesHost("http://LocalHost:3000", "localhost:3000")).toBe( + true + ); + }); + + it("canonicalises IPv6 spellings through URL", () => { + expect(originMatchesHost("http://[::1]:3000", "[::1]:3000")).toBe(true); + }); + + for (const [origin, host] of [ + ["http://evil.com", "localhost:3000"], + ["null", "localhost:3000"], + ["", "localhost:3000"], + ["file:///x", "localhost:3000"], + ["http://localhost:3000, http://evil.com", "localhost:3000"], + ["http://localhost:3001", "localhost:3000"], + ["http://evil.com@localhost:3000", "localhost:3000"], + ] as const) { + it(`denies origin ${JSON.stringify(origin)} against ${host}`, () => { + expect(originMatchesHost(origin, host)).toBe(false); + }); + } + + it("denies a comma-joined duplicate Origin arriving as an array", () => { + expect( + originMatchesHost( + ["http://localhost:3000", "http://evil.com"], + "localhost:3000" + ) + ).toBe(false); + }); + + it("denies when the Host itself does not parse", () => { + expect(originMatchesHost("http://localhost:3000", undefined)).toBe(false); + expect( + originMatchesHost("http://localhost:3000", "evil.com@localhost:3000") + ).toBe(false); + }); +}); + +describe("isAllowedHost", () => { + for (const name of [ + "localhost", + "127.0.0.1", + "::1", + "0.0.0.0", + "192.168.1.5", + ]) { + it(`always allows ${name}`, () => { + expect(isAllowedHost(name, NONE)).toBe(true); + }); + } + + it("allows a bracketed IPv6 literal", () => { + expect(isAllowedHost("[::1]", NONE)).toBe(true); + }); + + for (const name of ["evil.com", "evillocalhost", "127.1", "2130706433"]) { + it(`denies ${name} with an empty allowlist`, () => { + expect(isAllowedHost(name, NONE)).toBe(false); + }); + } + + it("matches an allowlist entry exactly, case-insensitively", () => { + const allowed = new Set(["myapp.test"]); + expect(isAllowedHost("MyApp.Test", allowed)).toBe(true); + expect(isAllowedHost("myapp.test.", allowed)).toBe(true); + }); + + it("never suffix-matches an allowlist entry", () => { + const allowed = new Set(["myapp.test"]); + expect(isAllowedHost("x.myapp.test", allowed)).toBe(false); + expect(isAllowedHost("evilmyapp.test", allowed)).toBe(false); + }); +}); + +describe("buildAllowedHosts", () => { + it("normalizes entries", () => { + expect(buildAllowedHosts([" MyApp.Test. ", ""], undefined)).toEqual( + new Set(["myapp.test"]) + ); + }); + + it("adds a named bind host so airship serves its own URL", () => { + expect(buildAllowedHosts([], "dev.local")).toEqual(new Set(["dev.local"])); + }); + + it("does not add an IP bind host — literals are always allowed anyway", () => { + expect(buildAllowedHosts([], "0.0.0.0")).toEqual(new Set()); + }); +}); + +describe("bindUrl", () => { + it("presents loopback and wildcard binds as localhost", () => { + expect(bindUrl("127.0.0.1", 4001)).toBe("http://localhost:4001"); + expect(bindUrl("0.0.0.0", 4001)).toBe("http://localhost:4001"); + expect(bindUrl("::", 4001)).toBe("http://localhost:4001"); + expect(bindUrl("localhost", 4001)).toBe("http://localhost:4001"); + }); + + it("brackets an IPv6 literal", () => { + expect(bindUrl("::1", 4001)).toBe("http://[::1]:4001"); + }); + + it("keeps a name", () => { + expect(bindUrl("dev.local", 4001)).toBe("http://dev.local:4001"); + }); +}); + +describe("the IPv6 loopback round trip", () => { + // --host ::1 → the printed URL → the Host header a browser then sends → + // back through the parser and the allowlist. Each hop feeds the next; a + // break anywhere strands the user who asked for the v6 stack. + it("survives bind → url → Host header → allow", () => { + const url = new URL(bindUrl("::1", 4001)); + expect(url.host).toBe("[::1]:4001"); + const parsed = parseHostHeader(url.host); + expect(parsed).not.toBeNull(); + expect(isAllowedHost(parsed?.hostname ?? "", NONE)).toBe(true); + expect(originMatchesHost(url.origin, url.host)).toBe(true); + }); +}); + +describe("normalizeHostname", () => { + it("lowercases, unbrackets and strips one trailing dot", () => { + expect(normalizeHostname("LOCALHOST.")).toBe("localhost"); + expect(normalizeHostname("[::1]")).toBe("::1"); + }); +}); + +describe("denyResponse", () => { + it("is a complete, well-terminated 403 that never echoes input", () => { + const bytes = denyResponse("Forbidden host"); + expect(bytes.startsWith("HTTP/1.1 403 Forbidden\r\n")).toBe(true); + expect(bytes).toContain("connection: close\r\n"); + expect(bytes).toContain("content-length: 15\r\n"); + expect(bytes.endsWith("\r\n\r\nForbidden host\n")).toBe(true); + }); +}); diff --git a/packages/server/src/access.ts b/packages/server/src/access.ts new file mode 100644 index 0000000..53efc1c --- /dev/null +++ b/packages/server/src/access.ts @@ -0,0 +1,193 @@ +/** + * Reachability predicates for the editor server: which `Host` headers it will + * serve, which `Origin`s may complete a WebSocket upgrade, and how a bind + * address is presented back to the user. Pure functions, no I/O — the proxy + * applies them, the tests enumerate them. + * + * Three checks, each stopping an attack the other two do not: the loopback + * bind stops the LAN attacker, the Origin gate stops cross-site WebSocket + * hijacking from another tab, and the Host allowlist stops DNS rebinding — + * under rebinding the attacker's page sends an Origin and Host that *match*, + * so only the Host gate catches it. This is a reachability boundary, not + * authentication: a non-loopback bind is still an unauthenticated agent. + */ +import net from "node:net"; + +// Everything a legal Host header can contain: hostname characters, a port +// separator, and brackets for an IPv6 literal. Anything else is rejected +// before URL parsing gets a chance to be clever with it. +const HOST_CHARSET = /^[\w.:[\]-]+$/; + +/** Lowercase, strip one layer of IPv6 brackets and one trailing dot. */ +export function normalizeHostname(hostname: string): string { + let out = hostname.toLowerCase(); + if (out.startsWith("[") && out.endsWith("]")) { + out = out.slice(1, -1); + } + if (out.endsWith(".")) { + out = out.slice(0, -1); + } + return out; +} + +export interface ParsedHost { + /** `hostname[:port]` exactly as `URL.host` reports it. */ + host: string; + /** The name alone, normalized: no brackets, no port, lowercase. */ + hostname: string; +} + +/** + * Parse a raw `Host` header defensively. Returns null — meaning deny — for + * anything a plain `hostname[:port]` would not produce: userinfo smuggling + * (`evil.com@localhost`), an embedded path, characters outside the charset, + * or nothing at all. + */ +export function parseHostHeader(raw: string | undefined): ParsedHost | null { + if (!(raw && HOST_CHARSET.test(raw))) { + return null; + } + let url: URL; + try { + url = new URL(`http://${raw}`); + } catch { + return null; + } + if (url.username || url.password || url.pathname !== "/" || !url.hostname) { + return null; + } + return { host: url.host, hostname: normalizeHostname(url.hostname) }; +} + +/** + * Does the `Origin` of a WebSocket handshake match the `Host` it was sent to? + * + * An *absent* Origin is allowed: no browser omits it on a WebSocket + * handshake, so this only admits curl/CLI/tests — the same trust level as + * "can read our HTTP responses". `Origin: null` (sandboxed iframe, `data:`, + * `file://`) parses as no URL at all and is denied; it must never be folded + * into the absent case. Node joins duplicate Origin headers with a comma, + * which also fails to parse — denied by construction. + * + * The Host header is parsed *with the Origin's own scheme* so default ports + * cancel: `http://localhost` matches `localhost:80`, and `https://x.ngrok.io` + * matches `x.ngrok.io:443`. A naive string compare would refuse both. + */ +export function originMatchesHost( + origin: string | string[] | undefined, + hostHeader: string | undefined +): boolean { + if (origin === undefined) { + return true; + } + if (Array.isArray(origin) || !hostHeader || !HOST_CHARSET.test(hostHeader)) { + return false; + } + let originUrl: URL; + try { + originUrl = new URL(origin); + } catch { + return false; + } + if (originUrl.protocol !== "http:" && originUrl.protocol !== "https:") { + return false; + } + // A real Origin is scheme://host[:port] and nothing else. Userinfo would + // otherwise smuggle a matching `.host` past the compare below. + if (originUrl.username || originUrl.password || originUrl.pathname !== "/") { + return false; + } + let hostUrl: URL; + try { + hostUrl = new URL(`${originUrl.protocol}//${hostHeader}`); + } catch { + return false; + } + if (hostUrl.username || hostUrl.password || hostUrl.pathname !== "/") { + return false; + } + return originUrl.host === hostUrl.host; +} + +/** + * Is this (already parsed) hostname one airship should answer for? + * + * An IP literal is always allowed — a literal cannot be DNS-rebound, which is + * the entire basis of the check — and so is bare `localhost`. Everything else + * must match the allowlist exactly: no wildcards and no suffix matching, + * which is where the CVEs live. + */ +export function isAllowedHost( + hostname: string, + allowed: ReadonlySet +): boolean { + const name = normalizeHostname(hostname); + if (net.isIP(name) !== 0) { + return true; + } + if (name === "localhost") { + return true; + } + return allowed.has(name); +} + +/** + * The allowlist the gates consult: `--allowed-hosts` entries, normalized, + * plus the configured bind host when it is a name — a named bind that is not + * in its own allowlist would 403 the very URL airship prints. + */ +export function buildAllowedHosts( + entries: readonly string[] | undefined, + host: string | undefined +): ReadonlySet { + const out = new Set(); + for (const entry of entries ?? []) { + const name = normalizeHostname(entry.trim()); + if (name) { + out.add(name); + } + } + if (host) { + const name = normalizeHostname(host); + if (net.isIP(name) === 0) { + out.add(name); + } + } + return out; +} + +/** + * A complete 403 as raw bytes, for refusing an upgrade before it upgrades — + * after `wss.handleUpgrade` there is no way to send a status. Written with + * `socket.end`, never `write` + `destroy`, which can truncate. The offending + * Host/Origin value is deliberately not echoed into the body. + */ +export function denyResponse(message: string): string { + const body = `${message}\n`; + return [ + "HTTP/1.1 403 Forbidden", + "connection: close", + `content-length: ${Buffer.byteLength(body)}`, + "content-type: text/plain; charset=utf-8", + "x-content-type-options: nosniff", + "", + body, + ].join("\r\n"); +} + +/** + * The URL the user should open for a given bind. Loopback and wildcard binds + * present as `localhost` — `0.0.0.0`/`::` are not connectable in a browser, + * and the printed URL must stay clickable. `::1` is connectable and prints + * bracketed, since the user who typed it meant exactly that stack. + */ +export function bindUrl(host: string, port: number): string { + const name = normalizeHostname(host); + if (name === "127.0.0.1" || name === "0.0.0.0" || name === "::") { + return `http://localhost:${port}`; + } + if (net.isIP(name) === 6) { + return `http://[${name}]:${port}`; + } + return `http://${name}:${port}`; +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index a840c01..cce73bf 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -48,6 +48,7 @@ import { } from "@airship/protocol"; import { scanProjectTokens } from "@airship/source/tokens"; import { type RawData, WebSocket, WebSocketServer } from "ws"; +import { bindUrl, buildAllowedHosts } from "./access"; import { listHistory, readBundle, thread, writeBundle } from "./history"; import { JobStore } from "./jobs"; import { openInEditor } from "./open-editor"; @@ -70,6 +71,11 @@ const WS_PATH = "/__airship/ws"; export interface ServerOptions { /** Default backend for turns that do not name one. */ agent?: AgentKind; + /** + * Hostnames the server answers for beyond `localhost` and IP literals — + * `/etc/hosts` aliases, tunnel names. Exact matches only. + */ + allowedHosts?: readonly string[]; /** Auto-commit each accepted edit with a Conventional-Commits message. */ autoCommit?: boolean; /** Codex-only passthrough knobs; opaque here by design. */ @@ -77,6 +83,12 @@ export interface ServerOptions { /** Project root for file edits. */ cwd: string; effort?: Effort; + /** + * Interface the proxy *listens* on — not `targetHost`, the upstream dev + * server it forwards to. Loopback by default: this is an unauthenticated + * server that drives a coding agent with write access to the project. + */ + host?: string; /** * Keep upstream `Content-Security-Policy` headers on served surfaces * instead of stripping them. `X-Frame-Options` is dropped regardless. @@ -126,6 +138,10 @@ export async function startServer(opts: ServerOptions): Promise { // `localhost` (::1) only, so a hardcoded IPv4 target gets ECONNREFUSED. Node's // happy-eyeballs (autoSelectFamily) then reaches whichever family it bound. const targetHost = opts.targetHost ?? "localhost"; + // Explicit, and never a wildcard by default — the same posture as the + // opencode server (opencode-server.ts): this is an unauthenticated HTTP + // server that can drive a coding agent with filesystem write access. + const host = opts.host ?? "127.0.0.1"; const { cwd } = opts; const jobs = new JobStore(); const clients = new Set(); @@ -584,6 +600,7 @@ export async function startServer(opts: ServerOptions): Promise { } const server = createProxyServer({ + allowedHosts: buildAllowedHosts(opts.allowedHosts, opts.host), defaultMode: surfaceToMode(opts.surface ?? "canvas"), keepCsp: opts.keepCsp, onAirshipUpgrade: (req, socket, head) => { @@ -613,7 +630,7 @@ export async function startServer(opts: ServerOptions): Promise { // airship started with `--exec` is never stopped either. await new Promise((resolve, reject) => { server.once("error", reject); - server.listen(opts.port, () => { + server.listen(opts.port, host, () => { server.removeListener("error", reject); resolve(); }); @@ -621,7 +638,7 @@ export async function startServer(opts: ServerOptions): Promise { const addr = server.address() as AddressInfo | null; const port = addr?.port ?? opts.port; - const url = `http://localhost:${port}`; + const url = bindUrl(host, port); return { close: () => diff --git a/packages/server/src/proxy.ts b/packages/server/src/proxy.ts index 3279a1e..468bb1c 100644 --- a/packages/server/src/proxy.ts +++ b/packages/server/src/proxy.ts @@ -23,6 +23,12 @@ import { readSurfaceCookie, surfaceToMode, } from "@airship/protocol"; +import { + denyResponse, + isAllowedHost, + originMatchesHost, + parseHostHeader, +} from "./access"; import { escapeForScript, shellHtml } from "./shell"; /** The only shape of font filename this server will read off disk. */ @@ -222,6 +228,12 @@ function tokensFontPath(name: string): string | null { } export interface ProxyDeps { + /** + * Hostnames the server answers for beyond `localhost` and IP literals, + * already normalized. The gate this feeds is what stops DNS rebinding — + * see `isAllowedHost`. + */ + allowedHosts: ReadonlySet; /** * Surface a top-level navigation gets when nothing overrides it — the launch * `--mode`, translated. Overridable per request by `?__airship=` and per @@ -246,7 +258,20 @@ export interface ProxyDeps { export function createProxyServer(deps: ProxyDeps): http.Server { const server = http.createServer((req, res) => handleHttp(req, res, deps)); + // One gate for both doors — the control socket and the HMR tunnel. The + // rejection happens before any upgrade completes, because afterwards there + // is no way to send a 403; and it covers the tunnel too, or a cross-site + // page could read the dev server's HMR traffic through us. server.on("upgrade", (req, socket, head) => { + const host = parseHostHeader(req.headers.host); + if (!(host && isAllowedHost(host.hostname, deps.allowedHosts))) { + socket.end(denyResponse("Forbidden host")); + return; + } + if (!originMatchesHost(req.headers.origin, req.headers.host)) { + socket.end(denyResponse("Forbidden origin")); + return; + } if (req.url === deps.wsPath) { deps.onAirshipUpgrade(req, socket, head); return; @@ -261,6 +286,20 @@ function handleHttp( res: http.ServerResponse, deps: ProxyDeps ): void { + // Host gate first, ahead of even the editor's own assets: under DNS + // rebinding the attacker's Origin and Host *match*, so this — not the + // Origin check — is what stands between a hostile page and the proxy. + const host = parseHostHeader(req.headers.host); + if (!(host && isAllowedHost(host.hostname, deps.allowedHosts))) { + res.writeHead(403, { + connection: "close", + "content-type": "text/plain; charset=utf-8", + "x-content-type-options": "nosniff", + }); + res.end("Forbidden host\n"); + return; + } + if (req.url?.startsWith("/__airship/")) { serveAirshipAsset(req, res); return;