Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/selfhost-auth-client-ip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@executor-js/host-selfhost": patch
"executor": patch
---

Key the self-host's sign-in rate limit on the real client IP. The server now stamps the connecting address on every auth request, so a directly exposed instance limits each client separately with no configuration and a client cannot spoof its address.

Behaviour change for proxied deployments: `x-forwarded-for` is no longer read on its own, because any client could set it. If Executor runs behind a reverse proxy, set both `EXECUTOR_TRUSTED_PROXY_HEADER` (the header the proxy sets, e.g. `cf-connecting-ip` or `x-real-ip`) and `EXECUTOR_TRUSTED_PROXIES` (the proxy's own IPs or CIDR ranges), otherwise all users share one sign-in bucket. The server logs one warning naming both variables when an auth request carries a proxy header and neither is set. List only the proxy addresses, never a range that also contains your users; if every hop is trusted no client IP is found and the bucket is shared again. Half-configured or malformed values refuse to boot.
76 changes: 59 additions & 17 deletions apps/docs/hosted/docker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,23 +53,25 @@ Back it up by snapshotting that volume (or copying `/data`, primarily `data.db`)
Everything is optional: a bare run boots a working instance. The defaults below are
the container defaults.

| Variable | Default | Purpose |
| ----------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------- |
| `PORT` | `4788` | HTTP port the server listens on. |
| `EXECUTOR_HOST` | `0.0.0.0` | Bind address. The image binds all interfaces. |
| `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. |
| `EXECUTOR_DB_PATH` | `<data dir>/data.db` | SQLite database file. |
| `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:4788`) | Public URL browsers use. Required behind a domain or TLS (see below). |
| `EXECUTOR_TRUSTED_ORIGINS` | unset | Comma-separated browser aliases allowed to authenticate without changing the public URL. |
| `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. |
| `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. |
| `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | unset | Pre-create the admin headlessly (with the password below); skips browser first-run. |
| `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` | unset | Password for the bootstrap admin. |
| `EXECUTOR_BOOTSTRAP_ADMIN_NAME` | `Admin` | Display name for the bootstrap admin. |
| `EXECUTOR_ORG_NAME` | `Default` | Display name of the single org every user joins. |
| `EXECUTOR_ORG_SLUG` | `default` | URL slug for that org. |
| `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` | Allow sandboxed code to reach loopback / private addresses. Keep off unless you trust the code. |
| `EXECUTOR_DISABLE_AUTH_RATE_LIMIT` | `false` | Turn off sign-in rate limiting. Only when a proxy or WAF in front of Executor limits instead. |
| Variable | Default | Purpose |
| ----------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------- |
| `PORT` | `4788` | HTTP port the server listens on. |
| `EXECUTOR_HOST` | `0.0.0.0` | Bind address. The image binds all interfaces. |
| `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. |
| `EXECUTOR_DB_PATH` | `<data dir>/data.db` | SQLite database file. |
| `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:4788`) | Public URL browsers use. Required behind a domain or TLS (see below). |
| `EXECUTOR_TRUSTED_ORIGINS` | unset | Comma-separated browser aliases allowed to authenticate without changing the public URL. |
| `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. |
| `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. |
| `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | unset | Pre-create the admin headlessly (with the password below); skips browser first-run. |
| `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` | unset | Password for the bootstrap admin. |
| `EXECUTOR_BOOTSTRAP_ADMIN_NAME` | `Admin` | Display name for the bootstrap admin. |
| `EXECUTOR_ORG_NAME` | `Default` | Display name of the single org every user joins. |
| `EXECUTOR_ORG_SLUG` | `default` | URL slug for that org. |
| `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` | Allow sandboxed code to reach loopback / private addresses. Keep off unless you trust the code. |
| `EXECUTOR_TRUSTED_PROXY_HEADER` | unset | Header your reverse proxy sets to the real client IP, e.g. `cf-connecting-ip`. Set with the next. |
| `EXECUTOR_TRUSTED_PROXIES` | unset | Comma-separated IPs or CIDR ranges the proxy connects from. Set with the previous. |
| `EXECUTOR_DISABLE_AUTH_RATE_LIMIT` | `false` | Turn off sign-in rate limiting. Only when a proxy or WAF in front of Executor limits instead. |

Tracing is configured separately, and off unless you turn it on — see
[Tracing](/hosted/tracing).
Expand Down Expand Up @@ -109,6 +111,46 @@ Only cookie-authenticated browser requests use this allowlist. OAuth callbacks,
MCP metadata, approval links, and other absolute URLs remain pinned to
`EXECUTOR_WEB_BASE_URL`. Origins are never inferred from request headers.

### Behind a reverse proxy

Sign-in attempts are rate-limited per client IP (three per ten seconds). When
browsers reach the container directly, the IP is the connecting address and
nothing needs configuring.

Behind Caddy, nginx, Cloudflare, or another reverse proxy, every connection
comes from the proxy. If Executor runs behind a reverse proxy, set both
variables, otherwise all users share one sign-in bucket. Tell Executor which
header the proxy sets to the real client IP, and which addresses the proxy
connects from:

```bash
-e EXECUTOR_TRUSTED_PROXY_HEADER=x-real-ip \
-e EXECUTOR_TRUSTED_PROXIES=172.18.0.2
```

Set both together. The header is only honoured on connections from one of the
listed addresses, so a client that reaches the container directly cannot spoof
it. List only the proxy's own addresses (its IP, or the compose network it
shares with Executor, such as `172.18.0.0/24`), never a range that also
contains your users: every listed address counts as a proxy hop, and if every
hop is trusted Better Auth finds no client and falls back to one shared bucket.
Use `cf-connecting-ip` with Cloudflare's published IP ranges, `x-real-ip` for
nginx (`proxy_set_header X-Real-IP $remote_addr;`), or `x-forwarded-for` for
Caddy. A malformed entry refuses to boot rather than silently pooling every
user into one bucket.

