diff --git a/Containerfile b/Containerfile index 9e4dd44..7ad4997 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", "--grpc-tls"] +CMD ["start", "--port", "8787", "--host", "0.0.0.0", "--grpc-port", "50051", "--grpc-host", "0.0.0.0", "--grpc-tls"] 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..3f8b4ea 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,58 @@ 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. Open `http://127.0.0.1:8787/login` (or `POST /login` with the +token in the body) to establish a session — prefer that over putting the +token in the URL. Cookies include the `Secure` flag when the request is +HTTPS or arrives via a TLS-terminated proxy (`X-Forwarded-Proto: https`). +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. Combining `ABBENAY_HTTP_AUTH=0` +> with `--host 0.0.0.0` (or any non-loopback bind) fails closed — the HTTP +> server refuses to start. 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 75ed1a9..f422c29 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 @@ -133,8 +138,8 @@ podman logs -f abbenay ## Overriding the command The default `CMD` is -`start --port 8787 --grpc-port 50051 --grpc-host 0.0.0.0 --grpc-tls`, -which runs all services with TLS-protected gRPC accessible from outside the +`start --port 8787 --host 0.0.0.0 --grpc-port 50051 --grpc-host 0.0.0.0 --grpc-tls`, +which runs all services with HTTP and TLS-protected gRPC accessible from outside the container. You can override it to run a subset: ```bash @@ -243,6 +248,9 @@ metadata: type: Opaque stringData: OPENROUTER_API_KEY: "sk-or-..." + # Must match the Bearer value in the probe httpHeaders below. + # Kubernetes does not expand env vars in httpGet.httpHeaders. + ABBENAY_API_TOKEN: "replace-with-a-strong-token" --- apiVersion: v1 kind: ConfigMap @@ -291,12 +299,18 @@ spec: httpGet: path: /api/health port: 8787 + httpHeaders: + - name: Authorization + value: Bearer replace-with-a-strong-token initialDelaySeconds: 10 periodSeconds: 30 readinessProbe: httpGet: path: /api/health port: 8787 + httpHeaders: + - name: Authorization + value: Bearer replace-with-a-strong-token initialDelaySeconds: 5 periodSeconds: 10 volumes: @@ -324,14 +338,45 @@ 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, the sample `livenessProbe` / + `readinessProbe` send the same Bearer token via `httpHeaders` — keep that + value identical to `ABBENAY_API_TOKEN` in the Secret (Kubernetes does not + expand environment variables in `httpGet.httpHeaders`). - Sessions are ephemeral by default. To persist sessions across restarts, mount a volume at `/home/abbenay/.local/share/abbenay/sessions/`. --- +## Security: HTTP bind and authentication + +| Flag | Default | Effect | +|------|---------|--------| +| `--host` / `ABBENAY_HTTP_HOST` / `server.host` | `127.0.0.1` | HTTP bind (dashboard, `/api/*`, `/v1/*`, `/mcp`) | + +| Value | Effect | +|-------|--------| +| `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` 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 `*`). + +> **WARNING:** `ABBENAY_HTTP_AUTH=0` disables HTTP auth for local development +> only. Combining it with `--host 0.0.0.0` (or any non-loopback bind) fails +> closed — the HTTP server refuses to start. + +When exposing HTTP, always set a strong `ABBENAY_API_TOKEN` and restrict +`server.cors_origins`. Keep `ABBENAY_HTTP_AUTH` enabled (the default). + ## Security: gRPC bind, TLS, and `--insecure` The `--grpc-host` flag controls which network interface the TCP gRPC diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index b7fa724..a18787b 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. Combining that with `--host 0.0.0.0` refuses to +> start. 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,13 +287,21 @@ 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). For remote binds, use http://127.0.0.1:8787/login or +`POST /login` with the API token in the body — avoid putting the token in +the query string (it can leak via history, Referer, and logs). + +Use the dashboard to: - Add and configure providers - Store API keys in the system keychain - Enable/disable models - Test chat with streaming responses +Sessions created before ownership was introduced have no `owner` field and +are treated as CLI/`local` only — HTTP API clients will not list or open them. + --- ## 8. Connect MCP servers 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 9b8e207..23b829c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -425,3 +425,48 @@ default CMD uses `--grpc-tls`. config, and tools. A warning alone is insufficient (finding C2). Fail-closed startup forces an explicit security choice while preserving localhost DX and allowing an escape hatch for trusted networks. + +--- + +## DR-030: 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. Prefer `GET/POST /login` (token in the form/body) +over `/?token=` query login to avoid leaking credentials via history, +Referer, and access logs; the query form remains for compatibility and uses +a timing-safe compare. Cookies set the `Secure` flag when the request is +HTTPS or `X-Forwarded-Proto: https`. For local development only, +`ABBENAY_HTTP_AUTH=0` (or `false`/`off`/`no`/`disabled`) turns auth off and +logs a loud warning. Combining auth-disabled with a non-loopback bind +(`0.0.0.0`, LAN IP, etc.) fails closed: the HTTP server refuses to start. +**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-031: 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-030) 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 bd3925a..0f62524 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'; import type { GrpcTlsOptions } from './grpc-tls.js'; /** Shared CLI flags for TCP gRPC bind + TLS policy. */ @@ -118,6 +119,7 @@ program interface ServerOptions { port: number; + host?: string; mcp?: boolean; grpcPort?: number; grpcHost?: string; @@ -140,7 +142,9 @@ function validatePort(raw: string): number { } async function runServer(opts: ServerOptions): Promise { - const { port, mcp, grpcPort, grpcHost, grpcTls, bannerLines } = opts; + const { port, host, mcp, grpcPort, grpcHost, grpcTls, 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...'); @@ -153,7 +157,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()); }); @@ -181,7 +192,7 @@ async function runServer(opts: ServerOptions): Promise { } else { console.log('No daemon running, starting in-process...'); const daemonState = await startDaemon({ keepAlive: false, grpcPort, grpcHost, grpcTls }); - const { url, app } = await startEmbeddedWebServer(daemonState, port); + const { url, app, security } = await startEmbeddedWebServer(daemonState, port, host); if (mcp && app) { await daemonState.mcpServer.start(app); @@ -190,6 +201,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(() => {}); @@ -202,7 +216,8 @@ addGrpcBindOptions( 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('-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)'), ) .action(async (options) => { const port = validatePort(options.port); @@ -212,6 +227,7 @@ addGrpcBindOptions( const grpcTls = grpcTlsFromCli(options); await runServer({ port, + host: options.host || undefined, mcp: true, grpcPort, grpcHost, @@ -247,6 +263,7 @@ addGrpcBindOptions( .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('--mcp', 'Start MCP server on /mcp endpoint'), ) .action(async (options) => { @@ -254,6 +271,7 @@ addGrpcBindOptions( try { await runServer({ port, + host: options.host || undefined, mcp: options.mcp, grpcPort: options.grpcPort ? validatePort(options.grpcPort) : undefined, grpcHost: options.grpcHost, @@ -276,6 +294,7 @@ addGrpcBindOptions( .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('--mcp', 'Start MCP server on /mcp endpoint'), ) .action(async (options) => { @@ -283,6 +302,7 @@ addGrpcBindOptions( try { await runServer({ port, + host: options.host || undefined, mcp: options.mcp, grpcPort: options.grpcPort ? validatePort(options.grpcPort) : undefined, grpcHost: options.grpcHost, @@ -337,12 +357,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) { @@ -368,18 +389,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}`); @@ -402,12 +424,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..bbd9515 --- /dev/null +++ b/packages/daemon/src/daemon/web/http-security.test.ts @@ -0,0 +1,301 @@ +/** + * Unit tests for HTTP API security helpers. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { + isLocalhostBind, + isHttpAuthEnabled, + assertHttpAuthBindAllowed, + HttpAuthBindSecurityError, + resolveHttpApiToken, + resolveHttpHost, + resolveCorsOrigins, + extractBearerToken, + getCookie, + timingSafeEqualString, + cookieSecureFromRequest, + setAuthCookies, + clearAuthCookies, +} from './http-security.js'; +import { + ownerIdFromHttpToken, + resolveHttpSessionOwner, + resolveSessionOwner, + assertSessionOwner, +} from '../../core/session-store.js'; +import type { Request, Response } 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('assertHttpAuthBindAllowed', () => { + it('allows auth-disabled binds on loopback', () => { + expect(() => assertHttpAuthBindAllowed('127.0.0.1', false)).not.toThrow(); + expect(() => assertHttpAuthBindAllowed('::1', false)).not.toThrow(); + expect(() => assertHttpAuthBindAllowed('localhost', false)).not.toThrow(); + }); + + it('allows auth-enabled binds on any host', () => { + expect(() => assertHttpAuthBindAllowed('0.0.0.0', true)).not.toThrow(); + expect(() => assertHttpAuthBindAllowed('192.168.1.10', true)).not.toThrow(); + }); + + it('refuses auth-disabled binds beyond loopback', () => { + expect(() => assertHttpAuthBindAllowed('0.0.0.0', false)).toThrow(HttpAuthBindSecurityError); + expect(() => assertHttpAuthBindAllowed('192.168.1.10', false)).toThrow(HttpAuthBindSecurityError); + expect(() => assertHttpAuthBindAllowed('::', false)).toThrow(HttpAuthBindSecurityError); + }); +}); + +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('timingSafeEqualString', () => { + it('matches equal strings', () => { + expect(timingSafeEqualString('abc', 'abc')).toBe(true); + }); + + it('rejects unequal strings and unequal lengths', () => { + expect(timingSafeEqualString('abc', 'abd')).toBe(false); + expect(timingSafeEqualString('abc', 'ab')).toBe(false); + expect(timingSafeEqualString('', 'x')).toBe(false); + }); +}); + +describe('cookieSecureFromRequest / setAuthCookies', () => { + it('detects HTTPS via req.secure and X-Forwarded-Proto', () => { + expect(cookieSecureFromRequest({ secure: true, headers: {} } as Request)).toBe(true); + expect(cookieSecureFromRequest({ + secure: false, + headers: { 'x-forwarded-proto': 'https' }, + } as Request)).toBe(true); + expect(cookieSecureFromRequest({ + secure: false, + headers: { 'x-forwarded-proto': 'https, http' }, + } as Request)).toBe(true); + expect(cookieSecureFromRequest({ secure: false, headers: {} } as Request)).toBe(false); + }); + + it('sets Secure flag on cookies when requested', () => { + const cookies: string[] = []; + const res = { + append: (_name: string, value: string) => { cookies.push(value); }, + } as unknown as Response; + setAuthCookies(res, 'tok', { secure: true }); + expect(cookies.length).toBe(2); + expect(cookies[0]).toContain('Secure'); + expect(cookies[1]).toContain('Secure'); + cookies.length = 0; + clearAuthCookies(res, { secure: true }); + expect(cookies.every((c) => c.includes('Secure'))).toBe(true); + }); + + it('omits Secure flag by default', () => { + const cookies: string[] = []; + const res = { + append: (_name: string, value: string) => { cookies.push(value); }, + } as unknown as Response; + setAuthCookies(res, 'tok'); + expect(cookies.every((c) => !c.includes('Secure'))).toBe(true); + }); +}); + +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..042535e --- /dev/null +++ b/packages/daemon/src/daemon/web/http-security.ts @@ -0,0 +1,498 @@ +/** + * 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'; +} + +/** + * Thrown when HTTP auth is disabled on a non-loopback bind (fail-closed). + */ +export class HttpAuthBindSecurityError extends Error { + constructor(message: string) { + super(message); + this.name = 'HttpAuthBindSecurityError'; + } +} + +/** + * Refuse auth-disabled HTTP binds beyond loopback. + * + * `ABBENAY_HTTP_AUTH=0` is a local-dev escape hatch only. Combining it with + * `--host 0.0.0.0` (or any non-loopback bind) would expose an unauthenticated + * API on the network — refuse to start instead of warning. + */ +export function assertHttpAuthBindAllowed(host: string, authEnabled: boolean): void { + if (authEnabled || isLocalhostBind(host)) { + return; + } + throw new HttpAuthBindSecurityError( + `Refusing to bind HTTP on non-loopback address "${host}" with authentication disabled ` + + '(ABBENAY_HTTP_AUTH=0). Re-enable auth, or bind to 127.0.0.1 / ::1 / 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, + }; +} + +/** + * Timing-safe string equality (Bearer, cookie, query, and CSRF compares). + */ +export 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); +} + +/** + * True when cookies should include the Secure flag (HTTPS or TLS-terminated proxy). + */ +export function cookieSecureFromRequest(req: Request): boolean { + if (req.secure) return true; + const xf = req.headers['x-forwarded-proto']; + if (typeof xf === 'string') { + const proto = xf.split(',')[0]?.trim().toLowerCase(); + if (proto === 'https') return true; + } + return false; +} + +/** + * 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(); + }; +} + +export interface AuthCookieOptions { + /** Set the Secure flag (HTTPS / TLS-terminated reverse proxy). */ + secure?: boolean; +} + +/** + * Set SameSite=Strict auth + CSRF cookies for the dashboard. + */ +export function setAuthCookies( + res: Response, + apiToken: string, + opts?: AuthCookieOptions, +): string { + const csrf = crypto.randomBytes(24).toString('base64url'); + const secure = opts?.secure ? '; Secure' : ''; + const tokenCookie = + `${API_TOKEN_COOKIE}=${encodeURIComponent(apiToken)}; Path=/; HttpOnly; SameSite=Strict${secure}`; + // Not HttpOnly so dashboard JS can send X-CSRF-Token + const csrfCookie = + `${CSRF_COOKIE}=${encodeURIComponent(csrf)}; Path=/; SameSite=Strict${secure}`; + res.append('Set-Cookie', tokenCookie); + res.append('Set-Cookie', csrfCookie); + return csrf; +} + +/** + * Clear auth cookies. + */ +export function clearAuthCookies(res: Response, opts?: AuthCookieOptions): void { + const secure = opts?.secure ? '; Secure' : ''; + res.append( + 'Set-Cookie', + `${API_TOKEN_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict${secure}`, + ); + res.append('Set-Cookie', `${CSRF_COOKIE}=; Path=/; Max-Age=0; SameSite=Strict${secure}`); +} 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..bb9ecbf 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,27 @@ 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, + cookieSecureFromRequest, + timingSafeEqualString, + API_TOKEN_COOKIE, + CSRF_COOKIE, + isLocalhostBind, + assertHttpAuthBindAllowed, + 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 +76,24 @@ 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(); - - // Parse JSON bodies + const port = options?.port ?? DEFAULT_WEB_PORT; + const security = resolveHttpSecurity(port, options?.host, options); + app.locals.httpSecurity = security; + + // Parse JSON / form bodies (form used by POST /login) 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(); - }); - + app.use(express.urlencoded({ extended: false })); + + // 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 +102,155 @@ 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'; + }; + + const cookieOpts = (req: Request) => ({ secure: cookieSecureFromRequest(req) }); + + const hasValidAuthCookie = (req: Request): boolean => { + const cookieToken = getCookie(req, API_TOKEN_COOKIE); + return cookieToken !== null && timingSafeEqualString(cookieToken, security.apiToken); + }; + + const LOGIN_PAGE = ` +Abbenay login + +

