From ffaa97edbf5bddf93a9b30a6fcc0e2bc56ccd264 Mon Sep 17 00:00:00 2001 From: Sudhir Verma <9924513+sudhirverma@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:15:06 +0530 Subject: [PATCH 1/2] fix(daemon): secure HTTP API by default Authenticate all HTTP routes, allowlist CORS, bind to 127.0.0.1, CSRF-protect the dashboard, and enforce session ownership so principals cannot enumerate or read each other's sessions. BREAKING CHANGE: unauthenticated HTTP clients and 0.0.0.0 bind no longer work without explicit opt-in (token / --host / ABBENAY_HTTP_AUTH). --- Containerfile | 4 +- README.md | 15 +- docs/CONFIGURATION.md | 56 +++ docs/CONTAINER.md | 55 ++- docs/GETTING_STARTED.md | 30 +- docs/ROADMAP.md | 4 +- docs/decisions.md | 40 ++ packages/daemon/src/core/config.ts | 27 ++ packages/daemon/src/core/constants.ts | 3 + packages/daemon/src/core/index.ts | 3 +- packages/daemon/src/core/paths.test.ts | 10 +- .../daemon/src/core/session-store.test.ts | 54 +++ packages/daemon/src/core/session-store.ts | 97 ++++ packages/daemon/src/daemon/chat.ts | 6 +- packages/daemon/src/daemon/index.ts | 38 +- .../src/daemon/server/abbenay-service.ts | 51 +- .../src/daemon/web/http-security.test.ts | 226 +++++++++ .../daemon/src/daemon/web/http-security.ts | 439 ++++++++++++++++++ .../daemon/src/daemon/web/openai-compat.ts | 4 +- packages/daemon/src/daemon/web/server.ts | 268 +++++++++-- packages/daemon/static/index.html | 72 +-- .../tests/integration/http-security.test.ts | 366 +++++++++++++++ .../tests/integration/openai-compat.test.ts | 5 +- .../daemon/tests/integration/sessions.test.ts | 5 +- .../daemon/tests/integration/web-sse.test.ts | 5 +- 25 files changed, 1747 insertions(+), 136 deletions(-) create mode 100644 packages/daemon/src/daemon/web/http-security.test.ts create mode 100644 packages/daemon/src/daemon/web/http-security.ts create mode 100644 packages/daemon/tests/integration/http-security.test.ts diff --git a/Containerfile b/Containerfile index c88fc7e..7760cb1 100644 --- a/Containerfile +++ b/Containerfile @@ -102,7 +102,7 @@ USER ${APP_USER} EXPOSE 8787 50051 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD curl -sf http://localhost:8787/api/health || exit 1 + CMD curl -sf -H "Authorization: Bearer ${ABBENAY_API_TOKEN}" http://127.0.0.1:8787/api/health || exit 1 ENTRYPOINT ["./abbenay"] -CMD ["start", "--port", "8787", "--grpc-port", "50051", "--grpc-host", "0.0.0.0"] +CMD ["start", "--port", "8787", "--host", "0.0.0.0", "--grpc-port", "50051", "--grpc-host", "0.0.0.0"] diff --git a/README.md b/README.md index 2ac1c2b..f3fccc9 100644 --- a/README.md +++ b/README.md @@ -130,14 +130,21 @@ Any tool that speaks the OpenAI protocol can use Abbenay as a backend: ```bash aby serve -p 8787 -# Then point your client at it: -curl http://localhost:8787/v1/models -curl http://localhost:8787/v1/chat/completions \ +# HTTP routes require a Bearer token (ABBENAY_API_TOKEN or auto-generated http-api-token): +curl -H "Authorization: Bearer $ABBENAY_API_TOKEN" http://127.0.0.1:8787/v1/models +curl -H "Authorization: Bearer $ABBENAY_API_TOKEN" \ + http://127.0.0.1:8787/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hello"}]}' ``` -Works with Cursor, Continue, aider, and any `openai` SDK script. +Works with Cursor, Continue, aider, and any `openai` SDK script (use the same +token as the client API key). The HTTP server binds to `127.0.0.1` by default; +use `--host 0.0.0.0` only when you intentionally expose it. + +> **WARNING:** Auth is on by default. `ABBENAY_HTTP_AUTH=0` disables it for +> local development only — do not use with a non-loopback bind. See +> [Configuration](docs/CONFIGURATION.md#http-api-security-server). ### Using the core library diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 8f20c49..0c68814 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -13,6 +13,14 @@ Abbenay uses YAML configuration files and system keychain for secrets. ## Config File Format ```yaml +# HTTP API security (optional overrides — secure defaults apply without this block) +server: + api_token_env: "ABBENAY_API_TOKEN" # preferred: token from env (never commit secrets) + # api_token: "..." # discouraged plaintext; prefer api_token_env + host: "127.0.0.1" # default bind; use 0.0.0.0 only intentionally + cors_origins: # extra allowed Origins (localhost always included) + - "https://my-trusted-app.example" + providers: my-openai: # Virtual provider name (user-defined) engine: openai # Engine type (see Supported Engines below) @@ -40,6 +48,54 @@ providers: model_id: "qwen2.5-coder:7b" # Map virtual name to actual model ID ``` +### HTTP API security (`server`) + +The web dashboard, REST API (`/api/*`), OpenAI-compatible API (`/v1/*`), and +MCP endpoint (`/mcp`) require authentication by default. + +| Setting / env | Purpose | Default | +|---------------|---------|---------| +| `ABBENAY_API_TOKEN` or `server.api_token` / `server.api_token_env` | Bearer token for all HTTP routes | Auto-generated and stored as `http-api-token` in the config directory | +| `ABBENAY_HTTP_AUTH` | Enable/disable HTTP auth | Enabled (`1` / unset). Set to `0`, `false`, `off`, `no`, or `disabled` to turn auth off | +| `ABBENAY_HTTP_HOST` or `server.host` or `--host` | HTTP bind address | `127.0.0.1` | +| `ABBENAY_CORS_ORIGINS` or `server.cors_origins` | Extra CORS allowed origins | `http://127.0.0.1:`, `http://localhost:` | + +Call APIs with: + +```bash +curl -H "Authorization: Bearer $ABBENAY_API_TOKEN" http://127.0.0.1:8787/api/health +``` + +The dashboard uses SameSite=Strict cookies plus a CSRF token for browser +session auth. Opening `http://127.0.0.1:8787/?token=` establishes the +session. Binding to `0.0.0.0` requires an explicit opt-in and logs a warning — +only do this when you intentionally expose the HTTP API (e.g. containers) +and have set a strong token. + +> **WARNING — disabling HTTP auth:** Auth is **on by default**. For throwaway +> local development only you may set `ABBENAY_HTTP_AUTH=0`. That allows any +> process (and any website that can reach the bind address) to call the +> daemon and read/write secrets, config, chat, MCP, and sessions. The server +> logs a loud warning when auth is disabled. **Do not** combine +> `ABBENAY_HTTP_AUTH=0` with `--host 0.0.0.0` or any non-loopback bind. +> Prefer keeping auth enabled and using a local token instead. + +### Session ownership + +Every session is stamped with an `owner` principal: + +| Surface | Owner | +|---------|--------| +| CLI (`aby chat` / `aby sessions`) | `local` | +| HTTP API (Bearer / dashboard cookie) | `http:` | +| HTTP + `X-Abbenay-Session-Owner: ` | `http::` | +| gRPC with matching consumer token | `consumer:` | +| gRPC without consumer token | `local` | + +List/get/delete/chat only return sessions for the caller's owner. Cross-owner +access returns 404 (not 403) so session IDs are not leaked across principals. +Legacy sessions without an `owner` field are treated as `local`. + ### Key Concepts - **Virtual provider name** - The YAML key (e.g., `my-openai`). User-defined, must be lowercase alphanumeric with dots, hyphens, or underscores. diff --git a/docs/CONTAINER.md b/docs/CONTAINER.md index 9c269ac..6539914 100644 --- a/docs/CONTAINER.md +++ b/docs/CONTAINER.md @@ -99,6 +99,7 @@ Mount this file into the container at: podman run -d --name abbenay \ -v ./config.yaml:/home/abbenay/.config/abbenay/config.yaml:ro \ -e OPENROUTER_API_KEY=sk-or-... \ + -e ABBENAY_API_TOKEN=change-me \ -p 8787:8787 \ -p 50051:50051 \ abbenay:latest @@ -110,6 +111,7 @@ With consumer authentication (for programmatic clients like APME): podman run -d --name abbenay \ -v ./config.yaml:/home/abbenay/.config/abbenay/config.yaml:ro \ -e OPENROUTER_API_KEY=sk-or-... \ + -e ABBENAY_API_TOKEN=change-me \ -e APME_TOKEN=secret123 \ -p 8787:8787 \ -p 50051:50051 \ @@ -119,9 +121,12 @@ podman run -d --name abbenay \ ### Verify it's running ```bash -curl http://localhost:8787/api/health +curl -H "Authorization: Bearer $ABBENAY_API_TOKEN" http://127.0.0.1:8787/api/health ``` +Set `ABBENAY_API_TOKEN` in the container environment (required for the +built-in healthcheck and all HTTP routes). + ### View logs ```bash @@ -132,9 +137,10 @@ podman logs -f abbenay ## Overriding the command -The default `CMD` is `start --port 8787 --grpc-port 50051 --grpc-host 0.0.0.0`, -which runs all services with gRPC accessible from outside the container. -You can override it to run a subset: +The default `CMD` is +`start --port 8787 --host 0.0.0.0 --grpc-port 50051 --grpc-host 0.0.0.0`, +which runs all services with HTTP and gRPC accessible from outside the +container. You can override it to run a subset: ```bash # Web dashboard and REST API only @@ -312,29 +318,44 @@ spec: - The image runs as non-root user `abbenay` (UID 1001), compatible with OpenShift's restricted SCC. -- The built-in `HEALTHCHECK` uses `curl` against `/api/health`. In - Kubernetes, use the `livenessProbe` and `readinessProbe` shown above - instead. +- The built-in `HEALTHCHECK` uses `curl` against `/api/health` with + `Authorization: Bearer ${ABBENAY_API_TOKEN}`. Set that env var when + running the container. In Kubernetes, use the `livenessProbe` and + `readinessProbe` shown above instead (include the same Authorization + header). - Sessions are ephemeral by default. To persist sessions across restarts, mount a volume at `/home/abbenay/.local/share/abbenay/sessions/`. --- -## Security: `--grpc-host` +## Security: HTTP and gRPC bind hosts -The `--grpc-host` flag controls which network interface the TCP gRPC -listener binds to: +| Flag | Default | Effect | +|------|---------|--------| +| `--host` / `ABBENAY_HTTP_HOST` / `server.host` | `127.0.0.1` | HTTP bind (dashboard, `/api/*`, `/v1/*`, `/mcp`) | +| `--grpc-host` | `127.0.0.1` | gRPC TCP bind | | Value | Effect | |-------|--------| -| `127.0.0.1` (default) | Loopback only -- safe for local development | -| `0.0.0.0` | All interfaces -- required inside containers so that published ports are reachable | +| `127.0.0.1` (default) | Loopback only — safe for local development | +| `0.0.0.0` | All interfaces — required inside containers so published ports are reachable | + +The container's default `CMD` uses `--host 0.0.0.0` and `--grpc-host 0.0.0.0` +because container networking requires listeners to accept connections from +outside the container's network namespace. The daemon logs a warning when +HTTP is bound beyond loopback. + +**HTTP authentication is on by default.** Set `ABBENAY_API_TOKEN` (or +`server.api_token` / `server.api_token_env`) and pass +`Authorization: Bearer ` on every request. CORS is allowlist-only +(never `*`). -The container's default `CMD` uses `--grpc-host 0.0.0.0` because -container networking requires the listener to accept connections from -outside the container's network namespace. The daemon logs a warning -when `0.0.0.0` is used without consumer authentication. +> **WARNING:** `ABBENAY_HTTP_AUTH=0` disables HTTP auth for local development +> only. Never use it with `--host 0.0.0.0` in containers or on shared hosts — +> the API would be reachable without credentials. **Recommendation:** When exposing gRPC outside a trusted network, configure a `consumers` section in `config.yaml` to require -token-based authentication on every RPC. +token-based authentication on every RPC. When exposing HTTP, always set +a strong `ABBENAY_API_TOKEN` and restrict `server.cors_origins`. Keep +`ABBENAY_HTTP_AUTH` enabled (the default). diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index b7fa724..8d6cd16 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -230,15 +230,18 @@ Start the server: aby serve -p 8787 ``` -Then point any OpenAI-compatible client at it: +HTTP routes require a Bearer token (`ABBENAY_API_TOKEN`, `server.api_token`, or +the auto-generated `http-api-token` in your config directory): ```bash # List models -curl http://localhost:8787/v1/models +curl -H "Authorization: Bearer $ABBENAY_API_TOKEN" \ + http://127.0.0.1:8787/v1/models # Chat (streaming) -curl http://localhost:8787/v1/chat/completions \ +curl -H "Authorization: Bearer $ABBENAY_API_TOKEN" \ -H "Content-Type: application/json" \ + http://127.0.0.1:8787/v1/chat/completions \ -d '{ "model": "my-openai/gpt-4o", "messages": [{"role": "user", "content": "Hello!"}], @@ -246,12 +249,24 @@ curl http://localhost:8787/v1/chat/completions \ }' ``` +Point any OpenAI-compatible client at `http://127.0.0.1:8787/v1` and use the +same token as the API key. + +> **WARNING:** HTTP auth is **enabled by default**. For throwaway local +> development only you may set `ABBENAY_HTTP_AUTH=0` to skip Bearer tokens. +> The server logs a warning. Never combine that with `--host 0.0.0.0`. +> Prefer keeping auth on and using `ABBENAY_API_TOKEN` instead. + ### With the OpenAI Python SDK ```python from openai import OpenAI +import os -client = OpenAI(base_url="http://localhost:8787/v1", api_key="unused") +client = OpenAI( + base_url="http://127.0.0.1:8787/v1", + api_key=os.environ["ABBENAY_API_TOKEN"], +) response = client.chat.completions.create( model="my-openai/gpt-4o", messages=[{"role": "user", "content": "Hello!"}], @@ -261,8 +276,8 @@ print(response.choices[0].message.content) ### With Cursor / Continue / aider -Set the API base URL to `http://localhost:8787/v1` in your tool's -settings. The API key field can be any non-empty string. +Set the API base URL to `http://127.0.0.1:8787/v1` in your tool's +settings. Use your Abbenay HTTP API token as the API key. --- @@ -272,7 +287,8 @@ settings. The API key field can be any non-empty string. aby web ``` -Open http://localhost:8787 in your browser to: +Open http://127.0.0.1:8787 in your browser (loopback clients get a session +automatically; or open the `/?token=...` URL printed at startup) to: - Add and configure providers - Store API keys in the system keychain diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d545715..7c4ca08 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -121,7 +121,7 @@ aider, any `openai` SDK script, etc.). See DR-020. | `POST /v1/completions` | low — legacy | | `POST /v1/embeddings` | low — if engines support it | | Usage/token stats (real counts from providers) | medium | -| Optional Bearer token auth (`config.yaml → server.api_key`) | medium | +| Bearer token auth on all HTTP routes (`server.api_token` / `ABBENAY_API_TOKEN`) | **done** | | Rate limiting | low | ### Key files @@ -133,7 +133,7 @@ aider, any `openai` SDK script, etc.). See DR-020. ### Milestones - **M1**: `/v1/models` + `/v1/chat/completions` (streaming + non-streaming + tools) — **done** -- **M2**: Usage stats, optional auth +- **M2**: Usage stats, HTTP Bearer auth — **auth done** (usage stats remaining) - **M3**: Rate limiting, `/v1/completions` (legacy) --- diff --git a/docs/decisions.md b/docs/decisions.md index dff3513..8f2e7f8 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -407,3 +407,43 @@ fine for early adopters but a barrier to organic adoption. Publishing to both VS Code Marketplace and OpenVSX ensures coverage for VS Code and compatible editors (Eclipse Theia, VSCodium, Gitpod). Gating alpha releases prevents incomplete builds from reaching end users. + +--- + +## DR-029: Secure-by-default HTTP API + +**Date:** 2026-07-14 +**Decision:** Require Bearer (or SameSite cookie) authentication on all HTTP +routes (`/api/*`, `/v1/*`, `/mcp`) by default, restrict CORS to an explicit +origin allowlist (never `*`), and bind the HTTP server to `127.0.0.1` by +default. Non-localhost bind requires explicit opt-in (`--host`, +`ABBENAY_HTTP_HOST`, or `server.host`). The API token resolves from +`ABBENAY_API_TOKEN` / `server.api_token` / `server.api_token_env`, or is +auto-generated and persisted as `http-api-token` in the config directory. +The dashboard uses `SameSite=Strict` cookies plus a CSRF token for browser +state-changing requests. For local development only, `ABBENAY_HTTP_AUTH=0` +(or `false`/`off`/`no`/`disabled`) turns auth off and logs a loud warning; +it must not be combined with a non-loopback bind. +**Rationale:** The previous defaults (no auth, `Access-Control-Allow-Origin: *`, +`app.listen(port)` → `0.0.0.0`) allowed any website the user visited to +cross-origin call the daemon and read/write secrets, config, chat, MCP, and +sessions. Secure-by-default closes that gap while keeping intentional network +exposure possible for containers with an explicit opt-in and a strong token. +An env-var escape hatch keeps local DX workable without baking an insecure +default back into production paths. + +--- + +## DR-030: Session ownership principals + +**Date:** 2026-07-14 +**Decision:** Stamp every session with an `owner` principal and enforce +owner-scoped list/get/delete/chat on HTTP and gRPC. Principals are +`local` (CLI / local gRPC), `http:` (HTTP API token, with +optional `X-Abbenay-Session-Owner` claim), or `consumer:` (gRPC consumer +token). Legacy sessions without `owner` are treated as `local`. Cross-owner +access returns "not found". +**Rationale:** Authentication alone (DR-029) blocks anonymous access but does +not isolate sessions between authenticated principals sharing one daemon. +Ownership closes H9: HTTP clients, CLI, and named consumers cannot enumerate +or read each other's conversation history. diff --git a/packages/daemon/src/core/config.ts b/packages/daemon/src/core/config.ts index 7a9c408..e3be352 100644 --- a/packages/daemon/src/core/config.ts +++ b/packages/daemon/src/core/config.ts @@ -135,6 +135,31 @@ export interface ConsumerConfig { capabilities: ConsumerCapabilities; } +/** + * HTTP / web server security settings. + * Secure defaults live in code (localhost bind + required Bearer auth); + * this block overrides them when intentional network exposure is needed. + */ +export interface ServerConfig { + /** + * HTTP API Bearer token. Prefer `api_token_env` or the `ABBENAY_API_TOKEN` + * environment variable so the secret is not stored in plaintext YAML. + */ + api_token?: string; + /** Environment variable name holding the HTTP API Bearer token */ + api_token_env?: string; + /** + * Host/IP to bind the HTTP server. + * Default: 127.0.0.1. Use 0.0.0.0 only with an explicit opt-in (CLI/env/config). + */ + host?: string; + /** + * Extra CORS allowed origins (in addition to http://127.0.0.1: + * and http://localhost:). + */ + cors_origins?: string[]; +} + /** * Full configuration file structure. * Keys in `providers` are virtual provider names (user-defined IDs). @@ -147,6 +172,8 @@ export interface ConfigFile { tool_policy?: import('./tool-registry.js').ToolPolicyConfig; /** Consumer applications with token-based auth and capability gating */ consumers?: Record; + /** HTTP / web dashboard server security */ + server?: ServerConfig; } // ── Path helpers ─────────────────────────────────────────────────────── diff --git a/packages/daemon/src/core/constants.ts b/packages/daemon/src/core/constants.ts index 08309af..fd9512b 100644 --- a/packages/daemon/src/core/constants.ts +++ b/packages/daemon/src/core/constants.ts @@ -7,3 +7,6 @@ /** Default HTTP port for the web dashboard and API server. */ export const DEFAULT_WEB_PORT = 8787; + +/** Default HTTP bind host (loopback only — opt in to 0.0.0.0 explicitly). */ +export const DEFAULT_HTTP_HOST = '127.0.0.1'; diff --git a/packages/daemon/src/core/index.ts b/packages/daemon/src/core/index.ts index 7020bdd..d20c54d 100644 --- a/packages/daemon/src/core/index.ts +++ b/packages/daemon/src/core/index.ts @@ -153,7 +153,8 @@ export { } from './policies.js'; /** Shared constants */ -export { DEFAULT_WEB_PORT } from './constants.js'; +export { DEFAULT_WEB_PORT, DEFAULT_HTTP_HOST } from './constants.js'; +export type { ServerConfig } from './config.js'; /** Platform-aware path utilities */ export { diff --git a/packages/daemon/src/core/paths.test.ts b/packages/daemon/src/core/paths.test.ts index 98044fa..4b26c3a 100644 --- a/packages/daemon/src/core/paths.test.ts +++ b/packages/daemon/src/core/paths.test.ts @@ -13,9 +13,9 @@ import { getWorkspaceConfigPath, getUserPoliciesPath, } from './paths.js'; -import { DEFAULT_WEB_PORT } from './constants.js'; +import { DEFAULT_WEB_PORT, DEFAULT_HTTP_HOST } from './constants.js'; -// ── DEFAULT_WEB_PORT ───────────────────────────────────────────────────────── +// ── DEFAULT_WEB_PORT / DEFAULT_HTTP_HOST ───────────────────────────────────── describe('DEFAULT_WEB_PORT', () => { it('should be 8787', () => { @@ -23,6 +23,12 @@ describe('DEFAULT_WEB_PORT', () => { }); }); +describe('DEFAULT_HTTP_HOST', () => { + it('should be 127.0.0.1', () => { + expect(DEFAULT_HTTP_HOST).toBe('127.0.0.1'); + }); +}); + // ── getRuntimeDir ──────────────────────────────────────────────────────────── describe('getRuntimeDir', () => { diff --git a/packages/daemon/src/core/session-store.test.ts b/packages/daemon/src/core/session-store.test.ts index e9045b8..823d12d 100644 --- a/packages/daemon/src/core/session-store.test.ts +++ b/packages/daemon/src/core/session-store.test.ts @@ -65,6 +65,60 @@ describe('SessionStore.create', () => { expect(session.policy).toBe('strict'); expect(session.metadata).toEqual({ workspace: '/tmp' }); }); + + it('assigns local owner by default', async () => { + const session = await store.create('openai/gpt-4o'); + expect(session.owner).toBe('local'); + }); + + it('stores explicit owner', async () => { + const session = await store.create('openai/gpt-4o', 'Owned', undefined, undefined, 'http:abc'); + expect(session.owner).toBe('http:abc'); + const index = JSON.parse(fs.readFileSync(path.join(tmpDir, 'index.json'), 'utf-8')); + expect(index.sessions[0].owner).toBe('http:abc'); + }); +}); + +describe('SessionStore ownership', () => { + it('list filters by owner', async () => { + await store.create('openai/gpt-4o', 'Local', undefined, undefined, 'local'); + await store.create('openai/gpt-4o', 'Http', undefined, undefined, 'http:tok'); + const local = await store.list({ owner: 'local' }); + const http = await store.list({ owner: 'http:tok' }); + expect(local.sessions).toHaveLength(1); + expect(local.sessions[0].title).toBe('Local'); + expect(http.sessions).toHaveLength(1); + expect(http.sessions[0].title).toBe('Http'); + }); + + it('getOwned rejects other owners without leaking', async () => { + const session = await store.create('openai/gpt-4o', 'Secret', undefined, undefined, 'http:a'); + await expect(store.getOwned(session.id, 'http:b')).rejects.toThrow(`Session not found: ${session.id}`); + }); + + it('deleteOwned rejects other owners', async () => { + const session = await store.create('openai/gpt-4o', 'Keep', undefined, undefined, 'http:a'); + await expect(store.deleteOwned(session.id, 'http:b')).rejects.toThrow('Session not found'); + const still = await store.get(session.id, false); + expect(still.id).toBe(session.id); + }); + + it('treats legacy sessions without owner as local', async () => { + const session = await store.create('openai/gpt-4o', 'Legacy'); + // Simulate legacy file missing owner + const filePath = path.join(tmpDir, `${session.id}.json`); + const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + delete raw.owner; + fs.writeFileSync(filePath, JSON.stringify(raw)); + const indexPath = path.join(tmpDir, 'index.json'); + const index = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); + delete index.sessions[0].owner; + fs.writeFileSync(indexPath, JSON.stringify(index)); + + const listed = await store.list({ owner: 'local' }); + expect(listed.sessions).toHaveLength(1); + await expect(store.getOwned(session.id, 'local')).resolves.toMatchObject({ id: session.id }); + }); }); describe('SessionStore.get', () => { diff --git a/packages/daemon/src/core/session-store.ts b/packages/daemon/src/core/session-store.ts index 1b597e7..8394393 100644 --- a/packages/daemon/src/core/session-store.ts +++ b/packages/daemon/src/core/session-store.ts @@ -4,13 +4,74 @@ * Each session is stored as `.json` in the sessions directory. * An `index.json` file provides fast listing without reading every session file. * + * Sessions are owned by a principal string (e.g. "local", "http:", + * "consumer:apme"). Callers must filter/check ownership — see + * resolveSessionOwner / assertSessionOwner. + * * Core layer (no transport dependencies). */ +import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; import type { ChatMessage } from './engines.js'; +// ── Ownership ─────────────────────────────────────────────────────────── + +/** Owner for CLI / local unix-socket / unauthenticated-local access. */ +export const LOCAL_SESSION_OWNER = 'local'; + +/** Optional HTTP sub-owner header (scopes sessions under the API token). */ +export const SESSION_OWNER_HEADER = 'x-abbenay-session-owner'; + +const OWNER_CLAIM_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/; + +/** + * Stable HTTP principal derived from the API token fingerprint. + * Different tokens get different owners; the same token always matches. + */ +export function ownerIdFromHttpToken(token: string): string { + const hash = crypto.createHash('sha256').update(token, 'utf8').digest('hex').slice(0, 16); + return `http:${hash}`; +} + +/** + * Build the HTTP session owner from the API token and optional claim header. + */ +export function resolveHttpSessionOwner( + apiToken: string, + ownerClaim?: string | null, +): string { + const base = ownerIdFromHttpToken(apiToken); + const claim = ownerClaim?.trim().toLowerCase(); + if (claim && OWNER_CLAIM_RE.test(claim)) { + return `${base}:${claim}`; + } + return base; +} + +/** Normalize owner for a session (legacy sessions without owner → local). */ +export function resolveSessionOwner(session: { owner?: string }): string { + return session.owner?.trim() || LOCAL_SESSION_OWNER; +} + +/** + * Throw if the caller does not own the session. + * Uses "not found" wording to avoid leaking existence across owners. + */ +export function assertSessionOwner( + session: { id: string; owner?: string }, + owner: string, +): void { + if (resolveSessionOwner(session) !== owner) { + throw new Error(`Session not found: ${session.id}`); + } +} + +export function isValidOwnerClaim(claim: string): boolean { + return OWNER_CLAIM_RE.test(claim.trim().toLowerCase()); +} + // ── Data types ────────────────────────────────────────────────────────── export interface Session { @@ -22,6 +83,11 @@ export interface Session { createdAt: string; updatedAt: string; metadata: Record; + /** + * Principal that owns this session (e.g. "local", "http:"). + * Missing on legacy sessions — treated as LOCAL_SESSION_OWNER. + */ + owner?: string; parentSessionId?: string; forkPoint?: number; summary?: string; @@ -37,12 +103,16 @@ export interface SessionSummary { createdAt: string; updatedAt: string; summary?: string; + /** Principal that owns this session (legacy entries omit → treated as local). */ + owner?: string; } export interface SessionListOptions { model?: string; limit?: number; offset?: number; + /** When set, only return sessions owned by this principal. */ + owner?: string; } export interface SessionListResult { @@ -75,12 +145,14 @@ export class SessionStore { title?: string, policy?: string, metadata?: Record, + owner: string = LOCAL_SESSION_OWNER, ): Promise { return this.withWriteLock(async () => { await this.ensureDir(); const id = crypto.randomUUID(); const now = new Date().toISOString(); + const sessionOwner = owner.trim() || LOCAL_SESSION_OWNER; const session: Session = { id, @@ -91,6 +163,7 @@ export class SessionStore { createdAt: now, updatedAt: now, metadata: metadata || {}, + owner: sessionOwner, }; await this.writeSession(session); @@ -101,6 +174,7 @@ export class SessionStore { messageCount: 0, createdAt: now, updatedAt: now, + owner: sessionOwner, }); return session; @@ -123,6 +197,16 @@ export class SessionStore { return session; } + /** + * Get a session only if it belongs to `owner`. + * Throws Session not found when missing or owned by someone else. + */ + async getOwned(id: string, owner: string, includeMessages = true): Promise { + const session = await this.get(id, includeMessages); + assertSessionOwner(session, owner); + return session; + } + async list(options?: SessionListOptions): Promise { const index = await this.readIndex(); let sessions = index.sessions; @@ -130,6 +214,12 @@ export class SessionStore { // Sort by updatedAt descending sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + if (options?.owner !== undefined) { + sessions = sessions.filter( + (s) => resolveSessionOwner(s) === options.owner, + ); + } + if (options?.model) { sessions = sessions.filter((s) => s.model === options.model); } @@ -162,6 +252,13 @@ export class SessionStore { }); } + /** Delete only if owned by `owner`. */ + async deleteOwned(id: string, owner: string): Promise { + const session = await this.get(id, false); + assertSessionOwner(session, owner); + await this.delete(id); + } + async appendMessage(id: string, message: ChatMessage): Promise { return this.withWriteLock(async () => { const session = await this.get(id, true); diff --git a/packages/daemon/src/daemon/chat.ts b/packages/daemon/src/daemon/chat.ts index c700dfc..a085b47 100644 --- a/packages/daemon/src/daemon/chat.ts +++ b/packages/daemon/src/daemon/chat.ts @@ -15,7 +15,7 @@ import * as readline from 'node:readline'; import { startDaemon } from './daemon.js'; import { isDaemonRunningSync } from './transport.js'; import { DaemonState } from './state.js'; -import { SessionStore } from '../core/session-store.js'; +import { SessionStore, LOCAL_SESSION_OWNER } from '../core/session-store.js'; import { maybeSummarize } from '../core/session-summarizer.js'; import type { ChatToolOptions } from '../core/state.js'; @@ -64,12 +64,12 @@ export async function runInteractiveChat(options: ChatOptions): Promise { console.error('--model is required when creating a new session'); process.exit(1); } - const session = await store.create(model); + const session = await store.create(model, undefined, undefined, undefined, LOCAL_SESSION_OWNER); sessionId = session.id; console.error(`${DIM}Created session: ${sessionId}${RESET}`); } else { try { - const session = await store.get(options.session, true); + const session = await store.getOwned(options.session, LOCAL_SESSION_OWNER, true); sessionId = session.id; model = session.model; } catch { diff --git a/packages/daemon/src/daemon/index.ts b/packages/daemon/src/daemon/index.ts index 69f09ed..1d3ecdd 100644 --- a/packages/daemon/src/daemon/index.ts +++ b/packages/daemon/src/daemon/index.ts @@ -22,6 +22,7 @@ import { startEmbeddedWebServer } from './web/server.js'; import { getEngines, fetchModels } from '../core/engines.js'; import { DEFAULT_WEB_PORT } from '../core/constants.js'; import { VERSION } from '../version.js'; +import { resolveHttpApiToken } from './web/http-security.js'; function printTable(headers: string[], rows: string[][]): void { const widths = headers.map((h, i) => @@ -97,6 +98,7 @@ program interface ServerOptions { port: number; + host?: string; mcp?: boolean; grpcPort?: number; grpcHost?: string; @@ -118,7 +120,9 @@ function validatePort(raw: string): number { } async function runServer(opts: ServerOptions): Promise { - const { port, mcp, grpcPort, grpcHost, bannerLines } = opts; + const { port, host, mcp, grpcPort, grpcHost, bannerLines } = opts; + const { token: apiToken, source: tokenSource } = resolveHttpApiToken(); + const authEnabled = tokenSource !== 'disabled'; if (isDaemonRunningSync()) { console.log('Daemon is running, requesting web server start via gRPC...'); @@ -131,7 +135,14 @@ async function runServer(opts: ServerOptions): Promise { try { const http = await import('node:http'); await new Promise((resolve, reject) => { - const req = http.request(`${result.url}/api/mcp-server/start`, { method: 'POST' }, (res) => { + const headers: Record = {}; + if (authEnabled && apiToken) { + headers.Authorization = `Bearer ${apiToken}`; + } + const req = http.request(`${result.url}/api/mcp-server/start`, { + method: 'POST', + headers, + }, (res) => { res.resume(); res.on('end', () => resolve()); }); @@ -159,7 +170,7 @@ async function runServer(opts: ServerOptions): Promise { } else { console.log('No daemon running, starting in-process...'); const daemonState = await startDaemon({ keepAlive: false, grpcPort, grpcHost }); - const { url, app } = await startEmbeddedWebServer(daemonState, port); + const { url, app, security } = await startEmbeddedWebServer(daemonState, port, host); if (mcp && app) { await daemonState.mcpServer.start(app); @@ -168,6 +179,9 @@ async function runServer(opts: ServerOptions): Promise { for (const line of (bannerLines?.(url, !!mcp) ?? [`Server: ${url}`])) { console.log(line); } + if (security.tokenSource === 'generated' || security.generated) { + console.log(`API token file: use Authorization: Bearer (see http-api-token in config dir)`); + } console.log('Press Ctrl+C to stop'); await new Promise(() => {}); @@ -180,6 +194,7 @@ program .command('start') .description('Start all services (daemon, web dashboard, OpenAI API, MCP server)') .option('-p, --port ', 'Port to listen on', String(DEFAULT_WEB_PORT)) + .option('--host ', 'Host/IP to bind HTTP listener (default: 127.0.0.1, use 0.0.0.0 for containers)') .option('--grpc-port ', 'Also listen for gRPC on this TCP port (for remote/container access)') .option('--grpc-host ', 'Host/IP to bind gRPC TCP listener (default: 127.0.0.1, use 0.0.0.0 for containers)') .action(async (options) => { @@ -189,6 +204,7 @@ program const grpcHost = options.grpcHost; await runServer({ port, + host: options.host || undefined, mcp: true, grpcPort, grpcHost, @@ -219,6 +235,7 @@ program .command('web') .description('Start web dashboard') .option('-p, --port ', 'Port to listen on', String(DEFAULT_WEB_PORT)) + .option('--host ', 'Host/IP to bind HTTP listener (default: 127.0.0.1, use 0.0.0.0 for containers)') .option('--grpc-port ', 'Also listen for gRPC on this TCP port (for remote/container access)') .option('--grpc-host ', 'Host/IP to bind gRPC TCP listener (default: 127.0.0.1, use 0.0.0.0 for containers)') .option('--mcp', 'Start MCP server on /mcp endpoint') @@ -227,6 +244,7 @@ program try { await runServer({ port, + host: options.host || undefined, mcp: options.mcp, grpcPort: options.grpcPort ? validatePort(options.grpcPort) : undefined, grpcHost: options.grpcHost, @@ -247,6 +265,7 @@ program .command('serve') .description('Start OpenAI-compatible API server (serves /v1/models, /v1/chat/completions)') .option('-p, --port ', 'Port to listen on', String(DEFAULT_WEB_PORT)) + .option('--host ', 'Host/IP to bind HTTP listener (default: 127.0.0.1, use 0.0.0.0 for containers)') .option('--grpc-port ', 'Also listen for gRPC on this TCP port (for remote/container access)') .option('--grpc-host ', 'Host/IP to bind gRPC TCP listener (default: 127.0.0.1, use 0.0.0.0 for containers)') .option('--mcp', 'Start MCP server on /mcp endpoint') @@ -255,6 +274,7 @@ program try { await runServer({ port, + host: options.host || undefined, mcp: options.mcp, grpcPort: options.grpcPort ? validatePort(options.grpcPort) : undefined, grpcHost: options.grpcHost, @@ -308,12 +328,13 @@ sessions .option('--limit ', 'Max results', '20') .option('--json', 'Output as JSON') .action(async (options) => { - const { SessionStore } = await import('../core/session-store.js'); + const { SessionStore, LOCAL_SESSION_OWNER } = await import('../core/session-store.js'); const { getSessionsDir } = await import('../core/paths.js'); const store = new SessionStore(getSessionsDir()); const result = await store.list({ model: options.model, limit: Number(options.limit), + owner: LOCAL_SESSION_OWNER, }); if (options.json) { @@ -339,18 +360,19 @@ sessions .description('Show session messages') .option('--json', 'Output as JSON') .action(async (id: string, options) => { - const { SessionStore } = await import('../core/session-store.js'); + const { SessionStore, LOCAL_SESSION_OWNER } = await import('../core/session-store.js'); const { getSessionsDir } = await import('../core/paths.js'); const store = new SessionStore(getSessionsDir()); try { - const session = await store.get(id); + const session = await store.getOwned(id, LOCAL_SESSION_OWNER); if (options.json) { console.log(JSON.stringify(session, null, 2)); } else { console.log(`Session: ${session.id}`); console.log(`Title: ${session.title}`); console.log(`Model: ${session.model}`); + console.log(`Owner: ${session.owner || LOCAL_SESSION_OWNER}`); console.log(`Created: ${session.createdAt}`); console.log(`Updated: ${session.updatedAt}`); console.log(`Messages: ${session.messages.length}`); @@ -373,12 +395,12 @@ sessions .command('delete ') .description('Delete a session') .action(async (id: string) => { - const { SessionStore } = await import('../core/session-store.js'); + const { SessionStore, LOCAL_SESSION_OWNER } = await import('../core/session-store.js'); const { getSessionsDir } = await import('../core/paths.js'); const store = new SessionStore(getSessionsDir()); try { - await store.delete(id); + await store.deleteOwned(id, LOCAL_SESSION_OWNER); console.log(`Deleted session: ${id}`); } catch { console.error(`Session not found: ${id}`); diff --git a/packages/daemon/src/daemon/server/abbenay-service.ts b/packages/daemon/src/daemon/server/abbenay-service.ts index 2b734d1..be5c02b 100644 --- a/packages/daemon/src/daemon/server/abbenay-service.ts +++ b/packages/daemon/src/daemon/server/abbenay-service.ts @@ -20,6 +20,7 @@ import { } from '../../core/config.js'; import { DEFAULT_WEB_PORT } from '../../core/constants.js'; import { getProviderTemplates } from '../../core/engines.js'; +import { LOCAL_SESSION_OWNER } from '../../core/session-store.js'; interface ProtoClientInfo { client_type?: string | number; @@ -1046,7 +1047,8 @@ export function createAbbenayService(state: DaemonState) { callback({ code: grpc.status.INVALID_ARGUMENT, message: 'model is required' }); return; } - state.sessionStore.create(model, topic || undefined, undefined, metadata).then((session) => { + const owner = resolveGrpcSessionOwner(call, loadConfig()); + state.sessionStore.create(model, topic || undefined, undefined, metadata, owner).then((session) => { callback(null, sessionToProto(session)); }).catch((error: unknown) => { callback({ code: grpc.status.INTERNAL, message: error instanceof Error ? error.message : String(error) }); @@ -1063,7 +1065,8 @@ export function createAbbenayService(state: DaemonState) { callback({ code: grpc.status.INVALID_ARGUMENT, message: 'session_id is required' }); return; } - state.sessionStore.get(id, includeMessages).then((session) => { + const owner = resolveGrpcSessionOwner(call, loadConfig()); + state.sessionStore.getOwned(id, owner, includeMessages).then((session) => { callback(null, sessionToProto(session)); }).catch((error: unknown) => { callback({ code: grpc.status.NOT_FOUND, message: error instanceof Error ? error.message : String(error) }); @@ -1079,7 +1082,8 @@ export function createAbbenayService(state: DaemonState) { const rawOffset = call.request.offset; const limit = rawLimit == null || rawLimit < 0 ? undefined : rawLimit; const offset = rawOffset == null || rawOffset < 0 ? undefined : rawOffset; - state.sessionStore.list({ model, limit, offset }).then((result) => { + const owner = resolveGrpcSessionOwner(call, loadConfig()); + state.sessionStore.list({ model, limit, offset, owner }).then((result) => { callback(null, { sessions: result.sessions.map(summaryToProto), total_count: result.totalCount, @@ -1098,7 +1102,8 @@ export function createAbbenayService(state: DaemonState) { callback({ code: grpc.status.INVALID_ARGUMENT, message: 'session_id is required' }); return; } - state.sessionStore.delete(id).then(async () => { + const owner = resolveGrpcSessionOwner(call, loadConfig()); + state.sessionStore.deleteOwned(id, owner).then(async () => { // Clean up session-scoped dynamic MCP servers await state.mcpClientPool.disconnectByScope(id); state.toolRegistry?.clearSessionScope(id); @@ -1170,7 +1175,8 @@ export function createAbbenayService(state: DaemonState) { (async () => { try { - const session = await state.sessionStore.get(sessionId, true); + const owner = resolveGrpcSessionOwner(call, loadConfig()); + const session = await state.sessionStore.getOwned(sessionId, owner, true); await state.sessionStore.appendMessage(sessionId, chatMessage); const allMessages = [...session.messages, chatMessage]; @@ -1278,7 +1284,8 @@ export function createAbbenayService(state: DaemonState) { (async () => { try { - const session = await state.sessionStore.get(sessionId, true); + const owner = resolveGrpcSessionOwner(call, loadConfig()); + const session = await state.sessionStore.getOwned(sessionId, owner, true); const userCount = session.messages.filter((m) => m.role === 'user').length; if (session.summary && session.summaryMessageCount === userCount) { @@ -2053,6 +2060,38 @@ function transportProtoToConfig(transport: McpTransportProto): McpServerConfig { // ── Consumer authorization (DR-024 / DR-025) ────────────────────────── +/** + * Resolve the session owner principal for a gRPC call. + * - Matching consumer token → `consumer:` + * - Otherwise (local CLI / VS Code / no consumers) → `local` + */ +export function resolveGrpcSessionOwner( + call: { metadata: grpc.Metadata }, + config: ConfigFile | null, +): string { + const consumers = config?.consumers; + if (!consumers || Object.keys(consumers).length === 0) { + return LOCAL_SESSION_OWNER; + } + + const metadata = call.metadata.get('x-abbenay-token'); + const token = metadata.length > 0 ? String(metadata[0]) : undefined; + if (!token) { + return LOCAL_SESSION_OWNER; + } + + for (const [name, consumer] of Object.entries(consumers)) { + const expectedToken = consumer.token_env + ? process.env[consumer.token_env] + : undefined; + if (expectedToken && token === expectedToken) { + return `consumer:${name}`; + } + } + + return LOCAL_SESSION_OWNER; +} + /** @internal Exported for testing. */ export interface AuthResult { allowed: boolean; diff --git a/packages/daemon/src/daemon/web/http-security.test.ts b/packages/daemon/src/daemon/web/http-security.test.ts new file mode 100644 index 0000000..7e4f50b --- /dev/null +++ b/packages/daemon/src/daemon/web/http-security.test.ts @@ -0,0 +1,226 @@ +/** + * Unit tests for HTTP API security helpers. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { + isLocalhostBind, + isHttpAuthEnabled, + resolveHttpApiToken, + resolveHttpHost, + resolveCorsOrigins, + extractBearerToken, + getCookie, +} from './http-security.js'; +import { + ownerIdFromHttpToken, + resolveHttpSessionOwner, + resolveSessionOwner, + assertSessionOwner, +} from '../../core/session-store.js'; +import type { Request } from 'express'; + +describe('isLocalhostBind', () => { + it('accepts loopback addresses', () => { + expect(isLocalhostBind('127.0.0.1')).toBe(true); + expect(isLocalhostBind('::1')).toBe(true); + expect(isLocalhostBind('localhost')).toBe(true); + expect(isLocalhostBind(' 127.0.0.1 ')).toBe(true); + }); + + it('rejects non-loopback', () => { + expect(isLocalhostBind('0.0.0.0')).toBe(false); + expect(isLocalhostBind('192.168.1.1')).toBe(false); + }); +}); + +describe('isHttpAuthEnabled', () => { + const prev = process.env.ABBENAY_HTTP_AUTH; + + afterEach(() => { + if (prev === undefined) delete process.env.ABBENAY_HTTP_AUTH; + else process.env.ABBENAY_HTTP_AUTH = prev; + }); + + it('defaults to enabled', () => { + delete process.env.ABBENAY_HTTP_AUTH; + expect(isHttpAuthEnabled({ skipConfig: true })).toBe(true); + }); + + it('treats 1/true/on as enabled', () => { + for (const v of ['1', 'true', 'TRUE', 'on', 'yes']) { + process.env.ABBENAY_HTTP_AUTH = v; + expect(isHttpAuthEnabled()).toBe(true); + } + }); + + it('disables for 0/false/off/no/disabled', () => { + for (const v of ['0', 'false', 'FALSE', 'off', 'no', 'disabled']) { + process.env.ABBENAY_HTTP_AUTH = v; + expect(isHttpAuthEnabled()).toBe(false); + } + }); + + it('options.authEnabled overrides env', () => { + process.env.ABBENAY_HTTP_AUTH = '0'; + expect(isHttpAuthEnabled({ authEnabled: true })).toBe(true); + process.env.ABBENAY_HTTP_AUTH = '1'; + expect(isHttpAuthEnabled({ authEnabled: false })).toBe(false); + }); +}); + +describe('resolveHttpHost', () => { + const prev = process.env.ABBENAY_HTTP_HOST; + + afterEach(() => { + if (prev === undefined) delete process.env.ABBENAY_HTTP_HOST; + else process.env.ABBENAY_HTTP_HOST = prev; + }); + + it('defaults to 127.0.0.1', () => { + delete process.env.ABBENAY_HTTP_HOST; + expect(resolveHttpHost(undefined, null, { skipConfig: true })).toBe('127.0.0.1'); + }); + + it('prefers explicit host over env', () => { + process.env.ABBENAY_HTTP_HOST = '0.0.0.0'; + expect(resolveHttpHost('127.0.0.1', null, { skipConfig: true })).toBe('127.0.0.1'); + }); + + it('uses env when no explicit host', () => { + process.env.ABBENAY_HTTP_HOST = '0.0.0.0'; + expect(resolveHttpHost(undefined, null, { skipConfig: true })).toBe('0.0.0.0'); + }); + + it('uses config server.host', () => { + delete process.env.ABBENAY_HTTP_HOST; + expect(resolveHttpHost(undefined, { server: { host: '0.0.0.0' } }, { skipConfig: true })) + .toBe('0.0.0.0'); + }); +}); + +describe('resolveCorsOrigins', () => { + const prev = process.env.ABBENAY_CORS_ORIGINS; + + afterEach(() => { + if (prev === undefined) delete process.env.ABBENAY_CORS_ORIGINS; + else process.env.ABBENAY_CORS_ORIGINS = prev; + }); + + it('includes localhost defaults for the port', () => { + delete process.env.ABBENAY_CORS_ORIGINS; + const origins = resolveCorsOrigins(8787, { skipConfig: true }, null); + expect(origins).toContain('http://127.0.0.1:8787'); + expect(origins).toContain('http://localhost:8787'); + }); + + it('merges env and options', () => { + process.env.ABBENAY_CORS_ORIGINS = 'https://app.example.com,https://other.example'; + const origins = resolveCorsOrigins(9000, { + skipConfig: true, + corsOrigins: ['https://extra.example'], + }, null); + expect(origins).toContain('https://app.example.com'); + expect(origins).toContain('https://other.example'); + expect(origins).toContain('https://extra.example'); + expect(origins).toContain('http://127.0.0.1:9000'); + }); +}); + +describe('resolveHttpApiToken', () => { + const prevToken = process.env.ABBENAY_API_TOKEN; + + afterEach(() => { + if (prevToken === undefined) delete process.env.ABBENAY_API_TOKEN; + else process.env.ABBENAY_API_TOKEN = prevToken; + delete process.env.MY_HTTP_TOKEN; + }); + + it('uses explicit options token', () => { + const r = resolveHttpApiToken({ apiToken: 'opt-token', skipConfig: true }); + expect(r.token).toBe('opt-token'); + expect(r.source).toBe('options'); + }); + + it('uses ABBENAY_API_TOKEN env', () => { + process.env.ABBENAY_API_TOKEN = 'env-token'; + const r = resolveHttpApiToken({ skipConfig: true }); + expect(r.token).toBe('env-token'); + expect(r.source).toBe('env'); + }); + + it('uses config api_token', () => { + delete process.env.ABBENAY_API_TOKEN; + const r = resolveHttpApiToken( + { skipConfig: true }, + { server: { api_token: 'cfg-token' } }, + ); + expect(r.token).toBe('cfg-token'); + expect(r.source).toBe('config'); + }); + + it('uses config api_token_env', () => { + delete process.env.ABBENAY_API_TOKEN; + process.env.MY_HTTP_TOKEN = 'named-env'; + const r = resolveHttpApiToken( + { skipConfig: true }, + { server: { api_token_env: 'MY_HTTP_TOKEN' } }, + ); + expect(r.token).toBe('named-env'); + expect(r.source).toBe('config_env'); + }); + + it('returns empty token when auth is disabled', () => { + const r = resolveHttpApiToken({ authEnabled: false, skipConfig: true }); + expect(r.token).toBe(''); + expect(r.source).toBe('disabled'); + expect(r.generated).toBe(false); + }); +}); + +describe('extractBearerToken / getCookie', () => { + it('parses Bearer header', () => { + const req = { headers: { authorization: 'Bearer secret123' } } as Request; + expect(extractBearerToken(req)).toBe('secret123'); + }); + + it('returns null without Bearer', () => { + const req = { headers: { authorization: 'Basic x' } } as Request; + expect(extractBearerToken(req)).toBeNull(); + }); + + it('parses cookies', () => { + const req = { + headers: { cookie: 'foo=bar; abbenay_api_token=tok%2B1; other=1' }, + } as Request; + expect(getCookie(req, 'abbenay_api_token')).toBe('tok+1'); + expect(getCookie(req, 'missing')).toBeNull(); + }); +}); + +describe('session owner helpers', () => { + it('fingerprints HTTP tokens stably', () => { + const a = ownerIdFromHttpToken('same-token'); + const b = ownerIdFromHttpToken('same-token'); + const c = ownerIdFromHttpToken('other-token'); + expect(a).toBe(b); + expect(a).toMatch(/^http:[0-9a-f]{16}$/); + expect(a).not.toBe(c); + }); + + it('appends validated owner claims', () => { + const base = ownerIdFromHttpToken('tok'); + expect(resolveHttpSessionOwner('tok', 'my-app')).toBe(`${base}:my-app`); + expect(resolveHttpSessionOwner('tok', 'BAD CLAIM')).toBe(base); + expect(resolveHttpSessionOwner('tok', null)).toBe(base); + }); + + it('treats missing owner as local', () => { + expect(resolveSessionOwner({})).toBe('local'); + expect(resolveSessionOwner({ owner: 'http:x' })).toBe('http:x'); + }); + + it('assertSessionOwner throws not-found for wrong owner', () => { + expect(() => assertSessionOwner({ id: 'abc', owner: 'a' }, 'b')).toThrow('Session not found: abc'); + }); +}); diff --git a/packages/daemon/src/daemon/web/http-security.ts b/packages/daemon/src/daemon/web/http-security.ts new file mode 100644 index 0000000..3408f51 --- /dev/null +++ b/packages/daemon/src/daemon/web/http-security.ts @@ -0,0 +1,439 @@ +/** + * HTTP API security helpers — Bearer auth, CORS allowlist, bind host, CSRF. + * + * Secure defaults: + * - Every /api/*, /v1/*, and /mcp request requires a valid Bearer token (or + * SameSite=Strict session cookie with CSRF checks for cookie-only auth). + * - CORS allows only an explicit origin allowlist (never *). + * - HTTP bind defaults to 127.0.0.1. + */ + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { NextFunction, Request, Response } from 'express'; +import { loadConfig, type ConfigFile, type ServerConfig } from '../../core/config.js'; +import { getConfigDir } from '../../core/paths.js'; +import { DEFAULT_HTTP_HOST } from '../../core/constants.js'; +import { + LOCAL_SESSION_OWNER, + resolveHttpSessionOwner, + SESSION_OWNER_HEADER, +} from '../../core/session-store.js'; + +export const API_TOKEN_COOKIE = 'abbenay_api_token'; +export const CSRF_COOKIE = 'abbenay_csrf'; +export const CSRF_HEADER = 'x-csrf-token'; +export const TOKEN_FILE_NAME = 'http-api-token'; + +/** Express request extension for the authenticated session owner principal. */ +export type RequestWithOwner = Request & { abbenayOwner?: string }; + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +export interface ResolvedHttpSecurity { + apiToken: string; + host: string; + corsOrigins: string[]; + /** False when ABBENAY_HTTP_AUTH disables auth (local-dev escape hatch). */ + authEnabled: boolean; + /** True when the token was freshly generated and persisted */ + generated: boolean; + /** Source of the token for logging */ + tokenSource: 'env' | 'config' | 'config_env' | 'file' | 'generated' | 'options' | 'disabled'; +} + +export interface WebSecurityOptions { + /** Explicit API token (tests / callers). When set, skips file/env resolution. */ + apiToken?: string; + /** Explicit CORS allowlist. Merged with localhost defaults for the listen port. */ + corsOrigins?: string[]; + /** Listen port — used to build default localhost CORS origins. */ + port?: number; + /** Explicit bind host override (also used for cookie auto-establish policy). */ + host?: string; + /** + * Force auth on/off (tests). When unset, follows ABBENAY_HTTP_AUTH + * (default: enabled). + */ + authEnabled?: boolean; + /** Skip loading user config (tests). */ + skipConfig?: boolean; +} + +/** Env values that disable HTTP auth (local-dev escape hatch). */ +const AUTH_DISABLE_VALUES = new Set(['0', 'false', 'off', 'no', 'disabled']); + +/** + * Return true when HTTP auth is enabled (the secure default). + * + * Set `ABBENAY_HTTP_AUTH=0` (or false/off/no/disabled) to turn auth off for + * local development only. Never disable auth when binding beyond loopback. + */ +export function isHttpAuthEnabled(options?: WebSecurityOptions): boolean { + if (options?.authEnabled !== undefined) { + return options.authEnabled; + } + const raw = process.env.ABBENAY_HTTP_AUTH?.trim().toLowerCase(); + if (raw && AUTH_DISABLE_VALUES.has(raw)) { + return false; + } + return true; +} + +/** + * Return true when the bind address is loopback-only. + */ +export function isLocalhostBind(host: string): boolean { + const h = host.trim().toLowerCase(); + return h === '127.0.0.1' || h === '::1' || h === 'localhost'; +} + +/** + * Path to the persisted auto-generated HTTP API token. + */ +export function getHttpApiTokenPath(): string { + return path.join(getConfigDir(), TOKEN_FILE_NAME); +} + +function readPersistedToken(): string | null { + const tokenPath = getHttpApiTokenPath(); + try { + if (!fs.existsSync(tokenPath)) return null; + const raw = fs.readFileSync(tokenPath, 'utf-8').trim(); + return raw.length > 0 ? raw : null; + } catch { + return null; + } +} + +function persistToken(token: string): void { + const dir = getConfigDir(); + fs.mkdirSync(dir, { recursive: true }); + const tokenPath = getHttpApiTokenPath(); + fs.writeFileSync(tokenPath, `${token}\n`, { encoding: 'utf-8', mode: 0o600 }); + try { + fs.chmodSync(tokenPath, 0o600); + } catch { + // Windows may not support chmod the same way + } +} + +function serverConfig(config: ConfigFile | null): ServerConfig { + return config?.server ?? {}; +} + +/** + * Resolve the HTTP API token from options → env → config → persisted file → generate. + * When auth is disabled, returns an empty token without generating a file. + */ +export function resolveHttpApiToken( + options?: WebSecurityOptions, + config?: ConfigFile | null, +): { token: string; source: ResolvedHttpSecurity['tokenSource']; generated: boolean } { + if (!isHttpAuthEnabled(options)) { + return { token: '', source: 'disabled', generated: false }; + } + + if (options?.apiToken) { + return { token: options.apiToken, source: 'options', generated: false }; + } + + const envToken = process.env.ABBENAY_API_TOKEN?.trim(); + if (envToken) { + return { token: envToken, source: 'env', generated: false }; + } + + const cfg = config === undefined + ? (options?.skipConfig ? null : loadConfig()) + : config; + const server = serverConfig(cfg); + + if (server.api_token?.trim()) { + return { token: server.api_token.trim(), source: 'config', generated: false }; + } + + if (server.api_token_env?.trim()) { + const fromNamedEnv = process.env[server.api_token_env.trim()]?.trim(); + if (fromNamedEnv) { + return { token: fromNamedEnv, source: 'config_env', generated: false }; + } + } + + const persisted = readPersistedToken(); + if (persisted) { + return { token: persisted, source: 'file', generated: false }; + } + + const generated = crypto.randomBytes(32).toString('base64url'); + persistToken(generated); + return { token: generated, source: 'generated', generated: true }; +} + +/** + * Resolve HTTP bind host: options/CLI → env → config → default 127.0.0.1. + */ +export function resolveHttpHost( + explicitHost?: string, + config?: ConfigFile | null, + options?: WebSecurityOptions, +): string { + if (explicitHost?.trim()) return explicitHost.trim(); + if (options?.host?.trim()) return options.host.trim(); + + const envHost = process.env.ABBENAY_HTTP_HOST?.trim(); + if (envHost) return envHost; + + const cfg = config === undefined + ? (options?.skipConfig ? null : loadConfig()) + : config; + const fromConfig = serverConfig(cfg).host?.trim(); + if (fromConfig) return fromConfig; + + return DEFAULT_HTTP_HOST; +} + +/** + * Build the CORS origin allowlist. + */ +export function resolveCorsOrigins( + port: number, + options?: WebSecurityOptions, + config?: ConfigFile | null, +): string[] { + const origins = new Set(); + + // Always allow same-machine dashboard origins for the listen port + origins.add(`http://127.0.0.1:${port}`); + origins.add(`http://localhost:${port}`); + + for (const o of options?.corsOrigins ?? []) { + if (o.trim()) origins.add(o.trim()); + } + + const envList = process.env.ABBENAY_CORS_ORIGINS; + if (envList) { + for (const o of envList.split(',')) { + if (o.trim()) origins.add(o.trim()); + } + } + + const cfg = config === undefined + ? (options?.skipConfig ? null : loadConfig()) + : config; + for (const o of serverConfig(cfg).cors_origins ?? []) { + if (o.trim()) origins.add(o.trim()); + } + + return [...origins]; +} + +/** + * Resolve full HTTP security settings for the web server. + */ +export function resolveHttpSecurity( + port: number, + explicitHost?: string, + options?: WebSecurityOptions, +): ResolvedHttpSecurity { + const config = options?.skipConfig ? null : loadConfig(); + const authEnabled = isHttpAuthEnabled(options); + const { token, source, generated } = resolveHttpApiToken(options, config); + return { + apiToken: token, + host: resolveHttpHost(explicitHost, config, options), + corsOrigins: resolveCorsOrigins(port, options, config), + authEnabled, + generated, + tokenSource: source, + }; +} + +function timingSafeEqualString(a: string, b: string): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) { + // Compare against self to keep timing roughly constant + crypto.timingSafeEqual(bufA, bufA); + return false; + } + return crypto.timingSafeEqual(bufA, bufB); +} + +/** + * Extract Bearer token from Authorization header. + */ +export function extractBearerToken(req: Request): string | null { + const header = req.headers.authorization; + if (!header || typeof header !== 'string') return null; + const match = /^Bearer\s+(.+)$/i.exec(header.trim()); + return match?.[1]?.trim() || null; +} + +/** + * Parse a cookie value by name from the Cookie header. + */ +export function getCookie(req: Request, name: string): string | null { + const raw = req.headers.cookie; + if (!raw) return null; + for (const part of raw.split(';')) { + const idx = part.indexOf('='); + if (idx === -1) continue; + const key = part.slice(0, idx).trim(); + if (key === name) { + return decodeURIComponent(part.slice(idx + 1).trim()); + } + } + return null; +} + +function requestOrigin(req: Request): string | null { + const origin = req.headers.origin; + if (typeof origin === 'string' && origin.length > 0) return origin; + + const referer = req.headers.referer; + if (typeof referer === 'string' && referer.length > 0) { + try { + return new URL(referer).origin; + } catch { + return null; + } + } + return null; +} + +function isOriginAllowed(origin: string | null, allowlist: string[], req: Request): boolean { + // No Origin/Referer: non-browser clients (curl, SDKs) — allowed when Bearer is used. + if (!origin) return true; + if (allowlist.includes(origin)) return true; + + // Same-origin relative to the Host header (dashboard) + const host = req.headers.host; + if (host) { + const proto = (req.headers['x-forwarded-proto'] as string) || 'http'; + const selfOrigin = `${proto}://${host}`; + if (origin === selfOrigin) return true; + } + return false; +} + +/** + * CORS middleware with an explicit origin allowlist (never *). + */ +export function createCorsMiddleware(allowlist: string[]) { + return (req: Request, res: Response, next: NextFunction): void => { + const origin = typeof req.headers.origin === 'string' ? req.headers.origin : null; + + if (origin) { + if (!allowlist.includes(origin)) { + // Reject foreign origins for both preflight and actual requests + if (req.method === 'OPTIONS') { + res.sendStatus(403); + return; + } + res.status(403).json({ error: 'Origin not allowed' }); + return; + } + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + } + + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.setHeader( + 'Access-Control-Allow-Headers', + `Content-Type, Authorization, ${CSRF_HEADER}, ${SESSION_OWNER_HEADER}`, + ); + + if (req.method === 'OPTIONS') { + res.sendStatus(204); + return; + } + next(); + }; +} + +/** + * Auth + CSRF middleware for protected HTTP routes. + * + * When `authEnabled` is false (ABBENAY_HTTP_AUTH disable escape hatch), all + * requests pass through without checking credentials. + * + * Accepts: + * 1. Authorization: Bearer + * 2. SameSite cookie abbenay_api_token (browser dashboard) — mutating + * methods also require a matching CSRF header/cookie or allowlisted Origin. + */ +export function createAuthMiddleware( + apiToken: string, + corsOrigins: string[], + authEnabled: boolean = true, +) { + return (req: Request, res: Response, next: NextFunction): void => { + const reqWithOwner = req as RequestWithOwner; + + if (!authEnabled) { + reqWithOwner.abbenayOwner = LOCAL_SESSION_OWNER; + next(); + return; + } + + const bearer = extractBearerToken(req); + const cookieToken = getCookie(req, API_TOKEN_COOKIE); + + const bearerOk = bearer !== null && timingSafeEqualString(bearer, apiToken); + const cookieOk = cookieToken !== null && timingSafeEqualString(cookieToken, apiToken); + + if (!bearerOk && !cookieOk) { + res.setHeader('WWW-Authenticate', 'Bearer'); + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + // Cookie-only auth on state-changing methods needs CSRF protection. + // Bearer auth is CSRF-safe (custom header cannot be set cross-origin + // without a CORS preflight that we reject for foreign origins). + if (cookieOk && !bearerOk && !SAFE_METHODS.has(req.method)) { + const csrfHeader = req.headers[CSRF_HEADER]; + const csrfCookie = getCookie(req, CSRF_COOKIE); + const csrfOk = + typeof csrfHeader === 'string' && + csrfCookie !== null && + timingSafeEqualString(csrfHeader, csrfCookie); + + const origin = requestOrigin(req); + const originOk = isOriginAllowed(origin, corsOrigins, req) && origin !== null; + + if (!csrfOk && !originOk) { + res.status(403).json({ error: 'CSRF validation failed' }); + return; + } + } + + const claimRaw = req.headers[SESSION_OWNER_HEADER]; + const claim = typeof claimRaw === 'string' ? claimRaw : undefined; + reqWithOwner.abbenayOwner = resolveHttpSessionOwner(apiToken, claim); + next(); + }; +} + +/** + * Set SameSite=Strict auth + CSRF cookies for the dashboard. + */ +export function setAuthCookies(res: Response, apiToken: string): string { + const csrf = crypto.randomBytes(24).toString('base64url'); + const tokenCookie = `${API_TOKEN_COOKIE}=${encodeURIComponent(apiToken)}; Path=/; HttpOnly; SameSite=Strict`; + const csrfCookie = `${CSRF_COOKIE}=${encodeURIComponent(csrf)}; Path=/; SameSite=Strict`; + // Not HttpOnly so dashboard JS can send X-CSRF-Token + res.append('Set-Cookie', tokenCookie); + res.append('Set-Cookie', csrfCookie); + return csrf; +} + +/** + * Clear auth cookies. + */ +export function clearAuthCookies(res: Response): void { + res.append('Set-Cookie', `${API_TOKEN_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict`); + res.append('Set-Cookie', `${CSRF_COOKIE}=; Path=/; Max-Age=0; SameSite=Strict`); +} diff --git a/packages/daemon/src/daemon/web/openai-compat.ts b/packages/daemon/src/daemon/web/openai-compat.ts index c2821dd..8b98ca0 100644 --- a/packages/daemon/src/daemon/web/openai-compat.ts +++ b/packages/daemon/src/daemon/web/openai-compat.ts @@ -119,8 +119,8 @@ function openAIError(res: Response, status: number, message: string, type: strin // ── Route registration ────────────────────────────────────────────────── -// Future M2: add optional Bearer token auth here by reading config.yaml -> server.api_key -// and checking the Authorization header before each route handler. +// Auth is enforced globally in createWebApp() via Bearer / SameSite cookie middleware. +// See http-security.ts and config.yaml → server.api_token / ABBENAY_API_TOKEN. export function registerOpenAIRoutes(app: Express, state: DaemonState): void { /** diff --git a/packages/daemon/src/daemon/web/server.ts b/packages/daemon/src/daemon/web/server.ts index da4c73c..1d4d0e7 100644 --- a/packages/daemon/src/daemon/web/server.ts +++ b/packages/daemon/src/daemon/web/server.ts @@ -9,7 +9,7 @@ * - Stopped via Ctrl+C, `abbenay web` exit, or gRPC StopWebServer */ -import express, { type Express } from 'express'; +import express, { type Express, type Request, type Response } from 'express'; import * as crypto from 'node:crypto'; import * as http from 'node:http'; import * as path from 'node:path'; @@ -22,8 +22,24 @@ import { maybeSummarize, generateSessionSummary } from '../../core/session-summa import type { DaemonState } from '../state.js'; import type { ChatToolOptions } from '../../core/state.js'; import { getEngines, getProviderTemplates } from '../../core/engines.js'; -import { DEFAULT_WEB_PORT } from '../../core/constants.js'; +import { DEFAULT_WEB_PORT, DEFAULT_HTTP_HOST } from '../../core/constants.js'; import { registerOpenAIRoutes } from './openai-compat.js'; +import { + createAuthMiddleware, + createCorsMiddleware, + resolveHttpSecurity, + setAuthCookies, + getCookie, + API_TOKEN_COOKIE, + CSRF_COOKIE, + isLocalhostBind, + type WebSecurityOptions, + type ResolvedHttpSecurity, + type RequestWithOwner, +} from './http-security.js'; +import { LOCAL_SESSION_OWNER } from '../../core/session-store.js'; + +export type { WebSecurityOptions, ResolvedHttpSecurity } from './http-security.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -57,27 +73,23 @@ const STATIC_PATH = resolveStaticPath(); /** * Create the Express application with direct DaemonState access. - * + * * Used both for production (embedded in daemon) and testing. + * All /api/*, /v1/*, and /mcp routes require Bearer (or cookie) auth unless + * ABBENAY_HTTP_AUTH disables authentication for local development. */ -export function createWebApp(state: DaemonState): Express { +export function createWebApp(state: DaemonState, options?: WebSecurityOptions): Express { const app = express(); - + const port = options?.port ?? DEFAULT_WEB_PORT; + const security = resolveHttpSecurity(port, options?.host, options); + app.locals.httpSecurity = security; + // Parse JSON bodies app.use(express.json()); - - // CORS for local development - app.use((req, res, next) => { - res.header('Access-Control-Allow-Origin', '*'); - res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - res.header('Access-Control-Allow-Headers', 'Content-Type'); - if (req.method === 'OPTIONS') { - res.sendStatus(200); - return; - } - next(); - }); - + + // CORS — explicit allowlist only (never *) + app.use(createCorsMiddleware(security.corsOrigins)); + // Prevent browser caching of HTML so dashboard always serves fresh content app.use((req, res, next) => { if (req.path.endsWith('.html') || req.path === '/') { @@ -86,15 +98,82 @@ export function createWebApp(state: DaemonState): Express { next(); }); - // Serve static files + const isLoopbackClient = (req: Request): boolean => { + const addr = req.socket.remoteAddress || ''; + return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1'; + }; + + /** + * Serve dashboard HTML. Establishes SameSite auth cookies when auth is + * enabled and: + * - ?token= is present, or + * - the client is loopback (safe with default 127.0.0.1 bind) + * + * The API token is never embedded in HTML for remote clients; the dashboard + * authenticates via HttpOnly cookie + CSRF header (and API clients use Bearer). + */ + const serveDashboardHtml = (req: Request, res: Response): void => { + const indexPath = path.join(STATIC_PATH, 'index.html'); + if (!fs.existsSync(indexPath)) { + res.status(404).send('Web dashboard not found. Static files not at: ' + STATIC_PATH); + return; + } + + if (!security.authEnabled) { + res.type('html').send(fs.readFileSync(indexPath, 'utf-8')); + return; + } + + const q = typeof req.query.token === 'string' ? req.query.token : undefined; + if (q) { + if (q !== security.apiToken) { + res.status(401).send('Invalid token'); + return; + } + setAuthCookies(res, security.apiToken); + res.redirect(302, '/'); + return; + } + + const hasAuthCookie = getCookie(req, API_TOKEN_COOKIE) === security.apiToken; + const mayEstablishSession = hasAuthCookie || isLoopbackClient(req) || isLocalhostBind(security.host); + + let csrf = getCookie(req, CSRF_COOKIE); + if (mayEstablishSession && (!hasAuthCookie || !csrf)) { + csrf = setAuthCookies(res, security.apiToken); + } + + let html = fs.readFileSync(indexPath, 'utf-8'); + // Inject CSRF for dashboard mutating requests; never inject the API token. + const inject = ``; + html = html.includes('') + ? html.replace('', `${inject}`) + : `${inject}${html}`; + res.type('html').send(html); + }; + + app.get('/', serveDashboardHtml); + app.get('/index.html', serveDashboardHtml); + + // Serve other static files (CSS/JS assets next to index.html) if (fs.existsSync(STATIC_PATH)) { - app.use(express.static(STATIC_PATH)); + app.use(express.static(STATIC_PATH, { index: false })); } - + + // Auth gate for all API / OpenAI-compat / MCP routes (no-op when auth disabled) + const requireAuth = createAuthMiddleware( + security.apiToken, + security.corsOrigins, + security.authEnabled, + ); + app.use('/api', requireAuth); + app.use('/v1', requireAuth); + app.use('/mcp', requireAuth); + // ─── API Routes ──────────────────────────────────────────────────────── - + /** - * GET /api/health - Health check + * GET /api/health - Health check (requires auth; pass Bearer for probes) */ app.get('/api/health', (req, res) => { res.json({ @@ -896,6 +975,9 @@ export function createWebApp(state: DaemonState): Express { // ── Session Management ─────────────────────────────────────────────── + const sessionOwner = (req: Request): string => + (req as RequestWithOwner).abbenayOwner || LOCAL_SESSION_OWNER; + app.post('/api/sessions', async (req, res) => { try { const { model, title, policy, metadata } = req.body; @@ -903,7 +985,13 @@ export function createWebApp(state: DaemonState): Express { res.status(400).json({ error: 'model is required' }); return; } - const session = await state.sessionStore.create(model, title, policy, metadata); + const session = await state.sessionStore.create( + model, + title, + policy, + metadata, + sessionOwner(req), + ); res.json(session); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); @@ -934,7 +1022,12 @@ export function createWebApp(state: DaemonState): Express { } } - const result = await state.sessionStore.list({ model, limit, offset }); + const result = await state.sessionStore.list({ + model, + limit, + offset, + owner: sessionOwner(req), + }); res.json(result); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); @@ -946,7 +1039,11 @@ export function createWebApp(state: DaemonState): Express { app.get('/api/sessions/:id', async (req, res) => { try { const includeMessages = req.query.includeMessages !== 'false'; - const session = await state.sessionStore.get(req.params.id, includeMessages); + const session = await state.sessionStore.getOwned( + req.params.id, + sessionOwner(req), + includeMessages, + ); res.json(session); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); @@ -961,7 +1058,7 @@ export function createWebApp(state: DaemonState): Express { app.delete('/api/sessions/:id', async (req, res) => { try { - await state.sessionStore.delete(req.params.id); + await state.sessionStore.deleteOwned(req.params.id, sessionOwner(req)); res.json({ success: true }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); @@ -976,7 +1073,7 @@ export function createWebApp(state: DaemonState): Express { app.get('/api/sessions/:id/summary', async (req, res) => { try { - const session = await state.sessionStore.get(req.params.id, true); + const session = await state.sessionStore.getOwned(req.params.id, sessionOwner(req), true); const userCount = session.messages.filter((m) => m.role === 'user').length; if (session.summary && session.summaryMessageCount === userCount) { @@ -1004,6 +1101,7 @@ export function createWebApp(state: DaemonState): Express { app.post('/api/sessions/:id/chat', async (req, res) => { const sessionId = req.params.id; const { message } = req.body; + const owner = sessionOwner(req); if (!message || !message.content) { res.status(400).json({ error: 'message with content is required' }); @@ -1017,7 +1115,7 @@ export function createWebApp(state: DaemonState): Express { let session; try { - session = await state.sessionStore.get(sessionId, true); + session = await state.sessionStore.getOwned(sessionId, owner, true); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('not found') || msg.includes('Invalid session ID')) { @@ -1124,44 +1222,123 @@ export function createWebApp(state: DaemonState): Express { let _httpServer: http.Server | null = null; let _webPort: number | null = null; +let _webHost: string | null = null; let _lastApp: Express | null = null; +let _lastSecurity: ResolvedHttpSecurity | null = null; /** * Start the embedded web server in the daemon process. - * Returns the actual port and URL. + * Returns the actual port, URL, app, and resolved security settings. + * + * Binds to 127.0.0.1 by default. Non-localhost bind requires explicit opt-in + * via `host`, `ABBENAY_HTTP_HOST`, or `server.host` in config.yaml. */ export async function startEmbeddedWebServer( state: DaemonState, port: number = DEFAULT_WEB_PORT, -): Promise<{ port: number; url: string; app: Express }> { + host?: string, + options?: WebSecurityOptions, +): Promise<{ port: number; url: string; app: Express; security: ResolvedHttpSecurity }> { if (_httpServer) { - return { port: _webPort!, url: `http://localhost:${_webPort}`, app: _lastApp! }; + return { + port: _webPort!, + url: `http://${_webHost === '0.0.0.0' ? '127.0.0.1' : _webHost}:${_webPort}`, + app: _lastApp!, + security: _lastSecurity!, + }; } - - const app = createWebApp(state); - + + const security = resolveHttpSecurity(port, host, options); + const bindHost = security.host || DEFAULT_HTTP_HOST; + const app = createWebApp(state, { + ...options, + apiToken: security.apiToken, + port, + host: bindHost, + corsOrigins: security.corsOrigins, + skipConfig: true, + }); + // SPA fallback (serve index.html for non-API routes) app.get('*', (req, res) => { const indexPath = path.join(STATIC_PATH, 'index.html'); - if (fs.existsSync(indexPath)) { - res.sendFile(indexPath); - } else { + if (!fs.existsSync(indexPath)) { res.status(404).send('Web dashboard not found. Static files not at: ' + STATIC_PATH); + return; } + if (!security.authEnabled) { + res.type('html').send(fs.readFileSync(indexPath, 'utf-8')); + return; + } + const addr = req.socket.remoteAddress || ''; + const loopback = addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1'; + const hasAuthCookie = getCookie(req, API_TOKEN_COOKIE) === security.apiToken; + const mayEstablish = hasAuthCookie || loopback || isLocalhostBind(bindHost); + let csrf = getCookie(req, CSRF_COOKIE); + if (mayEstablish && (!hasAuthCookie || !csrf)) { + csrf = setAuthCookies(res, security.apiToken); + } + let html = fs.readFileSync(indexPath, 'utf-8'); + const inject = ``; + html = html.includes('') + ? html.replace('', `${inject}`) + : `${inject}${html}`; + res.type('html').send(html); }); - + _webPort = port; - + _webHost = bindHost; + + if (!security.authEnabled) { + console.warn( + '[Web] WARNING: HTTP authentication is DISABLED (ABBENAY_HTTP_AUTH). ' + + 'Any local process (and any site that can reach this bind address) can ' + + 'read/write secrets, config, chat, MCP, and sessions. ' + + 'Re-enable auth for anything beyond throwaway local development.', + ); + if (!isLocalhostBind(bindHost)) { + console.warn( + '[Web] CRITICAL: auth is disabled AND HTTP is bound beyond loopback ' + + `(${bindHost}). This exposes an unauthenticated API on the network.`, + ); + } + } + + if (!isLocalhostBind(bindHost)) { + console.warn( + `[Web] WARNING: HTTP server is bound to ${bindHost} — accessible beyond loopback. ` + + 'Ensure ABBENAY_API_TOKEN (or server.api_token) is set and CORS origins are restricted. ' + + 'Prefer --host 127.0.0.1 unless you intentionally expose the API.', + ); + } + await new Promise((resolve, reject) => { - _httpServer = app.listen(port, () => { - console.log(`[Web] Dashboard started: http://localhost:${port}`); + _httpServer = app.listen(port, bindHost, () => { + const displayHost = bindHost === '0.0.0.0' ? '127.0.0.1' : bindHost; + console.log(`[Web] Dashboard started: http://${displayHost}:${port} (bind ${bindHost})`); + if (security.authEnabled) { + if (security.generated) { + console.log(`[Web] Generated API token and saved to config dir (http-api-token).`); + } + console.log(`[Web] Authenticate with: Authorization: Bearer `); + console.log(`[Web] Dashboard login URL: http://${displayHost}:${port}/?token=`); + } else { + console.log(`[Web] HTTP auth disabled — requests are accepted without a Bearer token`); + } resolve(); }); _httpServer.on('error', reject); }); - + _lastApp = app; - return { port, url: `http://localhost:${port}`, app }; + _lastSecurity = security; + const displayHost = bindHost === '0.0.0.0' ? '127.0.0.1' : bindHost; + return { + port, + url: `http://${displayHost}:${port}`, + app, + security, + }; } /** @@ -1179,6 +1356,9 @@ export async function stopEmbeddedWebServer(): Promise { _httpServer = null; _webPort = null; + _webHost = null; + _lastApp = null; + _lastSecurity = null; } /** diff --git a/packages/daemon/static/index.html b/packages/daemon/static/index.html index bcfb9d7..d7e5504 100644 --- a/packages/daemon/static/index.html +++ b/packages/daemon/static/index.html @@ -1303,6 +1303,14 @@