Executor never trusts `x-forwarded-for` or a similar header on its own, because
any client could set it. When an auth request carries one of those headers and
no trusted proxy is configured, the server logs one warning naming these two
variables.

On Linux, Docker's default iptables NAT preserves the client IP on a published
port, so a container reached directly needs nothing. Docker Desktop (macOS and
Windows) routes published ports through Docker's userland proxy, as does
loopback traffic from the Docker host itself, so every connection arrives from
one internal address; there, put a reverse proxy on the same Docker network in
front of Executor and configure it as above.

## Connect an agent

The server exposes a streamable-HTTP MCP endpoint at `/mcp`. Point your client at
Expand Down
20 changes: 17 additions & 3 deletions apps/host-selfhost/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,23 @@
# EXECUTOR_ALLOW_LOCAL_NETWORK=false

# --- Auth rate limiting -------------------------------------------------------
# Sign-in attempts are rate-limited per client IP. Without a trusted proxy
# header every caller shares one bucket. Set the exact string "true" only when
# something in front of Executor rate-limits instead.
# Sign-in attempts are rate-limited per client IP. When browsers reach Executor
# directly, the IP is the connecting address and nothing needs configuring.
#
# Behind a reverse proxy (Caddy, nginx, Cloudflare, ...) every connection comes
# from the proxy, so name the header the proxy sets to the real client IP and
# the addresses the proxy connects from. Set BOTH, otherwise all users share
# one sign-in bucket. The header is only honoured on connections from those
# addresses, so it cannot be spoofed by a client that reaches the container
# directly. List only the proxy's own addresses (its IP, or the compose network
# it shares with Executor, e.g. 172.18.0.0/24), never a range that also
# contains your users: if every hop is trusted no client IP is found and
# everyone shares one bucket again.
# EXECUTOR_TRUSTED_PROXY_HEADER=x-real-ip
# EXECUTOR_TRUSTED_PROXIES=172.18.0.2
#
# Set the exact string "true" only when something in front of Executor
# rate-limits sign-ins instead.
# EXECUTOR_DISABLE_AUTH_RATE_LIMIT=false