Abbenay

+

Enter the HTTP API token to open the dashboard. Prefer this form (or +POST /login) over putting the token in the URL.

+{{ERROR}} +
+ + + +
+`; + + /** + * Serve dashboard HTML. Establishes SameSite auth cookies when auth is + * enabled and: + * - POST /login (preferred) or legacy ?token= succeeded, 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; + } + + // Legacy query login (kept for compat). Prefer POST /login — query tokens + // can leak via history, Referer, and access logs. + const q = typeof req.query.token === 'string' ? req.query.token : undefined; + if (q !== undefined) { + if (!timingSafeEqualString(q, security.apiToken)) { + res.status(401).send('Invalid token'); + return; + } + setAuthCookies(res, security.apiToken, cookieOpts(req)); + res.redirect(302, '/'); + return; + } + + const hasAuthCookie = hasValidAuthCookie(req); + const mayEstablishSession = hasAuthCookie || isLoopbackClient(req) || isLocalhostBind(security.host); + + let csrf = getCookie(req, CSRF_COOKIE); + if (mayEstablishSession && (!hasAuthCookie || !csrf)) { + csrf = setAuthCookies(res, security.apiToken, cookieOpts(req)); + } + + 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); + }; + + const extractLoginToken = (req: Request): string => { + const body = req.body as { token?: unknown; api_token?: unknown } | undefined; + if (typeof body?.token === 'string') return body.token; + if (typeof body?.api_token === 'string') return body.api_token; + return ''; + }; + + app.get('/login', (req, res) => { + // Always offer the form when unauthenticated — loopback clients can also + // open `/` for auto cookie establish, but /login must not put the token + // in the URL for remote (or any) users. + if (!security.authEnabled || hasValidAuthCookie(req)) { + res.redirect(302, '/'); + return; + } + res.type('html').send(LOGIN_PAGE.replace('{{ERROR}}', '')); + }); + + app.post('/login', (req, res) => { + if (!security.authEnabled) { + res.redirect(302, '/'); + return; + } + const token = extractLoginToken(req); + if (!timingSafeEqualString(token, security.apiToken)) { + const wantsJson = req.is('application/json') || (req.headers.accept || '').includes('application/json'); + if (wantsJson) { + res.status(401).json({ error: 'Invalid token' }); + return; + } + res.status(401).type('html').send( + LOGIN_PAGE.replace('{{ERROR}}', '

Invalid token

'), + ); + return; + } + setAuthCookies(res, security.apiToken, cookieOpts(req)); + const wantsJson = req.is('application/json') || (req.headers.accept || '').includes('application/json'); + if (wantsJson) { + res.status(204).end(); + return; + } + res.redirect(302, '/'); + }); + + 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 +1052,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 +1062,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 +1099,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 +1116,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 +1135,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 +1150,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 +1178,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 +1192,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 +1299,124 @@ 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; + assertHttpAuthBindAllowed(bindHost, security.authEnabled); + 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 cookieToken = getCookie(req, API_TOKEN_COOKIE); + const hasAuthCookie = + cookieToken !== null && timingSafeEqualString(cookieToken, security.apiToken); + const mayEstablish = hasAuthCookie || loopback || isLocalhostBind(bindHost); + let csrf = getCookie(req, CSRF_COOKIE); + if (mayEstablish && (!hasAuthCookie || !csrf)) { + csrf = setAuthCookies(res, security.apiToken, { secure: cookieSecureFromRequest(req) }); } + 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] 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). ' + + '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: http://${displayHost}:${port}/login`); + } 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 +1434,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