Save config to

`; @@ -1296,12 +1376,6 @@ export async function startEmbeddedWebServer( 'read/write secrets, config, chat, MCP, and sessions. ' + 'Re-enable auth for anything beyond throwaway local development.', ); - if (!isLocalhostBind(bindHost)) { - console.warn( - '[Web] CRITICAL: auth is disabled AND HTTP is bound beyond loopback ' + - `(${bindHost}). This exposes an unauthenticated API on the network.`, - ); - } } if (!isLocalhostBind(bindHost)) { @@ -1318,10 +1392,14 @@ export async function startEmbeddedWebServer( console.log(`[Web] Dashboard started: http://${displayHost}:${port} (bind ${bindHost})`); if (security.authEnabled) { if (security.generated) { - console.log(`[Web] Generated API token and saved to config dir (http-api-token).`); + console.log( + '[Web] Generated API token and saved to config dir (http-api-token). ' + + 'Prefer setting ABBENAY_API_TOKEN explicitly in containers — auto-generated ' + + 'tokens are hard to retrieve from inside the image.', + ); } console.log(`[Web] Authenticate with: Authorization: Bearer `); - console.log(`[Web] Dashboard login URL: http://${displayHost}:${port}/?token=`); + console.log(`[Web] Dashboard login: http://${displayHost}:${port}/login`); } else { console.log(`[Web] HTTP auth disabled — requests are accepted without a Bearer token`); } diff --git a/packages/daemon/tests/integration/http-security.test.ts b/packages/daemon/tests/integration/http-security.test.ts index 788b65e..83fdeaf 100644 --- a/packages/daemon/tests/integration/http-security.test.ts +++ b/packages/daemon/tests/integration/http-security.test.ts @@ -266,6 +266,102 @@ describe('ABBENAY_HTTP_AUTH disable escape hatch', () => { }); expect(status).toBe(200); }); + + it('refuses to start when auth is disabled on a non-loopback bind', async () => { + await stopEmbeddedWebServer(); + const state = createMockState(); + await expect( + startEmbeddedWebServer(state, 18787, '0.0.0.0', { + authEnabled: false, + skipConfig: true, + }), + ).rejects.toThrow(/authentication disabled|ABBENAY_HTTP_AUTH/); + await stopEmbeddedWebServer(); + }); + + it('allows auth-disabled start on loopback', async () => { + await stopEmbeddedWebServer(); + const state = createMockState(); + const probe = http.createServer(); + const freePort = await new Promise((resolve) => { + probe.listen(0, '127.0.0.1', () => { + const addr = probe.address(); + resolve(typeof addr === 'object' && addr ? addr.port : 0); + }); + }); + await new Promise((resolve) => probe.close(() => resolve())); + + const started = await startEmbeddedWebServer(state, freePort, '127.0.0.1', { + authEnabled: false, + skipConfig: true, + }); + expect(started.security.authEnabled).toBe(false); + expect(started.security.host).toBe('127.0.0.1'); + await stopEmbeddedWebServer(); + }); +}); + +describe('dashboard login', () => { + it('POST /login sets auth cookies with JSON body', async () => { + const res = await httpRequest('POST', '/login', { + token: null, + body: { token: TEST_TOKEN }, + headers: { Accept: 'application/json' }, + }); + expect(res.statusCode).toBe(204); + const setCookie = res.headers['set-cookie']; + const cookies = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : []; + expect(cookies.some((c) => c.startsWith('abbenay_api_token='))).toBe(true); + expect(cookies.some((c) => c.startsWith('abbenay_csrf='))).toBe(true); + }); + + it('POST /login rejects invalid token', async () => { + const res = await httpRequest('POST', '/login', { + token: null, + body: { token: 'wrong-token' }, + headers: { Accept: 'application/json' }, + }); + expect(res.statusCode).toBe(401); + }); + + it('POST /login sets Secure cookies behind X-Forwarded-Proto https', async () => { + const res = await httpRequest('POST', '/login', { + token: null, + body: { token: TEST_TOKEN }, + headers: { + Accept: 'application/json', + 'X-Forwarded-Proto': 'https', + }, + }); + expect(res.statusCode).toBe(204); + const setCookie = res.headers['set-cookie']; + const cookies = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : []; + expect(cookies.length).toBeGreaterThan(0); + expect(cookies.every((c) => /;\s*Secure/i.test(c))).toBe(true); + }); + + it('legacy ?token= login redirects and sets cookies (timing-safe path)', async () => { + const res = await httpRequest('GET', `/?token=${encodeURIComponent(TEST_TOKEN)}`, { + token: null, + }); + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe('/'); + const setCookie = res.headers['set-cookie']; + const cookies = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : []; + expect(cookies.some((c) => c.startsWith('abbenay_api_token='))).toBe(true); + }); + + it('legacy ?token= rejects invalid token', async () => { + const res = await httpRequest('GET', '/?token=not-the-token', { token: null }); + expect(res.statusCode).toBe(401); + }); + + it('GET /login serves a token form when unauthenticated', async () => { + const res = await httpRequest('GET', '/login', { token: null }); + expect(res.statusCode).toBe(200); + expect(String(res.body)).toContain('action="/login"'); + expect(String(res.body)).toContain('name="token"'); + }); }); describe('CORS allowlist', () => {