# --- Local stdio MCP (trusted deployments only) -------------------------------
Expand Down
19 changes: 17 additions & 2 deletions apps/host-selfhost/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { HttpApiSwagger } from "effect/unstable/httpapi";
import { HttpEffect, HttpRouter } from "effect/unstable/http";
import { HttpEffect, HttpRouter, HttpServerRequest } from "effect/unstable/http";
import { Effect, Layer } from "effect";

import {
Expand All @@ -12,6 +12,7 @@ import {
import { runSqliteDataMigrations } from "@executor-js/sdk";

import { resolveAuthProviders } from "./auth";
import { makeClientIpStamper } from "./auth/client-ip";
import { selfHostDataMigrations } from "./db/data-migrations";
import { makeSelfHostAdminApiLayer } from "./admin/handlers";
import { makeSelfHostAdminUsersApiLayer } from "./admin/admin-users-api";
Expand Down Expand Up @@ -76,6 +77,20 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => {
const { identityLayer, memberDirectoryLayer, authHandler, betterAuth } =
await resolveAuthProviders(dbHandle);

// Better Auth keys its rate limiter on a header, never on the socket. Stamp
// the TCP peer address onto the web request right before Better Auth reads
// it (this is the one place that has both the Effect request, which knows
// the peer, and the web handler). Done here rather than in serve.ts's
// middleware because `fromWebHandler` hands Better Auth the original Bun
// `Request`, so Effect-level header rewrites never reach it.
const stampClientIp = makeClientIpStamper(config.trustedProxy);
const authRoute = Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
return yield* HttpEffect.fromWebHandler((web) =>
authHandler(stampClientIp(web, request.remoteAddress)),
);
});

// ---- the in-process MCP serving seams (+ shutdown hook) ----------------
const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config);

Expand Down Expand Up @@ -121,7 +136,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => {
// (web/chromeless/device-page.tsx).
HttpRouter.add("GET", "/api/auth/cli-login", cliLoginHandler),
// Better Auth owns the rest of /api/auth/*, the full path reaches it.
HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)),
HttpRouter.add("*", "/api/auth/*", authRoute),
// Browser approval of paused MCP executions: the console resume page
// reads paused detail (GET) and records the decision (POST .../resume),
// session-cookie-gated, delegating to the in-process MCP store.
Expand Down
13 changes: 12 additions & 1 deletion apps/host-selfhost/src/auth/better-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { LibsqlDialect, type LibsqlDialectConfig } from "@libsql/kysely-libsql";
import { Context } from "effect";

import { loadConfig } from "../config";
import { clientIpAddressOptions } from "./client-ip";
import { seedOrgAndAdmin } from "./seed";
import { consumeInviteCode, ensureInviteCodeTable, findRedeemableCode } from "./invites";
import { isAdmitted, isOAuthCallback, ssoProviderConfig } from "./sso";
Expand Down Expand Up @@ -129,7 +130,17 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?:
// pinned to config.webBaseUrl.
baseURL: config.webBaseUrl,
trustedOrigins: [...config.trustedOrigins],
advanced: { useSecureCookies: !hasInsecureTrustedOrigin },
advanced: {
useSecureCookies: !hasInsecureTrustedOrigin,
// Where the rate limiter (and the session's recorded address) reads the
// client IP. Better Auth only ever looks at headers, so app.ts stamps the
// socket peer onto CLIENT_IP_HEADER before the request gets here (see
// ./client-ip); a configured reverse proxy's header is consulted first.
// `trustedProxies` lets Better Auth strip known hops from a forwarded
// chain; the stamper has already dropped the proxy header on any
// connection that did not come from one of those addresses.
ipAddress: clientIpAddressOptions(config.trustedProxy),
},
// Better Auth's own limiter is on in production and off in development.
// Only an explicit opt-out is passed through, so that environment default
// stays in charge everywhere else.
Expand Down
Loading
Loading